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:
commit
f404d50af7
3 changed files with 219 additions and 1 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue