44 executionLimitsMockFns ,
55} from '@sim/testing/mocks/execution-limits.mock'
66import { getMockLogger } from '@sim/testing/mocks/logger.mock'
7+ import { networkConfigMock , networkConfigMockFns } from '@sim/testing/mocks/network-config.mock'
78import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
89
910const { mockSdkConnect, mockSdkListTools, mockPinnedClose } = vi . hoisted ( ( ) => ( {
@@ -48,9 +49,15 @@ vi.mock('@modelcontextprotocol/sdk/types.js', () => ({
4849} ) )
4950
5051vi . mock ( '@/lib/core/execution-limits' , ( ) => executionLimitsMock )
52+ vi . mock ( '@/lib/core/network/config.server' , ( ) => networkConfigMock )
5153
5254import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
5355import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
56+ import {
57+ resolveCurrentOutboundRoute ,
58+ runWithOutboundOrganization ,
59+ } from '@/lib/core/network/context.server'
60+ import { OutboundRoutingError } from '@/lib/core/network/routing'
5461import { McpClient } from '@/lib/mcp/client'
5562import { createGuardedMcpFetch , createPinnedPrivateMcpFetch } from '@/lib/mcp/pinned-fetch'
5663import {
@@ -186,7 +193,7 @@ describe('McpClient notification handler', () => {
186193 expect ( JSON . stringify ( mockLogger . info . mock . calls ) ) . not . toContain ( 'also-secret' )
187194 } )
188195
189- it ( 'keeps the transport on the SSRF guard when no validated address is supplied' , ( ) => {
196+ it ( 'keeps the transport on the SSRF guard when no validated address is supplied' , async ( ) => {
190197 new McpClient ( {
191198 config : createConfig ( ) ,
192199 securityPolicy : { requireConsent : false , auditLevel : 'basic' } ,
@@ -195,12 +202,14 @@ describe('McpClient notification handler', () => {
195202 const guarded = vi . mocked ( createGuardedMcpFetch ) . mock . results . at ( - 1 ) ?. value
196203 expect ( createGuardedMcpFetch ) . toHaveBeenCalledWith ( 'https://test.example.com/mcp' )
197204 expect ( createPinnedPrivateMcpFetch ) . not . toHaveBeenCalled ( )
198- expect ( vi . mocked ( StreamableHTTPClientTransport ) . mock . calls . at ( - 1 ) ?. [ 1 ] ?. fetch ) . toBe (
199- guarded . fetch
200- )
205+ await vi
206+ . mocked ( StreamableHTTPClientTransport )
207+ . mock . calls . at ( - 1 ) ?. [ 1 ]
208+ ?. fetch ?.( 'https://test.example.com/mcp' )
209+ expect ( guarded . fetch ) . toHaveBeenCalledWith ( 'https://test.example.com/mcp' , undefined )
201210 } )
202211
203- it ( 'pins the transport to a validated private address' , ( ) => {
212+ it ( 'pins the transport to a validated private address' , async ( ) => {
204213 new McpClient ( {
205214 config : createConfig ( ) ,
206215 securityPolicy : { requireConsent : false , auditLevel : 'basic' } ,
@@ -213,9 +222,11 @@ describe('McpClient notification handler', () => {
213222 'https://test.example.com/mcp'
214223 )
215224 expect ( createGuardedMcpFetch ) . not . toHaveBeenCalled ( )
216- expect ( vi . mocked ( StreamableHTTPClientTransport ) . mock . calls . at ( - 1 ) ?. [ 1 ] ?. fetch ) . toBe (
217- pinned . fetch
218- )
225+ await vi
226+ . mocked ( StreamableHTTPClientTransport )
227+ . mock . calls . at ( - 1 ) ?. [ 1 ]
228+ ?. fetch ?.( 'https://test.example.com/mcp' )
229+ expect ( pinned . fetch ) . toHaveBeenCalledWith ( 'https://test.example.com/mcp' , undefined )
219230 } )
220231
221232 it ( 'closes the pinned transport Agent when connect fails' , async ( ) => {
@@ -322,3 +333,56 @@ describe('McpClient notification handler', () => {
322333 )
323334 } )
324335} )
336+
337+ describe ( 'McpClient outbound scope' , ( ) => {
338+ beforeEach ( ( ) => {
339+ networkConfigMockFns . mockIsOutboundRoutingEnabled . mockReturnValue ( true )
340+ networkConfigMockFns . mockResolveOutboundRoute . mockImplementation ( async ( organizationId ) => {
341+ if ( organizationId === undefined ) throw new OutboundRoutingError ( 'MISSING_SCOPE' )
342+ return { kind : 'direct' , organizationId }
343+ } )
344+ vi . mocked ( createGuardedMcpFetch ) . mockReturnValue ( {
345+ fetch : vi . fn ( async ( ) => Response . json ( await resolveCurrentOutboundRoute ( ) ) ) ,
346+ close : mockPinnedClose ,
347+ } )
348+ } )
349+
350+ afterEach ( ( ) => {
351+ networkConfigMockFns . mockIsOutboundRoutingEnabled . mockReturnValue ( false )
352+ networkConfigMockFns . mockResolveOutboundRoute . mockImplementation ( async ( ) => ( {
353+ kind : 'direct' ,
354+ } ) )
355+ vi . mocked ( createGuardedMcpFetch ) . mockImplementation ( ( ) => ( {
356+ fetch : vi . fn ( ) ,
357+ close : mockPinnedClose ,
358+ } ) )
359+ } )
360+
361+ function constructedTransportFetch ( ) {
362+ const fetch = vi . mocked ( StreamableHTTPClientTransport ) . mock . calls . at ( - 1 ) ?. [ 1 ] ?. fetch
363+ if ( ! fetch ) throw new Error ( 'transport was constructed without a fetch' )
364+ return async ( ) => ( await fetch ( 'https://test.example.com/mcp' ) ) . json ( )
365+ }
366+
367+ it ( 'routes a request the SDK sends from outside any scope under the constructing organization' , async ( ) => {
368+ runWithOutboundOrganization ( 'org_a' , ( ) => new McpClient ( { config : createConfig ( ) } ) )
369+ const transportFetch = constructedTransportFetch ( )
370+
371+ await expect ( transportFetch ( ) ) . resolves . toEqual ( { kind : 'direct' , organizationId : 'org_a' } )
372+ await expect ( runWithOutboundOrganization ( 'org_b' , transportFetch ) ) . resolves . toEqual ( {
373+ kind : 'direct' ,
374+ organizationId : 'org_b' ,
375+ } )
376+ } )
377+
378+ it ( 'still fails closed when the client was constructed outside any scope' , async ( ) => {
379+ new McpClient ( { config : createConfig ( ) } )
380+ const transportFetch = constructedTransportFetch ( )
381+
382+ await expect ( transportFetch ( ) ) . rejects . toMatchObject ( { code : 'MISSING_SCOPE' } )
383+ await expect ( runWithOutboundOrganization ( 'org_b' , transportFetch ) ) . resolves . toEqual ( {
384+ kind : 'direct' ,
385+ organizationId : 'org_b' ,
386+ } )
387+ } )
388+ } )
0 commit comments