# MCP Guard

A security scanner for Model Context Protocol servers.

[![tests](https://github.com/SaravanaGuhan/mcp-guard/actions/workflows/tests.yml/badge.svg)](https://github.com/SaravanaGuhan/mcp-guard/actions/workflows/tests.yml)
[![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![python: 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)

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](#install)
- [Quickstart](#quickstart)
- [Sample output](#sample-output)
- [What it detects](#what-it-detects)
- [What it does not detect](#what-it-does-not-detect)
- [Architecture](#architecture)
- [Scoring](#scoring)
- [Usage](#usage)
- [Safety](#safety)
- [Accuracy](#accuracy)
- [Performance](#performance)
- [Development](#development)
- [History](#history)
- [License](#license)

## Install

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

```bash
# 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:

```bash
# 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](#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](https://aivss.owasp.org/),
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:

```bash
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](#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.

```bash
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](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](docs/performance.md) for the full profile and
[docs/profile-after.md](docs/profile-after.md) for the raw measurements.

## Development

The layout is in [Architecture](#repository-layout).

```bash
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](docs/audit-2026-09.md), and rebuilt
around the evidence requirement described at the top of this file. See
[CHANGELOG.md](CHANGELOG.md).

## License

MIT. See [LICENSE](LICENSE). Contributions welcome; see
[CONTRIBUTING.md](CONTRIBUTING.md).
