Skip to content

Commit 0306324

Browse files
committed
fix(mcp): read the initialize handshake before the transport consumes the body
The #10175 handshake telemetry called `c.req.raw.clone()` after createMcpHandler had already read the request body. The Fetch spec forbids cloning a request whose body is disturbed, so it threw `TypeError: unusable`; handleMcpRequest's catch rethrew, and a correct 2xx MCP response was discarded in favour of an unhandled 500 -- on every `initialize`, the first call of every MCP session. The telemetry key gate did not contain it: the handshake read is an argument to recordMcpInitialize, evaluated before that function's own no-op check, so the throw happened whether or not POSTHOG_API_KEY was set. Parse the JSON-RPC envelope once, before the handler runs, and derive both the usage metadata and the clientInfo handshake from it. No clone survives past the handler. Closes #10190
1 parent 693aed8 commit 0306324

2 files changed

Lines changed: 76 additions & 9 deletions

File tree

src/mcp/server.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,12 @@ export async function handleMcpRequest(c: AppContext): Promise<Response> {
665665
if (!identity) return c.json({ error: "unauthorized" }, 401);
666666

667667
const telemetry = buildMcpClientTelemetry(c.req.raw.headers, { defaultClientName: "mcp" })!;
668-
const usageMetadata = await describeMcpUsageRequest(c.req.raw, telemetry.metadata);
668+
// ONE clone-and-parse of the JSON-RPC body, here, BEFORE createMcpHandler below consumes it (#10190).
669+
// A second `request.clone()` after that point throws `TypeError: unusable` -- the Fetch spec forbids
670+
// cloning a request whose body is already disturbed -- which is why the post-response handshake read this
671+
// replaces turned every `initialize` into an unhandled 500.
672+
const envelope = await readMcpRequestEnvelope(c.req.raw);
673+
const usageMetadata = describeMcpUsageRequest(envelope, c.req.raw.method, telemetry.metadata);
669674
const startedAt = Date.now();
670675
const executionCtx = getExecutionContext(c);
671676
// #9525: the dispatch chokepoint's sink is built per request so its deferred work rides this
@@ -693,7 +698,7 @@ export async function handleMcpRequest(c: AppContext): Promise<Response> {
693698
// a rejected handshake never inflates the session/client counts.
694699
if (response.status < 400) {
695700
if (usageMetadata.rpcMethod === "initialize") {
696-
recordMcpInitialize(c.env, defer, await readInitializeHandshake(c.req.raw), analyticsContext);
701+
recordMcpInitialize(c.env, defer, readInitializeHandshake(envelope), analyticsContext);
697702
} else if (usageMetadata.rpcMethod === "tools/list") {
698703
// Names come from this server's own registration chokepoint, not the cross-server contract
699704
// registry, so the event reports what was actually advertised to THIS client.
@@ -765,20 +770,35 @@ function trimmedHeader(value: string | null): string | undefined {
765770
* client. Returns an empty handshake for a malformed or absent body: the fields are optional by
766771
* contract, and a session that connected is still worth counting.
767772
*/
768-
async function readInitializeHandshake(request: Request): Promise<McpInitializeTelemetry> {
769-
const body = await request.clone().json().catch(() => null);
770-
if (!body || typeof body !== "object") return {};
771-
const clientInfo = (body as { params?: { clientInfo?: { name?: unknown; version?: unknown } } }).params?.clientInfo;
773+
function readInitializeHandshake(envelope: McpRequestEnvelope | null): McpInitializeTelemetry {
774+
const clientInfo = envelope?.params?.clientInfo;
772775
return {
773776
clientName: typeof clientInfo?.name === "string" ? clientInfo.name : undefined,
774777
clientVersion: typeof clientInfo?.version === "string" ? clientInfo.version : undefined,
775778
};
776779
}
777780

778-
async function describeMcpUsageRequest(request: Request, telemetryMetadata: Record<string, unknown> | undefined): Promise<Record<string, unknown>> {
781+
/** The JSON-RPC envelope fields the telemetry paths read. Deliberately structural and permissive: this is an
782+
* unvalidated client body, and every consumer below re-checks the type of the field it uses. */
783+
type McpRequestEnvelope = {
784+
method?: unknown;
785+
params?: { name?: unknown; clientInfo?: { name?: unknown; version?: unknown } };
786+
};
787+
788+
/** Clone-and-parse the request body exactly once, at the top of {@link handleMcpRequest} (#10190). Returns
789+
* null for an absent or malformed body -- the telemetry fields are all optional by contract, and a request
790+
* that is still worth counting must never be failed over its own instrumentation. */
791+
async function readMcpRequestEnvelope(request: Request): Promise<McpRequestEnvelope | null> {
779792
const body = await request.clone().json().catch(() => null);
780-
if (!body || typeof body !== "object") return { transport: "http", method: request.method, ...telemetryMetadata };
781-
const envelope = body as { method?: unknown; params?: { name?: unknown } };
793+
return body && typeof body === "object" ? (body as McpRequestEnvelope) : null;
794+
}
795+
796+
function describeMcpUsageRequest(
797+
envelope: McpRequestEnvelope | null,
798+
method: string,
799+
telemetryMetadata: Record<string, unknown> | undefined,
800+
): Record<string, unknown> {
801+
if (!envelope) return { transport: "http", method, ...telemetryMetadata };
782802
const rpcMethod = typeof envelope.method === "string" ? envelope.method : undefined;
783803
const toolName = envelope.params && typeof envelope.params.name === "string" ? envelope.params.name : undefined;
784804
return {

test/unit/mcp-server-telemetry.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,4 +273,51 @@ describe("MCP server telemetry", () => {
273273
expect(response.status).toBe(200);
274274
await expect(response.clone().json()).resolves.toEqual({ ok: true, result: "unchanged" });
275275
});
276+
277+
// REGRESSION (#10190): the #10175 handshake read called `c.req.raw.clone()` AFTER createMcpHandler had
278+
// consumed the body. The Fetch spec forbids cloning a request whose body is already disturbed, so it threw
279+
// `TypeError: unusable`, handleMcpRequest's catch rethrew, and a correct 2xx MCP response was replaced by an
280+
// unhandled 500 -- on every `initialize`, i.e. the first call of every MCP session. The telemetry key gate
281+
// did not save it: the handshake read is an ARGUMENT to recordMcpInitialize, evaluated before its no-op check.
282+
const runInitialize = async (params: unknown): Promise<Response> => {
283+
vi.resetModules();
284+
// A handler that CONSUMES the request body, exactly as the real MCP transport does -- the whole point of
285+
// the regression. A handler that ignored the body would pass even with the bug reintroduced.
286+
vi.doMock("agents/mcp", () => ({
287+
createMcpHandler: () => async (request: Request) => {
288+
await request.text();
289+
return Response.json({ jsonrpc: "2.0", id: "init", result: { protocolVersion: "2024-11-05" } });
290+
},
291+
}));
292+
const { handleMcpRequest } = await import("../../src/mcp/server");
293+
const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "mcp-initialize-clone-salt" });
294+
const request = new Request("https://api.test/mcp", {
295+
method: "POST",
296+
headers: { authorization: `Bearer ${env.LOOPOVER_MCP_TOKEN}`, "content-type": "application/json" },
297+
body: JSON.stringify({ jsonrpc: "2.0", id: "init", method: "initialize", ...(params === undefined ? {} : { params }) }),
298+
});
299+
return handleMcpRequest({
300+
env,
301+
executionCtx: { waitUntil() {}, passThroughOnException() {} },
302+
req: { method: "POST", raw: request, header: (name: string) => request.headers.get(name) ?? undefined },
303+
json: (body: unknown, status?: number) => Response.json(body, status === undefined ? undefined : { status }),
304+
} as never);
305+
};
306+
307+
it("REGRESSION (#10190): an initialize whose body the handler consumed still returns the handler's response, not a 500", async () => {
308+
const response = await runInitialize({
309+
protocolVersion: "2024-11-05",
310+
capabilities: {},
311+
clientInfo: { name: "claude-code", version: "2.1.0" },
312+
});
313+
expect(response.status).toBe(200);
314+
await expect(response.clone().json()).resolves.toMatchObject({ result: { protocolVersion: "2024-11-05" } });
315+
});
316+
317+
it("REGRESSION (#10190): an initialize with no params, and one whose clientInfo fields are not strings, still succeed", async () => {
318+
// The handshake fields are optional by contract -- a client that omits them must not be failed over
319+
// LoopOver's own instrumentation.
320+
await expect(runInitialize(undefined)).resolves.toMatchObject({ status: 200 });
321+
await expect(runInitialize({ clientInfo: { name: 42, version: null } })).resolves.toMatchObject({ status: 200 });
322+
});
276323
});

0 commit comments

Comments
 (0)