Model Context Protocol · API design · Testing

Version an MCP Server Without Breaking Clients

Evolve tools, resources, prompts, transports, and experimental features using protocol negotiation, additive contracts, compatibility tests, and staged removal.

Published
Updated
Code examples
Illustrative
Reading time
6 minutes

Version an MCP server at three separate layers: the negotiated MCP protocol date, the server implementation version, and each public capability contract. Protocol negotiation determines message semantics; serverInfo.version identifies your implementation; tool, resource, and prompt evolution still needs ordinary compatibility discipline.

Changing serverInfo.version does not automatically protect a client from a renamed tool or narrower schema. Conversely, adding an optional tool does not require inventing a new MCP protocol version.

Keep the version layers separate

LayerExamplePurpose
MCP protocol2025-11-25Negotiates wire semantics and capabilities
Server implementation3.8.0Identifies a release for support and telemetry
Public contractsearch_issues input/output schemaDefines client-visible behavior
Domain artifactrunbook://refunds/revision/18Identifies exact content

During initialization, the client proposes a supported protocol version. The server returns the same version if supported or another version it supports. If the client cannot support the response, it should disconnect. On HTTP, subsequent requests include MCP-Protocol-Version (MCP version negotiation).

Do not treat the date string as semver or guess compatibility from its order. Implement the behavior for explicitly supported protocol versions.

Build a compatibility inventory

Before changing anything, snapshot the externally observable surface:

initialize response and server capabilities
tools/list definitions and tool results
resources/list and resource templates
prompts/list and prompt messages
error codes and tool-level failure shapes
transport, session, notification, and Task behavior
authorization metadata and scopes

Store canonical JSON fixtures with volatile fields removed. Pair snapshots with behavioral tests; snapshots alone cannot prove authorization, idempotency, or error semantics.

For each consumer, record the protocol version, SDK or custom client, capabilities it uses, and contract fields it depends on. A field that was documented as optional may still be operationally required by an important client.

Classify changes before implementation

Usually additive:

  • adding a new optional tool, resource, or prompt;
  • adding an optional output field while preserving existing fields;
  • adding an optional input field with unchanged defaults;
  • improving a description without changing behavior; and
  • advertising an optional capability only to peers that negotiated it.

Potentially breaking:

  • renaming or removing a tool, resource, prompt, field, or enum value;
  • making an optional input required;
  • narrowing accepted input or changing defaults;
  • changing a result's meaning, type, units, ordering, or error classification;
  • changing a read operation into a write;
  • reusing a resource URI for incompatible content;
  • requiring an experimental feature; and
  • dropping a protocol version or transport.

Even a description change can be behaviorally significant because models use descriptions to select tools. Evaluate selection quality when wording changes, and compare the result with the protocol’s tool contract.

Evolve a tool additively

Suppose the original tool accepts:

{
  "name": "search_issues",
  "inputSchema": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "minLength": 1, "maxLength": 200 }
    },
    "required": ["query"],
    "additionalProperties": false
  }
}

Adding an optional bounded state with default behavior equivalent to the old server is compatible for callers:

{
  "state": {
    "type": "string",
    "enum": ["open", "closed", "all"],
    "default": "open"
  }
}

But additionalProperties: false means an older server rejects the new field. A client that may connect to both versions must inspect discovery or avoid the field until it knows the server advertises it. Compatibility is directional: a new server serving an old client differs from a new client calling an old server.

For output, keep old fields and semantics while adding new optional fields. If money changes from decimal strings to minor-unit integers, add amountMinor and currency; do not silently change amount from dollars to cents.

Use a parallel contract for real breaks

When semantics must change, introduce a new name:

create_refund_preview
create_refund_preview_v2

The suffix is not elegant, but it is safer than making one discovered name mean two incompatible things. An alternative is a clearly named new operation based on the new outcome. Keep the old tool available during a measured migration window and instrument usage by authenticated client identity.

Do not put a version argument into one tool if the versions have substantially different schemas; the union becomes hard for models and humans to use. Separate discovery entries make each contract explicit.

For resources, publish immutable versioned URIs and a mutable alias:

policy://refunds/current
policy://refunds/revision/18

For prompts, create a new prompt name when the workflow's objective or output contract changes. Minor wording fixes can stay under one name, but log a content revision if reproducibility matters.

Respect capabilities and list-change notifications

A client and server may use only successfully negotiated capabilities. If you add elicitation, sampling, or experimental Tasks, advertise the capability and handle clients that do not support it. Do not send a new server-to-client request and hope an older client ignores it.

When a server advertised listChanged, it should send the corresponding list-changed notification after adding or removing tools, resources, or prompts. Clients should refresh discovery rather than assuming the cached list is permanent. The notification says the list changed; clients still need to request the new definitions.

Treat MCP Tasks especially carefully: the 2025-11-25 specification labels them experimental. Keep a normal-call fallback where feasible, isolate Task persistence behind an adapter, and do not make them mandatory until every supported client has passed contract tests.

Build a compatibility test matrix

Test both directions:

ClientServerExpected result
OldOldExisting baseline passes
OldNewOld operations and result fields remain valid
NewOldNew client detects absent features and falls back
NewNewNew features and old contracts pass

For every supported protocol version and transport, test:

  1. initialization and exact negotiated version;
  2. capabilities before and after initialized;
  3. discovery pagination and optional list-change handling;
  4. old and new valid inputs;
  5. invalid, boundary, and unknown fields;
  6. structured results and errors;
  7. authorization scopes and wrong-tenant identifiers;
  8. timeout, cancellation, reconnect, and duplicate delivery; and
  9. SDKs or real client builds you claim to support.

Use captured transcripts as fixtures only after scrubbing tokens and tenant data. Raw protocol fixtures catch casing and field drift that a shared SDK may hide.

Roll out removals in stages

  1. Introduce: ship the new contract while the old one remains fully supported.
  2. Observe: measure authenticated client use and failure rates; do not infer migration from discovery alone.
  3. Warn: add human-visible documentation and operational notices. Avoid putting noisy deprecation prose into every model result.
  4. Default: update first-party clients and examples to the new contract.
  5. Restrict: stop new integrations from adopting the old contract where your registry or policy supports it.
  6. Remove: delete only after the announced window and verified usage threshold.

For a remote server, coordinate authorization metadata and scopes. Removing a tool while leaving its privileged scope broadly granted is incomplete cleanup; changing scope meaning under the same name is dangerous.

Diagnose compatibility failures

  • Initialization fails: log proposed, returned, and locally supported protocol versions.
  • Tool disappeared: confirm principal-specific discovery and list-change cache invalidation.
  • Arguments fail only on old servers: inspect tools/list before sending an added field.
  • Output parser fails: compare canonical fixtures and schema validation, not prose.
  • HTTP works until the second request: verify MCP-Protocol-Version, authorization, and session headers are present as required.
  • New feature hangs: verify both capability negotiation and request timeouts.
  • Model selects the wrong tool: evaluate changed names and descriptions as routing behavior.

Release checklist

  • List protocol, implementation, capability, and artifact versions separately.
  • Maintain an inventory of supported clients and their dependencies.
  • Classify schema, semantic, authorization, and transport changes.
  • Prefer additive fields with preserved defaults.
  • Create a parallel tool for incompatible semantics.
  • Refresh discovery through negotiated list-change behavior.
  • Run old/new client-server combinations.
  • Stage removals using observed authenticated usage.
  • Pin experimental features and retain fallbacks.
  • Publish the supported protocol dates and removal policy.

The typed MCP client guide shows defensive discovery, while the server guide establishes the schemas these tests should protect.