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

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