@@ -70,6 +70,24 @@ const {
7070 cacheStore . delete ( key )
7171 return true
7272 } ) ,
73+ applyMutationIfCurrent : vi . fn (
74+ async (
75+ scopeKey : string ,
76+ mutationId : number ,
77+ setEntry : { key : string ; tools : unknown [ ] ; ttlMs : number } | null ,
78+ deleteKeys : string [ ]
79+ ) => {
80+ if ( cacheMutations . get ( scopeKey ) !== mutationId ) return false
81+ if ( setEntry ) {
82+ cacheStore . set ( setEntry . key , {
83+ tools : setEntry . tools ,
84+ expiry : Date . now ( ) + setEntry . ttlMs ,
85+ } )
86+ }
87+ for ( const key of deleteKeys ) cacheStore . delete ( key )
88+ return true
89+ }
90+ ) ,
7391 clear : vi . fn ( async ( ) => {
7492 cacheStore . clear ( )
7593 for ( const scopeKey of cacheMutations . keys ( ) ) {
@@ -371,7 +389,7 @@ describe('McpService.discoverTools per-server caching', () => {
371389 statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
372390 } ) ,
373391 ] )
374- mockListTools . mockRejectedValueOnce (
392+ mockConnect . mockRejectedValueOnce (
375393 new UnauthorizedError ( `Rejected Authorization: ${ reflectedCredential } ` )
376394 )
377395
@@ -386,19 +404,23 @@ describe('McpService.discoverTools per-server caching', () => {
386404 statusConfig : { consecutiveFailures : 1 , lastSuccessfulDiscovery : null } ,
387405 } )
388406 )
389- expect ( mockCacheAdapter . setIfCurrentMutation ) . toHaveBeenCalledWith (
407+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledWith (
390408 `workspace:${ WORKSPACE_ID } :server:mcp-a` ,
391409 expect . any ( Number ) ,
392- `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ,
393- [ ] ,
394- expect . any ( Number )
410+ {
411+ key : `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ,
412+ tools : [ ] ,
413+ ttlMs : expect . any ( Number ) ,
414+ } ,
415+ [ `workspace:${ WORKSPACE_ID } :server:mcp-a` ]
395416 )
396417 } )
397418 expect ( JSON . stringify ( mockUpdateSet . mock . calls ) ) . not . toContain ( reflectedCredential )
398- expect ( JSON . stringify ( mockCacheAdapter . setIfCurrentMutation . mock . calls ) ) . not . toContain (
419+ expect ( JSON . stringify ( mockCacheAdapter . applyMutationIfCurrent . mock . calls ) ) . not . toContain (
399420 reflectedCredential
400421 )
401422 expect ( JSON . stringify ( mockLogger ?. warn . mock . calls ) ) . not . toContain ( reflectedCredential )
423+ expect ( mockListTools ) . not . toHaveBeenCalled ( )
402424
403425 mockListTools . mockClear ( )
404426 const second = await mcpService . discoverTools ( USER_ID , WORKSPACE_ID )
@@ -421,12 +443,11 @@ describe('McpService.discoverTools per-server caching', () => {
421443 } )
422444 )
423445 } )
424- expect ( mockCacheAdapter . setIfCurrentMutation ) . not . toHaveBeenCalledWith (
446+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . not . toHaveBeenCalledWith (
425447 expect . anything ( ) ,
426448 expect . anything ( ) ,
427- `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ,
428- [ ] ,
429- expect . any ( Number )
449+ expect . objectContaining ( { key : `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` } ) ,
450+ expect . anything ( )
430451 )
431452
432453 mockResolveEnvVars . mockClear ( )
@@ -543,13 +564,107 @@ describe('McpService.discoverTools per-server caching', () => {
543564 const tools = await mcpService . discoverTools ( USER_ID , WORKSPACE_ID , true )
544565
545566 expect ( tools ) . toEqual ( [ ] )
546- expect ( mockCacheAdapter . setIfCurrentMutation ) . not . toHaveBeenCalledWith (
547- expect . anything ( ) ,
567+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledWith (
548568 expect . anything ( ) ,
549- `workspace:${ WORKSPACE_ID } :server:mcp-a` ,
550569 expect . anything ( ) ,
551- expect . any ( Number )
570+ {
571+ key : `workspace:${ WORKSPACE_ID } :server:mcp-a` ,
572+ tools : [ tool ( 'stale-tool' , 'mcp-a' ) ] ,
573+ ttlMs : expect . any ( Number ) ,
574+ } ,
575+ [ `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ]
552576 )
577+ expect ( cacheStore . has ( `workspace:${ WORKSPACE_ID } :server:mcp-a` ) ) . toBe ( false )
578+ } )
579+
580+ it ( 'orders status publication by discovery start when an older run completes first' , async ( ) => {
581+ vi . useFakeTimers ( { toFake : [ 'Date' ] } )
582+ try {
583+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
584+ mockCacheAdapter . beginMutation
585+ . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
586+ . mockRejectedValueOnce ( new Error ( 'cache ordering unavailable' ) )
587+
588+ let resolveOlder : ( ( tools : ReturnType < typeof tool > [ ] ) => void ) | undefined
589+ let resolveNewer : ( ( tools : ReturnType < typeof tool > [ ] ) => void ) | undefined
590+ mockListTools
591+ . mockReturnValueOnce (
592+ new Promise ( ( resolve ) => {
593+ resolveOlder = resolve
594+ } )
595+ )
596+ . mockReturnValueOnce (
597+ new Promise ( ( resolve ) => {
598+ resolveNewer = resolve
599+ } )
600+ )
601+
602+ const olderStartedAt = new Date ( '2026-02-01T00:00:00.000Z' )
603+ vi . setSystemTime ( olderStartedAt )
604+ const older = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , false )
605+ await vi . waitFor ( ( ) => expect ( mockListTools ) . toHaveBeenCalledTimes ( 1 ) )
606+
607+ const newerStartedAt = new Date ( '2026-02-01T00:00:01.000Z' )
608+ vi . setSystemTime ( newerStartedAt )
609+ const newer = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
610+ await vi . waitFor ( ( ) => expect ( mockListTools ) . toHaveBeenCalledTimes ( 2 ) )
611+
612+ vi . setSystemTime ( new Date ( '2026-02-01T00:00:02.000Z' ) )
613+ resolveOlder ?.( [ tool ( 'old-tool' , 'mcp-a' ) ] )
614+ await expect ( older ) . resolves . toEqual ( [ tool ( 'old-tool' , 'mcp-a' ) ] )
615+
616+ vi . setSystemTime ( new Date ( '2026-02-01T00:00:03.000Z' ) )
617+ resolveNewer ?.( [ tool ( 'new-tool' , 'mcp-a' ) ] )
618+ await expect ( newer ) . resolves . toEqual ( [ tool ( 'new-tool' , 'mcp-a' ) ] )
619+
620+ const publishedRefreshTimes = mockUpdateSet . mock . calls
621+ . map ( ( [ update ] ) => update )
622+ . filter ( ( update ) => update . connectionStatus === 'connected' )
623+ . map ( ( update ) => update . lastToolsRefresh )
624+ expect ( publishedRefreshTimes ) . toEqual ( [ olderStartedAt , newerStartedAt ] )
625+ } finally {
626+ vi . useRealTimers ( )
627+ }
628+ } )
629+
630+ it ( 'does not write status after its cache mutation is superseded' , async ( ) => {
631+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
632+
633+ let resolveList : ( ( tools : ReturnType < typeof tool > [ ] ) => void ) | undefined
634+ mockListTools . mockReturnValueOnce (
635+ new Promise ( ( resolve ) => {
636+ resolveList = resolve
637+ } )
638+ )
639+
640+ let releaseOlderCacheMutation : ( ( ) => void ) | undefined
641+ const olderCacheMutationGate = new Promise < void > ( ( resolve ) => {
642+ releaseOlderCacheMutation = resolve
643+ } )
644+ const defaultApply = mockCacheAdapter . applyMutationIfCurrent . getMockImplementation ( )
645+ mockCacheAdapter . applyMutationIfCurrent . mockImplementationOnce (
646+ async (
647+ scopeKey : string ,
648+ mutationId : number ,
649+ setEntry : { key : string ; tools : unknown [ ] ; ttlMs : number } | null ,
650+ deleteKeys : string [ ]
651+ ) => {
652+ await olderCacheMutationGate
653+ return defaultApply ?.( scopeKey , mutationId , setEntry , deleteKeys ) ?? false
654+ }
655+ )
656+
657+ const discovery = mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , false )
658+ await vi . waitFor ( ( ) => expect ( mockListTools ) . toHaveBeenCalledTimes ( 1 ) )
659+ resolveList ?.( [ tool ( 'superseded-tool' , 'mcp-a' ) ] )
660+ await vi . waitFor ( ( ) => expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledTimes ( 1 ) )
661+
662+ await mockCacheAdapter . beginMutation ( `workspace:${ WORKSPACE_ID } :server:mcp-a` )
663+ releaseOlderCacheMutation ?.( )
664+
665+ await expect ( discovery ) . resolves . toEqual ( [ ] )
666+ expect ( mockUpdateSet ) . not . toHaveBeenCalled ( )
667+ expect ( cacheStore . has ( `workspace:${ WORKSPACE_ID } :server:mcp-a` ) ) . toBe ( false )
553668 } )
554669
555670 it ( 'waits for bulk discovery status publication before returning' , async ( ) => {
@@ -592,11 +707,16 @@ describe('McpService.discoverTools per-server caching', () => {
592707 const olderFailureCacheGate = new Promise < void > ( ( resolve ) => {
593708 releaseOlderFailureCache = resolve
594709 } )
595- const defaultSet = mockCacheAdapter . setIfCurrentMutation . getMockImplementation ( )
596- mockCacheAdapter . setIfCurrentMutation . mockImplementationOnce (
597- async ( scopeKey : string , mutationId : number , key : string , tools : unknown [ ] , ttl : number ) => {
598- if ( key . endsWith ( ':failure' ) ) await olderFailureCacheGate
599- return defaultSet ?.( scopeKey , mutationId , key , tools , ttl ) ?? false
710+ const defaultApply = mockCacheAdapter . applyMutationIfCurrent . getMockImplementation ( )
711+ mockCacheAdapter . applyMutationIfCurrent . mockImplementationOnce (
712+ async (
713+ scopeKey : string ,
714+ mutationId : number ,
715+ setEntry : { key : string ; tools : unknown [ ] ; ttlMs : number } | null ,
716+ deleteKeys : string [ ]
717+ ) => {
718+ await olderFailureCacheGate
719+ return defaultApply ?.( scopeKey , mutationId , setEntry , deleteKeys ) ?? false
600720 }
601721 )
602722
@@ -651,7 +771,7 @@ describe('McpService.discoverTools per-server caching', () => {
651771 } )
652772 )
653773 expect ( JSON . stringify ( mockUpdateSet . mock . calls ) ) . not . toContain ( reflectedCredential )
654- expect ( JSON . stringify ( mockCacheAdapter . setIfCurrentMutation . mock . calls ) ) . not . toContain (
774+ expect ( JSON . stringify ( mockCacheAdapter . applyMutationIfCurrent . mock . calls ) ) . not . toContain (
655775 reflectedCredential
656776 )
657777 expect ( JSON . stringify ( mockLogger ?. warn . mock . calls ) ) . not . toContain ( reflectedCredential )
@@ -677,12 +797,11 @@ describe('McpService.discoverTools per-server caching', () => {
677797 lastError : null ,
678798 } )
679799 )
680- expect ( mockCacheAdapter . setIfCurrentMutation ) . not . toHaveBeenCalledWith (
800+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . not . toHaveBeenCalledWith (
681801 expect . anything ( ) ,
682802 expect . anything ( ) ,
683- `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ,
684- [ ] ,
685- expect . any ( Number )
803+ expect . objectContaining ( { key : `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` } ) ,
804+ expect . anything ( )
686805 )
687806
688807 mockResolveEnvVars . mockClear ( )
0 commit comments