Skip to content

Commit 313015e

Browse files
committed
improvement(mcp): negotiate HTTP/2 for MCP transport
MCP servers are commonly behind HTTP/2 fronts (CDNs, cloud LBs), but the SSRF-pinned undici Agent is HTTP/1.1-only unless it opts into h2 via ALPN. Add an allowH2 option to createPinnedFetch (default false, so LLM-provider callers are unchanged) and centralize the MCP h2 decision in one createPinnedMcpFetch routed through the transport, OAuth probe, and SSRF-guarded fetch. Pinning is unaffected: the pinned lookup forces every connection to the resolved IP.
1 parent 1b06b6c commit 313015e

6 files changed

Lines changed: 44 additions & 11 deletions

File tree

apps/sim/lib/core/security/input-validation.server.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,9 +433,21 @@ export function createPinnedLookup(resolvedIP: string): LookupFunction {
433433
*
434434
* The `Agent` is captured for the lifetime of the returned function, so repeated
435435
* calls (e.g. a provider tool loop) reuse its keep-alive connections.
436+
*
437+
* `allowH2` opts the pinned Agent into HTTP/2 (ALPN-negotiated, h1.1 fallback).
438+
* It defaults to `false` to leave existing consumers unchanged. Enabling it does
439+
* not weaken pinning: the pinned `connect.lookup` forces every connection on the
440+
* Agent to `resolvedIP` regardless of authority, so h2 connection coalescing can
441+
* never reach an address other than the validated one.
436442
*/
437-
export function createPinnedFetch(resolvedIP: string): typeof fetch {
438-
const dispatcher = new Agent({ connect: { lookup: createPinnedLookup(resolvedIP) } })
443+
export function createPinnedFetch(
444+
resolvedIP: string,
445+
options?: { allowH2?: boolean }
446+
): typeof fetch {
447+
const dispatcher = new Agent({
448+
allowH2: options?.allowH2 ?? false,
449+
connect: { lookup: createPinnedLookup(resolvedIP) },
450+
})
439451

440452
const pinned = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
441453
// double-cast-allowed: DOM RequestInfo/URL and undici fetch input types differ but are structurally compatible at runtime (Node's global fetch IS undici)

apps/sim/lib/core/security/pinned-fetch.server.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ describe('createPinnedFetch', () => {
5555
expect(resolved).toEqual({ address: '203.0.113.10', family: 4 })
5656
})
5757

58+
it('defaults allowH2 to false so existing consumers are unchanged', () => {
59+
createPinnedFetch('203.0.113.10')
60+
const opts = capturedAgentOptions[0] as { allowH2?: boolean }
61+
expect(opts.allowH2).toBe(false)
62+
})
63+
64+
it('opts the Agent into HTTP/2 when allowH2 is requested', () => {
65+
createPinnedFetch('203.0.113.10', { allowH2: true })
66+
const opts = capturedAgentOptions[0] as { allowH2?: boolean }
67+
expect(opts.allowH2).toBe(true)
68+
})
69+
5870
it('uses IPv6 family when the validated IP is IPv6', async () => {
5971
createPinnedFetch('2606:4700:4700::1111')
6072
const { connect } = capturedAgentOptions[0] as { connect: { lookup: PinnedLookup } }

apps/sim/lib/mcp/oauth/probe.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@ const { mockCreatePinnedFetch, mockCreateSsrfGuardedMcpFetch, mockPinnedFetch, m
1515
}
1616
})
1717

18-
vi.mock('@/lib/core/security/input-validation.server', () => ({
19-
createPinnedFetch: mockCreatePinnedFetch,
20-
}))
2118
vi.mock('@/lib/mcp/pinned-fetch', () => ({
19+
createPinnedMcpFetch: mockCreatePinnedFetch,
2220
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
2321
}))
2422

apps/sim/lib/mcp/oauth/probe.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js'
22
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
33
import { createLogger } from '@sim/logger'
4-
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
54
import { isLoopbackHostname } from '@/lib/core/utils/urls'
6-
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
5+
import { createPinnedMcpFetch, createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
76
import type { McpAuthType } from '@/lib/mcp/types'
87

98
const logger = createLogger('McpOauthProbe')
@@ -34,7 +33,7 @@ export async function detectMcpAuthType(
3433
}
3534

3635
const probeFetch: FetchLike = resolvedIP
37-
? createPinnedFetch(resolvedIP)
36+
? createPinnedMcpFetch(resolvedIP)
3837
: createSsrfGuardedMcpFetch()
3938

4039
const controller = new AbortController()

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('createSsrfGuardedMcpFetch', () => {
3131
await fetchLike('https://attacker.example/revoke', { method: 'POST' })
3232

3333
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/revoke')
34-
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
34+
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true })
3535
expect(sentinelFetch).toHaveBeenCalledWith('https://attacker.example/revoke', {
3636
method: 'POST',
3737
})
@@ -54,7 +54,7 @@ describe('createSsrfGuardedMcpFetch', () => {
5454
await fetchLike(new URL('https://attacker.example/discover'))
5555

5656
expect(mockValidateMcpServerSsrf).toHaveBeenCalledWith('https://attacker.example/discover')
57-
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10')
57+
expect(mockCreatePinnedFetch).toHaveBeenCalledWith('203.0.113.10', { allowH2: true })
5858
})
5959

6060
it('falls back to global fetch when validation returns no IP', async () => {

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
22
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
33
import { validateMcpServerSsrf } from '@/lib/mcp/domain-check'
44

5+
/**
6+
* Pinned fetch for all MCP traffic. MCP servers are commonly deployed behind
7+
* HTTP/2 fronts (CDNs, cloud LBs), and undici's Agent is h1.1-only unless opted
8+
* into h2 via ALPN — so every MCP connection enables it. This is the single
9+
* source of the "MCP implies h2" decision; the base `createPinnedFetch` keeps
10+
* its h1.1 default for non-MCP consumers. Pinning is unaffected: the pinned
11+
* lookup forces the socket to `resolvedIP` regardless of negotiated protocol.
12+
*/
13+
export function createPinnedMcpFetch(resolvedIP: string): typeof fetch {
14+
return createPinnedFetch(resolvedIP, { allowH2: true })
15+
}
16+
517
/**
618
* Builds a `FetchLike` that validates every outbound request URL against the
719
* MCP SSRF policy before issuing it, then pins the connection to the resolved
@@ -25,7 +37,7 @@ export function createSsrfGuardedMcpFetch(): FetchLike {
2537
return (async (url, init) => {
2638
const target = typeof url === 'string' ? url : url.href
2739
const resolvedIP = await validateMcpServerSsrf(target)
28-
const pinnedFetch: FetchLike = resolvedIP ? createPinnedFetch(resolvedIP) : globalThis.fetch
40+
const pinnedFetch: FetchLike = resolvedIP ? createPinnedMcpFetch(resolvedIP) : globalThis.fetch
2941
return pinnedFetch(url, init)
3042
}) satisfies FetchLike
3143
}

0 commit comments

Comments
 (0)