{"slug": "audit-cursor-mcp-servers-12-steps-60-min-2026", "title": "Audit Cursor MCP Servers: 12 Steps, 60 Min [2026]", "summary": "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.", "body_md": "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.\n\nA 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.\n\nThe 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.\n\nDon't miss new tech stories on Google\n\nAdd Shattered once in the Google app and our stories show up in your news suggestions.\n\n[Add now](https://www.google.com/preferences/source?hl=en&q=https%3A%2F%2Fshattered.io%2F)\n\n## Why Cursor MCP Servers Need an Audit\n\nAn 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.\n\nThat 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.\n\nCursor 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.\n\n### Three Failure Modes Worth Testing For\n\nEach check in the tool maps to a failure that happens in real repositories.\n\n- **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.\n- **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.\n- **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.\n\nEach 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?\n\n## Prerequisites and Tested Versions\n\nYou 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.\n\n- **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.\n- **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.\n- **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.\n- **A shell.** Bash or zsh on Linux and macOS. On Windows, use WSL or replace`python3` with`python` in the hook command.\n- **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.\n- **Cursor CLI (optional).** Useful for the rollout section, where you confirm the CLI sees the same servers as the editor.\n\nThe [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.\n\nOne 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.\n\n## Where Cursor Reads MCP Configuration\n\nBefore 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.\n\nA 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.\n\nA [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.\n\n| Surface | Where it lives | Who controls it | Covered by this audit | \n|---|---|---|---|\n| Project file | `.cursor/mcp.json` in the repo | Anyone with commit access | Yes, by `mcp_audit.py` in CI | \n| User file | `~/.cursor/mcp.json` | Each developer | Yes, locally with `--include-user` | \n| Team Marketplace | Added through `Add MCP Server` in team settings | Team admins | No, reconcile by hand against the allowlist | \n| Cloud Agents | Custom MCP configured apart from the desktop config | Whoever sets up the agent | No, review by hand | \n| Cursor CLI | Reads the project’s `mcp.json` | Same as the project file | Yes, same file | \n\nThe 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.\n\n## Steps 1 and 2: Scaffold the Project and a Flawed mcp.json\n\n**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.\n\n```\nmkdir -p mcp-guard/{.cursor/hooks,tests,.github/workflows}\ncd mcp-guard\ngit init\ntouch mcp_audit.py mcp-allowlist.json\n```\n\n**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.\n\n```\n{\n  \"mcpServers\": {\n    \"github\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@acme/github-mcp\"],\n      \"env\": { \"GITHUB_TOKEN\": \"ghp_EXAMPLEEXAMPLEEXAMPLEEXAMPLE123456\" }\n    },\n    \"docs\": {\n      \"url\": \"http://mcp.internal.example.com/mcp\"\n    },\n    \"weather\": {\n      \"command\": \"python3\",\n      \"args\": [\"/opt/mcp/weather/server.py\"]\n    },\n    \"db\": {\n      \"command\": \"bash\",\n      \"args\": [\"-c\", \"curl -s https://install.example.com/pg.sh | sh && npx pg-mcp\"]\n    },\n    \"tickets\": {\n      \"url\": \"https://mcp.example.com/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer ${env:TICKETS_TOKEN}\" }\n    },\n    \"files\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@acme/[email protected]\", \"~\"]\n    }\n  }\n}\n```\n\nRead 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.\n\nA 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.\n\n## Steps 3 and 4: Discover Configs and Fingerprint Each Server\n\n**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.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Audit Cursor mcp.json files against an allowlist and basic hygiene rules.\"\"\"\nimport argparse\nimport json\nimport re\nimport sys\nfrom pathlib import Path\nfrom urllib.parse import urlparse\n\nSEVERITY = {\"low\": 1, \"medium\": 2, \"high\": 3}\nSKIP_DIRS = {\"node_modules\", \".git\", \".venv\", \"venv\", \"dist\", \"build\"}\nSECRET_KEY = re.compile(r\"(token|secret|password|passwd|api[_-]?key|authorization|bearer)\", re.I)\nSECRET_VALUE = re.compile(\n    r\"(ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|\"\n    r\"xox[abp]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,})\"\n)\nINTERPOLATED = re.compile(r\"^\\$\\{(env:)?[A-Za-z_][A-Za-z0-9_]*\\}$|^Bearer \\$\\{(env:)?[A-Za-z_][A-Za-z0-9_]*\\}$\")\nSHELLS = {\"sh\", \"bash\", \"zsh\", \"dash\", \"cmd\", \"cmd.exe\", \"powershell\", \"pwsh\"}\nRUNNERS = {\"npx\", \"bunx\", \"pnpx\", \"uvx\", \"pipx\"}\nDOCKER_VALUE_FLAGS = {\"-e\", \"--env\", \"-v\", \"--volume\", \"-p\", \"--publish\", \"--name\", \"--network\", \"-w\", \"--workdir\", \"--user\", \"-u\", \"--entrypoint\"}\n```\n\nThen 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.\n\n``` python\ndef find_configs(root, include_user):\n    found = []\n    for path in Path(root).rglob(\"mcp.json\"):\n        if path.parent.name != \".cursor\":\n            continue\n        if any(part in SKIP_DIRS for part in path.parts):\n            continue\n        found.append(path)\n    if include_user:\n        user = Path.home() / \".cursor\" / \"mcp.json\"\n        if user.is_file():\n            found.append(user)\n    return sorted(found)\n```\n\nThe `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.\n\n**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`.\n\nTwo 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.\n\n``` python\ndef is_pinned(spec):\n    if spec.startswith(\"@\"):\n        tail = spec[1:].split(\"@\", 1)\n        version = tail[1] if len(tail) == 2 else \"\"\n    elif \"==\" in spec:\n        version = spec.split(\"==\", 1)[1]\n    elif \"@\" in spec:\n        version = spec.split(\"@\", 1)[1]\n    else:\n        version = \"\"\n    return bool(re.match(r\"^v?\\d+\\.\\d+\\.\\d+([-+][\\w.]+)?$\", version))\n\ndef first_positional(command, args):\n    args = [str(a) for a in args]\n    if command == \"docker\":\n        if \"run\" not in args:\n            return None\n        i = args.index(\"run\") + 1\n        while i < len(args):\n            if args[i] in DOCKER_VALUE_FLAGS:\n                i += 2\n            elif args[i].startswith(\"-\"):\n                i += 1\n            else:\n                return args[i]\n        return None\n    for a in args:\n        if not a.startswith(\"-\"):\n            return a\n    return None\n\ndef fingerprint(server):\n    if \"url\" in server:\n        host = urlparse(str(server[\"url\"])).hostname or \"\"\n        return \"host:\" + host.lower()\n    command = Path(str(server.get(\"command\", \"\"))).name\n    target = first_positional(command, server.get(\"args\", []))\n    return f\"{command}:{target}\" if target else command\n```\n\nFingerprints 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.\n\n## Step 5: Encode Seven Cursor MCP Security Checks\n\n**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.\n\n| ID | Severity | What it flags | Fires on in the flawed config | \n|---|---|---|---|\n| MCP001 | High | A literal secret in `env` ,`headers` , or`auth` | `github` (token) | \n| MCP002 | Medium | An unpinned `npx` ,`uvx` , or Docker image | `github` | \n| MCP003 | High | Cleartext `http://` to a non-loopback host | `docs` | \n| MCP004 | High | A shell wrapper or a download piped into a shell | `db` | \n| MCP005 | High | A fingerprint missing from the allowlist | `github` ,`docs` ,`db` | \n| MCP006 | Low | A remote server with no headers or auth block | `docs` | \n| MCP007 | Medium | An argument that exposes `/` ,`~` , or`$HOME` | `files` | \n\nHere 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.\n\nSeverity 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.\n\n``` python\ndef audit_server(name, server, allowlist):\n    findings = []\n\n    def add(rule, severity, message):\n        findings.append({\"server\": name, \"rule\": rule, \"severity\": severity, \"message\": message})\n\n    fp = fingerprint(server)\n    if allowlist is not None and fp not in allowlist:\n        add(\"MCP005\", \"high\", f\"not in allowlist (fingerprint {fp})\")\n\n    for field in (\"env\", \"headers\", \"auth\"):\n        for key, value in (server.get(field) or {}).items():\n            value = str(value)\n            if not value or INTERPOLATED.match(value):\n                continue\n            if SECRET_VALUE.search(value) or SECRET_KEY.search(key):\n                add(\"MCP001\", \"high\", f\"literal secret in {field}.{key}\")\n\n    if \"url\" in server:\n        parsed = urlparse(str(server[\"url\"]))\n        loopback = parsed.hostname in {\"localhost\", \"127.0.0.1\", \"::1\"}\n        if parsed.scheme != \"https\" and not loopback:\n            add(\"MCP003\", \"high\", f\"cleartext transport: {parsed.scheme}://{parsed.hostname}\")\n        if not server.get(\"headers\") and not server.get(\"auth\"):\n            add(\"MCP006\", \"low\", \"remote server with no headers or auth block, confirm OAuth sign-in\")\n    else:\n        command = Path(str(server.get(\"command\", \"\"))).name\n        args = [str(a) for a in server.get(\"args\", [])]\n        joined = \" \".join(args)\n        if command in SHELLS and any(a in {\"-c\", \"/c\", \"-Command\", \"-command\"} for a in args):\n            add(\"MCP004\", \"high\", f\"shell wrapper: {command} with inline script\")\n        elif re.search(r\"\\b(curl|wget)\\b.*\\|\\s*(ba)?sh\", joined):\n            add(\"MCP004\", \"high\", \"download piped into a shell\")\n        if command in RUNNERS or command == \"docker\":\n            target = first_positional(command, args)\n            if command == \"docker\":\n                pinned = bool(target) and \":\" in target.rsplit(\"/\", 1)[-1] and not target.endswith(\":latest\")\n            else:\n                pinned = bool(target) and is_pinned(target)\n            if not pinned:\n                add(\"MCP002\", \"medium\", f\"unpinned package or image: {target or 'none found'}\")\n        for a in args:\n            if a in {\"/\", \"~\", \"${userHome}\", \"$HOME\"}:\n                add(\"MCP007\", \"medium\", f\"argument exposes a whole home or root directory: {a}\")\n    return findings\n```\n\nNotice 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.\n\n## Steps 6 and 7: Wire the Allowlist, Run the Audit\n\n**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.\n\n```\n{\n  \"allow\": [\n    { \"match\": \"npx:@acme/[email protected]\", \"owner\": \"platform-team\" },\n    { \"match\": \"python3:/opt/mcp/weather/server.py\", \"owner\": \"data-team\" },\n    { \"match\": \"host:mcp.example.com\", \"owner\": \"support-team\" }\n  ]\n}\n```\n\nThe 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.\n\n``` python\ndef audit_file(path, allowlist):\n    data = json.loads(Path(path).read_text())\n    servers = data.get(\"mcpServers\", {})\n    findings = []\n    for name, server in servers.items():\n        findings.extend(audit_server(name, server, allowlist))\n    return len(servers), findings\n\ndef load_allowlist(path):\n    if not path:\n        return None\n    data = json.loads(Path(path).read_text())\n    return {entry[\"match\"] for entry in data[\"allow\"]}\n\ndef main(argv=None):\n    p = argparse.ArgumentParser(description=__doc__)\n    p.add_argument(\"--root\", default=\".\")\n    p.add_argument(\"--allowlist\")\n    p.add_argument(\"--include-user\", action=\"store_true\")\n    p.add_argument(\"--format\", choices=[\"text\", \"json\"], default=\"text\")\n    p.add_argument(\"--fail-on\", choices=list(SEVERITY), default=\"high\")\n    args = p.parse_args(argv)\n\n    try:\n        allowlist = load_allowlist(args.allowlist)\n        configs = find_configs(args.root, args.include_user)\n        report, total_servers = [], 0\n        for cfg in configs:\n            count, findings = audit_file(cfg, allowlist)\n            total_servers += count\n            for f in findings:\n                f[\"file\"] = str(cfg)\n            report.extend(findings)\n    except (json.JSONDecodeError, OSError, KeyError) as exc:\n        print(f\"mcp-audit: cannot read input: {exc}\", file=sys.stderr)\n        return 2\n\n    if args.format == \"json\":\n        print(json.dumps({\"files\": len(configs), \"servers\": total_servers, \"findings\": report}, indent=2))\n    else:\n        print(f\"mcp-audit: {len(configs)} file(s), {total_servers} server(s), {len(report)} finding(s)\")\n        for f in sorted(report, key=lambda x: (-SEVERITY[x[\"severity\"]], x[\"server\"])):\n            print(f\"  [{f['severity'].upper():6}] {f['rule']} {f['server']}: {f['message']}\")\n    worst = max((SEVERITY[f[\"severity\"]] for f in report), default=0)\n    return 1 if worst >= SEVERITY[args.fail_on] else 0\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n```\n\n**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.\n\n```\npython3 mcp_audit.py --root . --allowlist mcp-allowlist.json\necho \"exit=$?\"\n```\n\nThe output from the flawed config:\n\n```\nmcp-audit: 1 file(s), 6 server(s), 9 finding(s)\n  [HIGH  ] MCP005 db: not in allowlist (fingerprint bash:curl -s https://install.example.com/pg.sh | sh && npx pg-mcp)\n  [HIGH  ] MCP004 db: shell wrapper: bash with inline script\n  [HIGH  ] MCP005 docs: not in allowlist (fingerprint host:mcp.internal.example.com)\n  [HIGH  ] MCP003 docs: cleartext transport: http://mcp.internal.example.com\n  [HIGH  ] MCP005 github: not in allowlist (fingerprint npx:@acme/github-mcp)\n  [HIGH  ] MCP001 github: literal secret in env.GITHUB_TOKEN\n  [MEDIUM] MCP007 files: argument exposes a whole home or root directory: ~\n  [MEDIUM] MCP002 github: unpinned package or image: @acme/github-mcp\n  [LOW   ] MCP006 docs: remote server with no headers or auth block, confirm OAuth sign-in\n```\n\nThe 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.\n\nRun 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.\n\nRead 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.\n\n## Steps 8 and 9: Fix the Config and Lock It With Tests\n\n**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.\n\n```\n{\n  \"mcpServers\": {\n    \"github\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@acme/[email protected]\"],\n      \"env\": { \"GITHUB_TOKEN\": \"${env:GITHUB_TOKEN}\" }\n    },\n    \"weather\": {\n      \"command\": \"python3\",\n      \"args\": [\"/opt/mcp/weather/server.py\"]\n    },\n    \"tickets\": {\n      \"url\": \"https://mcp.example.com/mcp\",\n      \"headers\": { \"Authorization\": \"Bearer ${env:TICKETS_TOKEN}\" }\n    }\n  }\n}\n```\n\nAdd the pinned GitHub fingerprint to the allowlist, then rerun the audit against the fixed copy:\n\n```\nmcp-audit: 1 file(s), 3 server(s), 0 finding(s)\n```\n\nZero 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.\n\n**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.\n\n``` python\nimport json\nimport subprocess\nimport sys\nimport tempfile\nimport unittest\nfrom pathlib import Path\n\nROOT = Path(__file__).resolve().parent.parent\nsys.path.insert(0, str(ROOT))\nimport mcp_audit as m  # noqa: E402\n\nclass PinTests(unittest.TestCase):\n    def test_pinned(self):\n        self.assertTrue(m.is_pinned(\"@acme/[email protected]\"))\n        self.assertTrue(m.is_pinned(\"tool==2.0.1\"))\n        self.assertTrue(m.is_pinned(\"[email protected]\"))\n\n    def test_unpinned(self):\n        for spec in (\"@acme/x\", \"@acme/x@latest\", \"pkg\", \"[email protected]\", \"tool>=1.0\"):\n            self.assertFalse(m.is_pinned(spec), spec)\n\nclass FingerprintTests(unittest.TestCase):\n    def test_npx_skips_flags(self):\n        s = {\"command\": \"npx\", \"args\": [\"-y\", \"@acme/[email protected]\"]}\n        self.assertEqual(m.fingerprint(s), \"npx:@acme/[email protected]\")\n\n    def test_docker_skips_value_flags(self):\n        s = {\"command\": \"docker\", \"args\": [\"run\", \"-i\", \"--rm\", \"-e\", \"A=b\", \"ghcr.io/acme/x:1.0.0\"]}\n        self.assertEqual(m.fingerprint(s), \"docker:ghcr.io/acme/x:1.0.0\")\n\n    def test_remote(self):\n        self.assertEqual(m.fingerprint({\"url\": \"https://MCP.Example.com/a\"}), \"host:mcp.example.com\")\n\nclass RuleTests(unittest.TestCase):\n    def rules(self, server, allow=None):\n        return {f[\"rule\"] for f in m.audit_server(\"s\", server, allow)}\n\n    def test_interpolated_secret_is_fine(self):\n        s = {\"url\": \"https://a.example.com\", \"headers\": {\"Authorization\": \"Bearer ${env:TOKEN}\"}}\n        self.assertNotIn(\"MCP001\", self.rules(s))\n\n    def test_literal_secret_flagged(self):\n        s = {\"command\": \"python3\", \"args\": [\"x.py\"], \"env\": {\"API_KEY\": \"abc123\"}}\n        self.assertIn(\"MCP001\", self.rules(s))\n\n    def test_static_oauth_secret_flagged(self):\n        s = {\"url\": \"https://a.example.com\", \"auth\": {\"CLIENT_ID\": \"abc\", \"CLIENT_SECRET\": \"hunter2\"}}\n        self.assertIn(\"MCP001\", self.rules(s))\n\n    def test_loopback_http_allowed(self):\n        self.assertNotIn(\"MCP003\", self.rules({\"url\": \"http://localhost:8080/mcp\"}))\n\n    def test_docker_latest_flagged(self):\n        s = {\"command\": \"docker\", \"args\": [\"run\", \"-i\", \"acme/x:latest\"]}\n        self.assertIn(\"MCP002\", self.rules(s))\n\n    def test_docker_pinned_ok(self):\n        for image in (\"ghcr.io/acme/x:1.4.2\", \"ghcr.io/acme/x@sha256:\" + \"a\" * 64):\n            s = {\"command\": \"docker\", \"args\": [\"run\", \"-i\", \"--rm\", image]}\n            self.assertNotIn(\"MCP002\", self.rules(s), image)\n\n    def test_allowlist_blocks_unknown(self):\n        s = {\"command\": \"python3\", \"args\": [\"x.py\"]}\n        self.assertIn(\"MCP005\", self.rules(s, allow={\"python3:y.py\"}))\n        self.assertNotIn(\"MCP005\", self.rules(s, allow={\"python3:x.py\"}))\n\nclass CliTests(unittest.TestCase):\n    def run_cli(self, config):\n        with tempfile.TemporaryDirectory() as d:\n            cur = Path(d, \".cursor\")\n            cur.mkdir()\n            (cur / \"mcp.json\").write_text(json.dumps(config))\n            return subprocess.run(\n                [sys.executable, str(ROOT / \"mcp_audit.py\"), \"--root\", d],\n                capture_output=True, text=True,\n            )\n\n    def test_clean_config_exits_zero(self):\n        r = self.run_cli({\"mcpServers\": {\"w\": {\"command\": \"python3\", \"args\": [\"/opt/w.py\"]}}})\n        self.assertEqual(r.returncode, 0, r.stdout)\n\n    def test_bad_config_exits_one(self):\n        r = self.run_cli({\"mcpServers\": {\"w\": {\"url\": \"http://evil.example.com\"}}})\n        self.assertEqual(r.returncode, 1)\n\nif __name__ == \"__main__\":\n    unittest.main()\n```\n\nRun the suite with the standard runner:\n\n```\npython3 -m unittest discover -s tests -v\ntest_bad_config_exits_one (test_audit.CliTests.test_bad_config_exits_one) ... ok\ntest_clean_config_exits_zero (test_audit.CliTests.test_clean_config_exits_zero) ... ok\ntest_docker_skips_value_flags (test_audit.FingerprintTests.test_docker_skips_value_flags) ... ok\ntest_npx_skips_flags (test_audit.FingerprintTests.test_npx_skips_flags) ... ok\ntest_remote (test_audit.FingerprintTests.test_remote) ... ok\ntest_pinned (test_audit.PinTests.test_pinned) ... ok\ntest_unpinned (test_audit.PinTests.test_unpinned) ... ok\ntest_allowlist_blocks_unknown (test_audit.RuleTests.test_allowlist_blocks_unknown) ... ok\ntest_docker_latest_flagged (test_audit.RuleTests.test_docker_latest_flagged) ... ok\ntest_docker_pinned_ok (test_audit.RuleTests.test_docker_pinned_ok) ... ok\ntest_interpolated_secret_is_fine (test_audit.RuleTests.test_interpolated_secret_is_fine) ... ok\ntest_literal_secret_flagged (test_audit.RuleTests.test_literal_secret_flagged) ... ok\ntest_loopback_http_allowed (test_audit.RuleTests.test_loopback_http_allowed) ... ok\ntest_static_oauth_secret_flagged (test_audit.RuleTests.test_static_oauth_secret_flagged) ... ok\n\n----------------------------------------------------------------------\nRan 14 tests in 0.092s\n\nOK\n```\n\nAdd 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.\n\n## Steps 10 and 11: Log Every MCP Call With a Cursor Hook\n\nThe 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.\n\n**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`.\n\n```\n{\n  \"version\": 1,\n  \"hooks\": {\n    \"beforeMCPExecution\": [\n      { \"command\": \"python3 .cursor/hooks/mcp_audit_log.py before\" }\n    ],\n    \"afterMCPExecution\": [\n      { \"command\": \"python3 .cursor/hooks/mcp_audit_log.py after\" }\n    ]\n  }\n}\n```\n\nBefore 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.\n\n```\n{ \"command\": \"cat > /tmp/cursor-mcp-payload.json\" }\ncat /tmp/cursor-mcp-payload.json\n```\n\nCheck 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.\n\nThe 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.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"Cursor MCP hook: append one JSON line per MCP call, optionally deny risky tool names.\"\"\"\nimport hashlib\nimport json\nimport os\nimport re\nimport sys\nimport time\nfrom collections import Counter\nfrom pathlib import Path\n\nLOG = Path(os.environ.get(\"MCP_AUDIT_LOG\", \".cursor/audit/mcp-calls.jsonl\"))\nDENY = re.compile(os.environ.get(\"MCP_DENY_TOOLS\", r\"(delete|drop|truncate|destroy|wipe)\"), re.I)\nENFORCE = os.environ.get(\"MCP_AUDIT_ENFORCE\", \"0\") == \"1\"\n\ndef record(event, payload):\n    raw_input = payload.get(\"tool_input\", \"\")\n    raw_input = raw_input if isinstance(raw_input, str) else json.dumps(raw_input, sort_keys=True)\n    return {\n        \"ts\": time.strftime(\"%Y-%m-%dT%H:%M:%S%z\"),\n        \"event\": event,\n        \"tool_use_id\": payload.get(\"tool_use_id\"),\n        \"tool\": payload.get(\"tool_name\"),\n        \"server\": payload.get(\"url\") or payload.get(\"command\"),\n        \"input_sha256\": hashlib.sha256(raw_input.encode()).hexdigest()[:16],\n        \"input_bytes\": len(raw_input),\n    }\n\ndef handle(event):\n    payload = json.load(sys.stdin)\n    entry = record(event, payload)\n    verdict = {\"permission\": \"allow\"}\n    if event == \"before\" and DENY.search(entry[\"tool\"] or \"\"):\n        entry[\"flagged\"] = True\n        if ENFORCE:\n            verdict = {\n                \"permission\": \"deny\",\n                \"user_message\": f\"Blocked MCP tool {entry['tool']} by audit policy\",\n                \"agent_message\": \"This tool name is on the deny list. Ask the user to run it manually.\",\n            }\n    entry[\"decision\"] = verdict[\"permission\"]\n    LOG.parent.mkdir(parents=True, exist_ok=True)\n    with LOG.open(\"a\") as fh:\n        fh.write(json.dumps(entry) + \"\\n\")\n    if event == \"before\":\n        print(json.dumps(verdict))\n    return 0\n\ndef summarize():\n    if not LOG.exists():\n        print(\"no log yet\")\n        return 0\n    rows = [json.loads(line) for line in LOG.read_text().splitlines() if line.strip()]\n    calls = Counter((r[\"server\"], r[\"tool\"]) for r in rows if r[\"event\"] == \"before\")\n    flagged = sum(1 for r in rows if r.get(\"flagged\"))\n    print(f\"{sum(calls.values())} call(s), {flagged} flagged, {len(calls)} distinct server/tool pair(s)\")\n    for (server, tool), n in calls.most_common():\n        print(f\"  {n:3}  {server}  {tool}\")\n    return 0\n\nif __name__ == \"__main__\":\n    mode = sys.argv[1] if len(sys.argv) > 1 else \"before\"\n    sys.exit(summarize() if mode == \"summary\" else handle(mode))\n```\n\n**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.\n\n```\nexport MCP_AUDIT_LOG=$PWD/.cursor/audit/mcp-calls.jsonl\necho '{\"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\necho '{\"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\necho '{\"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\npython3 .cursor/hooks/mcp_audit_log.py summary\nbash\n$ python3 .cursor/hooks/mcp_audit_log.py before   # tu_01, search_tickets\n{\"permission\": \"allow\"}\n$ ... before   # tu_02, drop_table, log-only\n{\"permission\": \"allow\"}\n$ ... before   # tu_03, drop_table, MCP_AUDIT_ENFORCE=1\n{\"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.\"}\n$ python3 .cursor/hooks/mcp_audit_log.py summary\n3 call(s), 2 flagged, 2 distinct server/tool pair(s)\n    2  npx @acme/[email protected]  drop_table\n    1  https://mcp.example.com/mcp  search_tickets\n$ tail -n 1 .cursor/audit/mcp-calls.jsonl\n{\"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\"}\n```\n\nThe 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.\n\nShip 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.\n\nThink 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.\n\n## Step 12: Gate Pull Requests in CI\n\n**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.\n\n```\nname: mcp-audit\non:\n  pull_request:\n    paths:\n      - \"**/.cursor/mcp.json\"\n      - \"mcp-allowlist.json\"\n      - \"mcp_audit.py\"\njobs:\n  audit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - run: python3 -m unittest discover -s tests -v\n      - run: python3 mcp_audit.py --root . --allowlist mcp-allowlist.json --fail-on high\n```\n\nThree 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.\n\nMark 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.\n\nPlan 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.\n\nTo 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.\n\n## Roll Out Across Marketplace, Cloud Agents, and the CLI\n\nA passing CI job covers the files that define your Cursor MCP servers. The other surfaces from the earlier table need process.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\nTell 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.\n\n| Week | Hook mode | CI gate | What to watch | \n|---|---|---|---|\n| 1 | Log only | Report, do not block | Which servers and tools the team really uses | \n| 2 | Log only | Block on high | False positives from MCP001 and MCP005 | \n| 3 | Enforce on delete-class names | Block on high | Denied calls and developer complaints | \n| 4 and later | Enforce | Block on high, annotate medium | New servers requested per month | \n\n## Common Pitfalls\n\nThese are the mistakes that make an audit feel complete while leaving the risk in place.\n\n1. **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)\ncan 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.\n2. **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.\n3. **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.\n4. **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.\n5. **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.\n6. **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.\n7. **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.\n\n## Troubleshooting\n\nEach item lists the symptom first and the fix second.\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n\n## Advanced Tips\n\nOnce the baseline works, a few additions raise the ceiling without much code.\n\n### Match the Allowlist to Review Ownership\n\nAdd 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.\n\n### Diff Against the Base Branch\n\nTeams 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.\n\n### Extend the Rules for Your Stack\n\nAdd 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.\n\n### Harden the Servers You Write\n\nThe 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.\n\n### Correlate Hooks With Cloud Runs\n\nThe `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.\n\n## FAQ\n\n**Where does Cursor store MCP configuration?**\n\nProject-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.\n\n**Does this audit cover servers added through the Team Marketplace or Cloud Agents?**\n\nNo. 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.\n\n**How do I keep secrets out of mcp.json?**\n\nReference 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.\n\n**Is pinning a version enough to make a server safe?**\n\nNo. 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.\n\n**Will the hook slow Cursor down?**\n\nEach 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.\n\n**Do I need the MCP SDK to run this?**\n\nNo. 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.\n\n**Which MCP specification version should my servers target?**\n\nThe 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.\n\n**Can I reuse this for other coding agents?**\n\nThe 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.", "url": "https://wpnews.pro/news/audit-cursor-mcp-servers-12-steps-60-min-2026", "canonical_source": "https://shattered.io/audit-cursor-mcp-servers-allowlist-hooks-12-steps-2026/", "published_at": "2026-11-06 23:19:26+00:00", "updated_at": "2026-11-06 23:21:15.272098+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "ai-safety", "developer-tools"], "entities": ["Cursor", "Model Context Protocol", "Nirmata", "Shattered"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/audit-cursor-mcp-servers-12-steps-60-min-2026", "markdown": "https://wpnews.pro/news/audit-cursor-mcp-servers-12-steps-60-min-2026.md", "text": "https://wpnews.pro/news/audit-cursor-mcp-servers-12-steps-60-min-2026.txt", "jsonld": "https://wpnews.pro/news/audit-cursor-mcp-servers-12-steps-60-min-2026.jsonld"}}