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.
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.
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.
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.
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.
| 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).
Config locations checked automatically, none required to exist:
~/.claude.json(Claude Code, both the top-levelmcpServersand everyprojects.*.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'sserverskey; trivially supported since it uses the same{command, args, env}shape as Claude'smcpServers)
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.
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.jsonfails 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.
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.
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.
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).
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
Detector design and severities were checked against, not built from memory:
- MCP Security Best Practices (official spec): confused-deputy, token-passthrough, SSRF, and local-server-compromise guidance shaped the approval-bypass and unpinned-execution checks.
- OWASP MCP Security Cheat Sheet and OWASP Gen AI: A Practical Guide for Secure MCP Server Development: named tool poisoning, rug pulls, tool shadowing, and over-broad permission grants as the core MCP-specific attack classes; recommended hashing tool definitions to detect post-deployment mutation, which is the lockfile's design.
- Invariant Labs: MCP Security Notification: Tool Poisoning Attacks and the mcp-injection-experiments repo: the original 2025 tool-poisoning/rug-pull/shadowing taxonomy this tool's detector names follow directly.
- CVE-2025-54136 ("MCPoison"): a real client (Cursor) trusted an approved config key even after the bound command changed. This is why
RUGPULLhashes the command, not just the tool description, and why the lockfile keys on the exact config path plus server name rather than a display name alone. - Claude Code: Extend Claude with skills: current
SKILL.mdfrontmatter (onlydescriptionis meaningfully required for auto-invocation) and skill discovery/install rules, checked before writingSKILL.mdrather than assumed.
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.
-
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_SECRETmatches on env key name only, not value shape; a boolean/enum config key whose name containsAPI_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_REFmatches 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.
MIT, see LICENSE.