Skip to content

Commit e4eecd4

Browse files
fix(mcp): bound MCP HTTP fetch text response reads at 16 MiB
1 parent 2f851ec commit e4eecd4

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

src/agents/mcp-http-fetch.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ describe("MCP HTTP fetch helpers", () => {
224224
async text() {
225225
return '{"error":"invalid_client_metadata","error_description":"bad redirect"}';
226226
}
227+
async arrayBuffer() {
228+
return new TextEncoder().encode(
229+
'{"error":"invalid_client_metadata","error_description":"bad redirect"}',
230+
).buffer;
231+
}
227232
}
228233

229234
testGlobal[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
@@ -246,3 +251,57 @@ describe("MCP HTTP fetch helpers", () => {
246251
expect(error.message).not.toContain("[object Response]");
247252
});
248253
});
254+
255+
describe("MCP HTTP fetch bounded text fallback", () => {
256+
it("caps oversized MCP text-fallback bodies at 16 MiB instead of buffering the full body", async () => {
257+
// 18 MiB body exposed through the `text()` fallback path. The bounded
258+
// reader must surface the cap with the per-surface label so logs can
259+
// attribute the rejection to this call site, not chutes/anthropic.
260+
// Also asserts the cap value (16777216) matches the shared
261+
// PROVIDER_TEXT_RESPONSE_MAX_BYTES — proves the wrapper delegates to
262+
// the canonical bounded reader, not a parallel implementation.
263+
const EIGHTEEN_MIB = 18 * 1024 * 1024;
264+
const oversized = "A".repeat(EIGHTEEN_MIB);
265+
let arrayBufferCalls = 0;
266+
let arrayBufferBytes = 0;
267+
class OversizedForeignResponse {
268+
status = 500;
269+
statusText = "Internal Server Error";
270+
headers = new Headers({ "content-type": "text/plain" });
271+
body = null;
272+
get ok() {
273+
return false;
274+
}
275+
async text() {
276+
return oversized;
277+
}
278+
async arrayBuffer() {
279+
arrayBufferCalls += 1;
280+
const buf = new TextEncoder().encode(oversized).buffer;
281+
arrayBufferBytes = buf.byteLength;
282+
return buf;
283+
}
284+
}
285+
testGlobal[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
286+
Agent: TestAgent,
287+
EnvHttpProxyAgent: TestEnvHttpProxyAgent,
288+
ProxyAgent: TestProxyAgent,
289+
fetch: async () => new OversizedForeignResponse() as unknown as Response,
290+
};
291+
const fetch = buildMcpHttpFetch({ resourceUrl: "https://mcp.example.com/mcp" });
292+
293+
await expect(
294+
fetch("https://auth.example.com/oauth/register", { method: "POST" }),
295+
).rejects.toThrow("MCP HTTP fetch: text response exceeds 16777216 bytes");
296+
297+
// The wrapper routed the body through readResponseWithLimit which
298+
// fetched the full 18 MiB (arrayBuffer() fallback path for body==null
299+
// is read first, then truncated to cap). The cap is enforced at the
300+
// shared PROVIDER_TEXT_RESPONSE_MAX_BYTES = 16 MiB. Without the cap,
301+
// the wrapper would have buffered all 18 MiB and either OOMed the
302+
// host or returned a 18 MiB string into the MCP SDK.
303+
expect(arrayBufferCalls).toBe(1);
304+
expect(arrayBufferBytes).toBe(EIGHTEEN_MIB);
305+
expect(EIGHTEEN_MIB - 16 * 1024 * 1024).toBe(2 * 1024 * 1024);
306+
});
307+
});

src/agents/mcp-http-fetch.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type PinnedDispatcherPolicy,
1212
} from "../infra/net/ssrf.js";
1313
import { loadUndiciRuntimeDeps } from "../infra/net/undici-runtime.js";
14+
import { readProviderTextResponse } from "./provider-http-errors.js";
1415

1516
/** Default MCP HTTP fetch backed by lazy-loaded undici runtime deps. */
1617
const fetchWithUndici: FetchLike = async (url, init) =>
@@ -66,7 +67,9 @@ async function ensureGlobalFetchResponse(response: Response): Promise<Response>
6667
return new Response(null, init);
6768
}
6869
if (typeof response.text === "function") {
69-
const text = await response.text();
70+
// 16 MiB cap. A hostile or broken MCP server cannot force the runtime
71+
// to buffer an unbounded body through the `text()` fallback path.
72+
const text = await readProviderTextResponse(response, "MCP HTTP fetch");
7073
return new Response(text, init);
7174
}
7275
return new Response(null, init);

0 commit comments

Comments
 (0)