Audit Cursor MCP Servers: 12 Steps, 60 Min [2026] A 60-minute, standard-library-only Python project can audit Cursor MCP servers by scanning every `.cursor/mcp.json` in a repository, flagging unpinned `npx -y` launches, committed credentials and delete/drop tools, comparing each server against an allowlist and failing pull requests when risky entries land, with a second script wired in as a Cursor hook that writes one audit line per MCP call. The project ships with 14 unit tests and JSON output for CI, and targets the Model Context Protocol specification dated 2026-07-28, in which Roots, Sampling and Logging are marked deprecated. The audit addresses config files that decide which MCP servers an entire team launches, a supply-chain surface the project's author says most teams review less carefully than a Dockerfile. Every MCP server you add to Cursor is code that runs with your credentials, and the protocol it speaks keeps moving. The official Model Context Protocol documentation https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro treats 2026-07-28 as the current specification, and Roots, Sampling, and Logging are marked deprecated in that version. Your servers, your config files, and your audit habits have to keep pace with it. A config file in a repository decides which Cursor MCP servers your whole team launches. That makes .cursor/mcp.json a supply-chain surface, yet most teams review it less carefully than a Dockerfile. A short Python script can read every mcp.json in a repo, flag common classes of risk, compare each server against an allowlist, and fail a pull request when something dangerous lands. A second script, wired in as a Cursor hook, writes one audit line for every MCP call the agent makes. If you still need a server to audit, build your own Cursor MCP server https://shattered.io/build-cursor-mcp-server-python-12-steps-2026/ first and come back. The result is a working project you can drop into any repository in about 60 minutes. It uses only the Python standard library, ships with 14 unit tests, and produces output you can read in a terminal or feed to CI as JSON. Don't miss new tech stories on Google Add Shattered once in the Google app and our stories show up in your news suggestions. Add now https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F Why Cursor MCP Servers Need an Audit An MCP server gives the model a menu of tools. A stdio server is a process your machine starts from the command in the config. A remote server is a URL that receives whatever the agent decides to send. In both cases the model picks the tool and fills in the arguments, and a human usually approves the call with a single click, if at all. That arrangement is useful and risky in equal measure. The risk does not come from MCP itself. It comes from how teams adopt it. Someone finds a server that solves a problem, pastes a snippet into .cursor/mcp.json , commits it, and moves on. Six weeks later nobody remembers who added it, which version it pulls, or what token it carries. Cursor gives admins more than one place to manage this. The Team Marketplace accepts MCP servers added directly through Add MCP Server , with no Git repository involved, according to a Cursor forum answer from October 2026. Cloud Agents https://shattered.io/build-cursor-cloud-agents-2026/ handle custom MCP separately from the desktop configuration. Cursor’s enterprise page reportedly describes admin controls for models, MCP, and agent rules. Nirmata’s guide to allowlisting MCP servers in Cursor https://nirmata.com/2026/11/03/how-to-allowlist-mcp-servers-in-cursor/ , published November 3, 2026, covers the in-product allowlist control. The project below covers the other half, which is the config files that land in your repositories before anyone reaches those settings. Three Failure Modes Worth Testing For Each check in the tool maps to a failure that happens in real repositories. - Unpinned code. A line such as npx -y some-package downloads the newest release every time the server starts. A compromised publish reaches every developer on the next launch, and nothing in your git history shows it. - Committed credentials. An env block with a literal token turns a convenience into a leak. Once the file is pushed, rotating the token is the only fix. - Unbounded tool authority. A server that exposes a delete or drop tool is one successful prompt injection https://shattered.io/category/ai-machine-learning/ away from using it. You cannot remove the tool from a third-party server, but you can log it, flag it, and block it at the hook layer. Each of those gets a matching control in the project, plus an allowlist that answers the question reviewers ask first: is this server supposed to be here at all? Prerequisites and Tested Versions You need less than most tutorials in this space ask for. The auditor and the hook are standard-library Python, so there is no virtual environment to maintain and no package to pin. - Python 3.10 or newer. The examples ran on Python 3.12.3 on Linux. Python 3.10 is the floor because it matches the minimum the official MCP server guide asks for when you write servers with the Python SDK, and keeping one interpreter version across tools simplifies CI. - Cursor desktop, current build. Any recent build with MCP enabled works. Nothing here depends on a specific Cursor version number, and Cursor ships changes quickly enough that a pinned version here would be stale within weeks. - Git and a GitHub repository. Step 12 uses GitHub Actions https://shattered.io/cursor-cli-github-actions-headless-agent-2026/ . The same two commands port to GitLab CI or Jenkins without changes. - A shell. Bash or zsh on Linux and macOS. On Windows, use WSL or replace python3 with python in the hook command. - Node.js LTS optional . Only needed if your servers launch through npx . The auditor never runs a server, so you can complete the tutorial without it. - Cursor CLI optional . Useful for the rollout section, where you confirm the CLI sees the same servers as the editor. The coding and dev tools archive https://shattered.io/category/software/ holds the Cursor, Copilot, and editor comparisons if you are still choosing an editor. Budget about 60 minutes. Steps 1 to 9 take roughly 40 minutes if you type the code, and far less if you paste it. The hook and CI steps take the rest. One caveat belongs here instead of buried in a footnote. The hook script was tested by piping synthetic JSON payloads into it, not by running a live Cursor session. The event names, payload fields, and response shape it assumes could not be checked against a live session, so Step 10 includes a one-minute payload dump that lets you confirm them on your own build before you rely on the log. Where Cursor Reads MCP Configuration Before auditing anything, map the places Cursor MCP servers can come from. Cursor documents project-level configuration at .cursor/mcp.json and user-level configuration at ~/.cursor/mcp.json . The project file travels with the repository. The user file stays on one machine and never shows up in a pull request. A stdio server entry carries a command , optional args , and optional env . A remote entry carries a url and optional headers . Cursor’s configuration reportedly supports an environment file for stdio servers, and its docs describe interpolation of values such as ${env:NAME} , so a secret can live in your shell or a git-ignored file instead of the JSON. Test the interpolation syntax on your own build, since Cursor changes its config handling often. For servers that do not support OAuth 2.0 Dynamic Client Registration, Cursor still accepts static credentials in the config, which means a client ID and, for confidential clients, a client secret can end up in the file too. A side-by-side look at config formats across coding agents https://agentstacklist.com/guides/how-to-add-an-mcp-server/ shows why a Cursor-specific audit is worth writing: each agent stores its servers differently, so a generic scanner misses fields. If you want the basics of adding servers through the editor first, the walkthrough on how to set up Cursor MCP servers https://shattered.io/set-up-cursor-mcp-servers-2026/ covers the Customize page and the Marketplace. | Surface | Where it lives | Who controls it | Covered by this audit | |---|---|---|---| | Project file | .cursor/mcp.json in the repo | Anyone with commit access | Yes, by mcp audit.py in CI | | User file | ~/.cursor/mcp.json | Each developer | Yes, locally with --include-user | | Team Marketplace | Added through Add MCP Server in team settings | Team admins | No, reconcile by hand against the allowlist | | Cloud Agents | Custom MCP configured apart from the desktop config | Whoever sets up the agent | No, review by hand | | Cursor CLI | Reads the project’s mcp.json | Same as the project file | Yes, same file | The table also shows the limit of any file-based audit. Two of the five surfaces never touch your repository. The rollout section below shows how to cover them with process instead of code. Steps 1 and 2: Scaffold the Project and a Flawed mcp.json Step 1. Create the project. Start with an empty folder and the four directories the project needs. The .cursor directory holds both the config under test and the hook, so the layout mirrors what a real repository looks like. mkdir -p mcp-guard/{.cursor/hooks,tests,.github/workflows} cd mcp-guard git init touch mcp audit.py mcp-allowlist.json Step 2. Write a config that fails on purpose. Testing a scanner against clean input proves nothing. This mcp.json contains six servers and seeds every problem the tool will learn to detect. All hostnames use example.com , and the GitHub token is a made-up string that matches the token pattern without being a real credential. { "mcpServers": { "github": { "command": "npx", "args": "-y", "@acme/github-mcp" , "env": { "GITHUB TOKEN": "ghp EXAMPLEEXAMPLEEXAMPLEEXAMPLE123456" } }, "docs": { "url": "http://mcp.internal.example.com/mcp" }, "weather": { "command": "python3", "args": "/opt/mcp/weather/server.py" }, "db": { "command": "bash", "args": "-c", "curl -s https://install.example.com/pg.sh | sh && npx pg-mcp" }, "tickets": { "url": "https://mcp.example.com/mcp", "headers": { "Authorization": "Bearer ${env:TICKETS TOKEN}" } }, "files": { "command": "npx", "args": "-y", "@acme/ email protected ", "~" } } } Read the six entries as a story. The github server pulls an unpinned package and carries a literal token. The docs server speaks plain HTTP to an internal host. The weather server runs a local script and is the only one that looks innocent. The db server pipes a downloaded script into a shell. The tickets server uses an interpolated bearer token, which is the correct pattern. The files server pins its version but passes ~ as an argument, which hands the whole home directory to a tool. A good audit flags four of these and leaves weather and tickets alone. Keep that expectation in mind. The line between true and false positives is where most scanners lose their users, so the checks in Step 5 stay narrow on purpose. Steps 3 and 4: Discover Configs and Fingerprint Each Server Step 3. Find every config file. Open mcp audit.py and start with the imports, the severity scale, and the pattern constants. Three constants carry most of the weight. SECRET KEY matches variable names that usually hold credentials. SECRET VALUE matches the shape of common token formats. INTERPOLATED recognizes references such as ${env:GITHUB TOKEN} so the scanner does not flag the safe pattern. bash /usr/bin/env python3 """Audit Cursor mcp.json files against an allowlist and basic hygiene rules.""" import argparse import json import re import sys from pathlib import Path from urllib.parse import urlparse SEVERITY = {"low": 1, "medium": 2, "high": 3} SKIP DIRS = {"node modules", ".git", ".venv", "venv", "dist", "build"} SECRET KEY = re.compile r" token|secret|password|passwd|api - ?key|authorization|bearer ", re.I SECRET VALUE = re.compile r" ghp A-Za-z0-9 {20,}|github pat A-Za-z0-9 {20,}|sk- A-Za-z0-9 - {20,}|" r"xox abp - A-Za-z0-9- {10,}|AKIA 0-9A-Z {16}|eyJ A-Za-z0-9 - {10,}\. A-Za-z0-9 - {10,} " INTERPOLATED = re.compile r"^\$\{ env: ? A-Za-z A-Za-z0-9 \}$|^Bearer \$\{ env: ? A-Za-z A-Za-z0-9 \}$" SHELLS = {"sh", "bash", "zsh", "dash", "cmd", "cmd.exe", "powershell", "pwsh"} RUNNERS = {"npx", "bunx", "pnpx", "uvx", "pipx"} DOCKER VALUE FLAGS = {"-e", "--env", "-v", "--volume", "-p", "--publish", "--name", "--network", "-w", "--workdir", "--user", "-u", "--entrypoint"} Then add the discovery function. It walks the tree for any mcp.json whose parent directory is named .cursor , skips vendored folders, and optionally adds the user-level file. python def find configs root, include user : found = for path in Path root .rglob "mcp.json" : if path.parent.name = ".cursor": continue if any part in SKIP DIRS for part in path.parts : continue found.append path if include user: user = Path.home / ".cursor" / "mcp.json" if user.is file : found.append user return sorted found The SKIP DIRS filter matters more than it looks. A repository that vendors a Node project can contain hundreds of unrelated mcp.json files inside node modules , and scanning them buries real findings under noise. Step 4. Reduce each server to a fingerprint. An allowlist needs a stable identity for each server. The identity cannot be the key name in the JSON, because anyone can call a server weather and point it anywhere. Instead the tool builds a fingerprint from what actually runs. For a stdio server that is the command plus the first positional argument, such as npx:@acme/ or \ email protected\ https://shattered.io/cdn-cgi/l/email-protection python3:/opt/mcp/weather/server.py . For a remote server it is the hostname, such as host:mcp.example.com . Two helpers do the heavy lifting. is pinned accepts only exact semantic versions, so @latest , 1.2 , and a bare package name all count as unpinned. first positional skips flags like -y and, for Docker, skips the value that follows flags such as -e and -v so it lands on the image name. python def is pinned spec : if spec.startswith "@" : tail = spec 1: .split "@", 1 version = tail 1 if len tail == 2 else "" elif "==" in spec: version = spec.split "==", 1 1 elif "@" in spec: version = spec.split "@", 1 1 else: version = "" return bool re.match r"^v?\d+\.\d+\.\d+ -+ \w. + ?$", version def first positional command, args : args = str a for a in args if command == "docker": if "run" not in args: return None i = args.index "run" + 1 while i < len args : if args i in DOCKER VALUE FLAGS: i += 2 elif args i .startswith "-" : i += 1 else: return args i return None for a in args: if not a.startswith "-" : return a return None def fingerprint server : if "url" in server: host = urlparse str server "url" .hostname or "" return "host:" + host.lower command = Path str server.get "command", "" .name target = first positional command, server.get "args", return f"{command}:{target}" if target else command Fingerprints are deliberately blunt. They tell you which executable or host a config points at, and they say nothing about what that code does. That distinction returns in the pitfalls section. Step 5: Encode Seven Cursor MCP Security Checks Step 5. Write the rules. The core of the tool is audit server . It takes one server entry and returns a list of findings, each with a rule ID, a severity, and a message. Seven rules cover the failure modes from the first section, and each one fires on at least one seeded server in the flawed config. | ID | Severity | What it flags | Fires on in the flawed config | |---|---|---|---| | MCP001 | High | A literal secret in env , headers , or auth | github token | | MCP002 | Medium | An unpinned npx , uvx , or Docker image | github | | MCP003 | High | Cleartext http:// to a non-loopback host | docs | | MCP004 | High | A shell wrapper or a download piped into a shell | db | | MCP005 | High | A fingerprint missing from the allowlist | github , docs , db | | MCP006 | Low | A remote server with no headers or auth block | docs | | MCP007 | Medium | An argument that exposes / , ~ , or $HOME | files | Here are the design choices that keep the rules quiet on healthy configs. MCP001 skips any value that is a pure interpolation, so Bearer ${env:TICKETS TOKEN} passes. It also scans the auth block, because static OAuth credentials sit there rather than in env . MCP003 exempts localhost , 127.0.0.1 , and ::1 , since local development servers legitimately use plain HTTP. MCP006 is low severity because an OAuth-based remote server has no static headers and should not fail a build. Severity follows one question: could this finding hurt someone before a human notices? A literal token, a cleartext channel, and a shell pipeline can all do that on the first launch, so they rank high. An unpinned package is a slower risk that depends on a future bad release, so it ranks medium. A remote server with no visible credentials is only a prompt to check, so it ranks low. If your team disagrees with a ranking, edit the string in one place and the --fail-on threshold adjusts the CI behavior without touching anything else. python def audit server name, server, allowlist : findings = def add rule, severity, message : findings.append {"server": name, "rule": rule, "severity": severity, "message": message} fp = fingerprint server if allowlist is not None and fp not in allowlist: add "MCP005", "high", f"not in allowlist fingerprint {fp} " for field in "env", "headers", "auth" : for key, value in server.get field or {} .items : value = str value if not value or INTERPOLATED.match value : continue if SECRET VALUE.search value or SECRET KEY.search key : add "MCP001", "high", f"literal secret in {field}.{key}" if "url" in server: parsed = urlparse str server "url" loopback = parsed.hostname in {"localhost", "127.0.0.1", "::1"} if parsed.scheme = "https" and not loopback: add "MCP003", "high", f"cleartext transport: {parsed.scheme}://{parsed.hostname}" if not server.get "headers" and not server.get "auth" : add "MCP006", "low", "remote server with no headers or auth block, confirm OAuth sign-in" else: command = Path str server.get "command", "" .name args = str a for a in server.get "args", joined = " ".join args if command in SHELLS and any a in {"-c", "/c", "-Command", "-command"} for a in args : add "MCP004", "high", f"shell wrapper: {command} with inline script" elif re.search r"\b curl|wget \b. \|\s ba ?sh", joined : add "MCP004", "high", "download piped into a shell" if command in RUNNERS or command == "docker": target = first positional command, args if command == "docker": pinned = bool target and ":" in target.rsplit "/", 1 -1 and not target.endswith ":latest" else: pinned = bool target and is pinned target if not pinned: add "MCP002", "medium", f"unpinned package or image: {target or 'none found'}" for a in args: if a in {"/", "~", "${userHome}", "$HOME"}: add "MCP007", "medium", f"argument exposes a whole home or root directory: {a}" return findings Notice what the code does not do. It never launches a server, never resolves a package, and never touches the network. That makes it safe to run in CI on untrusted pull requests, and it finished the sample project in about 60 milliseconds. The same least-privilege thinking applies to servers you write, and the guide to building a secure MCP server https://shattered.io/build-secure-mcp-server-claude-2026/ covers it from the server side. Steps 6 and 7: Wire the Allowlist, Run the Audit Step 6. Add the allowlist and the command line. The allowlist is a plain JSON file where each entry holds a match string equal to a fingerprint and an owner field that records who answers for it. The owner field is not enforced by code. It exists so a reviewer who sees an unfamiliar entry knows whom to ask. { "allow": { "match": "npx:@acme/ email protected ", "owner": "platform-team" }, { "match": "python3:/opt/mcp/weather/server.py", "owner": "data-team" }, { "match": "host:mcp.example.com", "owner": "support-team" } } The remaining functions load the allowlist, audit each file, and return an exit code. Exit code 0 means pass, 1 means at least one finding at or above --fail-on , and 2 means the input could not be read, for example because of a JSON syntax error. Keeping those three outcomes distinct lets CI tell a policy failure from a broken file. python def audit file path, allowlist : data = json.loads Path path .read text servers = data.get "mcpServers", {} findings = for name, server in servers.items : findings.extend audit server name, server, allowlist return len servers , findings def load allowlist path : if not path: return None data = json.loads Path path .read text return {entry "match" for entry in data "allow" } def main argv=None : p = argparse.ArgumentParser description= doc p.add argument "--root", default="." p.add argument "--allowlist" p.add argument "--include-user", action="store true" p.add argument "--format", choices= "text", "json" , default="text" p.add argument "--fail-on", choices=list SEVERITY , default="high" args = p.parse args argv try: allowlist = load allowlist args.allowlist configs = find configs args.root, args.include user report, total servers = , 0 for cfg in configs: count, findings = audit file cfg, allowlist total servers += count for f in findings: f "file" = str cfg report.extend findings except json.JSONDecodeError, OSError, KeyError as exc: print f"mcp-audit: cannot read input: {exc}", file=sys.stderr return 2 if args.format == "json": print json.dumps {"files": len configs , "servers": total servers, "findings": report}, indent=2 else: print f"mcp-audit: {len configs } file s , {total servers} server s , {len report } finding s " for f in sorted report, key=lambda x: -SEVERITY x "severity" , x "server" : print f" {f 'severity' .upper :6} {f 'rule' } {f 'server' }: {f 'message' }" worst = max SEVERITY f "severity" for f in report , default=0 return 1 if worst = SEVERITY args.fail on else 0 if name == " main ": sys.exit main Step 7. Run it. Point the tool at the project root with the allowlist. The report sorts findings by severity first, so the fixes with the biggest payoff sit at the top. python3 mcp audit.py --root . --allowlist mcp-allowlist.json echo "exit=$?" The output from the flawed config: mcp-audit: 1 file s , 6 server s , 9 finding s HIGH MCP005 db: not in allowlist fingerprint bash:curl -s https://install.example.com/pg.sh | sh && npx pg-mcp HIGH MCP004 db: shell wrapper: bash with inline script HIGH MCP005 docs: not in allowlist fingerprint host:mcp.internal.example.com HIGH MCP003 docs: cleartext transport: http://mcp.internal.example.com HIGH MCP005 github: not in allowlist fingerprint npx:@acme/github-mcp HIGH MCP001 github: literal secret in env.GITHUB TOKEN MEDIUM MCP007 files: argument exposes a whole home or root directory: ~ MEDIUM MCP002 github: unpinned package or image: @acme/github-mcp LOW MCP006 docs: remote server with no headers or auth block, confirm OAuth sign-in The exit code is 1. Nine findings across four servers, and the two servers you would expect to pass, weather and tickets , are absent from the list. The db fingerprint prints the whole inline script, which makes the shell-wrapper problem obvious in the report itself. Run the same command without --allowlist and MCP005 disappears, leaving six findings and a clear picture of which problems exist independently of policy. That mode is useful when you adopt the tool in a repository that has no agreed list yet. Start there, fix the hygiene findings, then build the list from what remains. Read the report from the top down and fix in that order. Secrets come first, because a leaked token stays valuable to an attacker even after you delete the line, so rotate it at the provider as well as removing it from the file. Transport and shell-wrapper findings come next, since they change what the server can do on launch. Pinning and allowlist entries come last, because they are policy work that benefits from a conversation with the server’s owner rather than a quick edit. Steps 8 and 9: Fix the Config and Lock It With Tests Step 8. Repair the config. Pin the GitHub server to an exact version, move its token into an interpolated reference, drop the docs and db servers that failed on transport and shell wrapping, and remove the files server along with its ~ argument. The result is three servers, each of which maps to an allowlist entry. { "mcpServers": { "github": { "command": "npx", "args": "-y", "@acme/ email protected " , "env": { "GITHUB TOKEN": "${env:GITHUB TOKEN}" } }, "weather": { "command": "python3", "args": "/opt/mcp/weather/server.py" }, "tickets": { "url": "https://mcp.example.com/mcp", "headers": { "Authorization": "Bearer ${env:TICKETS TOKEN}" } } } } Add the pinned GitHub fingerprint to the allowlist, then rerun the audit against the fixed copy: mcp-audit: 1 file s , 3 server s , 0 finding s Zero findings and exit code 0. Two details are worth checking by hand. The tickets server keeps its Authorization header because the value is a pure interpolation. The github token moved out of the file entirely, so you now need to export GITHUB TOKEN in the shell that launches Cursor, or load it from a git-ignored environment file. Step 9. Pin the behavior with unit tests. A scanner that silently stops flagging things is worse than no scanner. Fourteen tests cover the version logic, the fingerprints, the individual rules, and the command line. Four of them guard against false positives, which is where regressions hurt adoption most: interpolated secrets pass, loopback HTTP passes, a clean config exits 0, and a pinned Docker image passes. python import json import subprocess import sys import tempfile import unittest from pathlib import Path ROOT = Path file .resolve .parent.parent sys.path.insert 0, str ROOT import mcp audit as m noqa: E402 class PinTests unittest.TestCase : def test pinned self : self.assertTrue m.is pinned "@acme/ email protected " self.assertTrue m.is pinned "tool==2.0.1" self.assertTrue m.is pinned " email protected " def test unpinned self : for spec in "@acme/x", "@acme/x@latest", "pkg", " email protected ", "tool =1.0" : self.assertFalse m.is pinned spec , spec class FingerprintTests unittest.TestCase : def test npx skips flags self : s = {"command": "npx", "args": "-y", "@acme/ email protected " } self.assertEqual m.fingerprint s , "npx:@acme/ email protected " def test docker skips value flags self : s = {"command": "docker", "args": "run", "-i", "--rm", "-e", "A=b", "ghcr.io/acme/x:1.0.0" } self.assertEqual m.fingerprint s , "docker:ghcr.io/acme/x:1.0.0" def test remote self : self.assertEqual m.fingerprint {"url": "https://MCP.Example.com/a"} , "host:mcp.example.com" class RuleTests unittest.TestCase : def rules self, server, allow=None : return {f "rule" for f in m.audit server "s", server, allow } def test interpolated secret is fine self : s = {"url": "https://a.example.com", "headers": {"Authorization": "Bearer ${env:TOKEN}"}} self.assertNotIn "MCP001", self.rules s def test literal secret flagged self : s = {"command": "python3", "args": "x.py" , "env": {"API KEY": "abc123"}} self.assertIn "MCP001", self.rules s def test static oauth secret flagged self : s = {"url": "https://a.example.com", "auth": {"CLIENT ID": "abc", "CLIENT SECRET": "hunter2"}} self.assertIn "MCP001", self.rules s def test loopback http allowed self : self.assertNotIn "MCP003", self.rules {"url": "http://localhost:8080/mcp"} def test docker latest flagged self : s = {"command": "docker", "args": "run", "-i", "acme/x:latest" } self.assertIn "MCP002", self.rules s def test docker pinned ok self : for image in "ghcr.io/acme/x:1.4.2", "ghcr.io/acme/x@sha256:" + "a" 64 : s = {"command": "docker", "args": "run", "-i", "--rm", image } self.assertNotIn "MCP002", self.rules s , image def test allowlist blocks unknown self : s = {"command": "python3", "args": "x.py" } self.assertIn "MCP005", self.rules s, allow={"python3:y.py"} self.assertNotIn "MCP005", self.rules s, allow={"python3:x.py"} class CliTests unittest.TestCase : def run cli self, config : with tempfile.TemporaryDirectory as d: cur = Path d, ".cursor" cur.mkdir cur / "mcp.json" .write text json.dumps config return subprocess.run sys.executable, str ROOT / "mcp audit.py" , "--root", d , capture output=True, text=True, def test clean config exits zero self : r = self.run cli {"mcpServers": {"w": {"command": "python3", "args": "/opt/w.py" }}} self.assertEqual r.returncode, 0, r.stdout def test bad config exits one self : r = self.run cli {"mcpServers": {"w": {"url": "http://evil.example.com"}}} self.assertEqual r.returncode, 1 if name == " main ": unittest.main Run the suite with the standard runner: python3 -m unittest discover -s tests -v test bad config exits one test audit.CliTests.test bad config exits one ... ok test clean config exits zero test audit.CliTests.test clean config exits zero ... ok test docker skips value flags test audit.FingerprintTests.test docker skips value flags ... ok test npx skips flags test audit.FingerprintTests.test npx skips flags ... ok test remote test audit.FingerprintTests.test remote ... ok test pinned test audit.PinTests.test pinned ... ok test unpinned test audit.PinTests.test unpinned ... ok test allowlist blocks unknown test audit.RuleTests.test allowlist blocks unknown ... ok test docker latest flagged test audit.RuleTests.test docker latest flagged ... ok test docker pinned ok test audit.RuleTests.test docker pinned ok ... ok test interpolated secret is fine test audit.RuleTests.test interpolated secret is fine ... ok test literal secret flagged test audit.RuleTests.test literal secret flagged ... ok test loopback http allowed test audit.RuleTests.test loopback http allowed ... ok test static oauth secret flagged test audit.RuleTests.test static oauth secret flagged ... ok ---------------------------------------------------------------------- Ran 14 tests in 0.092s OK Add one test every time you tune a rule. When someone reports that MCP001 flagged a harmless variable called TOKEN URL , write the failing test first, then change SECRET KEY . The suite keeps the tool from drifting toward either extreme. Steps 10 and 11: Log Every MCP Call With a Cursor Hook The static audit answers what could run. The hook answers what did run. Cursor’s hooks let you run your own script around agent actions, and the same release notes that improved MCP connection status in early November 2026 added a tool use id to shell and MCP execution hooks. That ID matches the one on the preToolUse event, so a single tool call can be followed from start to finish across hook events. For a full tour of the hook system itself, the Cursor hooks tutorial https://shattered.io/cursor-hooks-tutorial-guard-agent-12-steps-2026/ covers events beyond MCP. Step 10. Add the hook and check the payload. The hook config registers one script for the events before and after an MCP call. Place it at .cursor/hooks.json . { "version": 1, "hooks": { "beforeMCPExecution": { "command": "python3 .cursor/hooks/mcp audit log.py before" } , "afterMCPExecution": { "command": "python3 .cursor/hooks/mcp audit log.py after" } } } Before trusting the script, confirm the payload fields on your build. Temporarily swap the command for a one-line dump, restart Cursor, trigger any MCP tool from the agent, and read the file. { "command": "cat /tmp/cursor-mcp-payload.json" } cat /tmp/cursor-mcp-payload.json Check that the JSON contains tool name , tool input , tool use id , and either url or command . If a field is named differently, change the matching payload.get ... line in the script. Compare the event names in hooks.json and the allow or deny response shape with Cursor’s hooks documentation too. Then restore the real command. The check takes a few minutes and removes the assumptions that synthetic testing could not verify. The script itself does four jobs. It hashes the tool input instead of storing it, so the log proves what was sent without copying secrets into a second file. It records the server, the tool, and the tool use id . It flags any tool whose name matches a deny pattern covering words like delete and drop. And when enforcement is on, it answers the before event with a deny verdict and a message for both the user and the agent. bash /usr/bin/env python3 """Cursor MCP hook: append one JSON line per MCP call, optionally deny risky tool names.""" import hashlib import json import os import re import sys import time from collections import Counter from pathlib import Path LOG = Path os.environ.get "MCP AUDIT LOG", ".cursor/audit/mcp-calls.jsonl" DENY = re.compile os.environ.get "MCP DENY TOOLS", r" delete|drop|truncate|destroy|wipe " , re.I ENFORCE = os.environ.get "MCP AUDIT ENFORCE", "0" == "1" def record event, payload : raw input = payload.get "tool input", "" raw input = raw input if isinstance raw input, str else json.dumps raw input, sort keys=True return { "ts": time.strftime "%Y-%m-%dT%H:%M:%S%z" , "event": event, "tool use id": payload.get "tool use id" , "tool": payload.get "tool name" , "server": payload.get "url" or payload.get "command" , "input sha256": hashlib.sha256 raw input.encode .hexdigest :16 , "input bytes": len raw input , } def handle event : payload = json.load sys.stdin entry = record event, payload verdict = {"permission": "allow"} if event == "before" and DENY.search entry "tool" or "" : entry "flagged" = True if ENFORCE: verdict = { "permission": "deny", "user message": f"Blocked MCP tool {entry 'tool' } by audit policy", "agent message": "This tool name is on the deny list. Ask the user to run it manually.", } entry "decision" = verdict "permission" LOG.parent.mkdir parents=True, exist ok=True with LOG.open "a" as fh: fh.write json.dumps entry + "\n" if event == "before": print json.dumps verdict return 0 def summarize : if not LOG.exists : print "no log yet" return 0 rows = json.loads line for line in LOG.read text .splitlines if line.strip calls = Counter r "server" , r "tool" for r in rows if r "event" == "before" flagged = sum 1 for r in rows if r.get "flagged" print f"{sum calls.values } call s , {flagged} flagged, {len calls } distinct server/tool pair s " for server, tool , n in calls.most common : print f" {n:3} {server} {tool}" return 0 if name == " main ": mode = sys.argv 1 if len sys.argv 1 else "before" sys.exit summarize if mode == "summary" else handle mode Step 11. Test log-only mode, then enforce mode. Pipe synthetic payloads into the script to see both behaviors without opening Cursor. The first call is a harmless search. The second is a flagged tool in log-only mode. The third repeats the flagged call with enforcement switched on. export MCP AUDIT LOG=$PWD/.cursor/audit/mcp-calls.jsonl echo '{"tool use id":"tu 01","tool name":"search tickets","tool input":"{\"q\":\"refund\"}","url":"https://mcp.example.com/mcp"}' | python3 .cursor/hooks/mcp audit log.py before echo '{"tool use id":"tu 02","tool name":"drop table","tool input":"{\"t\":\"users\"}","command":"npx @acme/ email protected "}' | python3 .cursor/hooks/mcp audit log.py before echo '{"tool use id":"tu 03","tool name":"drop table","tool input":"{\"t\":\"users\"}","command":"npx @acme/ email protected "}' | MCP AUDIT ENFORCE=1 python3 .cursor/hooks/mcp audit log.py before python3 .cursor/hooks/mcp audit log.py summary bash $ python3 .cursor/hooks/mcp audit log.py before tu 01, search tickets {"permission": "allow"} $ ... before tu 02, drop table, log-only {"permission": "allow"} $ ... before tu 03, drop table, MCP AUDIT ENFORCE=1 {"permission": "deny", "user message": "Blocked MCP tool drop table by audit policy", "agent message": "This tool name is on the deny list. Ask the user to run it manually."} $ python3 .cursor/hooks/mcp audit log.py summary 3 call s , 2 flagged, 2 distinct server/tool pair s 2 npx @acme/ email protected drop table 1 https://mcp.example.com/mcp search tickets $ tail -n 1 .cursor/audit/mcp-calls.jsonl {"ts": "2026-11-06T23:13:32+0000", "event": "before", "tool use id": "tu 03", "tool": "drop table", "server": "npx @acme/ email protected ", "input sha256": "fdb58e4b9f40884c", "input bytes": 13, "flagged": true, "decision": "deny"} The first two calls return an allow verdict, but the second one is marked flagged in the log. The third returns a deny with messages Cursor can show the user and pass back to the agent. The summary groups calls by server and tool, which is usually the first report a security team asks for. Ship in log-only mode first. Read the summary after a week, tune the deny pattern against the tools your servers expose, and only then set MCP AUDIT ENFORCE=1 . Turning on blocking before you know your traffic is the fastest way to teach developers to disable the hook. Think about retention before the log grows. The file holds tool names, server identifiers, hashes, and timestamps, which is far less sensitive than raw arguments, but it still describes how people work. Decide who can read it, rotate it on a schedule, and keep it out of git by adding .cursor/audit/ to .gitignore . Teams that centralize logs can ship the JSON lines to whatever collector they already run, since each line is a self-contained record with the tool use id as a join key. Step 12: Gate Pull Requests in CI Step 12. Run the audit on every change to MCP config. The workflow triggers only when an MCP config, the allowlist, or the tool changes, so it costs nothing on unrelated pull requests. It runs the tests first, which protects the scanner, then the audit, which protects the repository. name: mcp-audit on: pull request: paths: - " /.cursor/mcp.json" - "mcp-allowlist.json" - "mcp audit.py" jobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - run: python3 -m unittest discover -s tests -v - run: python3 mcp audit.py --root . --allowlist mcp-allowlist.json --fail-on high Three settings deserve a second look. The path filter /.cursor/mcp.json catches nested configs in monorepos, because the discovery code walks the whole tree. The --fail-on high flag blocks merges on secrets, cleartext transport, shell wrappers, and allowlist misses while letting medium and low findings appear as annotations a reviewer can weigh. And because the audit reads files without executing anything, running it on pull requests from forks does not expose repository secrets to untrusted code. Mark the audit job as a required status check in your branch protection rules. Without that step the check is advisory, and advisory checks lose to deadlines. Plan for exceptions before the first angry message arrives. Some teams will have a legitimate reason to run an unpinned or cleartext internal server for a week. Give them a path that leaves a trail: an allowlist entry reviewed by the security owner, with the reason in the pull request description, and an expiry date written into the entry as an extra JSON key that the code ignores today and a cleanup script can read later. A visible exception with an owner is a much better outcome than a developer quietly disabling the job. To get machine-readable output for dashboards or a pull request comment bot, add --format json . The JSON carries the file path, server name, rule ID, severity, and message for every finding, so you can group by rule over time and see whether the team is getting cleaner or just quieter. Roll Out Across Marketplace, Cloud Agents, and the CLI A passing CI job covers the files that define your Cursor MCP servers. The other surfaces from the earlier table need process. Team Marketplace. Admins can add an MCP-only server straight through Add MCP Server without a repository, so nothing in git records the change. Treat the allowlist file as the source of truth anyway. Whenever an admin adds a server in the UI, they open a pull request that adds the matching fingerprint and owner. Reviewers then see the same entry in both places. Cursor has also reportedly kept broader plugin bundles on a Git-backed path, so those still pass through a repository you can scan. Cloud Agents. Cloud Agents take custom MCP configuration separately from the desktop app, so a clean mcp.json says nothing about what a cloud run can reach. Review that configuration on the same schedule as the file. Cursor’s release notes also reportedly say cloud agents now cap the number of MCP tools sent to a model when a server exposes more than the model accepts, prioritizing browser and custom tools. A server with a very long tool list can therefore behave differently in the cloud than on your laptop. Cursor CLI. The CLI reads the project’s mcp.json , so the same file and the same CI gate apply. Run the CLI’s MCP listing command the docs describe an agent mcp list command, and agent mcp --help shows what your build supports and compare its output with the allowlist. If the lists disagree, a user-level config is adding servers you have not audited. User-level files. Developers can run python3 mcp audit.py --root . --include-user on their own machines. Nothing forces them to, so pair the command with a short onboarding note. The aim is not surveillance. It is catching a pasted token before it travels from the user file into a project file. Tell developers why the checks exist before the first failure lands. A short message in the team channel that explains the three failure modes, links to the allowlist, and names the owner of the process prevents most of the friction. People accept a blocked pull request when the reason is specific and the fix takes a minute, and they resent one that appears with no explanation. | Week | Hook mode | CI gate | What to watch | |---|---|---|---| | 1 | Log only | Report, do not block | Which servers and tools the team really uses | | 2 | Log only | Block on high | False positives from MCP001 and MCP005 | | 3 | Enforce on delete-class names | Block on high | Denied calls and developer complaints | | 4 and later | Enforce | Block on high, annotate medium | New servers requested per month | Common Pitfalls These are the mistakes that make an audit feel complete while leaving the risk in place. 1. Treating the allowlist as proof of safety. A fingerprint says which package or host a config points at. It does not say the package is clean. An allowlisted npx:@acme/ \ email protected\ https://shattered.io/cdn-cgi/l/email-protection can still be a compromised release. Add a lockfile, a registry mirror, or a review of the package source before an entry goes on the list. 2. Pinning the tag but not the digest. For Docker servers, a version tag can move. The tool counts image:1.4.2 as pinned, which is a reasonable floor. For high-value servers, pin the image by its sha256 digest, which the pin check also accepts because the colon in the digest satisfies the test. 3. Scanning only the project file. The user-level ~/.cursor/mcp.json and the Cloud Agents configuration are invisible to CI. Cover them with the local flag and the review cadence described above. 4. Flipping on enforcement first. A hook that blocks calls before you understand your traffic generates tickets and then gets removed. Run log-only for at least one week. 5. Logging raw tool input. Tool arguments often contain customer data or tokens. The hook stores a hash and a byte count for a reason. If you need raw payloads for debugging, write them to a short-lived local file you delete afterward. 6. Ignoring server-side drift. The audit watches the client config. If you also maintain servers, a protocol change can break them silently. The specification now marks Roots, Sampling, and Logging as deprecated, so a custom server that leans on them needs attention, and the guide on migrating an MCP server to SDK v2 https://shattered.io/migrate-mcp-server-sdk-v2-python-typescript-2026/ covers that work. 7. Trusting a Marketplace listing to behave like your JSON. One November 2026 forum report described a Marketplace server acting differently from the same server configured through mcp.json , with transport or schema differences. Test both paths if you use both. Troubleshooting Each item lists the symptom first and the fix second. - The audit reports 0 files. The tool only counts files named mcp.json inside a directory named .cursor , and it skips node modules , .git , .venv , venv , dist , and build . Check the spelling of the folder and run find . -name mcp.json to see what exists. - Exit code 2 and a JSON error. mcp.json must be strict JSON. A trailing comma or a comment is enough to break parsing. Run python3 -m json.tool .cursor/mcp.json to see the exact line. - MCP001 flags a harmless variable. The key pattern is broad on purpose, so a name like TOKEN URL with a plain URL value trips it. Either interpolate the value, rename the variable, or tighten SECRET KEY and add a regression test. - A pinned npx server still shows MCP005. The fingerprint takes the first argument that does not start with a dash. Flags that take a separate value, for instance a package option followed by a name, shift the result. Use only -y before the package, or write the allowlist entry to match what the report prints. - A Docker server fingerprints as docker:run . An option that takes a value is missing from DOCKER VALUE FLAGS , so the value is read as the image. Add the flag to the set and cover it with a test. - The hook never fires. Confirm the file is .cursor/hooks.json at the project root, that the JSON is valid, and that you restarted Cursor after editing. Run the command by hand from the project root with a sample payload. Use an absolute path to the script while you debug. - The hook runs but the log stays empty. The default log path is relative, so it resolves against whatever directory Cursor starts the hook in. Set MCP AUDIT LOG to an absolute path and confirm the folder is writable. - Enforce mode denies nothing. The environment variable may not reach the hook process. Set it inline in the hook command, for example MCP AUDIT ENFORCE=1 python3 .cursor/hooks/mcp audit log.py before , on systems where hook commands run through a shell. Then check the deny pattern against real tool names in the log. - A server sticks on Connecting or OAuth never finishes. Cursor’s early-November release notes reportedly show a Connecting state instead of a misleading zero-tools message, apply edits to mcp.json made during a connection, and allow up to 15 minutes for browser sign-in. If the provider rejects localhost redirects, the same notes reportedly describe a Cursor-hosted callback option. - The CLI ignores a form request from a server. A tracker page reported on November 6 that the CLI fails to display MCP form elicitation requests https://aicatchup.com/tracker/cursor and declines them automatically. Use the editor for servers that depend on elicitation until the report is resolved. - CI fails but the local run passes. CI does not have your user-level file or your exported variables. Run locally without --include-user and compare. Also check that the Python version in the workflow matches the one you tested. Advanced Tips Once the baseline works, a few additions raise the ceiling without much code. Match the Allowlist to Review Ownership Add a CODEOWNERS entry for mcp-allowlist.json and .cursor/mcp.json so a member of your security group must approve any change. The owner field in each allowlist entry then names the team that answers for the server’s behavior, and the two layers reinforce each other. Diff Against the Base Branch Teams with many legacy servers cannot fix everything at once. Run the audit on both the base branch and the pull request, then fail only on findings that are new. That lets you block regressions today while a cleanup backlog shrinks over time. A short script that compares the two JSON reports by server name and rule ID is enough. Extend the Rules for Your Stack Add rules for what your team actually does. Flag any server whose command points at a path inside the repository, since a committed binary skips package review. Flag any remote host outside your company’s domains. Flag env keys that disable TLS verification. Each rule is one conditional in audit server and one test. Harden the Servers You Write The audit is a client-side control. If you publish servers, apply least privilege on the server side too. Expose read-only tools by default, validate every argument, and avoid shelling out. Those patterns pair well with a client that logs everything. Correlate Hooks With Cloud Runs The tool use id links the before and after events for a single call. Join the two events in your log on that ID to compute call duration and spot tools that hang. Because the field matches the ID on the preToolUse event, you can also join MCP calls to the rest of an agent’s tool activity if you log that event as well. FAQ Where does Cursor store MCP configuration? Project-level servers live in .cursor/mcp.json inside the repository, and user-level servers live in ~/.cursor/mcp.json . Team Marketplace entries and Cloud Agents configuration are managed in Cursor’s settings instead, so they do not appear in either file. Does this audit cover servers added through the Team Marketplace or Cloud Agents? No. The tool reads files, and those surfaces are configured elsewhere. Cover them by requiring a pull request that adds the matching allowlist entry whenever an admin adds a server, and by reviewing the Cloud Agents configuration on a regular schedule. How do I keep secrets out of mcp.json? Reference environment variables with the ${env:NAME} interpolation that Cursor documents, and confirm it on your build, or point the server at a git-ignored environment file. For OAuth servers that support dynamic client registration, let Cursor handle sign-in and store nothing in the file. When a provider forces static credentials, the auth block still goes through MCP001, so a literal client secret fails the build. Is pinning a version enough to make a server safe? No. Pinning makes the code reproducible and stops silent upgrades, but it does not make the code trustworthy. Combine pinning with a source review, a lockfile or digest, and the runtime log. Will the hook slow Cursor down? Each MCP call starts one short Python process that writes one line to a file. Measure it with time on your own machine, since the cost depends on your interpreter and disk. If the overhead matters, rewrite the hook as a small compiled binary and keep the same input and output format. Do I need the MCP SDK to run this? No. The auditor and the hook use only the standard library. The SDK matters when you write servers. The official guide for Python servers asks for Python 3.10 or higher and the Python MCP SDK 2.0.0 or higher. Which MCP specification version should my servers target? The current specification is 2026-07-28 , and the official documentation lists Roots, Sampling, and Logging as deprecated in it. Test any server you maintain against clients that speak that version, and use the MCP Inspector from the official build guide for the check. Can I reuse this for other coding agents? The rules carry over, but the discovery code does not. Other agents store servers in different files and formats, so you would change find configs and the field names while keeping the fingerprint, allowlist, and rule engine as they are.