11import fs from "node:fs" ;
22import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js" ;
3+ import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js" ;
4+ import {
5+ ssrfPolicyFromHttpBaseUrlAllowedOrigin ,
6+ type PinnedDispatcherPolicy ,
7+ } from "../infra/net/ssrf.js" ;
38import { loadUndiciRuntimeDeps } from "../infra/net/undici-runtime.js" ;
49
510/** MCP SDK-compatible fetch function type. */
@@ -12,6 +17,98 @@ export const fetchWithUndici: FetchLike = async (url, init) =>
1217 init as Parameters < ReturnType < typeof loadUndiciRuntimeDeps > [ "fetch" ] > [ 1 ] ,
1318 ) ) as unknown as Response ;
1419
20+ const fetchWithUndiciGuard = async (
21+ input : RequestInfo | URL ,
22+ init ?: RequestInit ,
23+ ) : Promise < Response > => await fetchWithUndici ( input instanceof Request ? input . url : input , init ) ;
24+
25+ const MCP_HTTP_MAX_REDIRECTS = 20 ;
26+ const managedMcpResponseCleanupRegistry = new FinalizationRegistry < {
27+ finalize : ( ) => Promise < void > ;
28+ } > ( ( held ) => {
29+ void held . finalize ( ) ;
30+ } ) ;
31+
32+ function resolveFetchRequest ( input : RequestInfo | URL , init ?: RequestInit ) {
33+ if ( input instanceof Request ) {
34+ const request = new Request ( input , init ) ;
35+ const body = request . body ?? undefined ;
36+ return {
37+ url : request . url ,
38+ init : {
39+ method : request . method ,
40+ headers : request . headers ,
41+ body,
42+ redirect : request . redirect ,
43+ signal : request . signal ,
44+ ...( body ? ( { duplex : "half" } as const ) : { } ) ,
45+ } satisfies RequestInit & { duplex ?: "half" } ,
46+ } ;
47+ }
48+ return {
49+ url : input instanceof URL ? input . toString ( ) : input ,
50+ init,
51+ } ;
52+ }
53+
54+ function buildManagedMcpResponse (
55+ response : Response ,
56+ release : ( ) => Promise < void > ,
57+ refreshTimeout ?: ( ) => void ,
58+ ) : Response {
59+ if ( ! response . body ) {
60+ void release ( ) ;
61+ return response ;
62+ }
63+
64+ const source = response . body ;
65+ let reader : ReadableStreamDefaultReader < Uint8Array > | undefined ;
66+ let released = false ;
67+ const cleanupRegistrationToken = { } ;
68+ const finalize = async ( ) => {
69+ if ( released ) {
70+ return ;
71+ }
72+ released = true ;
73+ managedMcpResponseCleanupRegistry . unregister ( cleanupRegistrationToken ) ;
74+ await reader ?. cancel ( ) . catch ( ( ) => undefined ) ;
75+ await release ( ) . catch ( ( ) => undefined ) ;
76+ } ;
77+ const wrappedBody = new ReadableStream < Uint8Array > ( {
78+ start ( ) {
79+ reader = source . getReader ( ) ;
80+ } ,
81+ async pull ( controller ) {
82+ try {
83+ const chunk = await reader ?. read ( ) ;
84+ if ( ! chunk || chunk . done ) {
85+ controller . close ( ) ;
86+ await finalize ( ) ;
87+ return ;
88+ }
89+ refreshTimeout ?.( ) ;
90+ controller . enqueue ( chunk . value ) ;
91+ } catch ( error ) {
92+ controller . error ( error ) ;
93+ await finalize ( ) ;
94+ }
95+ } ,
96+ async cancel ( reason ) {
97+ try {
98+ await reader ?. cancel ( reason ) ;
99+ } finally {
100+ await finalize ( ) ;
101+ }
102+ } ,
103+ } ) ;
104+ managedMcpResponseCleanupRegistry . register ( wrappedBody , { finalize } , cleanupRegistrationToken ) ;
105+ return new Response ( wrappedBody , {
106+ status : response . status ,
107+ statusText : response . statusText ,
108+ headers : response . headers ,
109+ } ) ;
110+ }
111+
15112/** Builds an MCP fetch function with optional TLS/client-cert dispatcher support. */
16113export function buildMcpHttpFetch ( params : {
17114 sslVerify ?: boolean ;
@@ -21,32 +118,39 @@ export function buildMcpHttpFetch(params: {
21118} ) : FetchLike {
22119 const needsCustomDispatcher =
23120 params . sslVerify === false || Boolean ( params . clientCert || params . clientKey ) ;
24- if ( ! needsCustomDispatcher ) {
25- return fetchWithUndici ;
26- }
27121 const scopedOrigin = params . resourceUrl ? new URL ( params . resourceUrl ) . origin : undefined ;
122+ const policy = params . resourceUrl
123+ ? ssrfPolicyFromHttpBaseUrlAllowedOrigin ( params . resourceUrl )
124+ : undefined ;
28125
29- const buildDispatcher = ( ) => {
30- const { Agent } = loadUndiciRuntimeDeps ( ) ;
31- return new Agent ( {
32- connect : {
33- ...( params . sslVerify === false ? { rejectUnauthorized : false } : { } ) ,
34- ...( params . clientCert ? { cert : fs . readFileSync ( params . clientCert , "utf-8" ) } : { } ) ,
35- ...( params . clientKey ? { key : fs . readFileSync ( params . clientKey , "utf-8" ) } : { } ) ,
36- } ,
37- } ) ;
126+ let customConnect : Record < string , unknown > | undefined ;
127+ const resolveCustomDispatcherPolicy = ( url : URL ) : PinnedDispatcherPolicy | undefined => {
128+ if ( ! needsCustomDispatcher || ! scopedOrigin || url . origin !== scopedOrigin ) {
129+ return undefined ;
130+ }
131+ customConnect ??= {
132+ ...( params . sslVerify === false ? { rejectUnauthorized : false } : { } ) ,
133+ ...( params . clientCert ? { cert : fs . readFileSync ( params . clientCert , "utf-8" ) } : { } ) ,
134+ ...( params . clientKey ? { key : fs . readFileSync ( params . clientKey , "utf-8" ) } : { } ) ,
135+ } ;
136+ return { mode : "direct" , connect : customConnect } ;
38137 } ;
39138
40- let dispatcher : unknown ;
41139 return async ( url , init ) => {
42- if ( scopedOrigin && new URL ( url ) . origin !== scopedOrigin ) {
43- return fetchWithUndici ( url , init ) ;
44- }
45- dispatcher ??= buildDispatcher ( ) ;
46- return ( await loadUndiciRuntimeDeps ( ) . fetch ( url , {
47- ...( init as RequestInit ) ,
48- dispatcher,
49- } as Parameters < ReturnType < typeof loadUndiciRuntimeDeps > [ "fetch" ] > [ 1 ] ) ) as unknown as Response ;
140+ const request = resolveFetchRequest ( url , init ) ;
141+ const guardedFetchOptions = {
142+ url : request . url ,
143+ init : request . init ,
144+ fetchImpl : fetchWithUndiciGuard ,
145+ maxRedirects : MCP_HTTP_MAX_REDIRECTS ,
146+ allowCrossOriginUnsafeRedirectReplay : true ,
147+ auditContext : "mcp-http" ,
148+ useEnvProxyForEligibleUrls : true ,
149+ ...( policy ? { policy } : { } ) ,
150+ ...( needsCustomDispatcher ? { resolveDispatcherPolicy : resolveCustomDispatcherPolicy } : { } ) ,
151+ } ;
152+ const guarded = await fetchWithSsrFGuard ( guardedFetchOptions ) ;
153+ return buildManagedMcpResponse ( guarded . response , guarded . release , guarded . refreshTimeout ) ;
50154 } ;
51155}
52156
0 commit comments