Skip to main content
Glama
aca22akn

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.