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:
parent
a09f2dcf1d
commit
7a74b5eba1
3 changed files with 204 additions and 1 deletions
|
|
@ -515,4 +515,61 @@ 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: fetchFn as unknown as typeof fetch },
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,6 +120,40 @@ 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({ setProtocolVersion } as unknown as Transport);
|
||||
|
||||
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 or non-token version", () => {
|
||||
const session = new RemoteSession("s8b");
|
||||
const setProtocolVersion = vi.fn();
|
||||
session.setTransport({ setProtocolVersion } as unknown as Transport);
|
||||
|
||||
session.applyProtocolVersion(undefined);
|
||||
// Header injection attempt — must never reach the upstream transport.
|
||||
session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1");
|
||||
session.applyProtocolVersion("");
|
||||
expect(setProtocolVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => {
|
||||
const session = new RemoteSession("s8c");
|
||||
session.setTransport({} as unknown as Transport);
|
||||
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