cd /news/ai-agents/audit-cursor-mcp-servers-12-steps-60… · home › topics › ai-agents › article
[ARTICLE · art-165644] src=shattered.io ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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.

by read37 min views5 publishedNov 6, 2026
Audit Cursor MCP Servers: 12 Steps, 60 Min [2026]
Image: Shattered (auto-discovered)

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 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 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

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 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, 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 asnpx -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. Anenv 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 successfulprompt injection 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 usesGitHub Actions . 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 replacepython3 withpython in the hook command.
  • Node.js LTS (optional). Only needed if your servers launch throughnpx . 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 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 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 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.

#!/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.

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]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.

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 , orauth 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.

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 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.

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.

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 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.

#!/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 allowlistednpx:@acme/[email protected] 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 countsimage:1.4.2 as pinned, which is a reasonable floor. For high-value servers, pin the image by itssha256 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 onmigrating an MCP server to SDK v2 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 throughmcp.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 namedmcp.json inside a directory named.cursor , and it skipsnode_modules ,.git ,.venv ,venv ,dist , andbuild . Check the spelling of the folder and runfind . -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. Runpython3 -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 likeTOKEN_URL with a plain URL value trips it. Either interpolate the value, rename the variable, or tightenSECRET_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. SetMCP_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 exampleMCP_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 tomcp.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 CLIfails to display MCP form elicitation requests 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.

── more in #ai-agents 4 stories · sorted by recency
── more on @cursor 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/audit-cursor-mcp-ser…] indexed:0 read:37min 2026-11-06 · —