Does an MCP server check the protocol-version header?

Sample taken 2026-09-27, from the anonymous probe tools/survey-mcp-protocol-version-header.mjs 40.

The 2025-06-18 revision of MCP says a client MUST put MCP-Protocol-Version on every HTTP request after initialize, and that a server SHOULD reject a request whose header names a revision it did not agree to. Two questions follow, and they have different answers:

  1. Do public servers enforce it? In this sample: no, none of them.
  2. Did our own gateway send it? No — and that second one was a defect regardless of the first.

What the sample says

Of 40 streamable-http endpoints taken from the official MCP registry in its default order:

16 servers completed the handshake. For each, the same tools/list request was sent twice, differing by exactly one header — once naming the revision the server had answered with, once with the header absent entirely:

So enforcement is not a thing that is happening today. The honest reading is narrower than that, though: 16 answering servers is a small slice of a registry whose majority (22) sit behind OAuth, and the sample is one window of one ordering. It is evidence about September 2026, not a statement about the protocol.

The one disagreement that mattered

1 of the 16 servers that answered named a different revision than the one requested. For that server, asking tools/list while the header claimed the requested revision still worked. A permissive upstream, not a proof of safety: a conforming one is allowed to reject exactly that request.

The rows

serveranswered revisionwith headerwithout headersession id issued
ac.tandem/docs-mcp2025-06-18servedservedno
ad.getle/leads2025-06-18servedservedno
ad.inside/inside-ads2025-06-18servedservedno
ag.hood/name-service2025-06-18servedservedno
agency.goji/goji2025-06-18servedservedno
agency.ottobot/business-contact-finder2025-06-18servedservedno
agency.ottobot/contractor-license-changes2025-06-18servedservedno
agency.ottobot/licensed-house-painters2025-06-18servedservedno
ai.adoraads/beauty2024-11-05servedservedno
ai.advisorsai/service-navigator2025-06-18servedservedno
ai.advisorsai/store-readiness2025-06-18servedservedno
ai.afg/afg2025-06-18servedservedyes
ai.afmr/discovery2025-06-18servedservedno
ai.afmr/discovery2025-06-18servedservedno
ai.agent-bev/bev-door2025-06-18servedservedno
ai.agentberg/agentberg2025-06-18servedservedyes

What we found in ourselves

The gateway's own upstream client (apps/ai-gateway-service/src/mcpGateway/mcpUpstreamClient.ts) stores the session id the upstream issues and replays it on every later request. It did not do the same for the negotiated revision: it captured that value, reported it through GET /mcp/tools, and then never put it back on the wire. One server-issued value was treated as part of the contract and the other as decoration.

That asymmetry had a concrete failure shape, which the new tests pin. Before the fix, an operator-supplied mcp-protocol-version header was forwarded verbatim — so against an upstream that answers 2024-11-05 to a 2025-06-18 request, our client sent 2025-06-18 in the header: a revision the upstream had explicitly not agreed to, which is the request a conforming server is allowed to reject.

Now the post-handshake requests carry the revision the upstream named, never the one we asked for, and the initialize request itself carries none (the revision it proposes is in the body; a header asserting an agreement that has not happened yet is the wrong shape). An operator pin cannot outvote the server's answer.

Three arms prove the change rather than describe it: removing the one-line fix turns them red, and the operator-pin arm reports the exact wrong value (2025-06-18) on the wire. Two further arms — an upstream that answers no revision, and one that answers an empty string — stay green in both states by design: they are there to catch a future fallback, not to prove this one.

What our own server does, observed rather than read out of source

tools/probe-own-mcp-header-behavior.mjs boots packages/mcp-server/src/http.js in-process on the credential-free local runtime, asks it to initialize, then sends the same tools/list under three header shapes:

So we are one of the permissive ones, and that is now an observation instead of a code reading. The probe refuses to publish any of it if its own baseline leg fails to reach a tool list: --tamper-blind sends the request to a path one character off, and that run exits 3 with INSTRUMENT_BLIND and the sentence this run says nothing about header handling - because a misconfigured probe otherwise looks exactly like a server that accepted everything.

Deliberately not claimed

value — measured behaviour, and a documented choice: the gateway negotiates and records revisions (#178) rather than dropping connections that name a different one.

product path imports it outside tests. Recorded, not silently doubled.

Reproduce

node tools/survey-mcp-protocol-version-header.mjs 40   # anonymous, read-only, one window
node tools/probe-own-mcp-header-behavior.mjs              # our own server, local runtime, no credentials
node tools/probe-own-mcp-header-behavior.mjs --tamper-blind   # must exit 3: the probe can refuse
npx vitest run apps/ai-gateway-service/src/mcpGateway/mcpGateway.test.ts   # the five client arms