@@ -329,6 +329,15 @@ describe('McpService.discoverTools per-server caching', () => {
329329
330330 await mcpService . clearCache ( WORKSPACE_ID )
331331
332+ const [ discoveryUpdate , invalidationUpdate ] = mockUpdateSet . mock . calls . map ( ( [ update ] ) => update )
333+ expect ( invalidationUpdate ) . toEqual ( {
334+ toolCount : 0 ,
335+ lastToolsRefresh : expect . any ( Date ) ,
336+ } )
337+ expect ( invalidationUpdate . lastToolsRefresh . getTime ( ) ) . toBeGreaterThan (
338+ discoveryUpdate . lastToolsRefresh . getTime ( )
339+ )
340+
332341 mockListTools . mockClear ( )
333342 mockListTools . mockResolvedValueOnce ( [ tool ( 'a1' , 'mcp-a' ) ] )
334343 await mcpService . discoverTools ( USER_ID , WORKSPACE_ID )
@@ -967,6 +976,174 @@ describe('McpService.discoverTools per-server caching', () => {
967976 await expect ( discovery ) . resolves . toEqual ( [ tool ( 'a1' , 'mcp-a' ) ] )
968977 } )
969978
979+ it ( 'publishes a newer invalidation barrier while successful status publication is pending' , async ( ) => {
980+ vi . useFakeTimers ( { toFake : [ 'Date' ] } )
981+ try {
982+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
983+ const discoveryStartedAt = new Date ( '2100-02-01T00:00:00.000Z' )
984+ const invalidatedAt = new Date ( '2100-02-01T00:00:01.000Z' )
985+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
986+ mockListTools . mockResolvedValueOnce ( [ tool ( 'stale-tool' , 'mcp-a' ) ] )
987+
988+ let releaseDiscoveryStatus : ( ( rows : { id : string } [ ] ) => void ) | undefined
989+ mockUpdateReturning . mockReturnValueOnce (
990+ new Promise ( ( resolve ) => {
991+ releaseDiscoveryStatus = resolve
992+ } )
993+ )
994+
995+ vi . setSystemTime ( discoveryStartedAt )
996+ const discovery = mcpService . discoverServerToolsWithMetadata (
997+ USER_ID ,
998+ 'mcp-a' ,
999+ WORKSPACE_ID ,
1000+ true
1001+ )
1002+ await vi . waitFor ( ( ) =>
1003+ expect ( mockUpdateSet ) . toHaveBeenCalledWith (
1004+ expect . objectContaining ( {
1005+ connectionStatus : 'connected' ,
1006+ lastToolsRefresh : discoveryStartedAt ,
1007+ } )
1008+ )
1009+ )
1010+ expect ( cacheStore . get ( serverKey ) ?. tools ) . toEqual ( [ tool ( 'stale-tool' , 'mcp-a' ) ] )
1011+
1012+ vi . setSystemTime ( invalidatedAt )
1013+ await mcpService . clearCache ( WORKSPACE_ID )
1014+
1015+ const invalidationUpdate = mockUpdateSet . mock . calls
1016+ . map ( ( [ update ] ) => update )
1017+ . find (
1018+ ( update ) =>
1019+ update . toolCount === 0 && update . lastToolsRefresh ?. getTime ( ) === invalidatedAt . getTime ( )
1020+ )
1021+ expect ( invalidationUpdate ) . toEqual ( {
1022+ toolCount : 0 ,
1023+ lastToolsRefresh : invalidatedAt ,
1024+ } )
1025+ expect ( cacheStore . has ( serverKey ) ) . toBe ( false )
1026+
1027+ // The real lastToolsRefresh predicate rejects this older publication
1028+ // after the invalidation barrier wins the database race.
1029+ releaseDiscoveryStatus ?.( [ ] )
1030+ await expect ( discovery ) . resolves . toEqual ( { tools : [ ] , state : 'superseded' } )
1031+ } finally {
1032+ vi . useRealTimers ( )
1033+ }
1034+ } )
1035+
1036+ it ( 'publishes a newer invalidation barrier while failed status publication is pending' , async ( ) => {
1037+ vi . useFakeTimers ( { toFake : [ 'Date' ] } )
1038+ try {
1039+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
1040+ const failureKey = `${ serverKey } :failure`
1041+ const discoveryStartedAt = new Date ( '2100-03-01T00:00:00.000Z' )
1042+ const invalidatedAt = new Date ( '2100-03-01T00:00:01.000Z' )
1043+ mockGetWorkspaceServersRows . mockResolvedValue ( [
1044+ dbRow ( 'mcp-a' , 'A' , {
1045+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
1046+ } ) ,
1047+ ] )
1048+ mockListTools . mockRejectedValueOnce ( new Error ( 'Permanent discovery failure' ) )
1049+
1050+ let releaseDiscoveryStatus : ( ( rows : { id : string } [ ] ) => void ) | undefined
1051+ mockUpdateReturning
1052+ . mockReturnValueOnce (
1053+ new Promise ( ( resolve ) => {
1054+ releaseDiscoveryStatus = resolve
1055+ } )
1056+ )
1057+ . mockResolvedValueOnce ( [ { id : 'mcp-a' } ] )
1058+ . mockResolvedValueOnce ( [ ] )
1059+ . mockResolvedValueOnce ( [ ] )
1060+
1061+ vi . setSystemTime ( discoveryStartedAt )
1062+ const discovery = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
1063+ await vi . waitFor ( ( ) =>
1064+ expect ( mockUpdateSet ) . toHaveBeenCalledWith (
1065+ expect . objectContaining ( {
1066+ connectionStatus : 'disconnected' ,
1067+ lastError : 'Connection failed' ,
1068+ lastToolsRefresh : discoveryStartedAt ,
1069+ } )
1070+ )
1071+ )
1072+ expect ( cacheStore . has ( failureKey ) ) . toBe ( true )
1073+
1074+ vi . setSystemTime ( invalidatedAt )
1075+ await mcpService . clearCache ( WORKSPACE_ID )
1076+
1077+ expect ( mockUpdateSet ) . toHaveBeenCalledWith ( {
1078+ toolCount : 0 ,
1079+ lastToolsRefresh : invalidatedAt ,
1080+ } )
1081+ expect ( cacheStore . has ( serverKey ) ) . toBe ( false )
1082+ expect ( cacheStore . has ( failureKey ) ) . toBe ( false )
1083+
1084+ releaseDiscoveryStatus ?.( [ ] )
1085+ await expect ( discovery ) . rejects . toThrow ( 'Permanent discovery failure' )
1086+ expect (
1087+ mockUpdateSet . mock . calls
1088+ . map ( ( [ update ] ) => update )
1089+ . filter ( ( update ) => update . lastError === 'Connection failed' )
1090+ . every ( ( update ) => update . lastToolsRefresh ?. getTime ( ) === discoveryStartedAt . getTime ( ) )
1091+ ) . toBe ( true )
1092+ } finally {
1093+ vi . useRealTimers ( )
1094+ }
1095+ } )
1096+
1097+ it ( 'publishes a newer invalidation barrier while OAuth status publication is pending' , async ( ) => {
1098+ vi . useFakeTimers ( { toFake : [ 'Date' ] } )
1099+ try {
1100+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
1101+ const discoveryStartedAt = new Date ( '2100-04-01T00:00:00.000Z' )
1102+ const invalidatedAt = new Date ( '2100-04-01T00:00:01.000Z' )
1103+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
1104+ mockListTools . mockRejectedValueOnce ( new McpOauthAuthorizationRequiredError ( 'mcp-a' , 'A' ) )
1105+
1106+ let releaseDiscoveryStatus : ( ( rows : { id : string } [ ] ) => void ) | undefined
1107+ mockUpdateReturning . mockReturnValueOnce (
1108+ new Promise ( ( resolve ) => {
1109+ releaseDiscoveryStatus = resolve
1110+ } )
1111+ )
1112+
1113+ vi . setSystemTime ( discoveryStartedAt )
1114+ const discovery = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
1115+ await vi . waitFor ( ( ) =>
1116+ expect ( mockUpdateSet ) . toHaveBeenCalledWith (
1117+ expect . objectContaining ( {
1118+ connectionStatus : 'disconnected' ,
1119+ lastError : null ,
1120+ lastToolsRefresh : discoveryStartedAt ,
1121+ } )
1122+ )
1123+ )
1124+
1125+ vi . setSystemTime ( invalidatedAt )
1126+ await mcpService . clearCache ( WORKSPACE_ID )
1127+
1128+ const invalidationUpdate = mockUpdateSet . mock . calls
1129+ . map ( ( [ update ] ) => update )
1130+ . find (
1131+ ( update ) =>
1132+ update . toolCount === 0 && update . lastToolsRefresh ?. getTime ( ) === invalidatedAt . getTime ( )
1133+ )
1134+ expect ( invalidationUpdate ) . toEqual ( {
1135+ toolCount : 0 ,
1136+ lastToolsRefresh : invalidatedAt ,
1137+ } )
1138+ expect ( cacheStore . has ( serverKey ) ) . toBe ( false )
1139+
1140+ releaseDiscoveryStatus ?.( [ ] )
1141+ await expect ( discovery ) . rejects . toThrow ( 'OAuth authorization required' )
1142+ } finally {
1143+ vi . useRealTimers ( )
1144+ }
1145+ } )
1146+
9701147 it ( 'keeps a newer successful cache entry when an older failure finishes later' , async ( ) => {
9711148 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
9721149
0 commit comments