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

@ -221,6 +221,7 @@ async function* parseSSE(
*/
export class RemoteClientTransport implements Transport {
private _sessionId: string | undefined = undefined;
private _protocolVersion: string | undefined = undefined;
private eventStreamReader: ReadableStreamDefaultReader<Uint8Array> | null =
null;
private eventStreamAbort: AbortController | null = null;
@ -250,6 +251,22 @@ export class RemoteClientTransport implements Transport {
return this._sessionId;
}
/**
* The SDK Client calls this with the negotiated protocol version as soon as
* `initialize` resolves — before it sends `notifications/initialized` — so an
* HTTP transport can stamp `Mcp-Protocol-Version` on every later request.
*
* Here the real upstream transport lives on the backend, so we can only
* record the version and forward it on the next `/api/mcp/send`, which
* applies it to the upstream transport *before* sending. Because
* `notifications/initialized` is that next send, it and everything after it
* (including the standalone SSE GET and the session DELETE, both issued by
* the upstream transport itself) carry the header (#1935).
*/
setProtocolVersion(version: string): void {
this._protocolVersion = version;
}
/**
* Reattach to an existing remote backend session after a full-page OAuth
* redirect. Opens the SSE event stream without POST /connect.
@ -707,6 +724,11 @@ export class RemoteClientTransport implements Transport {
// Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to
// apply to the upstream send; the browser can't set them cross-origin.
...(options?.headers != null && { headers: options.headers }),
// Forward the negotiated protocol version so the backend's transport can
// stamp `Mcp-Protocol-Version` on this and every later request (#1935).
...(this._protocolVersion !== undefined && {
protocolVersion: this._protocolVersion,
}),
};
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {