Skip to content

Commit f6df75f

Browse files
waleedlatif1claude
andcommitted
fix(mcp): use undici fetch directly in pinned-fetch for typed dispatcher
Replace `globalThis.fetch` + double-cast with `undici.fetch` so the `dispatcher` option is part of the real type contract. This guarantees pinning won't silently break if a future runtime swaps the underlying fetch implementation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d4173b2 commit f6df75f

2 files changed

Lines changed: 40 additions & 34 deletions

File tree

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,38 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockAgent, mockCreatePinnedLookup, mockFetch, capturedAgentOptions } = vi.hoisted(() => {
7-
const capturedAgentOptions: unknown[] = []
8-
class MockAgent {
9-
constructor(options: unknown) {
10-
capturedAgentOptions.push(options)
6+
const { mockAgent, mockCreatePinnedLookup, mockUndiciFetch, capturedAgentOptions } = vi.hoisted(
7+
() => {
8+
const capturedAgentOptions: unknown[] = []
9+
class MockAgent {
10+
constructor(options: unknown) {
11+
capturedAgentOptions.push(options)
12+
}
13+
}
14+
return {
15+
mockAgent: MockAgent,
16+
mockCreatePinnedLookup: vi.fn(),
17+
mockUndiciFetch: vi.fn(),
18+
capturedAgentOptions,
1119
}
1220
}
13-
return {
14-
mockAgent: MockAgent,
15-
mockCreatePinnedLookup: vi.fn(),
16-
mockFetch: vi.fn(),
17-
capturedAgentOptions,
18-
}
19-
})
21+
)
2022

21-
vi.mock('undici', () => ({ Agent: mockAgent }))
23+
vi.mock('undici', () => ({ Agent: mockAgent, fetch: mockUndiciFetch }))
2224
vi.mock('@/lib/core/security/input-validation.server', () => ({
2325
createPinnedLookup: mockCreatePinnedLookup,
2426
}))
2527

26-
import { createMcpPinnedFetch } from './pinned-fetch'
28+
import { createMcpPinnedFetch } from '@/lib/mcp/pinned-fetch'
2729

2830
describe('createMcpPinnedFetch', () => {
29-
const originalFetch = globalThis.fetch
30-
3131
beforeEach(() => {
3232
vi.clearAllMocks()
3333
capturedAgentOptions.length = 0
3434
mockCreatePinnedLookup.mockReturnValue('pinned-lookup-fn')
35-
globalThis.fetch = mockFetch as unknown as typeof fetch
36-
mockFetch.mockResolvedValue(new Response('ok'))
37-
})
38-
39-
afterEach(() => {
40-
globalThis.fetch = originalFetch
35+
mockUndiciFetch.mockResolvedValue(new Response('ok'))
4136
})
4237

4338
it('builds an undici Agent with the pinned lookup for the resolved IP', () => {
@@ -50,8 +45,8 @@ describe('createMcpPinnedFetch', () => {
5045
it('forwards the dispatcher on every fetch call', async () => {
5146
const fetchLike = createMcpPinnedFetch('203.0.113.10')
5247
await fetchLike('https://example.com/mcp', { method: 'POST' })
53-
expect(mockFetch).toHaveBeenCalledTimes(1)
54-
const [url, init] = mockFetch.mock.calls[0]
48+
expect(mockUndiciFetch).toHaveBeenCalledTimes(1)
49+
const [url, init] = mockUndiciFetch.mock.calls[0]
5550
expect(url).toBe('https://example.com/mcp')
5651
expect((init as { dispatcher?: unknown }).dispatcher).toBeInstanceOf(mockAgent)
5752
expect((init as { method?: string }).method).toBe('POST')
@@ -65,7 +60,7 @@ describe('createMcpPinnedFetch', () => {
6560
headers: { 'x-test': '1' },
6661
signal: controller.signal,
6762
})
68-
const init = mockFetch.mock.calls[0][1] as RequestInit & { dispatcher?: unknown }
63+
const init = mockUndiciFetch.mock.calls[0][1] as RequestInit & { dispatcher?: unknown }
6964
expect(init.headers).toEqual({ 'x-test': '1' })
7065
expect(init.signal).toBe(controller.signal)
7166
expect(init.dispatcher).toBeInstanceOf(mockAgent)
@@ -74,7 +69,7 @@ describe('createMcpPinnedFetch', () => {
7469
it('handles undefined init gracefully', async () => {
7570
const fetchLike = createMcpPinnedFetch('203.0.113.10')
7671
await fetchLike('https://example.com/mcp')
77-
const init = mockFetch.mock.calls[0][1] as { dispatcher?: unknown }
72+
const init = mockUndiciFetch.mock.calls[0][1] as { dispatcher?: unknown }
7873
expect(init.dispatcher).toBeInstanceOf(mockAgent)
7974
})
8075

@@ -83,8 +78,8 @@ describe('createMcpPinnedFetch', () => {
8378
await fetchLike('https://example.com/a')
8479
await fetchLike('https://example.com/b')
8580
expect(capturedAgentOptions).toHaveLength(1)
86-
const d1 = (mockFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher
87-
const d2 = (mockFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher
81+
const d1 = (mockUndiciFetch.mock.calls[0][1] as { dispatcher: unknown }).dispatcher
82+
const d2 = (mockUndiciFetch.mock.calls[1][1] as { dispatcher: unknown }).dispatcher
8883
expect(d1).toBe(d2)
8984
})
9085
})

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
2-
import { Agent } from 'undici'
2+
import { Agent, type RequestInit as UndiciRequestInit, fetch as undiciFetch } from 'undici'
33
import { createPinnedLookup } from '@/lib/core/security/input-validation.server'
44

55
/**
@@ -9,6 +9,10 @@ import { createPinnedLookup } from '@/lib/core/security/input-validation.server'
99
* fetch then forces every subsequent request (initial POST, SSE GET, redirects)
1010
* to use that same IP, regardless of what the hostname now resolves to.
1111
*
12+
* Uses undici's `fetch` directly so the `dispatcher` option is part of the
13+
* real type contract — not a cast that would silently break if a future
14+
* runtime swapped out the implementation.
15+
*
1216
* The original hostname is preserved on the request so TLS SNI and the Host
1317
* header continue to match the certificate.
1418
*/
@@ -17,9 +21,16 @@ export function createMcpPinnedFetch(resolvedIP: string): FetchLike {
1721
connect: { lookup: createPinnedLookup(resolvedIP) },
1822
})
1923

20-
return (url, init) =>
21-
globalThis.fetch(url, {
22-
...(init ?? {}),
24+
return (async (url, init) => {
25+
// DOM `RequestInit` and undici's `RequestInit` are structurally compatible
26+
// at runtime (Node's global fetch IS undici) but differ in TS types.
27+
// Cast the init through unknown to bridge the typing without losing the
28+
// critical `dispatcher` typing on the call itself.
29+
const undiciInit: UndiciRequestInit = {
30+
...(init as unknown as UndiciRequestInit),
2331
dispatcher,
24-
} as RequestInit & { dispatcher: Agent })
32+
}
33+
const response = await undiciFetch(url as string | URL, undiciInit)
34+
return response as unknown as Response
35+
}) satisfies FetchLike
2536
}

0 commit comments

Comments
 (0)