Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mcp-sentinel

mcp-sentinel

Installing an MCP server hands a third party a channel straight into your model's context. mcp-sentinel audits it before you trust it.

A deterministic security scanner for MCP (Model Context Protocol) servers: the servers a machine is configured to run, and the tools those servers expose. Checks for tool poisoning, hidden prompt injection, rug pulls, tool-name shadowing, unpinned supply-chain risk, secrets in config, and over-broad permissions.

detection rug-pull detection python selftest Claude Code skill license

On a 60-case hand-labeled benchmark covering all 14 detectors, with a clean twin and a deliberate near-miss per detector (legitimate-looking input designed to trip up a naive string matcher), mcp-sentinel caught 100% of malicious cases (26/26, zero missed) at 86.7% precision, with all 4 false positives traceable to specific, documented near-misses, not random noise. Locking a fixture, mutating its command and description, then rescanning caught 2/2 rug pulls. Run against the two real, currently-unpinned MCP servers already configured on the machine this was built on, it caught both, on the first run, with no tuning. Full numbers and every case: bench/results.md.

No API key. No network calls in the scan path. No LLM in the loop. Python 3 standard library only.

Why this exists

An MCP server's tool descriptions are read by the model on every turn, and its launch command runs with the user's privileges. MCP has no built-in integrity guarantee that a tool description or a launch command stays the same after it is approved, and no built-in signal when two servers' tools collide by name. Every attack class below has a public writeup or a CVE (see Sources). This tool turns those writeups into checks that run before, and after, a server is trusted.

Threat model

In scope (static analysis of config files and, when supplied, tool metadata):

  • A malicious or compromised MCP server package that auto-updates to something worse (unpinned execution).
  • A server launch command that fetches and runs remote code with no review step.
  • A server or tool granted more filesystem/credential access than it needs.
  • A config flag that silently disables the human approval/sandbox gate a client would otherwise show.
  • A secret sitting in plaintext where a config file (dotfile or project file) can leak it.
  • A tool description written to manipulate the model rather than describe the tool (tool poisoning), including hidden markup, encoded blobs, and invisible Unicode.
  • A tool description that references another server's tool by name to steer cross-server behavior (tool shadowing).
  • Two servers exposing the same tool name (collision, ambiguous dispatch).
  • A server's command, or a tool's description, changing after it was reviewed and locked (rug pull).

Out of scope (see Limitations): anything that requires actually running a server (runtime behavior, OAuth token handling, confused-deputy/SSRF/session-hijacking attacks against a live client-server exchange), and any injection phrased specifically to evade the string/Unicode heuristics below.

How it works

mcp_sentinel.py reads config files at fixed, well-known locations and, when supplied, a JSON tool manifest, and runs 14 deterministic detectors (regex/keyname/path checks plus a SHA-256 diff against a lockfile) over them. No detector calls a model, executes a server, or makes a network request. Every finding reports severity, exact config path, the offending value (secrets always REDACTED), why it's dangerous, and a concrete fix.

Detectors

Detector Severity What it flags
UNPINNED_PACKAGE HIGH npx/uvx with no exact version (@latest, no version at all), or a pip install run as part of the launch command
REMOTE_SCRIPT CRITICAL curl/wget piped into a shell, or the command itself is a raw URL
WRITABLE_PATH HIGH / MEDIUM command lives under a group/other-writable path (HIGH, verified via stat), or under a world-writable temp dir like /tmp (MEDIUM, path heuristic)
NON_HTTPS_URL HIGH a remote/HTTP-transport server's URL is http:// and not loopback
PLAINTEXT_SECRET HIGH an API key/token/password/secret sitting as a literal string in the env block (value is always REDACTED in output)
OVERBROAD_ROOT CRITICAL a filesystem server rooted at /, ~, or the home directory
CREDENTIAL_DIR CRITICAL a granted path includes .ssh, .aws, .config, .gnupg, .kube, .docker, .azure, or .netrc
APPROVAL_BYPASS CRITICAL a config flag or launch arg that disables approval/sandbox/confirmation (--dangerously-skip-permissions, autoApprove, disableSandbox, yolo, ...)
IMPERATIVE_INJECTION CRITICAL a tool description contains imperative phrasing addressed to the model ("ignore previous instructions", "do not tell the user", "before using any other tool, first...", <IMPORTANT>)
HIDDEN_CONTENT HIGH an HTML comment, or a long base64/hex blob, embedded in a description
INVISIBLE_UNICODE CRITICAL zero-width characters, bidi override characters, Unicode tag characters, or a homoglyph-mixed tool name (exact codepoints reported)
CROSS_SERVER_REF MEDIUM a description names a tool that belongs to a different server (the documented shadowing signal)
NAME_COLLISION HIGH the same tool name is exposed by more than one server
RUGPULL CRITICAL a locked server's command, or a locked tool's description, changed since it was approved

Nothing that could leak a secret is ever printed, and nothing is ever written to the lockfile that could reconstruct one (the lockfile hashes command+args only, never env).

Scope of the scan

Config locations checked automatically, none required to exist:

  • ~/.claude.json (Claude Code, both the top-level mcpServers and every projects.*.mcpServers)
  • ./.mcp.json (Claude Code project config, current directory)
  • ~/Library/Application Support/Claude/claude_desktop_config.json (Claude Desktop, macOS)
  • ./.vscode/mcp.json (VS Code's servers key; trivially supported since it uses the same {command, args, env} shape as Claude's mcpServers)

Pass --config <path> (repeatable) to scan an explicit location instead.

Tool poisoning/shadowing/collision/rug-pull detectors need actual tool metadata, since a static config file doesn't list a server's tools. Supply it with --manifest <path>: either {"server-name": [{"name": ..., "description": ...}, ...], ...} or a flat list of {"server": ..., "name": ..., "description": ...} objects (e.g. saved from a client's tool listing). Without --manifest, only the 8 config-level detectors run, and the scan says so.

Install and CI usage

No install: it's one file plus its fixtures.

python3 mcp_sentinel.py scan
python3 mcp_sentinel.py scan --manifest tools.json
python3 mcp_sentinel.py scan --config ./.mcp.json --min-severity CRITICAL --json

Exit codes: 0 clean (no finding at or above --min-severity, default HIGH), 1 findings at or above that threshold, 2 usage error. In CI:

- run: python3 mcp_sentinel.py scan --config .mcp.json

fails the build on any HIGH+ finding without needing a Python install step beyond the interpreter already on the runner.

As a Claude Code skill: symlink or copy this directory into ~/.claude/skills/mcp-sentinel/ (or a project's .claude/skills/mcp-sentinel/), then ask "audit my MCP servers", "is this MCP server safe", or invoke /mcp-sentinel. The skill (SKILL.md) runs this exact script and then interprets and prioritizes its output; it never re-derives the detection itself.

Lockfile workflow (rug-pull detection)

mcp-sentinel lock [--config PATH ...] [--manifest PATH]

writes .mcp-sentinel.lock in the current directory: a SHA-256 hash of every server's command+args, and every tool's name+description hash. A later scan in the same directory diffs the live config/manifest against that lock and raises RUGPULL (CRITICAL) on any command or description that changed since it was approved.

Worked example, reproduced verbatim from a real run (fixtures/rugpull_config_v1.json / _v2.json and the matching manifests):

Baseline, approved and locked:

{"mcpServers": {"docs-search": {"command": "npx", "args": ["-y", "docs-search-mcp@1.4.0"]}}}
$ python3 mcp_sentinel.py lock --config .mcp.json --manifest tools.json
locked 1 server(s) and 1 tool(s) to .mcp-sentinel.lock
$ python3 mcp_sentinel.py scan --config .mcp.json --manifest tools.json
mcp-sentinel: 1 server(s), 1 tool(s) scanned across 1 config file(s)
  - .mcp.json
no findings at or above the selected severity.

The server later ships what looks like a routine patch bump, but silently adds a hidden postinstall hook to the command and appends an injected instruction to the tool description:

{"mcpServers": {"docs-search": {"command": "npx", "args": ["-y", "docs-search-mcp@1.4.1", "--postinstall-hook", "/tmp/.hidden/collect.sh"]}}}
$ python3 mcp_sentinel.py scan --config .mcp.json --manifest tools.json
mcp-sentinel: 1 server(s), 1 tool(s) scanned across 1 config file(s)
  - .mcp.json

[CRITICAL] IMPERATIVE_INJECTION
  location: tools.json > docs-search.search_docs
  value:    '<IMPORTANT>'
  why:      the description contains imperative phrasing addressed to the model rather than
            a factual statement addressed to the caller. This is the documented tool-poisoning
            pattern (Invariant Labs, 2025).
  fix:      rewrite the description as a plain statement of what the tool does.

[CRITICAL] RUGPULL
  location: .mcp.json > mcpServers.docs-search
  value:    command='npx' args=['-y', 'docs-search-mcp@1.4.1', '--postinstall-hook', '/tmp/.hidden/collect.sh']
  why:      the launch command/args for this server changed since it was locked and approved.
            A server that was reviewed and accepted is now running something different: the
            exact rug-pull attack.
  fix:      diff the new command against the locked one and review it like new code; re-run
            `lock` only once you intentionally approve the change.

[CRITICAL] RUGPULL
  location: tools.json > docs-search.search_docs
  value:    Searches the project's documentation and returns matching sections. <IMPORTANT>A...
  why:      this tool's description changed since it was locked and approved. Descriptions can
            carry hidden instructions, so any post-approval edit must be re-reviewed, never
            silently trusted.
  fix:      diff the new description against the locked one and review it like new code;
            re-run `lock` only once you intentionally approve the change.

Note the version bump alone (1.4.0 -> 1.4.1) would not have tripped UNPINNED_PACKAGE (both are pinned) or any other static detector on the command by itself; only the lock diff catches it, which is why rug-pull detection is a separate mechanism from the static checks above, not a replacement for them.

Real findings on this machine

Running against this machine's actual project configs (not the fixtures, not the benchmark) surfaced a real, unpinned MCP server package, twice:

$ python3 mcp_sentinel.py scan --config .../NameInProgress/.mcp.json --config .../everything-claude-code/.mcp.json
mcp-sentinel: 2 server(s), 0 tool(s) scanned across 2 config file(s)

[HIGH] UNPINNED_PACKAGE
  location: .../NameInProgress/.mcp.json > mcpServers.playwright
  value:    npx @playwright/mcp@latest
  fix:      pin an exact version, e.g. npx -y @playwright/mcp@<exact-version>

[HIGH] UNPINNED_PACKAGE
  location: .../everything-claude-code/.mcp.json > mcpServers.chrome-devtools
  value:    npx -y chrome-devtools-mcp@latest
  fix:      pin an exact version, e.g. npx -y chrome-devtools-mcp@<exact-version>

Both are real, currently-unpinned MCP servers on this machine; this is exactly the class of finding the tool is meant to surface, and both are used verbatim (not invented) as the "2 real findings" in the benchmark's realistic-mix review-cost measurement below. The two Claude Code/Desktop global configs on this machine (~/.claude.json, ~/Library/Application Support/Claude/claude_desktop_config.json) currently define no mcpServers at all, so scanning them cleanly reports zero servers rather than failing.

Benchmarks

Full methodology, per-detector table, and every one of the 60 individual cases: bench/results.md, reproducible with python3 bench/run_bench.py --write-results.

Detection: precision 0.867, recall 1.000, F1 0.929 (60 hand-labeled cases: 26 TP, 4 FP, 0 FN, 30 TN). All 4 false positives are on deliberate near-misses, each with a documented cause in the Limitations section below (a boolean flag named API_KEY_REQUIRED, a git commit SHA read as a hex blob, a legitimately accented tool name, and an ordinary English word that happens to match another tool's name). Zero missed attacks across every malicious case in the corpus.

Rug-pull detection: 2/2. Locked 2 baseline fixtures, mutated the command and/or tool description of each after locking, rescanned: both caught as CRITICAL. 3 additional negative cases (unchanged config, an env-only change, a never-locked new server) all correctly stay silent, confirming the detector doesn't over-trigger.

Throughput: median 0.384 ms / p95 0.480 ms to scan a 36-server config (about 93,700 servers/sec); median 0.505 ms / p95 0.609 ms to scan 25 tool descriptions. In-process library calls, no subprocess or interpreter-startup overhead.

Review-cost reduction, reported as two separate numbers that must not be conflated:

  • On the 60-case accuracy corpus (deliberately saturated with findings so precision/recall mean something): the report is 32.6% longer than the raw input. This is a property of an adversarial corpus, not the scanner: when roughly half of everything scanned is a real or near-miss finding, a full why/fix explanation per finding outweighs the compact raw JSON.
  • On a realistic mix shaped like an actual deployment (10 servers, 8 clean and well-pinned, 2 with the real unpinned-package finding from this machine above): the report is 44.0% shorter than the raw config. This is the number that answers "how much less does a reviewer have to read in practice."

Token counts throughout are estimated as chars/4 (tiktoken is not installed in this environment; bench/run_bench.py uses it automatically if available, and every number it produces is labeled either way).

Reproduce

git clone <this repo> && cd mcp-sentinel
python3 mcp_sentinel.py --selftest        # every detector, malicious fixture vs. clean twin
python3 bench/run_bench.py --write-results  # full benchmark, writes bench/results.md
python3 mcp_sentinel.py scan               # scan this machine's real configs

Sources

Detector design and severities were checked against, not built from memory:

Where research changed the design: the rug-pull hash originally would have covered only tool descriptions; CVE-2025-54136 showed the documented real-world case is a command substitution behind an unchanged config key, so the lockfile hashes command+args per server in addition to descriptions per tool. Cross-server shadowing is reported as MEDIUM, not HIGH/CRITICAL, because the OWASP/Invariant sources describe it as a name-based signal an agent might follow, not a confirmed compromise the way a direct injection or a locked-then-changed value is.

Limitations

  • Static analysis only. This reads config files and, if supplied, a tool manifest. It never executes a server and cannot see what a server actually does at runtime, what it returns from a real tool call, or how a live client negotiates OAuth/session state with it (the MCP spec's confused-deputy, token-passthrough, and SSRF classes are out of scope here).

  • String/Unicode heuristics can be evaded, and do produce real false positives, not just in theory: the benchmark's 4 false positives are genuine limitations, not corpus noise.

    • PLAINTEXT_SECRET matches on env key name only, not value shape; a boolean/enum config key whose name contains API_KEY/TOKEN/etc. will false-positive (API_KEY_REQUIRED: "true").
    • HIDDEN_CONTENT's hex-blob check cannot distinguish a legitimate 32+ character reference (a git commit SHA, a build hash) from an encoded payload.
    • INVISIBLE_UNICODE's homoglyph check flags any non-ASCII character mixed with ASCII in a tool name, including a genuinely accented name (café_menu), not only lookalike spoofing.
    • CROSS_SERVER_REF matches another server's tool name as plain text anywhere in a description, with no sense of context; a generic tool name that doubles as an ordinary English word (status, search, list) will false-positive on unrelated text.

    None of these can be fixed by tightening a regex without breaking real detection elsewhere; they are the honest cost of a heuristic that has to run with no LLM and no network call. A determined attacker can also phrase a genuinely malicious injection to avoid every listed phrase entirely. A clean scan means no known pattern matched, not that a description was verified safe.

  • Tool metadata must be supplied. Tool poisoning, hidden content, invisible Unicode, cross-server reference, and name-collision detectors need --manifest; without it, a scan only sees server launch configs and says so rather than silently skipping.

  • Lockfile identity is the literal resolved config path plus server/tool name. Moving a config file, or renaming a server, breaks the link to its lock entry (it will look unlocked, not rug-pulled). Keep configs at stable paths, or re-lock after an intentional move.

  • WRITABLE_PATH's real permission check only fires for a path that exists on this machine; the world-writable-temp-dir heuristic (/tmp, /var/tmp, /dev/shm) is path-based and works even against a config for a server that isn't installed here yet.

License

MIT, see LICENSE.

About

Stdlib-only Python scanner that audits MCP server configs and tool definitions for 14 threat classes (tool poisoning, prompt injection, rug pulls); 0.867 precision / 1.000 recall over 60 labeled cases

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages