使用说明
MCP Guard
A security scanner for Model Context Protocol servers.
A finding cannot exist without the observation that produced it: evidence is a required field with no default, and the report writer refuses to emit a report whose evidence does not point at something real. A static finding carries the literal source text at the position it names; a dynamic finding carries the exact bytes the server sent back, recorded by the transport before anything parses them. Command injection is proven by a canary that only a shell can produce, not inferred from a payload being accepted.
Contents
- Install
- Quickstart
- Sample output
- What it detects
- What it does not detect
- Architecture
- Scoring
- Usage
- Safety
- Accuracy
- Performance
- Development
- History
- License
Install
git clone https://github.com/SaravanaGuhan/mcp-guard.git
cd mcp-guard
pip install -e .
Python 3.11 or newer. Node is needed only to run dynamic analysis against Node.js targets.
Quickstart
# Static and dependency analysis. Executes nothing from the target.
mcp-guard path/to/mcp-server
# One screen: what ran, what did not, and the verdict.
mcp-guard path/to/mcp-server --format summary
Dynamic analysis launches the target and speaks JSON-RPC to it, so it runs the target's code and is off by default:
# WARNING: this installs and RUNS the target's code on this machine.
mcp-guard path/to/mcp-server --allow-execute
# The same, isolated in a container.
mcp-guard path/to/mcp-server --allow-execute --sandbox docker
Sample output
From docs/generated/sample-output.txt, regenerated by make sample:
------------------------------------------------------------------------------
STAGES
------------------------------------------------------------------------------
acquire ran 0.02s
detect ran 0.00s
server_type: nodejs
static ran 0.01s
findings: 3
dependencies DID NOT RUN 0.00s
reason: offline mode: OSV lookup skipped
dynamic ran 2.26s
handshake_rtt_ms: 2.0
launch_source: package.json main
------------------------------------------------------------------------------
FINDINGS
------------------------------------------------------------------------------
total 5 critical 2 high 3 medium 0 low 0
by source: static 3 dynamic 2 dependency 0
[1] CRITICAL 9.4 MCPG-DYN-CMDEXEC
Tool argument reaches a shell (proven by canary execution)
oracle: cmd-injection-canary
sent > {"method":"tools/call","params":{"name":"run",
"arguments":{"cmd":"echo MCPGUARD^_d31c5ea2..."}}}
recv < {"result":{"content":[{"type":"text",
"text":"MCPGUARD_d31c5ea2...\r\n"}]}}
proof : the collapsed marker is in the response and not in the request,
so the argument was interpreted by a shell, not echoed back.
The stage block comes first. A report with no findings means nothing until you know which stages ran.
What it detects
| Rule | Method | CWE | CVSS |
|---|---|---|---|
MCPG-DYN-CMDEXEC |
canary probe | CWE-78 | 9.4 |
MCPG-DYN-PATHTRAVERSAL |
canary probe | CWE-22 | 8.2 |
MCPG-DYN-CRASH |
protocol probe | CWE-248 | 6.9 |
MCPG-DYN-UNDECLARED-METHOD |
protocol probe | CWE-749 | 5.1 |
MCPG-DYN-NO-DISPATCH |
protocol probe | CWE-1286 | 5.1 |
MCPG-DYN-JSONRPC-VIOLATION |
protocol probe | CWE-20 | 5.1 |
MCPG-DYN-SCHEMA-UNENFORCED |
protocol probe | CWE-20 | 5.1 |
MCPG-PY-SHELL-TAINT |
AST | CWE-78 | 9.4 |
MCPG-PY-PATH-TAINT |
AST | CWE-22 | 7.0 |
MCPG-JS-SHELL-TAINT |
AST | CWE-78 | 9.4 |
MCPG-JS-VM-EVAL |
AST | CWE-95 | 9.4 |
MCPG-JS-PATH-TAINT |
AST | CWE-22 | 7.0 |
MCPG-MCP-PROMPT-INJECTION-SURFACE |
AST | CWE-77 | 7.1 |
MCPG-MCP-URI-CONCAT |
AST | CWE-22 | 6.9 |
MCPG-MCP-SCHEMA-UNDECLARED-ARGS |
AST | CWE-1286 | 5.1 |
MCPG-SECRET-HARDCODED |
structure and entropy | CWE-798 | 8.8 |
MCPG-DOCKER-CURL-PIPE-SH |
AST | CWE-494 | 9.3 |
MCPG-DOCKER-ADD-REMOTE |
AST | CWE-494 | 8.8 |
MCPG-DOCKER-ENV-SECRET |
AST | CWE-798 | 6.8 |
MCPG-DOCKER-LATEST-TAG |
AST | CWE-1104 | 6.3 |
MCPG-DOCKER-ROOT |
AST | CWE-250 | 5.1 |
MCPG-DOCKER-CHMOD777 |
AST | CWE-732 | 4.8 |
MCPG-DEP-KNOWN-VULN |
OSV lookup | CWE-1395 | published |
Scores come from a hand-authored CVSS v4.0 vector attached to each rule in
src/mcp_guard/rules.py, and severity derives from the score. No rule carries
a hand-written number, and a test recomputes every score from its vector. An
OWASP AIVSS v0.8 score is reported alongside; see Scoring.
The command-injection probe sends echo MCPGUARD""_<id> on POSIX and
echo MCPGUARD^_<id> on Windows. A shell collapses the quoting and prints
MCPGUARD_<id>, a string that never appears in the bytes sent, so a server
that merely echoes the argument cannot produce it. The path-traversal probe
writes a file with unguessable contents outside the target root and reports a
finding only if those contents come back.
What it does not detect
- Taint tracking is intraprocedural. A value laundered through a helper function is not followed. Both analyzers track from a function's own parameters to a sink in the same function, and no further.
- No authentication testing for stdio. An MCP stdio server has no auth layer, so there is nothing to bypass. Auth checks belong on HTTP transports with a declared scheme, which is not implemented.
- Go support is unverified. The code paths exist, but no Go fixture is in the corpus and no Go toolchain was present where this was developed.
- Docker targets are analysed statically only. The Dockerfile is read; the image is not built or run.
- A server that needs unannounced configuration cannot be probed. If it wants an API key and does not say so, it starts and stays silent, and the dynamic stage reports a handshake timeout. That is a documented outcome, not a clean bill of health. Of 21 profiled targets, 13 do not launch, and the report always says which and why.
- Probing can be truncated. With
--probe-budgetexhausted, remaining probes are skipped and listed by name. A skipped probe is not a negative result. - Only the first launch candidate that handshakes is probed. A monorepo exposing several MCP servers is scanned through one of them; the others appear in the candidate chain but are not fuzzed.
- MCP schema rules are single-file and syntactic. A tool schema assembled at runtime, or imported from another module, is not analysed.
- Dependency findings need resolved versions.
^4.17.15is a range, and MCP Guard does not guess what it resolves to.
Architecture
A scan is five stages. Acquire and detect run first because everything else depends on what they conclude; static, dependencies and dynamic then produce findings independently, and the report is written last.
target (path or GitHub URL)
|
v
+-------------+ acquire.py fetch or open the target, record the commit
| acquire |
+-------------+
|
v
+-------------+ detect.py server type, and a CHAIN of launch
| detect | candidates from the target's own metadata
+-------------+ (bin, main, exports, scripts.start,
| pyproject scripts, mcp.json, workspaces)
|
+----------------+------------------+
| | |
v v v
+-----------+ +--------------+ +---------------+
| static | | dependencies | | dynamic | requires --allow-execute
+-----------+ +--------------+ +---------------+
static/ deps/ dynamic/
one walk, lockfiles.py harness.py launch, MCP handshake
one parse, parse pins transport.py the only writer of
AST rules osv.py | raw response bytes
query OSV probes.py what to send
oracles.py what proves a finding
| | |
+----------------+------------------+
|
v
+-------------+ report/verify.py refuse unbacked findings
| report | console | summary | json | sarif
+-------------+
The dependency stage starts on a worker thread before static runs and is joined
after dynamic, in scan.py. It is network bound and the others are CPU and
subprocess bound, so they overlap. Each stage still reports its own wall time
rather than the overlap window: the worker times itself and the main thread only
waits for the result.
Every stage records a ScanStatus whether it ran or not, and a stage that did
not run must give a reason. ScanStatus.__post_init__ rejects ran=False with
no reason, because a silently absent stage reads as a clean result.
Evidence
Finding.evidence in models.py is a required field with no default. Omitting
it is a TypeError from the generated __init__; passing None or anything
that is not an Evidence instance raises in __post_init__. There is no code
path that constructs a finding first and attaches evidence later.
Three frozen dataclasses carry the observation, and each rejects empty raw data at construction:
| Type | Carries | Rejects |
|---|---|---|
StaticEvidence |
file, line, column, the literal matched_source sliced from the parsed buffer, rule id |
empty matched_source |
DynamicEvidence |
request_json, response_raw exactly as read, the parsed form, oracle id, and why it proves the finding |
empty response_raw |
DependencyEvidence |
package, installed version, advisory ids, affected range, lockfile and line | empty advisory id |
report/verify.py runs before any format is emitted and checks that each
finding's evidence names a file that exists under the scanned root or carries
non-empty response bytes. A violation raises EvidenceViolation and the CLI
exits 2 without printing a report, rather than emitting one finding it cannot
back.
transport.py is the only writer of response_raw. Detection rules receive
those bytes; they never construct them. A read timeout returns a sentinel, not a
synthesised response.
How a dynamic finding is proven
Command injection is the clearest case, because accepting a payload proves nothing on its own. The probe sends a marker that only a shell can produce.
The payload, from probes.py, is echo MCPGUARD^_<uuid> on Windows and
echo MCPGUARD""_<uuid> on POSIX. Both contain a character the shell removes:
the caret escape, or the empty quotes. Here is a real exchange, from
docs/generated/sample-output.txt:
sent > {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run",
"arguments":{"cmd":"echo MCPGUARD^_3c6ed270d6b346a3bef2dab49f41a3cb"}}}
recv < {"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text",
"text":"MCPGUARD_3c6ed270d6b346a3bef2dab49f41a3cb\r\n"}]}}
The sent bytes contain MCPGUARD^_3c6ed270.... The received bytes contain
MCPGUARD_3c6ed270..., without the caret. That collapsed form never appears in
the request, so a server that echoed the argument back, logged it, or included
it in an error message could not produce it. Only something that ran the string
through a shell could.
oracle_command_execution in oracles.py checks exactly that: the marker is
present in the response and absent from the request bytes. It returns the
sentence that becomes why_this_proves_it, or None for no finding. An oracle
that ignored its response argument would be the fabrication bug this codebase
exists to prevent, so tests/test_invariants.py walks the AST of every
oracle_* function and fails if one does not reference it.
The uuid is fresh per scan. A fixed marker could be hardcoded by a target, and the proof would be worth nothing.
The path-traversal probe works the same way: a file with unguessable contents is written outside the target root before launch, and the finding is reported only if those contents come back.
Repository layout
src/mcp_guard/
models.py Finding, the three Evidence types, ScanStatus, ScanResult
rules.py rule registry: one CVSS vector and CWE per rule
scan.py stage orchestration and the dependency overlap
cli.py argument parsing, output selection, exit codes
acquire.py fetch a GitHub repository or open a local path
detect.py server type and the launch candidate chain
execution.py the only module permitted to run target code
static/ ast_python, ast_javascript, secrets, dockerfile, mcp_rules
dynamic/ transport, harness, probes, oracles
deps/ lockfiles, osv
report/ console, summary, json, sarif, verify
scoring/ cvss, aivss, agentic factor observation
tests/
fixtures/ nine target repositories, from safe to deliberately broken
golden/ the findings each fixture must produce
schemas/ SARIF 2.1.0, for validating output
scripts/ golden.py, benchmark.py, profile.py
docs/generated/ produced by make targets, not edited by hand
Scoring
Two scores, computed from vectors rather than authored.
CVSS v4.0 is the primary score. Each rule in src/mcp_guard/rules.py
carries a hand-authored base vector with a justification per metric, and
scoring/cvss.py computes the number from it. Finding.severity derives from
that score and from nothing else, so exit codes, sorting and the severity floor
depend only on CVSS.
OWASP AIVSS v0.8 is additional, never a replacement. It is implemented in
scoring/aivss.py against
AIVSS Scoring System For OWASP Agentic AI Core Security Risks v0.8,
using the specification's own formula:
Factor_Sum = sum of the ten Risk Amplification Factors, each 0.0, 0.5 or 1.0
AARS = (10 - CVSS_Base) * (Factor_Sum / 10) * ThM
AIVSS = RoundHalfUp((CVSS_Base + AARS) * Mitigation_Factor, 1)
The factor order, the 0.0/0.5/1.0 rubric, the threat multiplier table (0.97 by default, Proof-of-Concept), the mitigation factor table (1.0 by default, no or weak mitigation) and the severity bands are the specification's.
What the scanner can and cannot observe
AIVSS combines a technical baseline with ten agentic amplification factors. A repository scanner can see one of them.
| Factor | Observable | From what |
|---|---|---|
| External Tool Control Surface | yes | tools/list during the dynamic stage, plus whether a canary probe proved a tool reaches a shell |
| Execution Autonomy | no | whether a human co-signs actions is a deployment choice |
| Natural Language Interface | no | depends on the client wired to the server |
| Contextual Awareness | no | environmental signals come from the deployment |
| Behavioral Non-Determinism | no | a property of the model behind the client |
| Opacity and Reflexivity | no | depends on the operator's logging and audit setup |
| Persistent State Retention | no | memory across sessions is a deployment property |
| Dynamic Identity | no | runtime role assumption is configured by the operator |
| Multi-Agent Interactions | no | not visible from one repository |
| Self-Modification | no | whether the agent may rewrite its own config is a permission |
An unobserved factor is reported as unobserved. It is not defaulted to a middle value and not assumed absent, because either would be inventing data. The score is emitted as a bound: the low end scores every unknown factor 0.0, the high end scores it 1.0, and the true value lies between. The report says so in those words and lists the factors it could not determine:
aivss: partial AIVSS 9.5 to 9.6, OWASP AIVSS v0.8. 2 of 10 factors could not
be determined by scanning: Execution Autonomy, Natural Language Interface
Each rule declares which factors amplify it, because a hardcoded credential and a command injection through a tool do not amplify the same way. Factors a rule is not affected by score 0.0 as "not applicable to this finding class", which is a scored value rather than an assumption about the deployment. Section 3.3.1 of the specification asks for the factors to be reviewed per vulnerability, which is what this does.
An operator who knows their deployment can supply the rest:
mcp-guard . --allow-execute --aivss-factors 'autonomy=1.0,persistence=0.5'
Supplied values are recorded as operator-supplied rather than observed, so a reader can tell which is which. When all ten factors are known the bound collapses to a single AIVSS score.
AIVSS appears in the JSON report always, and in the console only when the
dynamic stage ran. Without tools/list even the one observable factor is
unknown, and every finding would carry the same maximally wide bound, which is
noise rather than information.
Usage
| Flag | Effect |
|---|---|
--allow-execute |
Permit dynamic analysis. Without it the dynamic stage reports ran=False. |
--sandbox none|docker |
Isolation for dynamic analysis. docker errors out if docker is unavailable rather than downgrading. |
--entrypoint 'node dist/x.js' |
Launch this instead of the derived command. The derived candidate chain is still reported. |
--skip-install |
Assume dependencies are present. Also automatic when node_modules exists. |
--probe-budget N |
Seconds of dynamic probing before the rest are skipped, default 30. Skipped probes are named. |
--handshake-timeout N |
Seconds to wait for initialize, default 10. |
--timeout N |
Per-subprocess timeout, default 120. |
--no-static, --no-deps, --offline |
Skip a stage. The report says it was skipped and why. |
--no-cache |
Bypass the static result cache. |
--quiet |
Suppress per-stage progress on stderr. |
--min-severity, --include-transitive, --include-dev |
Console density. JSON is never filtered. |
--aivss-factors |
Agentic factors the scanner cannot observe, as factor=value pairs. See Scoring. |
--fail-on none|low|medium|high|critical |
Exit-code threshold, default high. |
Output formats: console (default), summary, json, sarif. SARIF is
2.1.0 and is validated against the OASIS schema in CI.
Exit codes: 0 clean, 1 findings at or above --fail-on, 2 scan error
including an evidence violation, 3 target could not be analysed.
mcp-guard . --format sarif -o results.sarif --fail-on high
Safety
Static and dependency analysis execute nothing from the target; running with no
flags cannot spawn a target process. Dynamic analysis requires
--allow-execute, and npm installs always pass --ignore-scripts, every
subprocess has a timeout, and its whole process tree is killed on expiry.
--sandbox docker adds --network none, a read-only mount, a non-root user,
--memory 512m and --pids-limit 256, and errors out rather than silently
downgrading if docker is unavailable.
Read docs/execution-model.md for exactly what runs in which mode. Secrets are redacted in console output and appear in full in the JSON report, so treat JSON reports as sensitive.
Accuracy
On the 9-fixture corpus in tests/fixtures/, regenerated by make bench into
docs/generated/fixture-benchmark.txt, MCP Guard finds 8 of 8 planted
vulnerabilities with 0 false positives and 0 false negatives, but that number
should not be read as field accuracy: the fixtures were written alongside the
rules by the same author, so it measures whether the rules still do what they
were built to do and stay silent on inert inputs, which is regression safety
rather than evidence of how the tool behaves on code it has never seen. Two of
the four negative fixtures are also degenerate by design, being an empty
repository and a process that exits immediately.
Performance
On the profiling corpus of 9 fixtures and 12 real MCP server repositories, 8 of 21 targets reach an MCP handshake, static analysis of a 291-file repository takes 744 ms, and a 551-package dependency scan takes 17.8 seconds cold and 1.1 seconds against warm caches. Each source file is parsed once.
Two caches make rescans cheap: static results keyed by file hash and rule set version, and OSV responses keyed by package URL. See docs/performance.md for the full profile and docs/profile-after.md for the raw measurements.
Development
The layout is in Architecture.
pip install -e ".[dev]"
make test # pytest, 78% coverage gate
make lint # ruff
make golden # regenerate the golden set
make sample bench # regenerate the artifacts the README quotes
To add a rule: register it in src/mcp_guard/rules.py with a CVSS vector and a
justification per metric, emit it from an analyzer in static/ or a probe in
dynamic/probes.py, and give it evidence. A finding without evidence will not
construct, and an oracle that ignores its response argument fails a test in
tests/test_invariants.py.
History
Versions before 2.0.0 emitted fabricated findings: a large part of the reported output was not derived from any observation of the target. The tool was audited, the findings documented in docs/audit-2026-09.md, and rebuilt around the evidence requirement described at the top of this file. See CHANGELOG.md.
License
MIT. See LICENSE. Contributions welcome; see CONTRIBUTING.md.
