Merge pull request #1963 from modelcontextprotocol/v2/fix/proxy-protocol-version-header

fix: forward the negotiated Mcp-Protocol-Version through the remote proxy
This commit is contained in:
Cliff Hall 2026-08-11 11:14:23 -04:00 • committed by GitHub
commit f404d50af7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 219 additions and 1 deletions

View file

@ -515,4 +515,58 @@ 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 }, 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();
});
});

View file

@ -25,6 +25,16 @@ function makeFetchEntry(
};
}
/** A structurally valid `Transport`, so a fixture needs no cast. */
function makeTransport(overrides: Partial<Transport> = {}): Transport {
return {
start: async () => {},
send: async () => {},
close: async () => {},
...overrides,
};
}
describe("RemoteSession", () => {
it("queues events before a consumer attaches and drains them on attach", () => {
const session = new RemoteSession("s1");
@ -120,6 +130,45 @@ describe("RemoteSession", () => {
expect(session.transport).toBe(transport);
});
// #1935: the browser's Client negotiates the version, so the backend has to
// be told before it can stamp `Mcp-Protocol-Version` on upstream requests.
it("applyProtocolVersion forwards a new version to the transport once", () => {
const session = new RemoteSession("s8a");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion("2025-11-25");
session.applyProtocolVersion("2025-11-25");
expect(setProtocolVersion.mock.calls).toEqual([["2025-11-25"]]);
// A renegotiated version (e.g. after a reconnect) is re-applied.
session.applyProtocolVersion("2026-07-28");
expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28");
});
it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => {
const session = new RemoteSession("s8b");
const setProtocolVersion = vi.fn();
session.setTransport(makeTransport({ setProtocolVersion }));
session.applyProtocolVersion(undefined);
// Header injection attempt — must never reach the upstream transport.
session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1");
session.applyProtocolVersion("");
// The value arrives from an unvalidated JSON body, so a non-string must be
// rejected on its type — `RegExp.test` would coerce these into a match.
session.applyProtocolVersion(123);
session.applyProtocolVersion(true);
session.applyProtocolVersion(null);
expect(setProtocolVersion).not.toHaveBeenCalled();
});
it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => {
const session = new RemoteSession("s8c");
session.setTransport(makeTransport());
expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow();
});
it("hasEventConsumer reflects whether a consumer is attached", () => {
const session = new RemoteSession("s9");
expect(session.hasEventConsumer()).toBe(false);

View file

@ -452,6 +452,58 @@ describe("Remote transport e2e", () => {
}
});
it("end-to-end: the negotiated Mcp-Protocol-Version reaches the upstream server after initialize (#1935)", async () => {
// The browser's SDK Client owns the initialize handshake, so its
// setProtocolVersion() lands on the *remote* transport. Without the
// forward to the backend, the backend's real streamable-HTTP transport
// never learns the version and every post-initialize request — starting
// with `notifications/initialized` — goes out without the header, which
// a stateful server may reject outright.
mcpHttpServer = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
serverType: "streamable-http",
});
await mcpHttpServer.start();
const config: MCPServerConfig = {
type: "streamable-http",
url: mcpHttpServer.url,
};
const { client, fetchRequestLogState } =
await setupRemoteAndConnect(config);
try {
await client.listTools();
const protocolVersion = client.getProtocolVersion();
expect(protocolVersion).toBeDefined();
const posts = fetchRequestLogState
.getFetchRequests()
.filter((r) => r.method === "POST");
const headerFor = (body: string): string | undefined => {
const entry = posts.find((r) => (r.requestBody ?? "").includes(body));
expect(entry).toBeDefined();
for (const [k, v] of Object.entries(entry?.requestHeaders ?? {})) {
if (k.toLowerCase() === "mcp-protocol-version") return v;
}
return undefined;
};
// The initialize POST itself predates negotiation and carries none.
expect(headerFor('"method":"initialize"')).toBeUndefined();
// Everything after it does.
expect(headerFor('"method":"notifications/initialized"')).toBe(
protocolVersion,
);
expect(headerFor('"method":"tools/list"')).toBe(protocolVersion);
} finally {
await client.disconnect();
}
});
it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => {
// A modern (2026-07-28) server whose `get_weather` tool annotates `city`
// with `x-mcp-header: "City"`. The SDK's modern handler validates the

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,32 @@ 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`). The value arrives from an
* unvalidated JSON body, so the type is checked rather than assumed —
* `RegExp.test` would otherwise coerce a `123` or `true` into a "matching"
* string and hand the wrong type to the transport.
*/
applyProtocolVersion(version: unknown): void {
if (
typeof version !== "string" ||
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;

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`, {

View file

@ -125,6 +125,12 @@ export interface RemoteSendRequest {
* them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix.
*/
headers?: Record<string, string>;
/**
* MCP protocol version negotiated by the client's `initialize`. The backend
* applies it to the upstream transport before the send so HTTP transports
* stamp `Mcp-Protocol-Version` on this and every later request (#1935).
*/
protocolVersion?: string;
}
export type RemoteEventType =