An MCP server that changes tool contracts without a versioning discipline will eventually break some agent that was built against the old shape, often silently, since the failure shows up as a confusing tool-call error rather than an obvious deployment issue. Treat tool schemas with the same rigor as a public API, and be aware of the specific gap this discipline does not close on its own: changes to behavior that leave the schema itself untouched.
Classifying Changes: Backward-Compatible vs. Breaking
flowchart TD
Change["Proposed schema change"] --> Q1{"Does it remove a\nfield, change a type,\nor add a required param?"}
Q1 -->|yes| Breaking["Breaking change:\nrequires new tool version"]
Q1 -->|no| Q2{"Is the change additive\nand optional, with a\ndefault preserving old behavior?"}
Q2 -->|yes| Compatible["Backward-compatible:\nsame tool version, no migration needed"]
Q2 -->|no| Breaking
style Change fill:#EEF0F7,stroke:#6366F1,color:#0F172A
style Q1 fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style Q2 fill:#fff7ed,stroke:#f59e0b,color:#0F172A
style Breaking fill:#fef2f2,stroke:#dc2626,color:#0F172A
style Compatible fill:#f0fdf9,stroke:#0D9488,color:#0F172A
Backward-compatible: adding an optional field with a sensible default, adding a new tool entirely, widening an enum’s allowed values, improving a description without changing behavior. Breaking: removing or renaming a field, changing a field’s type, adding a new required parameter, or changing what the tool’s output structure looks like.
Versioning Tool Names Explicitly
MCP has no built-in version negotiation for individual tools, so the established convention is encoding the version directly in the tool name, so old and new clients can coexist against the same server during a migration window.
class KBQueryInputV1(BaseModel):
query: str
top_k: int = 5
class KBQueryInputV2(BaseModel):
query: str
top_k: int = 5
doc_type: str | None = None # New: filter by document type
min_relevance: float = 0.0 # New: minimum relevance threshold
@mcp.tool(name="kb_query/v1", description="[DEPRECATED, use kb_query/v2] Search the knowledge base")
async def kb_query_v1(input: KBQueryInputV1) -> list[dict]:
"""Kept running unchanged for agents still on the old contract."""
return await _kb_search(input.query, input.top_k)
@mcp.tool(name="kb_query/v2", description="Search the knowledge base with document-type filtering")
async def kb_query_v2(input: KBQueryInputV2) -> list[dict]:
"""New callers get filtering and relevance thresholds."""
return await _kb_search(input.query, input.top_k, doc_type=input.doc_type, min_relevance=input.min_relevance)
The Expand-Contract Migration Sequence
sequenceDiagram
participant OldAgent as Agent (v1 client)
participant NewAgent as Agent (v2 client)
participant Server as MCP Server
Note over Server: Phase 1: Expand, register both versions
OldAgent->>Server: tools/call kb_query/v1
Server-->>OldAgent: Result (unchanged behavior)
NewAgent->>Server: tools/call kb_query/v2
Server-->>NewAgent: Result (new fields)
Note over Server: Phase 2: Monitor v1 call volume,\nmigrate remaining agents
Note over Server: Phase 3: Contract, remove v1\nonce volume reaches zero
Server-->>Server: kb_query/v1 removed from registration
# Monitoring: log which version is actually being called, drives the
# decision of when it is safe to remove kb_query/v1 entirely.
import logging
@mcp.middleware()
async def log_tool_version(request, call_next):
if request.tool_name.startswith("kb_query/"):
version = request.tool_name.split("/")[-1]
tool_version_metrics.increment(f"kb_query.{version}.calls")
return await call_next(request)
Only remove kb_query/v1 once its call volume has been at or near zero for a defined period, or after a communicated sunset date, whichever governance process your organization uses.
The Gap Schema Versioning Does Not Close: Silent Behavior Changes
Everything above versions changes to a tool’s shape, its parameters and its return structure. It says nothing about changes to a tool’s behavior that leave the shape completely untouched: a ranking algorithm tweak that reorders results, a default filter that quietly becomes stricter, a downstream data source swap that returns subtly different values for the same query. These changes produce a zero-line diff in the schema, meaning the CI schema-diff gate built in Lesson 29 will not flag them at all, and yet they can meaningfully change what a caller receives for identical input.
# Schema-identical, behaviorally different: no schema diff will catch this
@mcp.tool(name="kb_query/v2")
async def kb_query_v2(input: KBQueryInputV2) -> list[dict]:
"""
Same input/output schema as last week. But the ranking algorithm
underneath was swapped from BM25 to a hybrid dense+sparse reranker,
changing result order and occasionally which documents appear at all.
"""
return await _kb_search_v2_hybrid(input.query, input.top_k, input.doc_type, input.min_relevance)
There is no purely mechanical fix for this, since a diff tool can only compare what is structurally represented, behavior lives in code the diff tool does not execute. Three practical mitigations, used together rather than any one alone: a changelog convention requiring any behavior-affecting change (even schema-identical ones) to be called out explicitly in the tool’s description or a linked changelog entry, so a human reviewer is prompted to consider whether it warrants a version bump anyway; a model-in-the-loop eval suite (Lesson 26) run against a fixed set of representative queries before and after the change, to catch meaningful output drift empirically rather than relying on someone remembering to flag it; and, for changes expected to be large enough to matter, simply choosing to version-bump anyway even though the schema alone did not require it, treating “meaningfully different results for the same input” as a breaking change in spirit even when it is not one in the JSON Schema sense.
The next lesson scales this versioning discipline across many servers with tool discovery, namespacing, and federation.