@@ -14,10 +14,22 @@ const {
1414 mockValidateSsrf,
1515 mockIsDomainAllowed,
1616 mockCacheAdapter,
17+ mockDbUpdateSet,
18+ MockMcpOauthRedirectRequired,
19+ mockGetOrCreateOauthRow,
20+ mockLoadPreregisteredClient,
21+ mockWithMcpOauthRefreshLock,
1722} = vi . hoisted ( ( ) => {
1823 const mockListTools = vi . fn ( )
1924 const mockConnect = vi . fn ( )
2025 const mockDisconnect = vi . fn ( )
26+ class MockMcpOauthRedirectRequired extends Error {
27+ constructor ( public readonly authorizationUrl : string ) {
28+ super ( 'MCP OAuth redirect required' )
29+ this . name = 'McpOauthRedirectRequired'
30+ }
31+ }
32+ const mockDbUpdateSet = vi . fn ( ) . mockReturnValue ( { where : vi . fn ( ) . mockResolvedValue ( undefined ) } )
2133 // In-memory cache adapter so the service never touches the real Redis the
2234 // local .env points at (unreachable in CI/sandbox → hangs). Honors TTL via
2335 // an expiry timestamp so negative-cache assertions behave like production.
@@ -67,6 +79,11 @@ const {
6779 mockValidateDomain : vi . fn ( ) ,
6880 mockValidateSsrf : vi . fn ( ) ,
6981 mockIsDomainAllowed : vi . fn ( ( ) => true ) ,
82+ mockDbUpdateSet,
83+ MockMcpOauthRedirectRequired,
84+ mockGetOrCreateOauthRow : vi . fn ( ) ,
85+ mockLoadPreregisteredClient : vi . fn ( ) ,
86+ mockWithMcpOauthRefreshLock : vi . fn ( ) ,
7087 }
7188} )
7289
@@ -80,13 +97,12 @@ vi.mock('@sim/db', () => {
8097 } )
8198 return thenable
8299 }
83- const setter = vi . fn ( ) . mockReturnValue ( { where : vi . fn ( ) . mockResolvedValue ( undefined ) } )
84100 return {
85101 db : {
86102 select : vi . fn ( ) . mockReturnValue ( {
87103 from : vi . fn ( ) . mockReturnValue ( { where } ) ,
88104 } ) ,
89- update : vi . fn ( ) . mockReturnValue ( { set : setter } ) ,
105+ update : vi . fn ( ) . mockReturnValue ( { set : mockDbUpdateSet } ) ,
90106 insert : vi . fn ( ) ,
91107 delete : vi . fn ( ) ,
92108 } ,
@@ -108,10 +124,11 @@ vi.mock('@/lib/mcp/domain-check', () => ({
108124} ) )
109125
110126vi . mock ( '@/lib/mcp/oauth' , ( ) => ( {
111- getOrCreateOauthRow : vi . fn ( ) ,
112- loadPreregisteredClient : vi . fn ( ) ,
127+ getOrCreateOauthRow : mockGetOrCreateOauthRow ,
128+ loadPreregisteredClient : mockLoadPreregisteredClient ,
129+ McpOauthRedirectRequired : MockMcpOauthRedirectRequired ,
113130 SimMcpOauthProvider : vi . fn ( ) ,
114- withMcpOauthRefreshLock : vi . fn ( ) ,
131+ withMcpOauthRefreshLock : mockWithMcpOauthRefreshLock ,
115132} ) )
116133
117134vi . mock ( '@/lib/mcp/resolve-config' , ( ) => ( {
@@ -123,6 +140,7 @@ vi.mock('@/lib/mcp/storage', () => ({
123140 getMcpCacheType : ( ) => 'memory' ,
124141} ) )
125142
143+ import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
126144import { mcpService } from '@/lib/mcp/service'
127145import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
128146
@@ -171,6 +189,13 @@ describe('McpService.discoverTools per-server caching', () => {
171189 mockResolveEnvVars . mockImplementation ( ( config : { url : string } ) =>
172190 Promise . resolve ( { config : { ...config , url : config . url } , missingVars : [ ] } )
173191 )
192+ mockGetOrCreateOauthRow . mockResolvedValue ( {
193+ tokens : { access_token : 'access-token' , token_type : 'bearer' } ,
194+ } )
195+ mockLoadPreregisteredClient . mockResolvedValue ( undefined )
196+ mockWithMcpOauthRefreshLock . mockImplementation ( ( _serverId : string , callback : ( ) => unknown ) =>
197+ callback ( )
198+ )
174199 mockConnect . mockResolvedValue ( undefined )
175200 mockDisconnect . mockResolvedValue ( undefined )
176201 // The McpService singleton holds cache state across imports.
@@ -392,5 +417,43 @@ describe('McpService.discoverTools per-server caching', () => {
392417 requiresAuthorization : false ,
393418 error : 'Request timed out' ,
394419 } )
420+ expect ( mockDbUpdateSet ) . toHaveBeenCalledWith ( {
421+ connectionStatus : 'disconnected' ,
422+ lastError : 'Request timed out' ,
423+ statusConfig : {
424+ consecutiveFailures : 1 ,
425+ lastSuccessfulDiscovery : null ,
426+ } ,
427+ updatedAt : expect . any ( Date ) ,
428+ } )
429+ } )
430+
431+ it ( 'treats an OAuth redirect as authorization-required without poisoning status or cache' , async ( ) => {
432+ mockGetWorkspaceServersRows . mockResolvedValue ( [ dbRow ( 'mcp-a' , 'A' , { authType : 'oauth' } ) ] )
433+ mockConnect . mockRejectedValueOnce (
434+ new McpOauthRedirectRequired ( 'https://mcp-a.example.com/authorize' )
435+ )
436+
437+ await expect (
438+ mcpService . verifyServerConnection ( USER_ID , 'mcp-a' , WORKSPACE_ID )
439+ ) . resolves . toEqual ( {
440+ verified : false ,
441+ toolCount : 0 ,
442+ requiresAuthorization : true ,
443+ error : 'MCP OAuth redirect required' ,
444+ } )
445+ expect ( mockDbUpdateSet ) . toHaveBeenCalledTimes ( 1 )
446+ expect ( mockDbUpdateSet ) . toHaveBeenCalledWith ( {
447+ connectionStatus : 'disconnected' ,
448+ lastError : null ,
449+ updatedAt : expect . any ( Date ) ,
450+ } )
451+
452+ mockListTools . mockClear ( )
453+ mockListTools . mockResolvedValueOnce ( [ tool ( 'a1' , 'mcp-a' ) ] )
454+ const tools = await mcpService . discoverServerTools ( USER_ID , 'mcp-a' , WORKSPACE_ID )
455+
456+ expect ( tools . map ( ( item ) => item . name ) ) . toEqual ( [ 'a1' ] )
457+ expect ( mockListTools ) . toHaveBeenCalledTimes ( 1 )
395458 } )
396459} )
0 commit comments