diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 81327c170..8eae7e4e9 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -18,6 +18,8 @@ - OAuth metadata parsing and `startAuthorization()` now accept only `http:` and `https:` URLs. Before, a server could supply a `file:` or custom protocol authorization endpoint (for example `search-ms:`), which the platform URL handler would launch on sign-in. - Fixed the excerpt of an HTTP error body in Streamable HTTP errors keeping the whole response body alive ([#9265](https://github.com/earendil-works/pi/issues/9265)) +- Fixed Streamable HTTP `application/json` responses bypassing `maxMessageBytes`; oversized JSON responses are now cancelled and rejected like oversized SSE events ([#11335](https://github.com/earendil-works/pi/issues/11335)) +- Fixed Streamable HTTP errors reading the whole HTTP error body; only the first 8 KiB kept in the error is read, and the rest is cancelled ## [1.4.0] - 2026-10-21 diff --git a/packages/mcp/src/transports/streamable-http.ts b/packages/mcp/src/transports/streamable-http.ts index b55cb2084..9ecdb2973 100644 --- a/packages/mcp/src/transports/streamable-http.ts +++ b/packages/mcp/src/transports/streamable-http.ts @@ -182,6 +182,33 @@ function discard(response: Response): Promise { return response.body?.cancel().catch(() => {}) ?? Promise.resolve(); } +/** + * Reads a response body as text, decoding chunks as they arrive. Once the body exceeds `maxBytes`, the rest is + * cancelled and the text holds only the first `maxBytes` bytes. + */ +async function readBodyText(response: Response, maxBytes: number): Promise<{ text: string; truncated: boolean }> { + if (!response.body) return { text: "", truncated: false }; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let remaining = maxBytes; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return { text: text + decoder.decode(), truncated: false }; + if (value.byteLength > remaining) { + await reader.cancel().catch(() => {}); + // Decoding a subarray copies only the kept bytes, so the result never references the dropped rest. + return { text: text + decoder.decode(value.subarray(0, remaining), { stream: true }), truncated: true }; + } + remaining -= value.byteLength; + text += decoder.decode(value, { stream: true }); + } + } finally { + reader.releaseLock(); + } +} + function describeHttpFailure(status: number, body: string): string { const text = body.trim(); const snippet = text.length > ERROR_MESSAGE_BODY_CHARS ? `${text.slice(0, ERROR_MESSAGE_BODY_CHARS - 3)}...` : text; @@ -257,7 +284,10 @@ export class StreamableHttpTransport extends TransportEvents implements McpTrans } const type = contentType(response); if (type === "application/json") { - const body: unknown = await response.json(); + const maxBytes = this.options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES; + const { text, truncated } = await readBodyText(response, maxBytes); + if (truncated) throw new Error(`MCP JSON response exceeds ${maxBytes} bytes`); + const body: unknown = JSON.parse(text); for (const item of Array.isArray(body) ? body : [body]) this.emitMessage(parseJsonRpcMessage(item)); return; } @@ -342,8 +372,8 @@ export class StreamableHttpTransport extends TransportEvents implements McpTrans private async checkResponse(response: Response): Promise { if (response.ok) return; - // A copy, not a slice: V8 slices keep the whole body alive, and the error keeps this excerpt. - const body = `${(await response.text().catch(() => "")).slice(0, MAX_ERROR_BODY_BYTES)} `.slice(0, -1); + // Reads only the excerpt the error keeps and cancels the rest, so a huge error body is never buffered. + const body = (await readBodyText(response, MAX_ERROR_BODY_BYTES).catch(() => ({ text: "" }))).text; if (response.status === 401) throw new McpAuthRequiredError(response, body); if (response.status === 404 && this.sessionIdValue) throw new McpSessionExpiredError(body); throw new McpHttpError(response.status, describeHttpFailure(response.status, body), body); diff --git a/packages/mcp/test/streamable-http.test.ts b/packages/mcp/test/streamable-http.test.ts index 81ae1d33d..bfef0210e 100644 --- a/packages/mcp/test/streamable-http.test.ts +++ b/packages/mcp/test/streamable-http.test.ts @@ -5,6 +5,7 @@ import { type McpAuthRequiredError, McpClient, type McpFetch, + McpHttpError, McpSessionExpiredError, StreamableHttpTransport, } from "../src/index.ts"; @@ -310,6 +311,80 @@ describe("StreamableHttpTransport", () => { await client.close(); }); + // #11335 + it("applies maxMessageBytes to JSON responses", async () => { + const send = async (descriptionBytes: number) => { + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { tools: [{ name: "echo", description: "x".repeat(descriptionBytes), inputSchema: {} }] }, + }); + const transport = new StreamableHttpTransport({ + url: "http://127.0.0.1/mcp", + maxMessageBytes: 512, + openGetStream: false, + fetch: async () => new Response(body, { headers: { "content-type": "application/json" } }), + }); + const messages: unknown[] = []; + transport.onMessage((message) => messages.push(message)); + await transport.start(); + try { + await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + return messages; + } finally { + await transport.close(); + } + }; + + expect(await send(32)).toHaveLength(1); + await expect(send(2048)).rejects.toThrow("MCP JSON response exceeds 512 bytes"); + }); + + it("decodes JSON responses split inside a BOM and a multi-byte character", async () => { + const bytes = new TextEncoder().encode( + `\uFEFF${JSON.stringify({ jsonrpc: "2.0", id: 1, result: { text: "é€" } })}`, + ); + const body = new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); + controller.close(); + }, + }); + const transport = new StreamableHttpTransport({ + url: "http://127.0.0.1/mcp", + openGetStream: false, + fetch: async () => new Response(body, { headers: { "content-type": "application/json" } }), + }); + const messages: unknown[] = []; + transport.onMessage((message) => messages.push(message)); + await transport.start(); + await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + await transport.close(); + expect(messages).toEqual([{ jsonrpc: "2.0", id: 1, result: { text: "é€" } }]); + }); + + it("reads only an excerpt of HTTP error bodies and cancels the rest", async () => { + let cancelled = false; + // An endless error body: reading it whole would never finish. + const body = new ReadableStream({ + pull: (controller) => controller.enqueue(new TextEncoder().encode("x".repeat(1024))), + cancel: () => { + cancelled = true; + }, + }); + const transport = new StreamableHttpTransport({ + url: "http://127.0.0.1/mcp", + openGetStream: false, + fetch: async () => new Response(body, { status: 500 }), + }); + await transport.start(); + const error = await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }).catch((e: unknown) => e); + await transport.close(); + expect(error).toBeInstanceOf(McpHttpError); + expect((error as McpHttpError).body).toHaveLength(8 * 1024); + expect(cancelled).toBe(true); + }); + it("includes the response body in HTTP errors", async () => { const { url } = await startServer(async (request, response) => { await readBody(request);