fix: forward the negotiated protocol version to the remote backend transport

The web client's SDK Client owns the `initialize` handshake, so the
`setProtocolVersion()` call it makes after initialize lands on
RemoteClientTransport — a pass-through to the Node backend, not the real
HTTP transport. The backend's StreamableHTTPClientTransport therefore never
learned the negotiated version and stamped no `Mcp-Protocol-Version` on any
request after initialize, starting with `notifications/initialized`. A
stateful server that requires the header rejects that notification with
HTTP 400.

RemoteClientTransport now records the version and rides it on every
`/api/mcp/send`; the backend applies it to its transport before the send, so
that request and everything the transport issues afterwards (the standalone
SSE GET, the session DELETE) carry the header. The version is validated
against a token pattern on the backend so a client can't push arbitrary
bytes into an upstream header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84
This commit is contained in:
cliffhall 2026-08-10 20:44:37 -04:00
commit 7a74b5eba1

View file

@ -515,4 +515,61 @@ describe("RemoteClientTransport", () => {
reason: "token_expired",
});
});
// #1935: the SDK Client calls setProtocolVersion() on this transport, but the
// real HTTP transport lives on the backend — so the version has to ride the
// send envelope or the backend drops `Mcp-Protocol-Version` upstream.
it("forwards the negotiated protocol version on every send after setProtocolVersion", async () => {
const seenBodies: string[] = [];
let sse = createPushableSseStream();
const fetchFn = vi
.fn<typeof fetch>()
.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/mcp/connect")) {
return new Response(JSON.stringify({ sessionId: "abc" }), {
status: 200,
});
}
if (url.includes("/api/mcp/events")) {
sse = createPushableSseStream();
return sse.response;
}
if (url.endsWith("/api/mcp/send")) {
seenBodies.push(String(init?.body));
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const transport = new RemoteClientTransport(
{ baseUrl, fetchFn: fetchFn as unknown as typeof fetch },
config,
);
await transport.start();
// Pre-initialize sends carry no version (none negotiated yet).
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
expect(JSON.parse(seenBodies[0]!)).not.toHaveProperty("protocolVersion");
transport.setProtocolVersion("2025-11-25");
// The first post-initialize send is `notifications/initialized` — the one
// the reporting server rejected for a missing header.
await transport.send({
jsonrpc: "2.0",
method: "notifications/initialized",
});
await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" });
expect(JSON.parse(seenBodies[1]!)).toMatchObject({
protocolVersion: "2025-11-25",
message: { method: "notifications/initialized" },
});
expect(JSON.parse(seenBodies[2]!)).toMatchObject({
protocolVersion: "2025-11-25",
});
await transport.close();
});
});