An agent that worked yesterday can start failing after a change that looks harmless in a pull request: rename location to query, make a field required, or reshape a result object. The model has not suddenly become worse. It is still following the tool contract it saw before the deployment.
That is why MCP schema versioning needs the same care as a public API migration. Tool names, descriptions, input JSON Schema, output shape, and runtime behavior all influence an agent's next action. A server can successfully deploy while a cached client, a long-running workflow, or a prompt template continues to make yesterday's call.
This guide shows a practical way to evolve MCP tools without turning routine backend work into mysterious agent failures. The goal is not to freeze every tool forever. It is to make changes visible, compatible when possible, and reversible when they are not.
Separate protocol versions from tool-schema versions
MCP has protocol-version compatibility rules. Clients and servers use those rules to agree on the protocol revision they speak. That does not automatically version your application tools.
For example, both of these servers might support the same MCP protocol revision:
// Yesterday
{
"name": "find_customer",
"inputSchema": {
"type": "object",
"properties": { "email": { "type": "string" } },
"required": ["email"]
}
}
// Today
{
"name": "find_customer",
"inputSchema": {
"type": "object",
"properties": { "email": { "type": "string" }, "tenant_id": { "type": "string" } },
"required": ["email", "tenant_id"]
}
}
The protocol can be perfectly compatible while old agent calls now fail validation. Treat the tool schema as its own public contract. Give it an owner, a baseline, a change review, and a migration path.
Know which changes break agents
Start every tool change by asking a narrow question: can a call that was valid yesterday be rejected or mean something different today?
| Change | Compatibility | Safe response |
|---|---|---|
| Add an optional input with a default | Usually compatible | Release normally; snapshot the change. |
| Add a new tool | Usually compatible | Keep existing tools stable and test tool selection. |
| Add a required input | Breaking | Keep the old input contract or introduce a versioned tool. |
| Rename/remove an input | Breaking | Accept the old name during a deprecation window. |
| Narrow a type or enum | Breaking | Preserve old values or map them explicitly. |
| Rename/remove an output field | Often silently breaking | Return both fields until clients migrate. |
| Change a description | Behavior-changing | Review it like code; models use descriptions to choose tools. |
| Keep the schema but change the business meaning | Breaking | Publish a new contract and require a migration. |
The last two are easy to miss. A description is not merely documentation when it enters an agent's context. And a JSON Schema diff cannot see that status: "complete" now means “queued” instead of “executed.”
Make dynamic discovery a controlled deployment boundary
MCP makes tool discovery convenient, but convenience can hide a release. If an agent fetches a live tools/list response on every connection, an upstream definition can change between two user sessions without a code deployment in the agent service.
Choose deliberately which of these modes each integration uses:
- Pinned: the agent uses an approved tool manifest for its release. This is the safest default for write-capable tools.
- Monitored dynamic: the agent discovers live tools, but a fingerprint diff creates an alert and can disable a changed high-risk tool.
- Fully dynamic: the agent accepts current definitions immediately. Reserve this for low-risk, read-only experimentation.
Pinning does not mean copying a tool implementation. It means recording the interface your agent was evaluated against. Refresh that manifest through the same review process as an SDK upgrade. For dynamic integrations, record the discovered fingerprint with the call trace; otherwise, a later investigation cannot distinguish a prompt regression from an upstream contract change.
This boundary also helps with tool selection. Adding ten unrelated tools may not invalidate any JSON payload, yet it can change which tool the model chooses. Keep the exposed tool set small per workflow, and run a representative selection test whenever the manifest changes.
Build a schema snapshot before you need it
Do not discover the old contract by reading a production incident. In CI, call tools/list, normalize the result, and commit a snapshot for every supported server and environment.
import { createHash } from "node:crypto";
function stableToolFingerprint(tool: unknown) {
const canonical = JSON.stringify(tool, Object.keys(tool as object).sort());
return createHash("sha256").update(canonical).digest("hex");
}
const current = (await client.listTools()).tools.map((tool) => ({
name: tool.name,
fingerprint: stableToolFingerprint({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
outputSchema: tool.outputSchema
})
}));
In real code, use a recursive canonical JSON serializer; sorting only top-level keys is not sufficient. Keep two artifacts:
- A readable JSON file for review.
- A fingerprint manifest for a fast deploy check.
Avoid storing credentials, tenant examples, or live tool results in the snapshot. The contract should be enough to detect a change without copying sensitive data into the repository.
Diff by risk, not just by bytes
A byte diff says that something changed. A compatibility classifier says whether it can break work.
Your classifier should flag at least these conditions:
- a tool disappeared or was renamed;
- a property was removed or became required;
- a type, numeric range, regex, or enum narrowed;
- a default value changed;
- an output property disappeared or changed type;
- a description changed for a high-impact tool;
- an authorization, side-effect, or idempotency expectation changed.
Put the result in the pull request. A useful summary is more actionable than a large JSON patch:
BREAKING: find_customer now requires tenant_id
COMPATIBLE: list_invoices gained optional page_size (default 50)
REVIEW: refund_payment description changed; execution remains side-effecting
For tools that create records, move money, send messages, or alter infrastructure, make a breaking diff block the release until an approved migration plan is attached.
Prefer adapters over abrupt replacements
Suppose location is no longer precise enough and your API now needs latitude and longitude. Do not immediately force every agent to learn the new fields. Accept the old call, translate it, and emit a deprecation signal that your logs can count.
type WeatherArgs =
| { location: string; unit?: "c" | "f" }
| { lat: number; lng: number; unit?: "c" | "f" };
function normalizeWeatherArgs(args: WeatherArgs) {
if ("location" in args) {
return {
kind: "legacy" as const,
query: { place: args.location, unit: args.unit ?? "c" }
};
}
return {
kind: "current" as const,
query: { lat: args.lat, lng: args.lng, unit: args.unit ?? "c" }
};
}
The adapter is a temporary compatibility boundary, not permanent clutter. Give it a removal date and measure use of the legacy branch. If old calls are still common, find out where they originate: a scheduled workflow, a prompt stored in a database, a client that caches tools, or an integration you do not control.
When the semantics truly differ, use a new tool name such as get_weather_by_coordinates rather than overloading the old name. An agent can reason about two clear options better than one tool with surprising behavior.
Version outputs as carefully as inputs
Input errors usually fail loudly. Output changes often fail silently.
Imagine a tool used to return this:
{ "ok": true, "ticket_id": "T-42" }
Then a refactor changes it to:
{ "state": "accepted", "job_id": "J-96" }
An agent may interpret missing ok as success, retry unnecessarily, or tell the user a task is complete before it runs. During a transition, return an envelope that preserves the old fields while introducing the new ones:
{
"ok": true,
"ticket_id": "T-42",
"state": "accepted",
"job_id": "J-96",
"deprecation": { "fields": ["ok", "ticket_id"], "remove_after": "release-2027.03" }
}
Use a release identifier or a policy window in that metadata, rather than a vague promise to remove it “soon.” Validate that old and new fields always agree while both exist.
Test three layers of the contract
Schema validation alone is necessary but not enough. Add three test layers to each MCP tool.
1. Snapshot tests
Compare tools/list against the approved manifest. This catches accidental schema regeneration after a Pydantic, Zod, OpenAPI, or SDK upgrade.
2. Compatibility fixtures
Run old valid payloads and current payloads through the handler. The old fixture should either succeed through the adapter or fail with an explicit, actionable migration error. Never let an old call silently map to a different action.
3. Behavioral contract tests
Assert the promise that JSON Schema cannot express: a read tool does not write, a dry run does not execute, a retry does not duplicate a charge, and a returned accepted state does not mean completed.
it("keeps legacy lookup behavior during migration", async () => {
const result = await callTool("find_customer", { email: "a@example.test" });
expect(result.isError).toBe(false);
expect(result.structuredContent.customer).toMatchObject({ email: "a@example.test" });
expect(result.structuredContent.deprecation).toBeDefined();
});
Use synthetic tenants and fake side-effect adapters for these tests. Contract tests should be safe enough to run on every pull request.
Roll out schema changes in stages
A careful rollout makes the blast radius small and observable.
- Inventory consumers. Find interactive agents, scheduled jobs, API clients, prompt templates, and external integrations that call the tool.
- Ship additive support. Add the new shape or new tool while the old contract remains valid.
- Release the snapshot and fixtures. Make the intended diff visible in code review.
- Canary by tenant or workflow. Send a limited, reversible population to the new contract.
- Observe tool errors and outcome errors. Track validation failures, fallback use, retries, and task completion—not only HTTP status.
- Notify and deprecate. State the replacement, an example payload, and the removal condition.
- Remove only after evidence. Delete the adapter after legacy traffic stays at zero for the defined window.
Keep a server build identifier and tool-manifest fingerprint in every trace. When a failure appears, this lets an engineer answer “which contract did the agent see?” without reconstructing a deployment timeline.
A small release checklist
Before merging an MCP tool change, answer these questions:
- Does any formerly valid request now fail or mean something different?
- Did input, output, description, side effect, authorization, or idempotency change?
- Is the schema snapshot updated and classified by compatibility risk?
- Do old and new fixtures run in CI?
- Is there an adapter, a new tool name, or an explicit migration error?
- Can the change be canaried and rolled back without deleting state?
- Is there an owner and a measurable removal condition for deprecated behavior?
If any answer is “no,” the tool is not ready for a routine deployment. That is not bureaucracy. It is how you prevent an API change from masquerading as an agent reliability problem.
FAQ
What is MCP schema versioning?
MCP schema versioning is the discipline of tracking and evolving an MCP tool's name, description, input schema, output shape, and behavior without breaking agents that use an older contract. It is separate from the MCP protocol version negotiated by clients and servers.
Is adding a required MCP tool parameter a breaking change?
Yes. Any older call that omits the new parameter can fail validation. Prefer an optional field with a safe default, a compatibility adapter, or a new tool name when the new information is essential.
Should MCP tool descriptions be versioned too?
Yes. Agents use descriptions to select tools and construct arguments, so a description change can alter behavior even if the JSON Schema is identical. Snapshot descriptions and require review for changes to sensitive or side-effecting tools.
How long should an MCP schema deprecation window last?
Base it on observed legacy traffic and the longest-running consumer in your system. Define a minimum window, publish a removal condition, and remove the adapter only after telemetry shows the old shape is unused for that period.
Can JSON Schema tests catch every breaking agent change?
No. They catch structural differences, but not changed business meaning, tool-selection behavior, authorization assumptions, or unwanted side effects. Pair schema snapshots with compatibility fixtures and behavioral contract tests.
Should I create a new MCP tool instead of changing an old one?
Create a new tool when the input or output semantics change materially. For a small additive change, keep the existing tool and use an optional field. A clearly named second tool is safer than one overloaded tool whose old calls now do something surprising.
Top comments (0)