11/**
22 * @vitest -environment node
33 */
4+
5+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
6+ import { loggerMock } from '@sim/testing'
47import { beforeEach , describe , expect , it , vi } from 'vitest'
58
69const {
@@ -26,24 +29,24 @@ const {
2629 // an expiry timestamp so negative-cache assertions behave like production.
2730 const cacheStore = new Map < string , { tools : unknown [ ] ; expiry : number } > ( )
2831 const mockCacheAdapter = {
29- get : async ( key : string ) => {
32+ get : vi . fn ( async ( key : string ) => {
3033 const entry = cacheStore . get ( key )
3134 if ( ! entry ) return null
3235 if ( entry . expiry <= Date . now ( ) ) {
3336 cacheStore . delete ( key )
3437 return null
3538 }
3639 return entry
37- } ,
38- set : async ( key : string , tools : unknown [ ] , ttlMs : number ) => {
40+ } ) ,
41+ set : vi . fn ( async ( key : string , tools : unknown [ ] , ttlMs : number ) => {
3942 cacheStore . set ( key , { tools, expiry : Date . now ( ) + ttlMs } )
40- } ,
41- delete : async ( key : string ) => {
43+ } ) ,
44+ delete : vi . fn ( async ( key : string ) => {
4245 cacheStore . delete ( key )
43- } ,
44- clear : async ( ) => {
46+ } ) ,
47+ clear : vi . fn ( async ( ) => {
4548 cacheStore . clear ( )
46- } ,
49+ } ) ,
4750 dispose : ( ) => { } ,
4851 }
4952 return {
@@ -132,6 +135,8 @@ vi.mock('@/lib/mcp/storage', () => ({
132135import { mcpService } from '@/lib/mcp/service'
133136import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
134137
138+ const mockLogger = vi . mocked ( loggerMock . createLogger ) . mock . results . at ( - 1 ) ?. value
139+
135140const WORKSPACE_ID = 'workspace-test'
136141const USER_ID = 'user-test'
137142
@@ -329,6 +334,70 @@ describe('McpService.discoverTools per-server caching', () => {
329334 expect ( mockListTools ) . not . toHaveBeenCalled ( )
330335 } )
331336
337+ it ( 'persists and negative-caches UnauthorizedError for a headers-auth server' , async ( ) => {
338+ const reflectedCredential = 'Bearer static-secret-for-bulk-discovery'
339+ mockGetWorkspaceServersRows . mockResolvedValue ( [
340+ dbRow ( 'mcp-a' , 'A' , {
341+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
342+ } ) ,
343+ ] )
344+ mockListTools . mockRejectedValueOnce (
345+ new UnauthorizedError ( `Rejected Authorization: ${ reflectedCredential } ` )
346+ )
347+
348+ const first = await mcpService . discoverTools ( USER_ID , WORKSPACE_ID )
349+ expect ( first ) . toEqual ( [ ] )
350+
351+ await vi . waitFor ( ( ) => {
352+ expect ( mockUpdateSet ) . toHaveBeenCalledWith (
353+ expect . objectContaining ( {
354+ connectionStatus : 'disconnected' ,
355+ lastError : 'Authentication failed' ,
356+ statusConfig : { consecutiveFailures : 1 , lastSuccessfulDiscovery : null } ,
357+ } )
358+ )
359+ expect ( mockCacheAdapter . set ) . toHaveBeenCalledWith (
360+ `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ,
361+ [ ] ,
362+ expect . any ( Number )
363+ )
364+ } )
365+ expect ( JSON . stringify ( mockUpdateSet . mock . calls ) ) . not . toContain ( reflectedCredential )
366+ expect ( JSON . stringify ( mockCacheAdapter . set . mock . calls ) ) . not . toContain ( reflectedCredential )
367+ expect ( JSON . stringify ( mockLogger ?. warn . mock . calls ) ) . not . toContain ( reflectedCredential )
368+
369+ mockListTools . mockClear ( )
370+ const second = await mcpService . discoverTools ( USER_ID , WORKSPACE_ID )
371+ expect ( second ) . toEqual ( [ ] )
372+ expect ( mockListTools ) . not . toHaveBeenCalled ( )
373+ } )
374+
375+ it ( 'keeps UnauthorizedError soft-pending for an OAuth server' , async ( ) => {
376+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' , { authType : 'oauth' } ) ] )
377+ mockResolveEnvVars . mockRejectedValue ( new UnauthorizedError ( 'OAuth token rejected' ) )
378+
379+ const first = await mcpService . discoverTools ( USER_ID , WORKSPACE_ID )
380+ expect ( first ) . toEqual ( [ ] )
381+
382+ await vi . waitFor ( ( ) => {
383+ expect ( mockUpdateSet ) . toHaveBeenCalledWith (
384+ expect . objectContaining ( {
385+ connectionStatus : 'disconnected' ,
386+ lastError : null ,
387+ } )
388+ )
389+ } )
390+ expect ( mockCacheAdapter . set ) . not . toHaveBeenCalledWith (
391+ `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ,
392+ [ ] ,
393+ expect . any ( Number )
394+ )
395+
396+ mockResolveEnvVars . mockClear ( )
397+ await mcpService . discoverTools ( USER_ID , WORKSPACE_ID )
398+ expect ( mockResolveEnvVars ) . toHaveBeenCalledTimes ( 1 )
399+ } )
400+
332401 it ( 'successful discoverServerTools clears the negative cache' , async ( ) => {
333402 mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' ) ] )
334403 mockListTools . mockRejectedValueOnce ( new Error ( 'Request timed out' ) )
@@ -396,6 +465,66 @@ describe('McpService.discoverTools per-server caching', () => {
396465 )
397466 } )
398467
468+ it ( 'persists and negative-caches per-server UnauthorizedError for headers auth' , async ( ) => {
469+ const reflectedCredential = 'Bearer static-secret-for-server-discovery'
470+ mockGetWorkspaceServersRows . mockResolvedValue ( [
471+ dbRow ( 'mcp-a' , 'A' , {
472+ statusConfig : { consecutiveFailures : 0 , lastSuccessfulDiscovery : null } ,
473+ } ) ,
474+ ] )
475+ mockListTools . mockRejectedValueOnce (
476+ new UnauthorizedError ( `Rejected Authorization: ${ reflectedCredential } ` )
477+ )
478+
479+ await expect ( mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID ) ) . rejects . toThrow (
480+ reflectedCredential
481+ )
482+
483+ expect ( mockUpdateSet ) . toHaveBeenCalledWith (
484+ expect . objectContaining ( {
485+ connectionStatus : 'disconnected' ,
486+ lastError : 'Authentication failed' ,
487+ statusConfig : { consecutiveFailures : 1 , lastSuccessfulDiscovery : null } ,
488+ } )
489+ )
490+ expect ( JSON . stringify ( mockUpdateSet . mock . calls ) ) . not . toContain ( reflectedCredential )
491+ expect ( JSON . stringify ( mockCacheAdapter . set . mock . calls ) ) . not . toContain ( reflectedCredential )
492+ expect ( JSON . stringify ( mockLogger ?. warn . mock . calls ) ) . not . toContain ( reflectedCredential )
493+
494+ mockListTools . mockClear ( )
495+ await expect ( mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID ) ) . rejects . toThrow (
496+ 'cooldown'
497+ )
498+ expect ( mockListTools ) . not . toHaveBeenCalled ( )
499+ } )
500+
501+ it ( 'keeps per-server UnauthorizedError soft-pending for OAuth auth' , async ( ) => {
502+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' , { authType : 'oauth' } ) ] )
503+ mockResolveEnvVars . mockRejectedValue ( new UnauthorizedError ( 'OAuth token rejected' ) )
504+
505+ await expect ( mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID ) ) . rejects . toThrow (
506+ 'OAuth token rejected'
507+ )
508+
509+ expect ( mockUpdateSet ) . toHaveBeenCalledWith (
510+ expect . objectContaining ( {
511+ connectionStatus : 'disconnected' ,
512+ lastError : null ,
513+ } )
514+ )
515+ expect ( mockCacheAdapter . set ) . not . toHaveBeenCalledWith (
516+ `workspace:${ WORKSPACE_ID } :server:mcp-a:failure` ,
517+ [ ] ,
518+ expect . any ( Number )
519+ )
520+
521+ mockResolveEnvVars . mockClear ( )
522+ await expect ( mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID ) ) . rejects . toThrow (
523+ 'OAuth token rejected'
524+ )
525+ expect ( mockResolveEnvVars ) . toHaveBeenCalledTimes ( 1 )
526+ } )
527+
399528 it ( 'promotes the persisted server status to error on the third consecutive failure' , async ( ) => {
400529 mockGetWorkspaceServersRows . mockResolvedValue ( [
401530 dbRow ( 'mcp-a' , 'A' , {
0 commit comments