Skip to content

Commit a5e1030

Browse files
authored
fix(mcp): stream guarded transport via undici.request so SSE responses work under Bun (#5897)
* fix(mcp): stream guarded transport via undici.request so SSE responses work under Bun undici's fetch exposes its response body as a WHATWG ReadableStream whose bridge is broken under the Bun runtime the standalone server runs on: headers arrive but response.body never yields data, hanging every MCP streamable-HTTP (text/event-stream) tools/list read to its 30s timeout. undici's lower-level request() returns a Node Readable, which Bun streams natively. createSsrfGuardedFetchWithDispatcher (the MCP transport builder) now routes through undiciRequestAsResponse: same guarded Agent + connect.lookup (SSRF unchanged), request() instead of fetch(), and a hand-rolled Node->Web body bridge (not Readable.toWeb, which throws ERR_INVALID_STATE on the redirect body.cancel()). Buffered reads, followRedirectsGuarded, maxResponseSize, and abort all preserved and verified on both Node and Bun. * fix(mcp): use a single cast for header-iterable detection to satisfy boundary audit * fix(mcp): handle URLSearchParams OAuth bodies and copy stream chunks - toUndiciRequestBody serializes a URLSearchParams body (the MCP SDK's OAuth token/refresh exchange sends one); undici.request rejects it otherwise. - Default content-type application/x-www-form-urlencoded when the caller didn't set one (fetch parity). - nodeReadableToWebStream enqueues a copy (new Uint8Array(chunk)) not a view, so undici recycling the pooled source buffer can't corrupt queued chunks. - Tests for both. * fix(mcp): decode Content-Encoding on the undici.request transport (fetch parity) undici.request returns raw bytes; fetch auto-decompresses. Restore that: pipe a gzip/deflate/br response body through the matching zlib decoder before the WHATWG bridge and strip content-encoding/content-length. maxResponseSize still caps the compressed wire bytes; errors forward into the decoder and the source is torn down when the decoded stream ends or is cancelled. Adds a gzip decode test. * fix(mcp): guard decoder errors and null-body drain against unhandled crashes - Attach the stream bridge's error listener before piping into the zlib decoder, so a synchronous zlib error (server mislabeling a non-gzip body as gzip) rejects the reader instead of crashing the process. - Attach an error listener before draining a null-body response, and wrap Response construction in try/catch that destroys the source (no socket leak) on an out-of-range status. Adds an invalid-gzip regression test.
1 parent e737901 commit a5e1030

2 files changed

Lines changed: 497 additions & 6 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Covers the `undici.request()`-backed guarded fetch: `createSsrfGuardedFetchWithDispatcher`
5+
* builds its `fetch` on `undici.request` (not `undici.fetch`) because undici's `fetch` never
6+
* delivers a streaming `response.body` under the Bun runtime the server runs on. These tests
7+
* drive the real builder with a mocked `undici.request` and assert the constructed `Response`
8+
* preserves status/headers/url, streams its body, follows redirects via `followRedirectsGuarded`,
9+
* serves buffered reads, and settles the reader when the source is destroyed without an error.
10+
*/
11+
import { Readable } from 'node:stream'
12+
import { gzipSync } from 'node:zlib'
13+
import { beforeEach, describe, expect, it, vi } from 'vitest'
14+
15+
const { mockAgent, mockUndiciRequest } = vi.hoisted(() => {
16+
class MockAgent {
17+
close() {
18+
return Promise.resolve()
19+
}
20+
destroy() {
21+
return Promise.resolve()
22+
}
23+
}
24+
return { mockAgent: MockAgent, mockUndiciRequest: vi.fn() }
25+
})
26+
27+
vi.mock('undici', () => ({ Agent: mockAgent, request: mockUndiciRequest, fetch: vi.fn() }))
28+
29+
declare module '@/lib/core/security/input-validation.server?guarded-request-test' {
30+
// biome-ignore lint/suspicious/noExportsInTest: ambient re-declaration for the query-suffixed specifier
31+
export * from '@/lib/core/security/input-validation.server'
32+
}
33+
34+
import { createSsrfGuardedFetchWithDispatcher } from '@/lib/core/security/input-validation.server?guarded-request-test'
35+
36+
/** A byte stream that yields a single `Buffer` chunk then ends — mirrors undici's body. */
37+
function byteStream(text: string): Readable {
38+
const stream = new Readable({ read() {} })
39+
stream.push(Buffer.from(text))
40+
stream.push(null)
41+
return stream
42+
}
43+
44+
function undiciReply(
45+
statusCode: number,
46+
headers: Record<string, string | string[]>,
47+
body: Readable
48+
) {
49+
return { statusCode, headers, body, trailers: {}, opaque: null, context: {} }
50+
}
51+
52+
describe('createSsrfGuardedFetchWithDispatcher (undici.request backed)', () => {
53+
beforeEach(() => {
54+
vi.clearAllMocks()
55+
})
56+
57+
it('constructs a Response with the reply status, headers, url, and a streaming body', async () => {
58+
mockUndiciRequest.mockResolvedValueOnce(
59+
undiciReply(
60+
200,
61+
{ 'content-type': 'text/event-stream', 'mcp-session-id': 's-1' },
62+
byteStream('event: message\ndata: {"id":1}\n\n')
63+
)
64+
)
65+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
66+
67+
const response = await fetch('https://mcp.example.com/serve', {
68+
method: 'POST',
69+
headers: { 'content-type': 'application/json' },
70+
body: '{"jsonrpc":"2.0"}',
71+
})
72+
73+
expect(response.status).toBe(200)
74+
expect(response.headers.get('mcp-session-id')).toBe('s-1')
75+
expect(response.url).toBe('https://mcp.example.com/serve')
76+
77+
const text = await response.text()
78+
expect(text).toContain('"id":1')
79+
80+
// Does NOT auto-follow redirects — followRedirectsGuarded drives them instead.
81+
expect(mockUndiciRequest).toHaveBeenCalledTimes(1)
82+
const [, options] = mockUndiciRequest.mock.calls[0]
83+
expect(options.method).toBe('POST')
84+
expect(options.headers).toEqual({ 'content-type': 'application/json' })
85+
expect(options.body).toBe('{"jsonrpc":"2.0"}')
86+
expect(options.maxRedirections).toBeUndefined()
87+
})
88+
89+
it('follows a redirect through followRedirectsGuarded and reports the final url', async () => {
90+
mockUndiciRequest
91+
.mockResolvedValueOnce(
92+
undiciReply(302, { location: 'https://mcp.example.com/final' }, byteStream('redirect'))
93+
)
94+
.mockResolvedValueOnce(undiciReply(200, {}, byteStream('final-body')))
95+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
96+
97+
const response = await fetch('https://mcp.example.com/start', { method: 'GET' })
98+
99+
expect(mockUndiciRequest).toHaveBeenCalledTimes(2)
100+
expect(response.status).toBe(200)
101+
expect(response.url).toBe('https://mcp.example.com/final')
102+
expect(await response.text()).toBe('final-body')
103+
})
104+
105+
it('supports buffered reads (.json()) through the constructed body', async () => {
106+
mockUndiciRequest.mockResolvedValueOnce(
107+
undiciReply(
108+
200,
109+
{ 'content-type': 'application/json' },
110+
byteStream(JSON.stringify({ ok: true }))
111+
)
112+
)
113+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
114+
115+
const response = await fetch('https://mcp.example.com/data', { method: 'GET' })
116+
117+
expect(await response.json()).toEqual({ ok: true })
118+
})
119+
120+
it('normalizes a Headers instance and an ArrayBuffer body for undici.request', async () => {
121+
mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('x')))
122+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
123+
124+
await fetch('https://mcp.example.com/x', {
125+
method: 'POST',
126+
headers: new Headers({ authorization: 'Bearer t' }),
127+
body: new TextEncoder().encode('payload').buffer,
128+
})
129+
130+
const [, options] = mockUndiciRequest.mock.calls[0]
131+
expect(options.headers).toEqual({ authorization: 'Bearer t' })
132+
expect(Buffer.isBuffer(options.body)).toBe(true)
133+
expect(Buffer.from(options.body).toString()).toBe('payload')
134+
})
135+
136+
it('serializes a URLSearchParams body and defaults the form content-type (OAuth token exchange)', async () => {
137+
mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('{}')))
138+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
139+
140+
await fetch('https://auth.example.com/token', {
141+
method: 'POST',
142+
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'r-1' }),
143+
})
144+
145+
const [, options] = mockUndiciRequest.mock.calls[0]
146+
expect(options.body).toBe('grant_type=refresh_token&refresh_token=r-1')
147+
expect(options.headers['content-type']).toBe('application/x-www-form-urlencoded;charset=UTF-8')
148+
})
149+
150+
it('does not override an explicit content-type on a URLSearchParams body', async () => {
151+
mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, byteStream('{}')))
152+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
153+
154+
await fetch('https://auth.example.com/token', {
155+
method: 'POST',
156+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
157+
body: new URLSearchParams({ grant_type: 'authorization_code' }),
158+
})
159+
160+
const [, options] = mockUndiciRequest.mock.calls[0]
161+
expect(options.body).toBe('grant_type=authorization_code')
162+
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
163+
expect(options.headers['content-type']).toBeUndefined()
164+
})
165+
166+
it('copies each chunk so a recycled source buffer cannot corrupt queued data', async () => {
167+
const source = new Readable({ read() {} })
168+
mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, source))
169+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
170+
171+
const response = await fetch('https://mcp.example.com/stream', { method: 'GET' })
172+
const reader = response.body!.getReader()
173+
174+
// Emit a chunk, then mutate the SAME backing buffer (as undici's pool reuse would).
175+
const buf = Buffer.from('AB')
176+
source.push(buf)
177+
const first = await reader.read()
178+
buf[0] = 0x00 // corrupt the source buffer after the chunk was enqueued
179+
expect(Buffer.from(first.value!).toString()).toBe('AB') // copy is unaffected
180+
source.push(null)
181+
await reader.read()
182+
})
183+
184+
it('decodes a gzip Content-Encoding body and strips the framing headers (fetch parity)', async () => {
185+
const gzipped = gzipSync(Buffer.from(JSON.stringify({ ok: true, msg: 'compressed' })))
186+
const source = new Readable({ read() {} })
187+
source.push(gzipped)
188+
source.push(null)
189+
mockUndiciRequest.mockResolvedValueOnce(
190+
undiciReply(
191+
200,
192+
{ 'content-type': 'application/json', 'content-encoding': 'gzip', 'content-length': '40' },
193+
source
194+
)
195+
)
196+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
197+
198+
const response = await fetch('https://mcp.example.com/data', { method: 'GET' })
199+
200+
expect(await response.json()).toEqual({ ok: true, msg: 'compressed' })
201+
expect(response.headers.get('content-encoding')).toBeNull()
202+
expect(response.headers.get('content-length')).toBeNull()
203+
})
204+
205+
it('rejects the reader (not crash) when Content-Encoding is gzip but the body is not', async () => {
206+
const source = new Readable({ read() {} })
207+
source.push(Buffer.from('this is definitely not gzip'))
208+
source.push(null)
209+
mockUndiciRequest.mockResolvedValueOnce(
210+
undiciReply(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' }, source)
211+
)
212+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
213+
214+
const response = await fetch('https://mcp.example.com/bad', { method: 'GET' })
215+
216+
// The zlib error must surface as a rejected read, never an unhandled 'error' event.
217+
await expect(response.text()).rejects.toThrow()
218+
})
219+
220+
it('rejects the reader when the source is destroyed without an error (abort/reset)', async () => {
221+
const source = new Readable({ read() {} }) // stays open, never pushes
222+
mockUndiciRequest.mockResolvedValueOnce(undiciReply(200, {}, source))
223+
const { fetch } = createSsrfGuardedFetchWithDispatcher()
224+
225+
const response = await fetch('https://mcp.example.com/hang', { method: 'GET' })
226+
const reader = response.body!.getReader()
227+
const read = reader.read()
228+
source.destroy() // no error argument — mirrors an aborted/reset socket
229+
230+
await expect(read).rejects.toThrow(/closed before completing/)
231+
})
232+
})

0 commit comments

Comments
 (0)