Your MCP server is green in CI. Your integration test passes. Then a real client connects — maybe an older desktop app, maybe another team’s Python service, maybe a hosted agent platform running an SDK from three releases ago — and the logs fill with one of the classics: Method not found, Missing MCP-Protocol-Version header, or a silent disconnect right after initialize.
The annoying part is that the failure is not a bug in your business logic. It is a protocol-version mismatch: two implementations speaking slightly different dialects of the Model Context Protocol. Your tests passed because the test client and server were built against the same SDK snapshot, so they accidentally agreed on every assumption.
MCP compatibility testing is the discipline of making those assumptions explicit before release. You test the handshake, the negotiated version, the advertised capabilities, the transport behavior, and the failure paths — across more than one protocol revision.
TL;DR: Treat MCP compatibility as a matrix, not a boolean. Test initialize negotiation across every protocol version you claim to support. Probe capabilities instead of assuming them. Keep older SDK clients in your test matrix. Test stdio and Streamable HTTP separately. Make unknown fields tolerable and invalid requests survivable. Record real sessions and replay them in CI. Add runtime telemetry for the clients you did not test. Then turn all of that into a release gate.
📋 Table of Contents
- 1. The Handshake Is the Compatibility Test You Cannot Skip
- 2. Test the Version Ladder, Not Just Your Preferred Version
- 3. Capabilities Must Be Probed, Not Assumed
- 4. Build a Client Matrix That Includes Older SDKs
- 5. stdio and Streamable HTTP Fail in Different Ways
- 6. Schema Drift: Ignore Unknown Fields, Reject Invalid Ones
- 7. Negative Compatibility Tests Are the Useful Ones
- 8. Record Real Sessions and Replay Them in CI
- 9. Telemetry Catches the Matrix You Did Not Test
- 10. Turn the Support Window Into a Release Gate
- A Release-Day Checklist
1. The Handshake Is the Compatibility Test You Cannot Skip
Scenario: Your client is built against a newer MCP SDK. It sends initialize with the latest protocolVersion it knows. The server does not recognize that version, responds with an older one, and your client proceeds as if nothing happened. Ten messages later, the client tries to use a feature that only exists in the newer revision and gets method not found.
Why it matters: MCP does version negotiation during initialize. The client sends the protocol version it would like to use. The server responds with the version it will actually use. If the client does not validate that response, it can end up operating under assumptions the server never agreed to.
Solution: Make negotiation validation part of the connection path, not an afterthought.
At the protocol level, the first request looks roughly like this:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {
"name": "compat-probe",
"version": "1.0.0"
}
}
}
The server responds with its chosen protocolVersion, its capabilities, and server info. The client then sends notifications/initialized and continues.
The important part is what the client does with the negotiated version:
const SUPPORTED_VERSIONS = new Set([
"2024-11-05",
"2025-03-26",
"2025-06-18",
]);
function validateNegotiation(requested: string, negotiated: string) {
if (!SUPPORTED_VERSIONS.has(negotiated)) {
throw new Error(
`Server negotiated unsupported MCP version: ${negotiated}`
);
}
if (negotiated !== requested) {
console.warn(
`MCP version downgraded: requested=${requested}, negotiated=${negotiated}`
);
}
}
Why this works: The failure becomes immediate and explicit. Instead of discovering the mismatch when tools/list, prompts/get, or some optional notification fails later, you fail at the handshake where the cause is obvious.
⚠️ Gotcha: Do not compare protocol versions as if they were normal semver strings. MCP revisions are date-stamped strings. Use an explicit allowlist or ordered table maintained in your codebase, not string comparison logic scattered across the client.
2. Test the Version Ladder, Not Just Your Preferred Version
Scenario: You test your server against the latest SDK. Everything works. But a meaningful chunk of real-world clients is still using an older revision — perhaps because they are pinned, air-gapped, or maintained by another team. Your server either rejects them, or worse, accepts them and then behaves inconsistently.
Why it matters: Protocol revisions are not just version labels. They change behavior. Streamable HTTP replaced the older HTTP+SSE pattern in later revisions. Tool annotations and structured tool output arrived in later revisions. Later revisions tightened Streamable HTTP requirements, including carrying the negotiated protocol version in HTTP headers. If your tests only exercise one revision, you are not testing compatibility. You are testing one dialect.
Solution: Build a small version ladder into your compatibility suite. For every protocol version you claim to support, run the same core sequence:
- Send
initializewith that version. - Verify the negotiated version.
- Send
notifications/initialized. - List the capabilities you expect under that revision.
- Exercise the methods that changed between revisions.
A simplified probe might look like this:
const VERSIONS = [
"2024-11-05",
"2025-03-26",
"2025-06-18",
];
for (const protocolVersion of VERSIONS) {
const { negotiated, capabilities, headers } = await handshake(
serverUrl,
protocolVersion
);
assert.ok(
VERSIONS.includes(negotiated),
`Unexpected negotiated version: ${negotiated}`
);
await assertRevisionBehavior(negotiated, capabilities, headers);
}
The assertRevisionBehavior function is where the real value lives. It encodes what should differ by revision. For example:
| Revision under test | Example behaviors to assert |
|---|---|
2024-11-05 | Baseline tools/resources/prompts behavior; no dependency on newer optional fields |
2025-03-26 | Streamable HTTP behavior; tool annotations and structured output tolerated or used correctly |
2025-06-18 | Streamable HTTP header expectations; resource links in tool results; stricter transport behavior |
Why this works: You stop treating “MCP support” as one thing. You test the actual contract surface that changes between revisions.
💡 Practical note: Keep the version list in configuration, not buried in test code. When a newer protocol revision becomes relevant, you want to add one entry and get a clear list of failing assumptions — not grep through half your codebase.
3. Capabilities Must Be Probed, Not Assumed
Scenario: Your server sees that the client advertised roots, so it calls roots/list. The client technically included the capability object, but its implementation is incomplete. The call fails. Or the reverse: your client assumes the server supports tool list-change notifications because the server returned a tools capability, but the server never advertised listChanged.
Why it matters: MCP capabilities are not decorative. They are the mechanism by which client and server tell each other which optional parts of the protocol are available. But capabilities are also easy to misuse. Teams often check for the existence of a capability object and then assume every related method and notification is safe to use.
Solution: Test capabilities as contracts.
For servers:
- If a capability is not advertised, related methods should not be called by clients.
- If a client calls a method for an unsupported capability, the server should return a clean JSON-RPC error, not crash or hang.
For clients:
- If the server does not advertise a capability, the client should degrade gracefully.
- If the server advertises a capability with sub-features, such as list-change notifications, test those sub-features explicitly.
A capability probe can be as simple as this:
async function expectMethodNotFound(
send: (msg: unknown) => Promise<JsonRpcResponse>,
method: string
) {
const response = await send({
jsonrpc: "2.0",
id: crypto.randomUUID(),
method,
});
assert.equal(response.error?.code, -32601);
}
// If server capabilities.tools is absent:
await expectMethodNotFound(send, "tools/list");
The relevant JSON-RPC error codes are boring but important:
-32700— parse error-32600— invalid request-32601— method not found-32602— invalid params-32603— internal error
Why this works: Capability mismatches become observable at the protocol boundary. You learn whether your implementation is merely optimistic or actually compliant.
🔍 Why this matters: Capabilities are not authorization. A client advertising
samplingorelicitationdoes not mean every request should be trusted. Capability testing checks protocol support; your auth layer still has to do its own job.
4. Build a Client Matrix That Includes Older SDKs
Scenario: Your server works perfectly with the current official TypeScript SDK. Then a Python client using an older MCP library connects and fails during initialization because it does not send the headers your server now expects.
Why it matters: If you only test your server against the newest SDK, you are testing a future that has not fully arrived yet. Real ecosystems lag. Teams pin dependencies. Hosted platforms upgrade slowly. Internal clients may be maintained by people who never update unless something breaks.
Solution: Maintain a client matrix.
A practical matrix usually includes:
| Client type | Why include it |
|---|---|
| Current official TypeScript SDK | Tests the newest behavior you likely develop against |
| Previous stable TypeScript SDK | Catches regressions for clients that have not upgraded |
| Current official Python SDK | Validates cross-language compatibility |
| Previous stable Python SDK | Represents real-world lag |
| Raw JSON-RPC probe | Lets you test edge cases SDKs may hide |
The raw probe is especially valuable. SDKs are convenient, but they also conceal assumptions. A small raw client lets you send messages that no well-behaved SDK would send, which is exactly what you need for robustness testing.
If you are using GitHub Actions, the shape of the job matters more than the exact YAML:
jobs:
mcp-compat:
strategy:
fail-fast: false
matrix:
client:
- ts-current
- ts-previous
- python-current
- python-previous
- raw-probe
steps:
- uses: actions/checkout@v4
- run: ./scripts/setup-mcp-compat-client.sh ${{ matrix.client }}
- run: npm run test:mcp:compat -- --client=${{ matrix.client }}
Why this works: You stop assuming that “the SDK” is a single moving target. You test the ecosystem as it actually exists.
🚨 Production warning: Do not let
latestbe your only test dependency. If your compatibility suite always floats to the newest SDK, it can pass while your real users fall behind. Pin at least one older version.
5. stdio and Streamable HTTP Fail in Different Ways
Scenario: Your MCP server passes every stdio test. Then you deploy it over Streamable HTTP and discover that session termination, header handling, and streaming responses behave completely differently. Or the reverse: HTTP works, but stdio breaks because your server writes debug logs to stdout.
Why it matters: MCP transports are not interchangeable wrappers around the same protocol. They have different failure modes. stdio is process-oriented: framing is newline-delimited JSON, stdout is reserved for protocol messages, and logs belong on stderr. Streamable HTTP is request/response and stream-oriented: headers matter, sessions may be stateful or stateless, and responses may be JSON or server-sent events.
Solution: Test transports separately.
For stdio, test at least these cases:
- Server writes only MCP messages to stdout.
- Logs and diagnostics go to stderr.
- Malformed input does not crash the process.
- Closing stdin terminates the session cleanly.
For Streamable HTTP, test these:
POSTaccepts JSON-RPC messages.Accepthandling supports both JSON and event-stream responses where appropriate.- Session ID header behavior is correct when sessions are used.
- The negotiated protocol version header is handled according to the revision under test.
GETbehavior is correct if your server supports a server-initiated stream.DELETEterminates a session cleanly if your server uses sessions.
A transport-specific assertion might look like this:
const initResponse = await postJsonRpc(serverUrl, initializeMessage);
const sessionId = initResponse.headers.get("mcp-session-id");
const negotiated = initResponse.headers.get("mcp-protocol-version");
if (sessionId) {
await postJsonRpc(
serverUrl,
initializedNotification,
{
"mcp-session-id": sessionId,
"mcp-protocol-version": negotiated ?? expectedVersion,
}
);
}
Why this works: You isolate transport-level regressions before they become mysterious client failures.
💡 Practical note: If you are dropping support for an older transport, test the failure path. A clear error is better than a hang. Clients stuck on the old transport should receive an understandable response, not an indefinite timeout.
6. Schema Drift: Ignore Unknown Fields, Reject Invalid Ones
Scenario: A newer MCP server returns tool metadata containing fields your older client has never seen. Your client uses strict schema validation and rejects the entire response. Suddenly, a server upgrade breaks clients that never changed.
Why it matters: Protocol evolution depends on tolerance. Newer revisions add optional fields. Older clients should generally be able to ignore what they do not understand. If every implementation treats unknown fields as fatal, protocol evolution becomes a breaking-change arms race.
Solution: Separate protocol-envelope validation from application-data validation.
For protocol messages, prefer tolerant parsing:
- Required fields must be present and valid.
- Unknown optional fields should be ignored.
- Missing optional fields should not be fatal.
For example, if you are using Zod, avoid .strict() on protocol objects that may evolve:
import { z } from "zod";
const ToolSchema = z.object({
name: z.string(),
description: z.string().optional(),
inputSchema: z.object({
type: z.literal("object"),
}).passthrough(),
annotations: z.unknown().optional(),
}).passthrough();
Then add compatibility fixtures that include fields from newer revisions:
const futureTool = {
name: "get_weather",
description: "Get weather",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
annotations: {
readOnlyHint: true,
},
someFutureField: {
experimental: true,
},
};
expect(() => ToolSchema.parse(futureTool)).not.toThrow();
Why this works: Your client becomes resilient to additive protocol evolution. It still rejects genuinely malformed messages, but it does not choke on harmless new metadata.
⚠️ Gotcha: Do not confuse protocol tolerance with tool-input tolerance. Your tool arguments can and often should be strictly validated. The protocol envelope should be tolerant of unknown optional fields; your business inputs should be as strict as your contract requires.
7. Negative Compatibility Tests Are the Useful Ones
Scenario: Your MCP implementation works when clients behave perfectly. Then a client sends a request before initialization, or a malformed JSON-RPC message, or a method your server does not support. The server crashes, hangs, or returns an error so vague that nobody can debug it.
Why it matters: Compatibility is not only about correct clients. It is about surviving incorrect ones gracefully. A robust MCP implementation should return useful JSON-RPC errors and keep the connection usable where possible.
Solution: Add a negative test suite.
At minimum, test these cases:
- Request sent before
initialize. - Unknown method.
- Malformed JSON.
- Missing
jsonrpcfield. - Invalid params for a known method.
- Notification incorrectly sent with an
id. - Unsupported capability method.
- Batch behavior according to the negotiated revision, if relevant.
A compact helper makes these tests readable:
async function expectErrorCode(
send: (msg: unknown) => Promise<JsonRpcResponse>,
message: unknown,
code: number
) {
const response = await send(message);
assert.equal(response.error?.code, code);
}
await expectErrorCode(send, {
jsonrpc: "2.0",
id: "1",
method: "definitely/not/a/method",
}, -32601);
await expectErrorCode(send, {
jsonrpc: "2.0",
id: "2",
method: "tools/call",
params: {
name: "some_tool",
// missing or invalid arguments
},
}, -32602);
Why this works: You learn how your implementation behaves under confusion. That is the state real-world clients are often in during version transitions.
🧠 The important part: A negative test is not only about returning the right error code. It is also about proving the server stays alive. After each malformed request, send a valid request and assert that the session still works.
8. Record Real Sessions and Replay Them in CI
Scenario: You write a compatibility suite from scratch. It covers the cases you imagined. Then production produces a message sequence you never imagined: a client that initializes, lists tools, cancels a request, reconnects, and sends a notification in an order your tests never generated.
Why it matters: Synthetic tests are necessary but incomplete. Real clients have quirks. They retry. They disconnect mid-stream. They negotiate old versions. They send capabilities that do not match their behavior. Recorded traffic gives you a corpus of actual protocol dialects.
Solution: Record MCP sessions, redact secrets, and replay them against new server builds.
A minimal recorder can be this simple:
import fs from "node:fs";
function recordSessionEvent(
sessionId: string,
direction: "client" | "server",
message: unknown
) {
const line = JSON.stringify({
sessionId,
direction,
ts: new Date().toISOString(),
message,
});
fs.appendFileSync("mcp-session-log.jsonl", `${line}\n`);
}
The replay harness then reads the recorded client messages, sends them to the server under test, and asserts one of three outcomes:
- The response matches the recorded successful response.
- The response is an acceptable newer equivalent.
- The response is a clean protocol error with a known code.
This is also where MCP Inspector earns its keep. It is not a replacement for automated CI, but it is excellent for manually exploring a server’s handshake, capabilities, tools, resources, and prompts before you encode those expectations into tests.
npx @modelcontextprotocol/inspector node build/index.js
Why this works: Your compatibility suite stops being only a reflection of what you thought could happen. It becomes a regression net for what actually happened.
💡 Practical note: Treat recorded sessions as sensitive data. MCP traffic can contain tool arguments, prompts, resource contents, and user-adjacent context. Redact before committing fixtures.
9. Telemetry Catches the Matrix You Did Not Test
Scenario: You test against three SDK versions. Production contains seven. One of them is an internal client nobody remembers building. It starts failing after your release, but because the failure happens at handshake, the client never gets far enough to log a useful application error.
Why it matters: No test matrix is complete. Compatibility testing reduces risk, but telemetry tells you what happened in the wild. If you do not observe protocol negotiation in production, you will discover mismatches through support tickets.
Solution: Log and metric the handshake.
Useful fields include:
- Client name and version from
clientInfo - Requested protocol version
- Negotiated protocol version
- Transport: stdio or Streamable HTTP
- Advertised client capabilities
- Advertised server capabilities
- Handshake outcome: success, downgrade, unsupported version, transport error
A structured log line might look like this:
{
"event": "mcp_handshake",
"clientName": "internal-agent",
"clientVersion": "0.14.2",
"requestedProtocolVersion": "2025-03-26",
"negotiatedProtocolVersion": "2025-03-26",
"transport": "streamable_http",
"outcome": "success"
}
From there, metrics become useful:
mcp_handshake_total{requested_version, negotiated_version, outcome}
mcp_method_not_found_total{method, client_name, protocol_version}
mcp_transport_error_total{transport, error_type}
Alert on patterns, not individual events:
- A sudden spike in handshake failures.
- A new client version appearing with an unsupported protocol version.
- A method-not-found increase for a specific method and client version.
- A downgrade rate that suddenly changes after release.
Why this works: Telemetry turns compatibility from a pre-release assumption into an operational signal.
⚠️ Gotcha: Do not use
clientInfoas a security boundary. It is useful for debugging and compatibility analytics, but it is self-reported. Authentication and authorization still need to be enforced independently.
10. Turn the Support Window Into a Release Gate
Scenario: Your team says, “We support MCP.” When asked, “Which protocol revisions? Which SDK versions? For how long?” the room goes quiet. Without a support window, compatibility becomes a matter of individual memory.
Why it matters: Compatibility testing needs a policy. Otherwise, you either test everything forever or nothing intentionally. A support window makes the trade-off explicit and gives CI something concrete to enforce.
Solution: Define a compatibility policy and wire it into release.
A reasonable policy might look like this:
- Support the current MCP protocol revision and the previous one.
- Support the current and previous stable versions of the official TypeScript and Python SDKs.
- Maintain raw protocol probes for edge cases.
- Require all compatibility suites to pass before release.
- Document the support matrix in the repository.
Then encode it in CI:
Required CI jobs:
- mcp-handshake-negotiation
- mcp-version-matrix
- mcp-capability-probes
- mcp-transport-stdio
- mcp-transport-streamable-http
- mcp-negative-tests
- mcp-recorded-session-replay
The release decision becomes mechanical. If the version matrix fails, you do not ship. If a downgrade path breaks, you do not ship. If a transport regression appears, you do not ship.
Why this works: Compatibility stops being a vague promise and becomes a testable requirement.
A Release-Day Checklist
Before releasing an MCP server or client, I would want all of these to be true:
- The
initializehandshake is tested for every supported protocol version. - The client validates the negotiated version and fails fast when it is unsupported.
- Downgrades are logged and tested, not silently accepted.
- Capabilities are probed both when present and when absent.
- Unsupported methods return clean JSON-RPC errors.
- The test matrix includes at least one older SDK version.
- stdio and Streamable HTTP have separate test suites.
- Unknown optional fields do not cause parsing failures.
- Malformed messages do not crash the process.
- Real session recordings are replayed in CI.
- Production telemetry captures requested and negotiated protocol versions.
- The supported protocol and SDK matrix is documented.
The deeper lesson is that MCP compatibility is not a single version check. It is a contract surface: version negotiation, capabilities, transports, optional fields, and error behavior all need to agree. The teams that catch protocol-version mismatches before release are not the ones with the most features. They are the ones that test the handshake as seriously as the tools behind it.