The Tool Contract Is the Product: Preventing MCP Schema Drift in Production Agents
An MCP server can be online, authenticated, and returning HTTP 200 while quietly breaking the agent that depends on it.
The failure often starts with a small change: a parameter becomes required, an enum value changes, a result field is renamed, a tool description grows until it dominates selection, or a state handle expires without an actionable error. The server still “works” when tested manually. The agent, however, is now operating against a different contract.
For tool-using systems, the schema is not merely documentation. It is part of the runtime interface between the server, client, model, policy layer, and downstream application. Treating it like an unversioned implementation detail is how silent regressions reach production.
If an agent can call it, its contract needs a test, a version, and a compatibility policy.
Why MCP drift is different from ordinary API drift
A conventional API client usually has typed code, explicit request construction, and a visible failure when a field changes. An agent has another participant in the loop: the model interprets names, descriptions, schemas, annotations, and error text to decide what to call next.
That creates at least four contracts:
- Discovery contract: which tools exist, how they are named, and how they are described.
- Input contract: which arguments are valid, required, bounded, and authorized.
- Output contract: what structured content, text, errors, and state handles mean.
- Behavior contract: what side effects, retries, idempotency rules, permissions, and audit events occur.
A change can be structurally compatible but behaviorally breaking. For example, adding a second search tool may leave every old schema valid while making the model choose the wrong tool. A description that changes from “update one draft” to “update records” can alter the model’s perceived scope even though the JSON schema is unchanged.
A current practitioner discussion in r/mcp describes an MCP schema changing overnight without a warning, causing an agent that had worked for weeks to fail silently. Another production report describes wrong tool selection, oversized descriptions, expiring OAuth ownership, and context-cost growth when many MCP servers are loaded together. These are qualitative reports rather than benchmarks, but they expose the operational gap: tool discovery itself needs observability and release control. 1
What the current MCP contract contains
The current MCP tools specification defines a tool with a unique name, description, inputSchema, optional outputSchema, and optional annotations. Input and output schemas follow JSON Schema. Servers that return structuredContent must conform to the declared output schema, and clients should validate results before passing them to the model. 3
The protocol also has important compatibility behavior around inventory and state. Servers that declare the relevant capability should notify clients when the tool list changes. Stateful workflows should use explicit handles as ordinary arguments rather than relying on an implicit connection session. Servers must authorize a handle on every call, define its lifetime, and return an actionable error when it expires. 3
The July 2026 specification changes make this even more relevant to release engineering. MCP moved toward stateless operation, added server discovery, encouraged deterministic tool ordering, added cache freshness metadata, expanded JSON Schema 2020-12 support, and clarified $ref resolution. A client that only tests a happy-path tools/call is not testing the compatibility surface. 4
Build a contract snapshot
The first practical step is to snapshot what the server advertises in a test or staging environment. Store the protocol version, server identity, capabilities, tool list, descriptions, annotations, input schemas, output schemas, and representative error shapes.
A compact snapshot can look like this:
{
"server": {
"name": "crm-tools",
"version": "2.4.1"
},
"protocol": "2026-07-28",
"tools": [
{
"name": "crm.update_draft",
"description": "Update one draft field for an authorized record.",
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": true
},
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"record_id": { "type": "string" },
"field": { "type": "string", "enum": ["next_action"] },
"value": { "type": "string", "maxLength": 500 }
},
"required": ["record_id", "field", "value"]
},
"outputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"record_id": { "type": "string" },
"updated": { "type": "boolean" },
"request_id": { "type": "string" }
},
"required": ["record_id", "updated", "request_id"]
}
}
]
}
Keep snapshots in version control or an artifact store. A snapshot is not a replacement for tests; it is the input to compatibility analysis and a way to explain exactly what changed.
Classify changes before you block a release
Not every change deserves a major version. Define compatibility rules for your environment and make them executable.
| Change | Default classification | Why |
|---|---|---|
| Add an optional input property | Compatible | Existing calls remain valid |
| Add a new read-only tool | Review | May alter model tool selection |
| Add a required input property | Breaking | Existing calls become invalid |
| Remove a tool or rename it | Breaking | Existing clients cannot discover it |
| Narrow an enum | Breaking | Previously valid calls fail |
| Narrow max/min bounds | Breaking | Previously valid values fail |
| Widen output with optional fields | Usually compatible | Consumers should ignore unknown fields |
| Remove or rename an output field | Breaking | Structured consumers may fail |
| Change description or annotations | Behavioral review | Model selection and approval may change |
Change error text or isError behavior |
Behavioral review | Retry and recovery behavior may change |
| Change handle lifetime or authorization | Operationally breaking | Long-running workflows may fail |
This table is intentionally conservative. For an agent, a “compatible” structural change can still need a behavior evaluation if it changes what the model sees or how it chooses tools.
Test the producer and the consumer
A server-side unit test proves that a handler works. A contract test proves that the server still fulfills the interface its clients expect. An agent evaluation proves that the model can select and use that interface appropriately.
Use a layered pyramid:
- Unit: validate the handler with deterministic dependencies.
- Contract: compare advertised schemas and error behavior with the approved snapshot.
- Integration: test the adapter against realistic backend data, pagination, empty states, and outages.
- Authorization: test tenant, role, ownership, and negative access cases.
- Workflow: exercise multi-tool state transitions, retries, handles, and idempotency.
- Agent behavior: test tool choice, argument minimization, clarification, and overreach.
Cisco’s MCP testing methodology proposes a similar five-layer approach, moving from individual tools to contract verification, multi-tool integration, end-to-end scenarios, and AI-powered behavioral analysis. 5
The official TypeScript SDK demonstrates an important implementation property: one Zod input schema can derive the advertised JSON Schema, validate arguments before the handler runs, and infer handler types. An output schema can validate structuredContent before it leaves the server. This reduces duplicate definitions, but it does not eliminate the need to test the wire contract and the behavior of real clients. 6
A minimal contract gate in TypeScript
The following example compares a live tool snapshot against a committed baseline. It blocks obvious breaking changes while leaving description changes for a separate behavioral review.
import { readFile } from "node:fs/promises";
type JsonSchema = {
type?: string;
required?: string[];
properties?: Record<string, JsonSchema>;
enum?: unknown[];
additionalProperties?: boolean;
[key: string]: unknown;
};
type Tool = {
name: string;
description?: string;
inputSchema: JsonSchema;
outputSchema?: JsonSchema;
};
type Snapshot = { tools: Tool[] };
function required(schema: JsonSchema): Set<string> {
return new Set(schema.required ?? []);
}
function breakingInputChanges(oldSchema: JsonSchema, nextSchema: JsonSchema): string[] {
const failures: string[] = [];
const oldProps = oldSchema.properties ?? {};
const nextProps = nextSchema.properties ?? {};
const oldRequired = required(oldSchema);
const nextRequired = required(nextSchema);
for (const field of oldRequired) {
if (!(field in nextProps)) failures.push(`removed required input: ${field}`);
}
for (const field of nextRequired) {
if (!oldRequired.has(field)) failures.push(`new required input: ${field}`);
}
for (const field of Object.keys(oldProps)) {
if (field in nextProps && JSON.stringify(oldProps[field]) !== JSON.stringify(nextProps[field])) {
failures.push(`changed input definition: ${field}`);
}
}
return failures;
}
function check(oldSnapshot: Snapshot, nextSnapshot: Snapshot): string[] {
const failures: string[] = [];
const oldTools = new Map(oldSnapshot.tools.map(tool => [tool.name, tool]));
const nextTools = new Map(nextSnapshot.tools.map(tool => [tool.name, tool]));
for (const [name, oldTool] of oldTools) {
const nextTool = nextTools.get(name);
if (!nextTool) {
failures.push(`removed tool: ${name}`);
continue;
}
failures.push(...breakingInputChanges(oldTool.inputSchema, nextTool.inputSchema)
.map(reason => `${name}: ${reason}`));
}
return failures;
}
const [oldPath, nextPath] = process.argv.slice(2);
if (!oldPath || !nextPath) throw new Error("usage: tsx contract-gate.ts old.json next.json");
const oldSnapshot = JSON.parse(await readFile(oldPath, "utf8")) as Snapshot;
const nextSnapshot = JSON.parse(await readFile(nextPath, "utf8")) as Snapshot;
const failures = check(oldSnapshot, nextSnapshot);
if (failures.length) {
console.error(failures.join("\n"));
process.exit(1);
}
console.log("MCP contract gate passed");
In a production system, extend this with output-schema compatibility, $ref resolution, enum and bound analysis, annotation changes, server discovery, protocol-version negotiation, and normalized error contracts.
Generate tests from schemas, then add semantic cases
Schema-derived tests are an efficient starting point. Specmatic’s MCP Auto-Test approach fetches tool definitions, generates inputs from declared schemas, invokes tools, validates outputs, and reports failures. Its resiliency mode explores enums, optional properties, nested objects, and permutations. 7
Generated tests are not sufficient by themselves. A schema can truthfully say that record_id is a string while the business rule requires the record to belong to the caller’s tenant. Add semantic fixtures for:
- valid input with the smallest permitted scope;
- missing, null, wrong-type, and out-of-range values;
- empty and partial backend responses;
- unauthorized tenant and role combinations;
- expired and unknown state handles;
- duplicate calls with the same idempotency key;
- downstream timeouts after a mutation may have committed;
- prompt-injection strings inside records, documents, and tool output;
- broad exports, unbounded date ranges, and suspicious pagination;
- tool calls that should be refused or replaced with a clarification question.
The MCP testing walkthrough linked below makes the same point: contract tests should cover valid and invalid arguments, predictable errors, authorization negatives, retries, idempotency, audit trails, and full agent tool choice—not only whether a handler returns success.
Test the behavior of descriptions and inventories
Schema compatibility cannot protect against every model-facing regression. Tool descriptions are part of the model’s selection context. Long, vague, overlapping, or promotional descriptions increase ambiguity and token cost.
Add a small tool-selection benchmark to CI. Use realistic user requests and assert:
- the intended tool is selected;
- arguments are minimal and within scope;
- the agent asks for clarification when required information is missing;
- a read-only request does not invoke a mutation tool;
- a tenant-scoped request does not broaden into an export;
- a failed call produces the expected recovery behavior;
- the final response reflects the actual tool result rather than assuming success.
Keep golden traces for important workflows. Do not require byte-for-byte equality; compare invariants such as tool names, argument scope, authorization outcomes, state transitions, and final task result.
When the server’s tool list changes, run the benchmark twice: once with the old inventory and once with the new inventory. This identifies a subtle class of regression where every individual tool passes contract tests but the combined inventory causes the model to choose incorrectly.
Make state handles testable
Explicit handles are useful, but they create lifecycle contracts. A handle should be opaque, authorized on every use, bounded in lifetime, and accompanied by a clear recovery error when expired. 3
Test a complete lifecycle:
it("recreates an expired handle without repeating the mutation", async () => {
const created = await client.callTool({ name: "cart.create", arguments: {} });
const cartId = created.structuredContent.cart_id;
await clock.advanceBy("25h");
const expired = await client.callTool({
name: "cart.add_item",
arguments: { cart_id: cartId, sku: "sku_123", idempotency_key: "req_001" }
});
expect(expired.isError).toBe(true);
expect(text(expired)).toContain("expired");
const recreated = await client.callTool({ name: "cart.create", arguments: {} });
const retry = await client.callTool({
name: "cart.add_item",
arguments: {
cart_id: recreated.structuredContent.cart_id,
sku: "sku_123",
idempotency_key: "req_001"
}
});
expect(retry.structuredContent.added).toBe(true);
expect(await inventory.count("sku_123")).toBe(1);
});
The exact test depends on the business system, but the invariant is stable: recovery after expiry must not create duplicate effects, leak authorization, or force the model to infer a hidden session protocol.
Put drift detection in the release path
A practical pipeline has three gates:
mcp_release:
discovery:
- fetch_server_identity
- negotiate_protocol_version
- snapshot_tools_and_schemas
pull_request:
- compare_contract_snapshot
- run_schema_generated_cases
- run_semantic_negative_cases
staging:
- run_integration_workflows
- run_authorization_matrix
- run_agent_tool_selection_eval
- run_prompt_injection_cases
production:
- monitor_tools_list_changes
- validate_structured_outputs
- sample_golden_traces
- alert_on_unknown_errors
- preserve_previous_contract
If a tool is mutated by a deployment, the pipeline should fail closed until one of three things happens: the change is proven compatible, the client is upgraded alongside the server, or the tool is published under a new versioned name. Do not silently overwrite a production contract and hope the model adapts.
A gateway can help centralize this policy. It can pin server versions, expose only approved tools, normalize errors, enforce timeouts and input limits, retain the previous snapshot, and record the contract hash with every call. But the gateway cannot replace server-side validation or agent behavior tests. It is a control point, not a substitute for a trustworthy producer.
References & Community Insights
Release checklist
1. Is the advertised tool inventory snapshotted and reviewed?
2. Are input and output schemas validated against a committed baseline?
- Are description, annotation, error, and tool-order changes reviewed for model behavior?
4. Are state handles authorized, opaque, expiring, and recoverable?
- Are generated schema cases supplemented by semantic, authorization, injection, and idempotency tests?
- Does the full tool inventory have a selection benchmark, not only individual tool tests?
- Can a deployment roll back to the previous contract without changing application code?
- Are production tool-list changes, validation failures, and unknown errors observable?
MCP makes tools easier to connect. That convenience raises the importance of compatibility engineering. Once an agent depends on a server, the tool schema, description, error behavior, state lifecycle, and side effects become part of the product surface. Contract snapshots, generated tests, semantic workflows, and model-facing evaluations turn that surface from a hidden liability into a release artifact you can inspect and control.

