mcp-trust-scanner
by aca22akn
README.md
# MCP Trust Proxy
## The problem
Most MCP security scanners check a tool's declared name, description, and
input schema once - at install/connect time - and never again. That misses
the attacks that actually matter: a tool's description silently changing
*after* it's been approved (a "rug pull"), two individually-safe tools that
become dangerous when combined (e.g. a file-reader plus a message-sender is
an exfiltration path even if neither tool ever changes), and a live call
that's malicious in the moment even though the tool's declaration looks
perfectly innocent. A one-time static check catches none of these.
## How it works - four layers
1. **Trust scanner** (`scanner/scan.py`, `scanner/hashing.py`,
`scanner/storage.py`) - hashes every tool's `{name, description,
input_schema}` and stores it in SQLite. Every subsequent scan compares
today's hash against the last trusted one: NEW / OK / CHANGED. A CHANGED
tool prints a readable diff - of the description if that's what moved,
or of the pretty-printed input schema if the description is identical
but the schema still changed underneath it.
2. **Blast-radius composition scoring** (`scanner/blast_radius.py`) - tags
every tool by capability (file_read, file_write, network_egress,
credential_access, code_execution, destructive) using whole-word keyword
matching, then looks at the *union* of capabilities across every
connected server and flags dangerous combinations - like a file-reader
and a message-sender together forming an exfiltration path - even when
no single tool's hash has changed.
3. **Runtime policy proxy** (`scanner/proxy_server.py`,
`scanner/runtime_policy.py`) - a real MCP server that sits in front of
the backend servers. Every call is policy-checked *before* it's
forwarded: a read of a sensitive-looking path (`.ssh`, `.env`,
credentials, private keys) gets WARNed but still allowed through; a
message whose body looks like it contains credential material, or whose
recipient isn't on an allowlist, gets BLOCKed outright - the backend
never sees it.
4. **Optional LLM explain layer** (`scanner/incident_explainer.py`) - only
runs on WARN/BLOCK verdicts, and only if `ANTHROPIC_API_KEY` is set. It
turns an already-made rule-based verdict into one plain-English sentence
for a human reading the log. It never makes the ALLOW/WARN/BLOCK
decision - that stays fully deterministic. No key set -> this layer is
skipped instantly, no network call attempted, and the log just shows the
rule-based reason instead.
## Quickstart
```bash
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python -m scanner.scan # baseline every tool, flag drift + risky combos
python run_live_demo.py # fire real calls through the proxy: ALLOW / WARN / BLOCK
python -m scanner.report --open # generate + open the audit dashboard
```
First run of `scanner.scan` against any server: everything comes back NEW
(nothing trusted yet). Every run after that: tools are compared against the
stored baseline.
## Demo the rug-pull attack
```bash
python toy_servers/mutate_demo.py # silently poisons send-message-server
python -m scanner.scan # catches it, shows the diff
python toy_servers/mutate_demo.py --undo # restore clean state
```
## What's in here
- `toy_servers/` - three minimal MCP servers (weather, file-reader,
send-message) standing in for real ones. Point `scanner/config.py` at
real MCP servers (filesystem, Slack, whatever Cursor is connected to)
when you're ready - same interface, nothing else changes.
- `scanner/` - the actual product: trust scanner, blast-radius scorer,
runtime policy proxy, and the optional LLM explain layer.
- `test_core.py` - plain-assert smoke tests (no pytest) for the
deterministic core: `python test_core.py`.
- `trust_store.db` - created on first run. Delete it to reset the baseline.
## Known limitations
- `test_core.py` covers basic smoke checks (hash determinism/change
detection, one capability tag, one policy verdict) - it is not a full
test suite and doesn't cover every rule or edge case.
- Blast-radius tagging and runtime policy checks are keyword/pattern-based
(word-boundary regex), not semantic. They're fast, deterministic, and
auditable, but a sufficiently reworded description or argument can still
slip past them - this is a v1 scope cut, not a claim of full coverage.
- `proxy_server.py`'s exposed tool schemas are hand-maintained to match the
real backend servers, not fetched from them dynamically. If a backend
tool's signature changes, the proxy needs a manual update to match - it
doesn't automatically mirror backend drift into its own declared schema.
- The LLM explain layer only visibly does anything if `ANTHROPIC_API_KEY`
is set before running the demo; without it, WARN/BLOCK log entries show
the same rule-based reason with or without that layer.
- Only watches tool *definitions* (name/description/schema) and *specific
runtime calls* matched by the two policy rules - a server that returns
injected instructions in a response, or a malicious call that doesn't
match either rule pattern, won't be caught by this version.
- Single-machine trust store (SQLite), not shared across a team.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues