内容
# mcp-surface-diff
Snapshot the tool surface of an MCP server, then diff two snapshots to catch
breaking changes and silent rug pulls before they reach your agents.
Zero runtime dependencies. Pure standard library. It has no supply chain of
its own, which is the right posture for a tool you point at servers you do
not fully trust.
## The problem
When you connect an agent to an MCP server, you review its tools once: the
names, the input schemas, the descriptions, the behaviour hints. You approve
what you saw. But nothing pins the surface in place. A server can present a
benign, read only tool at review time and later:
- rewrite a tool description to smuggle new instructions into your model's context (tool poisoning),
- flip `readOnlyHint` from true to false, or grow a `destructiveHint`,
- loosen a schema so it quietly accepts more than it used to,
- add a new required argument that breaks every existing caller.
None of that shows up in a version number. `mcp-surface-diff` makes the
surface reviewable as data: you snapshot it, commit the snapshot, and diff on
every update. If the contract moved, you find out in CI instead of in
production.
## Install
```bash
pip install mcp-surface-diff
```
Or from source:
```bash
git clone https://github.com/shriramkv/mcp-surface-diff
cd mcp-surface-diff
pip install -e .
```
## Quickstart
Capture a baseline from a live server over stdio:
```bash
mcp-surface-diff snapshot --cmd python my_server.py -o baseline.json
```
Or capture from a raw `tools/list` JSON document you already have:
```bash
mcp-surface-diff snapshot --from-json tools_list.json -o baseline.json
```
Later, capture again and diff:
```bash
mcp-surface-diff snapshot --cmd python my_server.py -o candidate.json
mcp-surface-diff diff baseline.json candidate.json
```
Example output:
```
[BREAKING] list_dir: tool 'list_dir' was present before and is now gone
[BREAKING] search: new required input property 'limit' was added
[SECURITY] read_file: annotation 'readOnlyHint' changed from True to False
[SECURITY] read_file: tool description was rewritten after the baseline snapshot
[WIDENING] search: property 'scope' now also accepts ['system']
[ADDED] read_file: new optional input property 'encoding' was added
[ADDED] write_file: new tool 'write_file' appeared
summary: 2 breaking, 2 additive, 2 security, 1 widening
```
## How changes are classified
| Severity | Meaning | Examples |
|---------------|------------------------------------------------------|----------|
| `breaking` | Existing callers can break | tool removed, property removed, new required property, type changed, enum value removed |
| `security` | The trust surface moved | tool description rewritten, `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint` changed |
| `widening` | The contract loosened | required field became optional, enum gained members |
| `additive` | Purely new surface | new tool, new optional property |
| `informational` | Cosmetic or non trust annotation change | a non security annotation changed |
The `security` band is the whole point. A rewritten description and a flipped
read only hint are not breaking in the compiler sense, so ordinary schema
diffs ignore them. They are exactly how a trusted server turns hostile, so
here they get their own severity and can gate a pipeline on their own.
## Use it in CI
`diff` exits non zero when any change meets or exceeds `--fail-on`
(default `breaking`). Gate on `security` to block rug pulls even when nothing
technically breaks:
```yaml
# .github/workflows/mcp-surface.yml
name: mcp surface
on: [pull_request]
jobs:
surface:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.11" }
- run: pip install mcp-surface-diff
- run: mcp-surface-diff snapshot --cmd python my_server.py -o candidate.json
- run: mcp-surface-diff diff surface-baseline.json candidate.json --fail-on security
```
Commit `surface-baseline.json` alongside your code and update it deliberately
in a reviewed PR. The snapshot is stable and sorted, so its diff is readable.
## Snapshot format
A snapshot is plain JSON: server identity, a capture timestamp, and the
normalised list of tools. It round trips cleanly, so a saved snapshot can be
fed straight back into `diff`.
## Library use
```python
from mcp_surface_diff import load_snapshot_file, diff_snapshots
report = diff_snapshots(load_snapshot_file("baseline.json"),
load_snapshot_file("candidate.json"))
print(report.max_severity)
for change in report.changes:
print(change.severity.value, change.tool, change.detail)
```
## Where it fits
This complements rather than replaces runtime controls. Static audit of a
single surface tells you what a server exposes today; a guardrail gateway
enforces policy at call time. `mcp-surface-diff` covers the axis in between:
how the surface changes over time, and whether a server you already trusted
is still the server you reviewed.
## License
Apache-2.0.