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
2 changed files with 204 additions and 1 deletions

View file

@ -37,6 +37,9 @@ export class RemoteSession {
static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000;
private readonly requestWaits = new Map<string | number, RequestWait>();
private authProviderHandle: RemoteAuthProviderHandle | null = null;
private appliedProtocolVersion: string | undefined;
/** A protocol version is a dated token (`2025-11-25`, `2026-07-28`, …). */
private static readonly PROTOCOL_VERSION_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
constructor(sessionId: string) {
this.sessionId = sessionId;
@ -57,6 +60,29 @@ export class RemoteSession {
this.transport = transport;
}
/**
* Apply the client's negotiated protocol version to the upstream transport
* (#1935). The browser's SDK Client owns the `initialize` handshake, so the
* `setProtocolVersion` call it makes lands on the *remote* transport; without
* this the backend's real HTTP transport never learns the version and drops
* `Mcp-Protocol-Version` from every subsequent request.
*
* Ignores anything that isn't a plausible version token so a client can't
* push arbitrary bytes into an upstream header, and re-applies only on
* change (the value rides every `/api/mcp/send`).
*/
applyProtocolVersion(version: string | undefined): void {
if (
version === undefined ||
version === this.appliedProtocolVersion ||
!RemoteSession.PROTOCOL_VERSION_PATTERN.test(version)
) {
return;
}
this.appliedProtocolVersion = version;
this.transport.setProtocolVersion?.(version);
}
setEventConsumer(consumer: (event: SessionEvent) => void): void {
this.eventConsumer = consumer;
// Flush queued events

View file

@ -728,7 +728,8 @@ export function createRemoteApp(
return c.json({ error: "Invalid JSON body" }, 400);
}
const { sessionId, message, relatedRequestId, headers } = body;
const { sessionId, message, relatedRequestId, headers, protocolVersion } =
body;
if (!sessionId || !message) {
return c.json({ error: "Missing sessionId or message" }, 400);
}
@ -744,6 +745,11 @@ export function createRemoteApp(
return c.json({ ok: false, kind: "transport_error", error: errorMsg });
}
// The browser's SDK Client negotiated the version; hand it to the real
// upstream transport before the send so `Mcp-Protocol-Version` is stamped
// on this request and everything after it (#1935).
session.applyProtocolVersion(protocolVersion);
session.beginSend();
const requestId = requestIdForSendWait(message);
let responseWait: Promise<void> | undefined;