@@ -904,6 +904,178 @@ describe('McpService.discoverTools per-server caching', () => {
904904 )
905905 } )
906906
907+ it ( 'publishes a failed discovery after repeated metadata-only renames win the database CAS' , async ( ) => {
908+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
909+ const failureKey = `${ serverKey } :failure`
910+ const original = dbRow ( 'mcp-a' , 'A' , {
911+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
912+ } )
913+ const renamed = dbRow ( 'mcp-a' , 'Renamed A' , {
914+ description : 'Updated display-only description' ,
915+ updatedAt : new Date ( '2026-01-01T00:00:01Z' ) ,
916+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
917+ } )
918+ const renamedAgain = dbRow ( 'mcp-a' , 'Renamed A Again' , {
919+ description : 'Another display-only description' ,
920+ updatedAt : new Date ( '2026-01-01T00:00:02Z' ) ,
921+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
922+ } )
923+ mockGetWorkspaceServersRows
924+ . mockResolvedValueOnce ( [ original ] )
925+ . mockResolvedValueOnce ( [ renamed ] )
926+ . mockResolvedValueOnce ( [ renamed ] )
927+ . mockResolvedValueOnce ( [ renamed ] )
928+ . mockResolvedValueOnce ( [ renamed ] )
929+ . mockResolvedValueOnce ( [ renamedAgain ] )
930+ . mockResolvedValueOnce ( [ renamedAgain ] )
931+ . mockResolvedValueOnce ( [ renamedAgain ] )
932+ . mockResolvedValueOnce ( [ renamedAgain ] )
933+ . mockResolvedValue ( [ renamedAgain ] )
934+ mockUpdateReturning
935+ . mockResolvedValueOnce ( [ ] )
936+ . mockResolvedValueOnce ( [ ] )
937+ . mockResolvedValueOnce ( [ ] )
938+ . mockResolvedValueOnce ( [ ] )
939+ . mockResolvedValueOnce ( [ ] )
940+ . mockResolvedValueOnce ( [ ] )
941+ . mockResolvedValueOnce ( [ { id : 'mcp-a' } ] )
942+ mockListTools . mockRejectedValueOnce ( new Error ( 'Permanent discovery failure' ) )
943+ cacheStore . set ( serverKey , {
944+ tools : [ tool ( 'previous-tool' , 'mcp-a' ) ] ,
945+ expiry : Date . now ( ) + 60_000 ,
946+ } )
947+
948+ await expect (
949+ mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
950+ ) . rejects . toThrow ( 'Permanent discovery failure' )
951+
952+ const failureStatusWrites = mockUpdateSet . mock . calls
953+ . map ( ( [ update ] ) => update )
954+ . filter ( ( update ) => update . lastError === 'Connection failed' )
955+ expect ( failureStatusWrites ) . toHaveLength ( 7 )
956+ expect ( failureStatusWrites . at ( - 1 ) ) . toEqual (
957+ expect . objectContaining ( { connectionStatus : 'disconnected' , toolCount : 0 } )
958+ )
959+ expect ( cacheStore . has ( serverKey ) ) . toBe ( false )
960+ expect ( cacheStore . has ( failureKey ) ) . toBe ( true )
961+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledWith (
962+ serverKey ,
963+ expect . any ( Number ) ,
964+ null ,
965+ [ ]
966+ )
967+ } )
968+
969+ it ( 'publishes OAuth pending after repeated metadata-only renames win the database CAS' , async ( ) => {
970+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
971+ const original = dbRow ( 'mcp-a' , 'A' )
972+ const renamed = dbRow ( 'mcp-a' , 'Renamed A' , {
973+ description : 'Updated display-only description' ,
974+ updatedAt : new Date ( '2026-01-01T00:00:01Z' ) ,
975+ } )
976+ const renamedAgain = dbRow ( 'mcp-a' , 'Renamed A Again' , {
977+ description : 'Another display-only description' ,
978+ updatedAt : new Date ( '2026-01-01T00:00:02Z' ) ,
979+ } )
980+ mockGetWorkspaceServersRows
981+ . mockResolvedValueOnce ( [ original ] )
982+ . mockResolvedValueOnce ( [ renamed ] )
983+ . mockResolvedValueOnce ( [ renamedAgain ] )
984+ . mockResolvedValue ( [ renamedAgain ] )
985+ mockUpdateReturning
986+ . mockResolvedValueOnce ( [ ] )
987+ . mockResolvedValueOnce ( [ ] )
988+ . mockResolvedValueOnce ( [ { id : 'mcp-a' } ] )
989+ mockListTools . mockRejectedValueOnce ( new McpOauthAuthorizationRequiredError ( 'mcp-a' , 'A' ) )
990+ cacheStore . set ( serverKey , {
991+ tools : [ tool ( 'previous-tool' , 'mcp-a' ) ] ,
992+ expiry : Date . now ( ) + 60_000 ,
993+ } )
994+
995+ await expect (
996+ mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
997+ ) . rejects . toThrow ( 'OAuth authorization required' )
998+
999+ const oauthStatusWrites = mockUpdateSet . mock . calls
1000+ . map ( ( [ update ] ) => update )
1001+ . filter ( ( update ) => update . connectionStatus === 'disconnected' && update . lastError === null )
1002+ expect ( oauthStatusWrites ) . toHaveLength ( 3 )
1003+ expect ( cacheStore . has ( serverKey ) ) . toBe ( false )
1004+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenCalledWith (
1005+ serverKey ,
1006+ expect . any ( Number ) ,
1007+ null ,
1008+ [ ]
1009+ )
1010+ } )
1011+
1012+ it ( 'does not retry failed status publication after a connection-config edit' , async ( ) => {
1013+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
1014+ const failureKey = `${ serverKey } :failure`
1015+ const original = dbRow ( 'mcp-a' , 'A' , {
1016+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
1017+ } )
1018+ const reconfigured = dbRow ( 'mcp-a' , 'A' , {
1019+ url : 'https://changed-config.example.com/mcp' ,
1020+ updatedAt : new Date ( '2026-01-01T00:00:01Z' ) ,
1021+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
1022+ } )
1023+ mockGetWorkspaceServersRows
1024+ . mockResolvedValueOnce ( [ original ] )
1025+ . mockResolvedValueOnce ( [ original ] )
1026+ . mockResolvedValueOnce ( [ original ] )
1027+ . mockResolvedValueOnce ( [ original ] )
1028+ . mockResolvedValue ( [ reconfigured ] )
1029+ mockUpdateReturning . mockResolvedValue ( [ ] )
1030+ mockListTools . mockRejectedValueOnce ( new Error ( 'Permanent discovery failure' ) )
1031+
1032+ await expect (
1033+ mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
1034+ ) . rejects . toThrow ( 'Permanent discovery failure' )
1035+
1036+ const failureStatusWrites = mockUpdateSet . mock . calls
1037+ . map ( ( [ update ] ) => update )
1038+ . filter ( ( update ) => update . lastError === 'Connection failed' )
1039+ expect ( failureStatusWrites ) . toHaveLength ( 3 )
1040+ expect ( cacheStore . has ( failureKey ) ) . toBe ( false )
1041+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenLastCalledWith (
1042+ serverKey ,
1043+ expect . any ( Number ) ,
1044+ null ,
1045+ [ failureKey ]
1046+ )
1047+ } )
1048+
1049+ it ( 'does not retry OAuth status publication after a connection-config edit' , async ( ) => {
1050+ const serverKey = `workspace:${ WORKSPACE_ID } :server:mcp-a`
1051+ const original = dbRow ( 'mcp-a' , 'A' )
1052+ const reconfigured = dbRow ( 'mcp-a' , 'A' , {
1053+ url : 'https://changed-config.example.com/mcp' ,
1054+ updatedAt : new Date ( '2026-01-01T00:00:01Z' ) ,
1055+ } )
1056+ mockGetWorkspaceServersRows
1057+ . mockResolvedValueOnce ( [ original ] )
1058+ . mockResolvedValueOnce ( [ reconfigured ] )
1059+ . mockResolvedValue ( [ reconfigured ] )
1060+ mockUpdateReturning . mockResolvedValue ( [ ] )
1061+ mockListTools . mockRejectedValueOnce ( new McpOauthAuthorizationRequiredError ( 'mcp-a' , 'A' ) )
1062+
1063+ await expect (
1064+ mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID , true )
1065+ ) . rejects . toThrow ( 'OAuth authorization required' )
1066+
1067+ const oauthStatusWrites = mockUpdateSet . mock . calls
1068+ . map ( ( [ update ] ) => update )
1069+ . filter ( ( update ) => update . connectionStatus === 'disconnected' && update . lastError === null )
1070+ expect ( oauthStatusWrites ) . toHaveLength ( 1 )
1071+ expect ( mockCacheAdapter . applyMutationIfCurrent ) . toHaveBeenLastCalledWith (
1072+ serverKey ,
1073+ expect . any ( Number ) ,
1074+ null ,
1075+ [ ]
1076+ )
1077+ } )
1078+
9071079 it ( 'supersedes an older discovery before it can publish status' , async ( ) => {
9081080 vi . useFakeTimers ( { toFake : [ 'Date' ] } )
9091081 try {
0 commit comments