🔏 mcp-contracts
Contract safety for MCP servers
Pin your tool schemas. Diff every change. Catch breaking changes and tool poisoning before your agents do.
Quick Start · Commands · Project Config · CI Integration · Packages
mcp-contracts diff — acme-server v1.0.0 → v1.1.0
🔴 BREAKING tool "create_contact" — required parameter "phone" added
🟡 WARNING tool "search_contacts" — description changed
🟢 SAFE tool "export_csv" — new tool added
Summary: 1 breaking · 1 warning · 1 safe
Your MCP server updated. Did the tool schemas change? Did descriptions get rewritten with hidden instructions? You'd never know — until now. mcpdiff treats MCP tool schemas as contracts: versionable, diffable, testable, and signable artifacts.
Highlights
- ⚡ Two-command onboarding —
mcpdiff initwrites your project config and captures the baseline;mcpdiff checkdoes the rest, locally and in CI - 🔍 Semantic diff engine — every schema change classified as 🔴 breaking, 🟡 warning, or 🟢 safe, with CI-friendly exit codes
- 🛡️ Tool-poisoning defense — description changes are never silent; they're warnings you review (OWASP MCP03)
- 🧪 Contract testing —
mcpdiff testverifies a live server actually conforms: schema conformance plus boundary inputs - ✍️ Signed baselines — Ed25519/RSA detached signatures with
sign/verify, enforceable in CI viacheck --verify-signature - 🧩 Multi-server aware — snapshot, diff, and conflict-check whole mcp.json compositions; render dependency graphs
- 🤖 CI-native — auto-detects GitHub Actions/GitLab/CircleCI, writes step summaries, ships a GitHub Action
The Problem
MCP servers expose tools, resources, and prompts to AI agents. These interfaces are defined by JSON schemas — but there's no mechanism to version, diff, or validate them. When a server updates:
- Breaking changes go unnoticed. A new required parameter silently breaks every agent using the tool.
- Description changes are invisible. Tool descriptions are the primary vector for tool poisoning attacks — and nobody reviews them.
- There's no contract to pin. Agents trust whatever schema the server serves at runtime, with no way to detect drift.
mcpdiff closes that gap: capture a baseline once, then every change to your server's interface is diffed, classified, and gated before it ships.
Quick Start
# One-command setup: writes mcpcontracts.json and captures the baseline
npx @mcp-contracts/cli init --command node --args ./my-server/dist/index.js
# From then on — zero flags, locally and in CI
npx @mcp-contracts/cli check
init writes a project config so every later invocation knows your server and baseline, captures contracts/baseline.mcpc.json, and prints the CI snippet to paste into your workflow.
You can also diff two snapshots manually:
npx @mcp-contracts/cli snapshot --command "node ./my-server/dist/index.js" -o v1.mcpc.json
# ... make changes ...
npx @mcp-contracts/cli snapshot --command "node ./my-server/dist/index.js" -o v2.mcpc.json
npx @mcp-contracts/cli diff v1.mcpc.json v2.mcpc.json
Commands
Migrating from an older version?
ci,watch,baseline verify,baseline update,diff --live, andverify-hashstill work but are deprecated — they are replaced bycheck(with--watch),update, andverify(without--key), and will be removed in a future release.
mcpdiff test
Runs the contract test suite from @mcp-contracts/test against a live server: schema conformance (every tool in the contract exists with a matching schema) plus boundary input tests.
# With a project config, zero arguments
mcpdiff test
# Explicit contract and transport
mcpdiff test contract.mcpc.json --command node --args server.js
mcpdiff test contract.mcpc.json --url http://localhost:3000/mcp --allow-extra-tools
# Tune the suite
mcpdiff test --no-boundary --skip-tools delete_contact --timeout 60000
mcpdiff test --no-conformance # boundary tests only
mcpdiff test --ignore-descriptions # tolerate description drift
Exit codes: 0 = all pass, 1 = failures, 2 = error. The standalone mcp-test bin still works but points here; the @mcp-contracts/test package remains the library for vitest integration.
mcpdiff snapshot
Connects to an MCP server and captures its complete tool/resource/prompt interface as a .mcpc.json file.
# Via stdio transport
mcpdiff snapshot --command "node server.js" -o snapshot.mcpc.json
# Via HTTP transport
mcpdiff snapshot --url http://localhost:3000/mcp -o snapshot.mcpc.json
# From an mcp.json config file
mcpdiff snapshot --config ./mcp.json --server my-server -o snapshot.mcpc.json
# Snapshot every server in the config at once (one file per server)
mcpdiff snapshot --config ./mcp.json --all --out-dir contracts/
mcpdiff diff
Compares two snapshots and classifies every change as breaking, warning, or safe.
mcpdiff diff before.mcpc.json after.mcpc.json
# Fail CI on warnings too (stricter)
mcpdiff diff before.mcpc.json after.mcpc.json --fail-on warning
# Output as JSON for programmatic use
mcpdiff diff before.mcpc.json after.mcpc.json --format json
# Send results to a webhook
mcpdiff diff before.mcpc.json after.mcpc.json --webhook https://example.com/hook
# Diff every server in an mcp.json config against its baseline in one report
mcpdiff diff --config ./mcp.json --baseline contracts/
Exit codes: 0 = no breaking changes, 1 = breaking changes detected, 2 = error.
mcpdiff check-conflicts
Detects duplicate tool names across the servers of an mcp.json config. Duplicates with
identical schemas are reported as exact; same-named tools with different schemas are
conflicting — dangerous for agents that route tool calls by bare tool name.
mcpdiff check-conflicts --config ./mcp.json
# Only fail CI on schema conflicts, tolerate identical duplicates
mcpdiff check-conflicts --config ./mcp.json --fail-on conflicting
Exit codes: 0 = no collisions, 1 = collisions found, 2 = error.
mcpdiff graph
Renders a dependency graph of all servers in an mcp.json config: every server with its tools, plus overlap edges where two servers expose the same tool name.
mcpdiff graph --config ./mcp.json
# Mermaid or Graphviz output for docs
mcpdiff graph --config ./mcp.json --format mermaid
mcpdiff graph --config ./mcp.json --format dot
mcpdiff init
One-command onboarding: writes mcpcontracts.json, captures the initial baseline, and prints next steps including a copy-pasteable CI workflow.
# Non-interactive (also works in scripts/CI)
mcpdiff init --command node --args dist/index.js
mcpdiff init --url http://localhost:3000/mcp
mcpdiff init --config ./mcp.json --server my-server
# Interactive: offers servers from ./mcp.json, or asks for a command/URL
mcpdiff init
# Overwrite an existing mcpcontracts.json
mcpdiff init --force --command node --args dist/index.js
Without a TTY, missing information exits with code 2 instead of prompting. Nothing is written unless the initial capture succeeds.
mcpdiff check
The one command for "does the live server still match the baseline": captures a snapshot, diffs it against the baseline, reports, and sets the exit code. Works the same locally, in CI, and in watch mode.
# Check against the default baseline (contracts/baseline.mcpc.json)
mcpdiff check --command "node server.js"
# With a project config, zero flags
mcpdiff check
# Fail on warnings too (stricter)
mcpdiff check --fail-on warning
# Only show breaking changes
mcpdiff check --severity breaking
# Re-run on file changes during development
mcpdiff check --watch --watch-paths src --debounce 1000 --clear
# Require a valid signature on the baseline before diffing
mcpdiff check --verify-signature --signature-key ./public.pem
# Send results to a webhook
mcpdiff check --webhook https://example.com/hook
Auto-detects CI environments (GitHub Actions, GitLab CI, CircleCI) and selects the appropriate output format. Writes to GITHUB_STEP_SUMMARY when running in GitHub Actions. When the live contract is unchanged (matching content hashes), the diff engine is skipped entirely.
Exit codes: 0 = clean, 1 = changes at or above the --fail-on threshold, 2 = error.
mcpdiff update
Captures the live server and writes the baseline. The counterpart to check.
# Write the default baseline (contracts/baseline.mcpc.json)
mcpdiff update --command "node server.js"
# With a project config, zero flags
mcpdiff update
# Write to a custom path
mcpdiff update --baseline custom/path.mcpc.json --command "node server.js"
For ad-hoc snapshots that are not baselines, use mcpdiff snapshot -o <file>.
mcpdiff sign
Signs a snapshot with a private key, producing a detached .mcpc.sig file.
# Sign with Ed25519
mcpdiff sign contracts/baseline.mcpc.json --key ./private.pem
# Sign with RSA
mcpdiff sign contracts/baseline.mcpc.json --key ./rsa-private.pem
# Write signature to a custom path
mcpdiff sign snapshot.mcpc.json --key ./private.pem -o custom/path.mcpc.sig
The sign command verifies the content hash before signing — it will refuse to sign a snapshot whose content has been tampered with.
mcpdiff verify
Verifies the integrity/authenticity of a snapshot file.
# Content hash check only — no keys needed
mcpdiff verify snapshot.mcpc.json
# Full signature verification (auto-discovers the .mcpc.sig next to the snapshot)
mcpdiff verify contracts/baseline.mcpc.json --key ./public.pem
# Explicit signature path
mcpdiff verify snapshot.mcpc.json --key ./public.pem --signature custom/path.mcpc.sig
# JSON output reports which checks ran: ["hash"] or ["hash", "binding", "signature"]
mcpdiff verify snapshot.mcpc.json --format json
Without --key, only the content hash is recomputed and compared (the output says so, so the weaker check is never mistaken for the stronger one). With --key, three checks run: content hash integrity, hash binding (signature matches this snapshot), and cryptographic verification. Exit code 0 on success, 1 on failure.
mcpdiff inspect
Summarizes a snapshot file.
mcpdiff inspect snapshot.mcpc.json
mcpdiff inspect snapshot.mcpc.json --tools
mcpdiff inspect snapshot.mcpc.json --resources
mcpdiff inspect snapshot.mcpc.json --prompts
mcpdiff inspect snapshot.mcpc.json --schema create_contact
Transport Options
All commands that connect to a live server accept these transport options:
# Stdio: command, arguments, and environment variables
mcpdiff snapshot --command node --args dist/index.js --env API_KEY=secret -o snapshot.mcpc.json
# SSE transport (instead of default streamable-http)
mcpdiff snapshot --url http://localhost:3000/sse --sse -o snapshot.mcpc.json
# Custom HTTP headers (repeatable)
mcpdiff snapshot --url http://localhost:3000/mcp \
--header "Authorization: Bearer token" \
--header "X-Custom: value" \
-o snapshot.mcpc.json
| Flag | Description |
|---|---|
--command <cmd> |
Server command to run via stdio transport |
--args <args...> |
Arguments for the server command |
--env <pairs...> |
Environment variables for the server as KEY=VALUE pairs |
--url <url> |
Server URL for streamable-http or SSE transport |
--sse |
Use SSE transport instead of streamable-http (requires --url) |
--header <header...> |
Custom HTTP headers as "Key: Value" (repeatable) |
--config <path> |
Path to mcp.json config file |
--server <name> |
Server name from config file |
Global Options
Available on every command, in any position (mcpdiff check --format json and mcpdiff --format json check both work):
| Flag | Description |
|---|---|
--format <format> |
Output format: terminal | json | markdown (default: terminal on a TTY, json otherwise) |
-o, --output <path> |
Write output to a file instead of stdout |
--no-color |
Disable colored output |
--quiet |
Suppress non-essential output |
--verbose |
Show detailed information |
--project <path> |
Path to mcpcontracts.json (default: discovered by walking up from the CWD) |
-V, --version |
Print the CLI version |
Project Config (mcpcontracts.json)
Instead of repeating transport and baseline flags on every invocation, put them in an mcpcontracts.json at your project root. It is discovered by walking up from the current directory (like tsconfig.json), or passed explicitly with the global --project <path> option.
{
// How to reach your server — exactly one of the three transport shapes:
"server": {
"command": "node",
"args": ["dist/index.js"],
"env": { "API_KEY": "..." }
// or: "url": "http://localhost:3000/mcp", "sse": true, "headers": { "Authorization": "..." }
// or: "config": "./mcp.json", "name": "my-server"
},
"baseline": "contracts/baseline.mcpc.json",
"failOn": "breaking",
"watch": { "paths": ["src"], "debounce": 500 }
}
With the config in place, the repeated commands need no flags at all:
mcpdiff update # knows the server + baseline path
mcpdiff check # zero flags locally and in CI
mcpdiff check --watch # zero flags in dev
The config is read by every command that connects to a live server: check, update, and snapshot. Pure file commands (diff a b, inspect, sign, verify) ignore it.
Precedence: explicit CLI flags > config file > built-in defaults. Passing any transport flag (--command, --url, --config, …) ignores the config's server block entirely — the two sources are never partially merged. failOn and the watch block apply to check.
Paths in the config (baseline, watch.paths, server.config) are resolved relative to the config file's directory, so commands behave the same from any subdirectory.
Validation is strict: unknown keys are an error (catching typos like "failon"), and an invalid config exits with code 2 and a message naming the file and the offending key — even when flags would have overridden it.
Why Description Changes are Warnings
Tool descriptions aren't just documentation — they're instructions to the model. A changed description can embed hidden prompt injections that alter how an agent uses the tool, without changing the schema at all. This is OWASP MCP03:2025 — Tool Poisoning.
mcpdiff flags every description change as a warning and shows you a readable diff of what changed, so you can review it before deploying.
CI Integration
GitHub Action
The easiest way to integrate with GitHub is the official action:
# .github/workflows/mcp-contract.yml
name: MCP Contract Check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mcp-contracts/github-action@v0
with:
baseline: contracts/baseline.mcpc.json
command: node dist/index.js
fail-on: breaking # or "warning" / "safe"
comment-on-pr: true
github-token: ${{ secrets.GITHUB_TOKEN }}
# Optional: verify baseline signature before diffing
verify-signature: true
signature-key: ${{ secrets.MCP_PUBLIC_KEY }}
Outputs: has-changes, has-breaking, summary, exit-code — use them in subsequent steps.
CLI in CI
Use mcpdiff check directly in any CI system:
# .github/workflows/mcp-contract.yml
name: MCP Contract Check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install -g @mcp-contracts/cli
- run: mcpdiff check --baseline contracts/baseline.mcpc.json --command "node dist/index.js"
# Or with a committed mcpcontracts.json, zero flags
- run: mcpdiff check
# Or with signature verification
- run: >
mcpdiff check
--baseline contracts/baseline.mcpc.json
--command "node dist/index.js"
--verify-signature
--signature-key ./public.pem
The check command auto-detects the CI environment and selects the right output format (markdown for GitHub Actions, JSON otherwise). It also writes to GITHUB_STEP_SUMMARY automatically.
Packages
| Package | Description |
|---|---|
@mcp-contracts/core |
Snapshot types, diff engine, classification logic |
@mcp-contracts/cli |
The mcpdiff CLI tool |
@mcp-contracts/test |
Contract testing library: conformance + boundary runner, vitest matchers |
mcp-contracts is an open-source project (MIT license). It's community tooling for the MCP ecosystem, not affiliated with Anthropic or the MCP project. Contributions and feedback are welcome.
License
MIT
No comments yet
Be the first to share your take.