Skip to main content

mcp-contract

PyPI CI Python License: MIT

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.

(Illustrative — a made-up server, to show the shape of a report. Below is a real run.)

What it actually prints

mcp-contract recording the tool surface of the public @modelcontextprotocol/server-memory: nine tools with their arguments and typed results, including nested fields like entities[].entityType resolved through $ref

That is mcp-contract show against a public, version-pinned server — reproduce it yourself with one command, no credentials:

uvx mcp-contract show -- npx -y @modelcontextprotocol/server-memory@2026.1.26

Note the nested rows (entities[].entityType, observations[].contents): the surface is recorded through $ref indirection, so a change buried inside a referenced model still shows up as a change in the promise. Probing is read-only — no tool is ever called.

The image is regenerated by scripts/make_demo_svg.py, not drawn by hand.

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.

What a tool promises about itself

A server can advertise how a tool behaves, separately from what it takes:

{ "name": "export_rows",
  "annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true } }

Callers act on these before ever reading a schema — an agent host auto-approves a read-only tool, retries an idempotent one, asks for confirmation on a destructive one. So flipping a hint changes what is safe to do with the tool while every argument stays byte-identical:

$ mcp-contract check -- ./server

breaking (2)
  behaviour-read_only-reversed  export_rows → read_only
      the tool no longer promises to be read-only; a caller that auto-approved
      it on that basis is now approving a write
  behaviour-destructive-reversed  export_rows → destructive
      the tool now declares itself destructive; a caller that ran it unattended
      was not expecting that

That is the tool-poisoning shape — a server that looks safe at approval time and changes afterwards. Moving a hint away from the value that grants callers more freedom is breaking; moving toward it is additive.

Crucially, every hint has a documented default — readOnlyHint false, destructiveHint true, idempotentHint false, openWorldHint true — so an omitted hint is a promise, not a silence. Omissions are resolved to those defaults before anything is judged. A server that merely starts writing destructiveHint: true has published nothing new, and gets a cosmetic note rather than an alarm; and because destructiveHint and idempotentHint are meaningful "only when readOnlyHint == false", they are skipped entirely for a tool that is read-only on both sides.

execution.taskSupport follows the same logic: optional accepts callers of both styles, so arriving there never breaks anyone and leaving it drops whichever style was just refused.

How much is out there varies. The public @modelcontextprotocol/server-memory sets title on every tool and taskSupport: forbidden, but no annotation hints at all — for those there is simply nothing to diff, which is correct rather than a miss.

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.

Nested arguments

A tool that takes a model rather than a handful of scalars advertises a $ref:

{ "properties": { "filters": { "$ref": "#/$defs/Filters" } },
  "$defs": { "Filters": { "properties": { "city": {"type": "string"} }, "required": ["city"] } } }

That's what any Pydantic model compiles to, so it's the normal shape, not an exotic one. The fields a caller actually has to get right live behind the reference — so they're followed, and recorded with dotted names:

filters          object    required
filters.city     string    required
filters.year     integer   required
limit            integer

Rename city and you get argument-removed filters.city — breaking, exactly as a top-level rename is. Lists of models get bracket notation (tags[].name). Nested required-ness is relative to its parent: filters.city being required means a caller who supplies filters must include city, whatever filters' own required-ness is.

Resolution is bounded and never quiet about it. Nesting stops at four levels, a model that refers to itself stops where it loops, an external $ref is not fetched, and an anyOf with two object branches isn't guessed at — each of those prints under not recorded rather than being dropped in silence.

Contract files carry a format number. One recorded before nested fields were tracked (mcp-contract ≤ 0.3.0) never promised anything about them, so check holds itself to what that file actually claimed and tells you to re-snapshot — upgrading this tool never reports a breaking change on a server that didn't change.

Honest about the edges

  • It compares tools (input arguments — nested ones included — and output fields), prompts (presence and their arguments), and the presence of resources.
  • 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|null is widening (safe), the reverse is narrowing (breaking).
  • Per-call metadata is deliberately excluded. The ttlMs and cacheScope fields the 2026-07-28 spec requires on list responses are cache hints that can differ between two probes of an unchanged server; recording them would make every check a diff about nothing.
  • Listings are paginated and the SDK doesn't follow the cursor for you, so every page is read. A partial read would be worse than useless here: a tool that merely sat on page two would come back as removed.
  • A server that doesn't offer prompts or resources answers method-not-found, and recording nothing is the right reading of that. Any other failure of a listing is printed rather than read as "there are none" — those are different facts, and only one of them is safe to write into a contract.
  • Re-snapshotting is how you accept a change deliberately. Nothing is rewritten behind your back.
  • It speaks both MCP Python SDK spellings (inputSchema and input_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-contract watches the tools an agent calls; claude-skills-doctor watches 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

Metadata

Release files for mcp-contract 0.6.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcp-contract 0.6.0
File Size Uploaded
mcp_contract-0.6.0.tar.gz 126.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcp-contract 0.6.0
File Interpreter ABI Platform
mcp_contract-0.6.0-py3-none-any.whl Python 3 none any Details

Total release size: 155.0 kB

Release files / mcp_contract-0.6.0.tar.gz

Download URL mcp_contract-0.6.0.tar.gz
Size 126.4 kB
Tags Source
SHA-256 checksum
How to use checksums
badebce55d3c49b055917241a4170e7062c9bd9b85aa083a591bbd7b93db4a63
BLAKE2b-256 checksum
How to use checksums
50024730eec28a8e55e6ffdcde569f5d86f373c212a96c8ecaedc839c9490854
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 1, 2026.

Transparency log

Release files / mcp_contract-0.6.0-py3-none-any.whl

Download URL mcp_contract-0.6.0-py3-none-any.whl
Size 28.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1f47b422a9a5b280a662f6351b9cda46c1fa8430c51726e9105b49d7505a89f1
BLAKE2b-256 checksum
How to use checksums
3047425d7a74310953f0cdd71c58c765ddd11926b5ebba2f4dd95fd361c8d2ef
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.6.0 This release

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page