Skip to content

Commit e841e4e

Browse files
authored
fix(mcp): cap transport response bodies to bound a hostile tools/call payload (#5850)
* fix(mcp): cap transport response bodies to bound a hostile tools/call payload A full-lifecycle comparison against LibreChat found the one place a reference client was stricter: the live transport had no response-body byte cap, so a hostile server could stream an unbounded tools/call result and OOM the process (discovery and OAuth bodies were already capped). Cap non-GET response bodies at 16 MiB — counted as they stream, not buffered — while leaving the standalone GET SSE notification stream uncapped (a cumulative cap would break its long-lived nature). Mirrors LibreChat's transport response-size cap. * fix(mcp): preserve url and redirected when capping a response body new Response() resets url/redirected; the SDK resolves relative auth-metadata URLs (resource_metadata) against response.url, so carry the originals over via defineProperty on the wrapped response. * fix(mcp): drop stale framing headers on a capped response body The wrapped body is the already-decoded stream, so content-encoding/content-length would misdescribe it — strip them, matching bufferUnderDeadline.
1 parent a0a437d commit e841e4e

2 files changed

Lines changed: 99 additions & 6 deletions

File tree

apps/sim/lib/mcp/pinned-fetch.test.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,50 @@ describe('createGuardedMcpFetch', () => {
4343
})
4444
})
4545

46-
it('builds the transport on the guarded connector with no response cap (streaming)', () => {
46+
it('builds the transport on the guarded connector with no dispatcher-level response cap', () => {
4747
const { close } = createGuardedMcpFetch()
4848

49-
// No options: no `allowH2` opt-in (h1.1 default) and no maxResponseSize —
50-
// the long-lived transport must stream unbounded SSE.
49+
// No dispatcher options: no `allowH2` opt-in (h1.1 default) and no Agent-level
50+
// maxResponseSize — the standalone GET SSE stream must stream unbounded (the body cap
51+
// is applied per-response to non-GET exchanges instead).
5152
expect(mockCreateGuardedFetchWithDispatcher).toHaveBeenCalledWith()
5253

53-
// close() tears down the pooled sockets (incl. the long-lived SSE) on disconnect.
5454
void close()
5555
expect(mockDestroy).toHaveBeenCalledTimes(1)
5656
})
57+
58+
it('caps an oversized non-GET response body but leaves the GET SSE stream unbounded', async () => {
59+
const big = new Uint8Array(20 * 1024 * 1024) // 20 MiB > 16 MiB cap
60+
const makeBody = () =>
61+
new ReadableStream<Uint8Array>({
62+
start(c) {
63+
c.enqueue(big)
64+
c.close()
65+
},
66+
})
67+
sentinelFetch.mockImplementation(async () => new Response(makeBody()))
68+
const { fetch: guarded } = createGuardedMcpFetch()
69+
70+
// A POST (tools/call) body over the cap errors when read.
71+
const post = await guarded('https://mcp.example/mcp', { method: 'POST' })
72+
await expect(new Response(post.body).arrayBuffer()).rejects.toThrow(/exceeded \d+ bytes/)
73+
74+
// The standalone GET SSE stream is not capped — its body streams through.
75+
const get = await guarded('https://mcp.example/mcp', { method: 'GET' })
76+
await expect(new Response(get.body).arrayBuffer()).resolves.toBeInstanceOf(ArrayBuffer)
77+
})
78+
79+
it('preserves url and redirected on a capped response (SDK auth-metadata resolution)', async () => {
80+
const small = new Response('{"ok":true}', { status: 200 })
81+
Object.defineProperty(small, 'url', { value: 'https://mcp.example/mcp' })
82+
Object.defineProperty(small, 'redirected', { value: true })
83+
sentinelFetch.mockImplementation(async () => small)
84+
const { fetch: guarded } = createGuardedMcpFetch()
85+
86+
const res = await guarded('https://mcp.example/mcp', { method: 'POST' })
87+
expect(res.url).toBe('https://mcp.example/mcp')
88+
expect(res.redirected).toBe(true)
89+
})
5790
})
5891

5992
describe('createSsrfGuardedMcpFetch', () => {

apps/sim/lib/mcp/pinned-fetch.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,57 @@ export interface GuardedMcpFetch {
3636
* the transport has no concurrency to gain from h2. `close` tears down pooled sockets
3737
* (incl. the SSE connection) on disconnect.
3838
*/
39+
/**
40+
* Byte ceiling for a single request/response exchange on the transport (JSON-RPC
41+
* results, `initialize`). A hostile server could otherwise stream an unbounded
42+
* `tools/call` body and OOM the process. Applied ONLY to non-GET responses — the
43+
* standalone GET SSE notification stream is deliberately long-lived and would be
44+
* broken by a cumulative cap. Mirrors LibreChat's transport response-size cap.
45+
*/
46+
const MAX_TRANSPORT_RESPONSE_BYTES = 16 * 1024 * 1024
47+
48+
/** True for the standalone server→client SSE stream (GET), which must stay uncapped. */
49+
function isStandaloneStream(method: string): boolean {
50+
return method.toUpperCase() === 'GET'
51+
}
52+
53+
/**
54+
* Wraps a response so its body errors once it exceeds `maxBytes`, without buffering —
55+
* bytes are counted as they stream, so an oversized body aborts the SDK's read instead
56+
* of accumulating in memory. Passthrough for normal-sized responses.
57+
*/
58+
function capResponseBody(response: Response, maxBytes: number): Response {
59+
if (!response.body) return response
60+
let seen = 0
61+
const limited = response.body.pipeThrough(
62+
new TransformStream<Uint8Array, Uint8Array>({
63+
transform(chunk, controller) {
64+
seen += chunk.byteLength
65+
if (seen > maxBytes) {
66+
controller.error(new McpError(`MCP response body exceeded ${maxBytes} bytes`))
67+
return
68+
}
69+
controller.enqueue(chunk)
70+
},
71+
})
72+
)
73+
const headers = new Headers(response.headers)
74+
// The wrapped body is the already-decoded stream; drop framing headers that would
75+
// misdescribe it (consistent with `bufferUnderDeadline`).
76+
headers.delete('content-encoding')
77+
headers.delete('content-length')
78+
const wrapped = new Response(limited, {
79+
status: response.status,
80+
statusText: response.statusText,
81+
headers,
82+
})
83+
// `new Response()` resets `url`/`redirected` (empty/false); the SDK resolves relative
84+
// auth-metadata URLs (e.g. `resource_metadata`) against `response.url`, so carry them over.
85+
Object.defineProperty(wrapped, 'url', { value: response.url, configurable: true })
86+
Object.defineProperty(wrapped, 'redirected', { value: response.redirected, configurable: true })
87+
return wrapped
88+
}
89+
3990
/**
4091
* Legacy single-IP pin, kept ONLY for self-hosted private/loopback resolutions
4192
* (a DNS alias the policy explicitly permits): the guarded lookup would filter
@@ -45,7 +96,14 @@ export interface GuardedMcpFetch {
4596
*/
4697
export function createPinnedPrivateMcpFetch(resolvedIP: string): GuardedMcpFetch {
4798
const { fetch: pinnedFetch, dispatcher } = createPinnedFetchWithDispatcher(resolvedIP)
48-
return { fetch: pinnedFetch, close: () => dispatcher.destroy() }
99+
const capped: typeof fetch = async (input, init) => {
100+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET')
101+
const response = await pinnedFetch(input, init)
102+
return isStandaloneStream(method)
103+
? response
104+
: capResponseBody(response, MAX_TRANSPORT_RESPONSE_BYTES)
105+
}
106+
return { fetch: capped, close: () => dispatcher.destroy() }
49107
}
50108

51109
export function createGuardedMcpFetch(): GuardedMcpFetch {
@@ -75,7 +133,9 @@ export function createGuardedMcpFetch(): GuardedMcpFetch {
75133
status: response.status,
76134
ttfbMs: Date.now() - startedAt,
77135
})
78-
return response
136+
return isStandaloneStream(method)
137+
? response
138+
: capResponseBody(response, MAX_TRANSPORT_RESPONSE_BYTES)
79139
} catch (error) {
80140
const e = error as { name?: string; code?: string; cause?: { name?: string; code?: string } }
81141
transportLogger.warn('MCP transport request failed', {

0 commit comments

Comments
 (0)