@@ -10,6 +10,10 @@ import {
1010 mothershipWorkspaceTargetMock ,
1111 mothershipWorkspaceTargetMockFns ,
1212} from '@sim/testing/mocks/mothership-workspace-target.mock'
13+ import {
14+ permissionCheckMock ,
15+ permissionCheckMockFns ,
16+ } from '@sim/testing/mocks/permission-check.mock'
1317import {
1418 permissionGroupsResolveMock ,
1519 permissionGroupsResolveMockFns ,
@@ -19,6 +23,7 @@ import {
1923 workspaceContextMockFns ,
2024} from '@sim/testing/mocks/workspace-context.mock'
2125import { beforeEach , describe , expect , it , vi } from 'vitest'
26+ import { OrchestrationError } from '@/lib/core/orchestration/types'
2227import {
2328 createTrustedCopilotPrincipal ,
2429 createTrustedOrganizationCopilotPrincipal ,
@@ -30,9 +35,11 @@ import {
3035 readIntegrationCatalog ,
3136} from '@/lib/mothership/integrations/application/catalog'
3237
33- const hoisted = vi . hoisted ( ( ) => ( { mcp : vi . fn ( ) , flag : vi . fn ( async ( ) => true ) } ) )
38+ const hoisted = vi . hoisted ( ( ) => ( { flag : vi . fn ( async ( ) => true ) } ) )
3439const mocks = {
3540 ...hoisted ,
41+ discover : mcpUseCasesMockFns . mockDiscoverMcpServerToolsUseCase ,
42+ mcpPermission : permissionCheckMockFns . mockAssertPermissionsAllowed ,
3643 build : mothershipChatPayloadMockFns . mockBuildIntegrationToolSchemas ,
3744 banned : authBanMockFns . mockGetActivelyBannedUserIds ,
3845 target : mothershipWorkspaceTargetMockFns . mockResolveInvocationWorkspace ,
@@ -42,7 +49,8 @@ const mocks = {
4249}
4350vi . mock ( '@/lib/mcp/application/use-cases' , ( ) => mcpUseCasesMock )
4451vi . mock ( '@/lib/mothership/chat/payload' , ( ) => mothershipChatPayloadMock )
45- vi . mock ( '@/lib/mothership/mcp-tools' , ( ) => ( { buildTaggedMcpToolSchemas : hoisted . mcp } ) )
52+ vi . mock ( '@/lib/internal/mcp/discover-tools' , ( ) => ( { discoverMcpServerToolsAsExecutor : vi . fn ( ) } ) )
53+ vi . mock ( '@/ee/access-control/utils/permission-check' , ( ) => permissionCheckMock )
4654vi . mock ( '@/lib/mothership/application/workspace-target' , ( ) => mothershipWorkspaceTargetMock )
4755vi . mock ( '@/lib/workspaces/application/context' , ( ) => workspaceContextMock )
4856vi . mock ( '@/lib/auth/ban' , ( ) => authBanMock )
@@ -64,6 +72,9 @@ const tools = [
6472 input_schema : { type : 'object' } ,
6573 } ,
6674]
75+ function discoveredServerIds ( ) {
76+ return mocks . discover . mock . calls . map ( ( [ args ] ) => args . input . serverId )
77+ }
6778function principal ( ) {
6879 return createTrustedOrganizationCopilotPrincipal (
6980 { userId : 'actor' , organizationId : 'org-1' , chatId : 'chat-1' , delegationId : 'catalog-1' } ,
@@ -82,7 +93,9 @@ beforeEach(() => {
8293 mocks . banned . mockResolvedValue ( [ ] )
8394 mocks . config . mockResolvedValue ( null )
8495 mocks . build . mockResolvedValue ( [ ...tools ] )
85- mocks . mcp . mockResolvedValue ( [ ] )
96+ mocks . discover . mockReset ( )
97+ mocks . discover . mockResolvedValue ( { tools : [ ] } )
98+ mocks . mcpPermission . mockResolvedValue ( undefined )
8699 mocks . listServers . mockResolvedValue ( { servers : [ { id : 'mcp-abc' } ] } )
87100} )
88101describe ( 'integration catalog projection' , ( ) => {
@@ -238,7 +251,9 @@ describe('catalog authorization', () => {
238251
239252 it ( 'does not discover MCP operations in Search even with selected servers' , async ( ) => {
240253 queueChat ( )
241- mocks . mcp . mockResolvedValue ( [ { ...tools [ 0 ] , name : 'mcp-abc-send' , service : 'mcp:mcp-abc' } ] )
254+ mocks . discover . mockResolvedValue ( {
255+ tools : [ { serverId : 'mcp-abc' , name : 'send' , description : 'Send' , inputSchema : { } } ] ,
256+ } )
242257 const result = await readIntegrationCatalog . execute ( {
243258 principal : principal ( ) ,
244259 input : { ...input , service : 'mcp:mcp-abc' , mcpServerIds : [ 'mcp-abc' ] } ,
@@ -297,10 +312,12 @@ it.each([undefined, 'mcp-abc-write'])(
297312 async ( toolId ) => {
298313 queueChat ( 'agent' )
299314 mocks . target . mockResolvedValue ( { workspaceId : 'workspace-1' } )
300- mocks . mcp . mockResolvedValue ( [
301- { name : 'mcp-abc-read' , service : 'mcp:mcp-abc' , description : 'Read' , input_schema : { } } ,
302- { name : 'mcp-abc-write' , service : 'mcp:mcp-abc' , description : 'Write' , input_schema : { } } ,
303- ] )
315+ mocks . discover . mockResolvedValue ( {
316+ tools : [
317+ { serverId : 'mcp-abc' , name : 'read' , description : 'Read' , inputSchema : { } } ,
318+ { serverId : 'mcp-abc' , name : 'write' , description : 'Write' , inputSchema : { } } ,
319+ ] ,
320+ } )
304321 const result = await readIntegrationCatalog . execute ( {
305322 principal : principal ( ) ,
306323 input : {
@@ -312,7 +329,7 @@ it.each([undefined, 'mcp-abc-write'])(
312329 ...( toolId ? { toolId } : { } ) ,
313330 } ,
314331 } )
315- expect ( mocks . mcp ) . toHaveBeenCalledWith ( 'actor' , 'workspace-1' , [ 'mcp-abc' ] , undefined )
332+ expect ( discoveredServerIds ( ) ) . toEqual ( [ 'mcp-abc' ] )
316333 expect ( result . operations . map ( ( operation ) => operation . toolId ) ) . toEqual (
317334 toolId ? [ ] : [ 'mcp-abc-read' ]
318335 )
@@ -325,7 +342,7 @@ it('keeps native discovery available in organization chats with tagged MCP serve
325342 input : { ...input , mode : 'agent' , mcpServerIds : [ 'mcp-abc' ] , toolId : 'gmail_send' } ,
326343 } )
327344 expect ( result . operations . map ( ( operation ) => operation . toolId ) ) . toEqual ( [ 'gmail_send' ] )
328- expect ( mocks . mcp ) . not . toHaveBeenCalled ( )
345+ expect ( mocks . discover ) . not . toHaveBeenCalled ( )
329346} )
330347it ( 'requires an explicit workspace target for organization MCP discovery' , async ( ) => {
331348 queueChat ( 'agent' )
@@ -335,7 +352,7 @@ it('requires an explicit workspace target for organization MCP discovery', async
335352 input : { ...input , mode : 'agent' , mcpServerIds : [ 'mcp-abc' ] , toolId : 'mcp-abc-read' } ,
336353 } )
337354 ) . rejects . toThrow ( 'explicit workspace ID' )
338- expect ( mocks . mcp ) . not . toHaveBeenCalled ( )
355+ expect ( mocks . discover ) . not . toHaveBeenCalled ( )
339356} )
340357
341358it ( 'ranks query tokens across operation names, descriptions, and service while prioritizing exact IDs' , ( ) => {
@@ -381,7 +398,7 @@ it('filters organization enabled servers to the authorized target before broad M
381398 principal : expect . objectContaining ( { workspaceId : 'workspace-1' , subjectUserId : 'actor' } ) ,
382399 input : { workspaceId : 'workspace-1' } ,
383400 } )
384- expect ( mocks . mcp ) . toHaveBeenCalledWith ( 'actor' , 'workspace-1' , [ 'mcp-abc' ] , undefined )
401+ expect ( discoveredServerIds ( ) ) . toEqual ( [ 'mcp-abc' ] )
385402} )
386403
387404it ( 'removes previously discoverable Search operations when the runtime flag turns off' , async ( ) => {
@@ -394,3 +411,59 @@ it('removes previously discoverable Search operations when the runtime flag turn
394411 )
395412 }
396413} )
414+
415+ describe ( 'tagged MCP servers that cannot be discovered' , ( ) => {
416+ const failures : Record < string , ( ) => Promise < never > | Promise < { tools : never [ ] } > > = {
417+ 'mcp-deleted' : async ( ) => {
418+ throw new OrchestrationError ( 'not_found' , 'MCP server not found' )
419+ } ,
420+ 'mcp-down' : async ( ) => {
421+ throw new Error ( 'connect ECONNREFUSED' )
422+ } ,
423+ 'mcp-empty' : async ( ) => ( { tools : [ ] } ) ,
424+ }
425+ const serverIds = [ 'mcp-abc' , ...Object . keys ( failures ) ]
426+ function catalog ( request : Partial < IntegrationCatalogRequest > ) {
427+ queueChat ( 'agent' )
428+ return readIntegrationCatalog . execute ( {
429+ principal : principal ( ) ,
430+ input : {
431+ ...input ,
432+ mode : 'agent' ,
433+ workspaceId : 'workspace-1' ,
434+ mcpServerIds : serverIds ,
435+ ...request ,
436+ } ,
437+ } )
438+ }
439+ beforeEach ( ( ) => {
440+ mocks . target . mockResolvedValue ( { workspaceId : 'workspace-1' } )
441+ mocks . listServers . mockResolvedValue ( { servers : serverIds . map ( ( id ) => ( { id } ) ) } )
442+ mocks . discover . mockImplementation ( async ( { input : { serverId } } ) => {
443+ const failure = failures [ serverId ]
444+ if ( failure ) return failure ( )
445+ return { tools : [ { serverId, name : 'send' , description : 'Send a note' , inputSchema : { } } ] }
446+ } )
447+ } )
448+
449+ it . each ( [ undefined , 'send' ] ) (
450+ 'still lists native and healthy MCP operations (query: %s)' ,
451+ async ( query ) => {
452+ const result = await catalog ( query ? { query } : { } )
453+ expect ( result . operations . map ( ( operation ) => operation . toolId ) ) . toEqual (
454+ expect . arrayContaining ( [ 'gmail_send' , 'slack_send' , 'mcp-abc-send' ] )
455+ )
456+ expect ( result . operations ) . toHaveLength ( 3 )
457+ expect ( discoveredServerIds ( ) . sort ( ) ) . toEqual ( [ ...serverIds ] . sort ( ) )
458+ }
459+ )
460+
461+ it . each ( [
462+ [ 'mcp-deleted' , 'MCP server not found' ] ,
463+ [ 'mcp-down' , 'connect ECONNREFUSED' ] ,
464+ [ 'mcp-empty' , 'No permitted MCP operations are available for mcp-empty' ] ,
465+ ] ) ( 'fails a request that targets %s with its own error' , async ( serverId , message ) => {
466+ await expect ( catalog ( { service : `mcp:${ serverId } ` } ) ) . rejects . toThrow ( message )
467+ await expect ( catalog ( { toolId : `${ serverId } -send` } ) ) . rejects . toThrow ( message )
468+ } )
469+ } )
0 commit comments