MCP服务器安全扫描器

MCP Guard 是一款专为 Model Context Protocol (MCP) 服务器设计的安全扫描器,通过静态分析、依赖分析和动态探测等方式检测命令注入、路径遍历、硬编码密钥等安全漏洞,适用于 MCP 服务器开发调试及上线前的安全审计场景。

  • by SaravanaGuhan
  • Python
  • 本地部署
  • 12 星标
  • 更新于
在 GitHub 查看 SaravanaGuhan/mcp-guard

使用说明

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

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-budget exhausted, 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.15 is 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.