@@ -556,7 +556,7 @@ describe('McpService.discoverTools per-server caching', () => {
556556 await expect ( newer ) . resolves . toEqual ( [ tool ( 'new-tool' , 'mcp-a' ) ] )
557557
558558 resolveOlder ?.( [ tool ( 'old-tool' , 'mcp-a' ) ] )
559- await expect ( older ) . resolves . toEqual ( [ ] )
559+ await expect ( older ) . resolves . toEqual ( [ tool ( 'new-tool' , 'mcp-a' ) ] )
560560
561561 expect ( mockCacheAdapter . beginMutation ) . toHaveBeenCalledTimes ( 3 )
562562 expect ( cacheStore . get ( serverKey ) ?. tools ) . toEqual ( [ tool ( 'new-tool' , 'mcp-a' ) ] )
@@ -613,7 +613,7 @@ describe('McpService.discoverTools per-server caching', () => {
613613 }
614614 } )
615615
616- it ( 'fails a newer discovery closed while an older mutation remains the owner ' , async ( ) => {
616+ it ( 'returns live tools without publishing when cache ownership is unavailable ' , async ( ) => {
617617 const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
618618 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
619619
@@ -633,7 +633,7 @@ describe('McpService.discoverTools per-server caching', () => {
633633 . mockRejectedValueOnce ( new Error ( 'ordering unavailable' ) )
634634 . mockRejectedValueOnce ( new Error ( 'ordering unavailable' ) )
635635 const newer = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
636- await expect ( newer ) . resolves . toEqual ( [ ] )
636+ await expect ( newer ) . resolves . toEqual ( [ tool ( 'unowned-new-tool' , 'mcp-a' ) ] )
637637
638638 resolveOlder ?.( [ tool ( 'owned-old-tool' , 'mcp-a' ) ] )
639639 await expect ( older ) . resolves . toEqual ( [ tool ( 'owned-old-tool' , 'mcp-a' ) ] )
@@ -645,7 +645,7 @@ describe('McpService.discoverTools per-server caching', () => {
645645 expect ( mockUpdateSet ) . toHaveBeenCalledTimes ( 1 )
646646 } )
647647
648- it ( 'fails discovery publication closed when mutation ownership stays unavailable' , async ( ) => {
648+ it ( 'returns live tools but skips publication when mutation ownership stays unavailable' , async ( ) => {
649649 const reflectedCredential = 'opaque-cache-provider-message'
650650 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
651651 mockCacheAdapter . beginMutation
@@ -655,7 +655,7 @@ describe('McpService.discoverTools per-server caching', () => {
655655
656656 await expect (
657657 mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
658- ) . resolves . toEqual ( [ ] )
658+ ) . resolves . toEqual ( [ tool ( 'a1' , 'mcp-a' ) ] )
659659
660660 expect ( mockCacheAdapter . applyMutationIfCurrent ) . not . toHaveBeenCalled ( )
661661 expect ( mockCacheAdapter . set ) . not . toHaveBeenCalled ( )
@@ -664,15 +664,30 @@ describe('McpService.discoverTools per-server caching', () => {
664664 expect ( JSON . stringify ( mockLogger ?. warn . mock . calls ) ) . not . toContain ( reflectedCredential )
665665 } )
666666
667- it ( 'fails discovery publication closed when the atomic cache transition fails' , async ( ) => {
667+ it ( 'returns bulk live tools without publication when mutation ownership is unavailable' , async ( ) => {
668+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
669+ mockCacheAdapter . beginMutation
670+ . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
671+ . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
672+ mockListTools . mockResolvedValueOnce ( [ tool ( 'a1' , 'mcp-a' ) ] )
673+
674+ await expect ( mcpService . discoverTools ( USER_ID , WORKSPACE_ID , true ) ) . resolves . toEqual ( [
675+ tool ( 'a1' , 'mcp-a' ) ,
676+ ] )
677+
678+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . not . toHaveBeenCalled ( )
679+ expect ( mockUpdateSet ) . not . toHaveBeenCalled ( )
680+ } )
681+
682+ it ( 'returns live tools but skips publication when the atomic cache transition fails' , async ( ) => {
668683 const reflectedCredential = 'opaque-atomic-cache-provider-message'
669684 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
670685 mockCacheAdapter . applyMutationIfCurrent . mockRejectedValueOnce ( new Error ( reflectedCredential ) )
671686 mockListTools . mockResolvedValueOnce ( [ tool ( 'a1' , 'mcp-a' ) ] )
672687
673688 await expect (
674689 mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
675- ) . resolves . toEqual ( [ ] )
690+ ) . resolves . toEqual ( [ tool ( 'a1' , 'mcp-a' ) ] )
676691
677692 expect ( mockCacheAdapter . beginMutation ) . toHaveBeenCalledTimes ( 1 )
678693 expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledTimes ( 1 )
@@ -1049,6 +1064,45 @@ describe('McpService.discoverTools per-server caching', () => {
10491064 )
10501065 } )
10511066
1067+ it ( 'recomputes a failure count after a concurrent status update wins the CAS' , async ( ) => {
1068+ const beforeSuccess = dbRow ( 'mcp-a' , 'A' , {
1069+ statusConfig : { consecutiveFailures : 2 , lastSuccessfulDiscovery : null } ,
1070+ } )
1071+ const afterSuccess = dbRow ( 'mcp-a' , 'A' , {
1072+ statusConfig : {
1073+ consecutiveFailures : 0 ,
1074+ lastSuccessfulDiscovery : '2030-02-01T00:00:00.000Z' ,
1075+ } ,
1076+ } )
1077+ mockGetWorkspaceServersRows
1078+ . mockResolvedValueOnce ( [ beforeSuccess ] )
1079+ . mockResolvedValueOnce ( [ beforeSuccess ] )
1080+ . mockResolvedValueOnce ( [ afterSuccess ] )
1081+ mockUpdateReturning . mockResolvedValueOnce ( [ ] ) . mockResolvedValueOnce ( [ { id : 'mcp-a' } ] )
1082+ mockListTools . mockRejectedValueOnce ( new Error ( 'Connection refused' ) )
1083+
1084+ await expect ( mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID ) ) . rejects . toThrow (
1085+ 'Connection refused'
1086+ )
1087+
1088+ const failureUpdates = mockUpdateSet . mock . calls
1089+ . map ( ( [ update ] ) => update )
1090+ . filter ( ( update ) => update . lastError === 'Connection failed' )
1091+ expect ( failureUpdates ) . toEqual ( [
1092+ expect . objectContaining ( {
1093+ connectionStatus : 'error' ,
1094+ statusConfig : { consecutiveFailures : 3 , lastSuccessfulDiscovery : null } ,
1095+ } ) ,
1096+ expect . objectContaining ( {
1097+ connectionStatus : 'disconnected' ,
1098+ statusConfig : {
1099+ consecutiveFailures : 1 ,
1100+ lastSuccessfulDiscovery : '2030-02-01T00:00:00.000Z' ,
1101+ } ,
1102+ } ) ,
1103+ ] )
1104+ } )
1105+
10521106 it ( 'persists OAuth-required discovery as disconnected without a failure error' , async ( ) => {
10531107 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
10541108 mockListTools . mockRejectedValueOnce ( new McpOauthAuthorizationRequiredError ( 'mcp-a' , 'A' ) )
@@ -1068,12 +1122,13 @@ describe('McpService.discoverTools per-server caching', () => {
10681122 it ( 'does not negative-cache a failure older than a successful discovery' , async ( ) => {
10691123 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
10701124 mockListTools . mockRejectedValueOnce ( new Error ( 'Older request failed' ) )
1071- mockUpdateReturning . mockResolvedValueOnce ( [ ] )
1125+ mockUpdateReturning . mockResolvedValue ( [ ] )
10721126
10731127 await expect ( mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID ) ) . rejects . toThrow (
10741128 'Older request failed'
10751129 )
10761130
1131+ mockUpdateReturning . mockResolvedValue ( [ { id : 'server-1' } ] )
10771132 mockListTools . mockResolvedValueOnce ( [ tool ( 'a1' , 'mcp-a' ) ] )
10781133 const tools = await mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID )
10791134
0 commit comments