Jack MVersion MCP tool schemas without breaking agents. Learn compatibility rules, schema snapshots, adapters, contract tests, staged rollouts, and rollback checks.
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.
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.
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.”
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:
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.
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:
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.
A byte diff says that something changed. A compatibility classifier says whether it can break work.
Your classifier should flag at least these conditions:
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.
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.
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.
Schema validation alone is necessary but not enough. Add three test layers to each MCP tool.
Compare tools/list against the approved manifest. This catches accidental schema regeneration after a Pydantic, Zod, OpenAPI, or SDK upgrade.
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.
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.
A careful rollout makes the blast radius small and observable.
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.
Before merging an MCP tool change, answer these questions:
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.
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.
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.
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.
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.
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.
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.