@formalcore/mcp-diff
v0.1.0
Published
Authority diff between two MCP server states. Black-box introspection, never executes tools. The permission-change screen for MCP servers.
Maintainers
Readme
mcp-diff
The permission-change screen for MCP servers.
What is it
mcp-diff compares two states of a Model Context Protocol server and reports how its authority changed. It performs the MCP handshake, walks the list* methods for tools, resources, and prompts, and diffs the two declared manifests. It never invokes a tool.
The output is a single verdict, CLEAN / REVIEW / DENY, plus a per-change breakdown suitable for a PR review comment or a SARIF upload to GitHub code scanning. Exit codes make it a first-class CI citizen.
This is the "this app wants new permissions - Allow / Deny" moment your phone has, applied to MCP servers.
Quick start
Check the MCP servers you already have. No install, no config, no account:
npx @formalcore/mcp-diffWith no arguments, mcp-diff discovers your installed MCP client configs (Cursor, Claude Desktop and Claude Code, VS Code, Windsurf, Gemini CLI), introspects each declared server once, and pins a baseline on the first run. On every later run it diffs the live servers against that pinned baseline and reports what changed. This mirrors the uvx mcp-scan@latest on-ramp, with one deliberate difference: nothing leaves your machine, there is no API and no account.
Point it at a specific config instead of auto-discovery:
npx @formalcore/mcp-diff ~/.cursor/mcp.jsonTo diff two explicit versions rather than a pinned baseline, pass them as two arguments (see Usage).
Why this exists
MCP security is 2026's loudest agent-safety issue. Recently reported figures:
- Roughly 200,000 MCP instances exposed by a single disclosed vulnerability class.
- Microsoft's June 2026 advisory on poisoned tool descriptions - instructions smuggled into the description field aimed at the model, not the human operator.
- 52% of audited public MCP servers are effectively abandoned (no updates, no maintenance).
- 8.5% of public servers use OAuth; the rest ship no or ad-hoc authentication.
- 50% of MCP adopters cite security as the #1 blocker to production rollout (Zuplo, mid-2026).
The trust gap the MCP spec itself acknowledges: tool descriptions and annotations are untrusted metadata. A server can declare readOnlyHint: true and delete your files anyway. Between v1.4.2 and v1.5.0, that guarantee can silently disappear and no one is watching. Static scanners look at one point in time; mcp-diff looks at the transition, which is where rug-pulls actually happen.
How it works
- Introspect two states. Spawn OLD, do the MCP handshake, list everything paginated to exhaustion. Repeat for NEW. Shut both down.
- Canonicalize + hash. Recursively key-sort every declared object; sha256 the canonical form. Cosmetic reorder is never a false positive; the whole-manifest fingerprint is stable and signable.
- Diff authority, not behavior. Classify added / removed / mutated tools,
readOnlyHintdowngrades, newdestructiveHint, input-schema changes, and model-directed text patterns in descriptions. - Verdict.
DENYon any deny-level change;REVIEWif changes exist but none rise to deny;CLEANotherwise. The exit code follows the verdict. - Fail-closed. Timeouts, malformed manifests, and pagination anomalies raise named errors and exit non-zero. A hung server never silently reports
CLEAN.
The introspection never calls a tool. That is the black-box discipline in the name.
Install
npm install -g @formalcore/mcp-diffRequires Node 18 or newer.
Usage
Commands
| Command | What it does |
|---------|--------------|
| mcp-diff | Scan installed MCP configs, pin on first run, diff against the pin (default) |
| mcp-diff [CONFIG...] | Scan specific config files |
| mcp-diff diff <A> <B> | Authority diff between two explicit servers |
| mcp-diff inspect [CONFIG...] | Print declared manifests, no verdict |
| mcp-diff whitelist <name> <hash> | Allowlist a tool (writes .mcp-diff-ignore) |
| mcp-diff pin [CONFIG...] | Re-record the baseline from the current state |
Common flags: --json, --sarif, --report-format=markdown|json|sarif, --report-path=<file>, --no-fail (advisory, always exit 0), --baseline-path=<file>, --transport=stdio|http, --url-a=, --url-b=, --timeout-ms=, --request-timeout-ms=, --help.
stdio servers (local commands)
mcp-diff "npx [email protected]" "npx [email protected]"The two positional arguments are shell command strings. mcp-diff spawns each in turn, does the handshake, and diffs.
HTTP servers (Streamable-HTTP transport)
mcp-diff --transport=http \
--url-a=https://old.example.com/mcp \
--url-b=https://new.example.com/mcpHTTP transport does not execute any local code and is preferred anywhere the choice exists.
GitHub Action
- name: MCP authority diff
uses: formalcore/mcp-diff@v1
with:
old-spec: 'npx acme-mcp-server@${{ github.event.before }}'
new-spec: 'npx acme-mcp-server@${{ github.sha }}'
mode: markdown
fail-on: denySee action.yml for the full input surface.
Output formats
Markdown (default)
Rendered for a PR comment:
## MCP capability authority diff
`[email protected]` -> `[email protected]`
### DENY
- ~ `search_repos` no longer read-only (readOnlyHint dropped)
- ! `search_repos` description carries model-directed instructions (poisoning / rug-pull)
- + tool `delete_repository` - DESTRUCTIVE
- ~ `create_issue` input schema changedJSON (--json)
The full DiffResult shape, deterministic, safe to pipe into jq or a bot:
{
"decision": "DENY",
"a": { "name": "acme-github", "version": "1.4.2", "fingerprint": "sha256:..." },
"b": { "name": "acme-github", "version": "1.5.0", "fingerprint": "sha256:..." },
"changes": [
{
"kind": "tool.annotation.read_only_lost",
"severity": "deny",
"tool": "tool:search_repos",
"detail": "...",
"frameworks": { "owasp_llm": "LLM06", "owasp_agentic": null, "mitre_atlas": null, "safe_mcp": "SAF-T1201" }
}
],
"ruleset": { "id": "formalcore.default", "version": "0.1.0", "fingerprint": "sha256:..." }
}Every verdict is stamped with the ruleset that produced it (id, version, and a content-addressed fingerprint), so the same two manifests and the same ruleset hash reproduce a byte-identical verdict. Each change also carries the external-standard IDs it is classified under (OWASP LLM Top 10, OWASP Agentic Top 10, MITRE ATLAS, and OpenSSF SAFE-MCP), cited rather than authored here.
SARIF (--sarif)
SARIF v2.1.0 for GitHub Advanced Security code scanning, GitLab, Azure DevOps, and other SARIF-native platforms. Each Change becomes one Result; severities map deny -> error, review -> warning, info -> note.
mcp-diff --sarif "server-old" "server-new" > mcp-diff.sarifExit codes
| Code | Verdict | Meaning | |------|---------|---------| | 0 | CLEAN | No material change | | 1 | DENY | One or more deny-level changes | | 2 | REVIEW | Changes present but none deny-level | | 3 | USAGE | CLI argument / validation error | | 4 | INFRA | Introspection / transport / malformed-manifest error |
Detected change kinds
Severity and provenance for every change kind are DATA, not code: they live in the ruleset (src/ruleset.default.json), validated against the engine vocabulary at load and content-hashed into every verdict. Only the detail wording lives in code (src/catalog.ts). The core set:
| Kind | Severity | Origin |
|------|----------|--------|
| tool.added | review | MCP spec (Tools) - new tool equals new authority surface |
| tool.removed | info | MCP spec (Tools) - removed tool equals authority decrease |
| tool.description_changed | review | MCP spec (Tools) - descriptions are model-visible and untrusted metadata |
| tool.schema_changed | review | MCP spec (Tools) - schema shift alters what a model can invoke |
| tool.annotation.read_only_lost | deny | Snyk / Invariant Labs mcp-scan disclosure (June 2025) - silent readOnlyHint downgrade is the canonical rug-pull |
| tool.annotation.destructive_added | deny | MCP spec (Tools, Annotations) - destructiveHint activation equals irreversible authority |
| tool.model_directed_text | deny | Microsoft security advisory (June 2026) - poisoned tool descriptions |
| resource.added | info | MCP spec (Resources) - resource surface expansion |
| resource.removed | info | MCP spec (Resources) - resource surface contraction |
| prompt.added | info | MCP spec (Prompts) - prompt surface expansion |
| prompt.removed | info | MCP spec (Prompts) - prompt surface contraction |
If you disagree with a severity, that is a row in the ruleset with a citation, not a config flag. The full technique coverage, including the runtime and multi-tool techniques this tool deliberately defers, is in docs/COVERAGE.md. See CONTRIBUTING.md.
Use with, not instead of
mcp-diff is complementary to Snyk / Invariant mcp-scan and Cisco mcp-scanner, not a replacement. Those tools scan point-in-time and watch runtime traffic; mcp-diff diffs declared authority version-to-version in CI and emits a signed, reproducible verdict, fully locally. Run both: the runtime guard is the ceiling, the declaration diff is the deterministic floor. The full rationale, the exact seam, and an honest account of the overlap (they already do local hash pinning) are in POSITIONING.md.
Badge
If your MCP server is authority-pinned by mcp-diff in CI, advertise it:
[](https://github.com/formalcore/mcp-diff)What it is not
- Not a behavioral rug-pull detector.
mcp-diffcatches changes to the declared manifest. A byte-identical manifest whose implementation quietly starts exfiltrating data is invisible to this tool. Runtime observation is a separate capability tracked for a future release. See docs/LIMITS.md for the exact boundary, including the postmark-mcp v1.0.16 rug pull that returns CLEAN because its declaration never changed. - Not a sandbox. Introspecting a stdio server means spawning its process. Capture-is-RCE by construction. Run stdio introspection inside E2B, a Firecracker microVM, or an equivalent isolated environment with
--ignore-scripts, no network, ephemeral filesystem, and dropped capabilities. HTTP transport has no such issue and is preferred. - Not a trust-the-annotation classifier.
mcp-diffnever lowers a severity because a self-reported annotation says so. A tool that declaresreadOnlyHint: truewhile its name contains a destructive verb is still flagged. - Not a defense against every evasion. Known open classes under active hardening: unicode homoglyphs in tool names, semantically-equivalent description rewrites that avoid the model-directed regex, and capabilities relocated from
toolsintoresourcesorprompts. Contributions on any of these are welcome.
Roadmap
- v0.1 (current): stdio + HTTP transport, catalog-driven classifier, markdown / JSON / SARIF renderers, GitHub Action, npm publish with provenance.
- v0.2: E2B-hosted sandboxed stdio capture (fail-closed by construction), classifier hardening per the 22-item internal audit.
- v0.3: Signed evidence trail (sigstore keyless) - every verdict signed and Rekor-logged so a downstream consumer can independently verify the diff was produced by
mcp-diff. - v1.0: The MCP Capability Drift Report - a full sweep of the official MCP Registry, one blog post per finding, published as the launch content.
Provenance
Built at packages/mcp-diff inside the FormalCore monorepo. Extracted to a standalone public repository for the launch. If you found this via a link and want the fuller architectural context, docs/superpowers/plans/BUILDERLOOP/ in the upstream repo has the market analysis and the seat play behind this tool.
License
Apache License 2.0. See LICENSE.
