mcp-contract
Contract testing for MCP servers — catch a breaking change before your users do.
Your MCP server's tool surface is a promise: these tools exist, they take these arguments, these ones are required. Agents are written against that promise. Rename an argument and every one of them breaks — silently, in somebody else's pipeline, with no test of yours turning red.
mcp-contract records the promise, and holds you to it.
$ mcp-contract check -- my-mcp-server
breaking (2)
tool-removed export_csv
the tool is gone; an agent that depends on it fails
argument-now-required parse_catalog → pages
was optional, now required; every call that omitted it breaks
routing (1)
tool-description-changed diff
no call breaks, but the agent routes on this text — it may stop choosing
this tool, or start choosing it for the wrong task
3 tool(s) · 2 breaking · 1 routing
Breaking: agents built against the recorded contract will fail.
Install
uvx mcp-contract # run without installing
pip install mcp-contract # or install it
Use
mcp-contract snapshot -- my-mcp-server # record the surface; commit the file
mcp-contract check -- my-mcp-server # non-zero when a change breaks callers
mcp-contract show -- my-mcp-server # just look, record nothing
The server command goes after --, so its own flags are never mistaken for ours. It's started the way your users start it — as a subprocess over stdio — and asked for tools/list. No tool is ever called, so checking is side-effect free.
Commit mcp-contract.json. Then the diff a reviewer sees in a pull request is the change in the promise.
- run: uvx mcp-contract check -- my-mcp-server
The three kinds of change
Every diff is noise unless you say who it hurts. Changes are classified by what they do to a caller already in the wild:
| breaking | An existing valid call stops working, or a promised result changes out from under a caller: a tool, argument, resource or prompt disappears; an argument is removed; an optional argument becomes required; an input type narrows; an output field is removed, becomes optional, or widens (the caller may now receive a value it didn't handle). Exits non-zero. |
| additive | New surface nobody was using yet: a new tool, a new optional argument, a widened input type, a new output field. Reported, never fatal. |
| routing | The schema is untouched but a description changed. See below. |
| cosmetic | The server's version string. Noise. |
Note the mirror: for an input argument, widening the accepted type is safe and narrowing it breaks callers; for an output field, it's the reverse — widening what you might return can break a caller that only handled the narrower shape. mcp-contract judges each from the caller's side.
Why "routing" is its own class
An agent doesn't read your JSON Schema to decide whether to call a tool — it reads the description. Reword it and no call breaks, no schema differs, every contract test in the ordinary sense passes... and the agent may quietly stop choosing that tool, or start choosing it for the wrong task. That's a real behavioural change a schema diff cannot see, so it gets named rather than buried. It doesn't fail the build by default; --strict is how you say it should.
Honest about the edges
- It compares tools (input arguments and output fields), plus the presence of resources and prompts. Prompt arguments aren't diffed field-by-field yet — only whether the prompt still exists.
- Output schemas are only as detailed as the server advertises. A server that returns an untyped object (
additionalProperties: true, common with dict-returning FastMCP tools) has no output fields to diff — that's correct, not a miss. - It reads what the server advertises. Whether a tool still behaves correctly is a different question, and this doesn't answer it.
- Type comparison is structural:
string→string|nullis widening (safe), the reverse is narrowing (breaking). Each argument is compared at the top level of its schema. - Re-snapshotting is how you accept a change deliberately. Nothing is rewritten behind your back.
- It speaks both MCP Python SDK spellings (
inputSchemaandinput_schema), because the SDK renamed them and servers in the wild use both — which is, more or less, the argument for this project.
Related
- claude-skills-doctor — the same idea one layer up.
mcp-contractwatches the tools an agent calls;claude-skills-doctorwatches the skills it can reach — the silent 15,000-char discovery budget, and descriptions that collide so Claude picks the wrong one. Both treat the text an agent routes on as a contract worth testing.
More tools by Eren Gülmez.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mcp_contract-0.2.0.tar.gz.
File metadata
- Download URL: mcp_contract-0.2.0.tar.gz
- Upload date:
- Size: 103.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
246ceddca0d934f42daafbb189ae300a5ff035de9f8d1b1d6baa8e74ee89ce62
|
|
| MD5 |
534c3d01dcf440df4c60854d3c558f5d
|
|
| BLAKE2b-256 |
1203a64a3da2e7a921600b7b9299f403ffed91ead672266c22b7af2e0747e8d1
|
Provenance
The following attestation bundles were made for mcp_contract-0.2.0.tar.gz:
Publisher:
release.yml on gulmezeren2-byte/mcp-contract
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcp_contract-0.2.0.tar.gz -
Subject digest:
246ceddca0d934f42daafbb189ae300a5ff035de9f8d1b1d6baa8e74ee89ce62 - Sigstore transparency entry: 2288414243
- Sigstore integration time:
-
Permalink:
gulmezeren2-byte/mcp-contract@c49ce0efe507fe6ca7de0b28ebf55b9bd67a0286 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/gulmezeren2-byte
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c49ce0efe507fe6ca7de0b28ebf55b9bd67a0286 -
Trigger Event:
push
-
Statement type:
File details
Details for the file mcp_contract-0.2.0-py3-none-any.whl.
File metadata
- Download URL: mcp_contract-0.2.0-py3-none-any.whl
- Upload date:
- Size: 17.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b5b023f64f1968e35edc436a0781e86db111b472208ac1dfb178c12b07e87b91
|
|
| MD5 |
db80f1430fce11d1667e3a2e68a3444d
|
|
| BLAKE2b-256 |
207df97c98c5395fd280d4e08421e7a7ce51976a4f11b8954fa3d5fec54de6e8
|
Provenance
The following attestation bundles were made for mcp_contract-0.2.0-py3-none-any.whl:
Publisher:
release.yml on gulmezeren2-byte/mcp-contract
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcp_contract-0.2.0-py3-none-any.whl -
Subject digest:
b5b023f64f1968e35edc436a0781e86db111b472208ac1dfb178c12b07e87b91 - Sigstore transparency entry: 2288414280
- Sigstore integration time:
-
Permalink:
gulmezeren2-byte/mcp-contract@c49ce0efe507fe6ca7de0b28ebf55b9bd67a0286 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/gulmezeren2-byte
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c49ce0efe507fe6ca7de0b28ebf55b9bd67a0286 -
Trigger Event:
push
-
Statement type: