Skip to content

Commit 246f145

Browse files
committed
Fix managed MCP verification state (#5596)
- persist failed verification status before returning - classify OAuth redirect failures as authorization-required - cover status persistence and immediate OAuth retry
1 parent c51b45b commit 246f145

2 files changed

Lines changed: 108 additions & 10 deletions

File tree

apps/sim/lib/mcp/service.test.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

110126
vi.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

117134
vi.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'
126144
import { mcpService } from '@/lib/mcp/service'
127145
import { 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
})

apps/sim/lib/mcp/service.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import {
2020
getOrCreateOauthRow,
2121
loadPreregisteredClient,
22+
McpOauthRedirectRequired,
2223
SimMcpOauthProvider,
2324
withMcpOauthRefreshLock,
2425
} from '@/lib/mcp/oauth'
@@ -45,6 +46,14 @@ import { MCP_CLIENT_CONSTANTS, MCP_CONSTANTS } from '@/lib/mcp/utils'
4546
const logger = createLogger('McpService')
4647
const MAX_VERIFICATION_ERROR_LENGTH = 200
4748

49+
function isMcpAuthorizationRequired(error: unknown): boolean {
50+
return (
51+
error instanceof McpOauthAuthorizationRequiredError ||
52+
error instanceof McpOauthRedirectRequired ||
53+
error instanceof UnauthorizedError
54+
)
55+
}
56+
4857
function serverCacheKey(workspaceId: string, serverId: string): string {
4958
return `workspace:${workspaceId}:server:${serverId}`
5059
}
@@ -395,7 +404,7 @@ class McpService {
395404
serverId: string,
396405
error: unknown
397406
): Promise<void> {
398-
if (error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError) {
407+
if (isMcpAuthorizationRequired(error)) {
399408
return
400409
}
401410
try {
@@ -426,6 +435,21 @@ class McpService {
426435
}
427436
}
428437

438+
private async updateServerAuthorizationRequiredStatus(serverId: string): Promise<void> {
439+
try {
440+
await db
441+
.update(mcpServers)
442+
.set({
443+
connectionStatus: 'disconnected',
444+
lastError: null,
445+
updatedAt: new Date(),
446+
})
447+
.where(eq(mcpServers.id, serverId))
448+
} catch (error) {
449+
logger.error(`Failed to update authorization-required status for ${serverId}:`, error)
450+
}
451+
}
452+
429453
async verifyServerConnection(
430454
userId: string,
431455
serverId: string,
@@ -439,13 +463,24 @@ class McpService {
439463
requiresAuthorization: false,
440464
}
441465
} catch (error) {
442-
const message = getErrorMessage(error, 'Connection failed').split('\n')[0]
466+
const message = truncate(
467+
getErrorMessage(error, 'Connection failed').split('\n')[0],
468+
MAX_VERIFICATION_ERROR_LENGTH,
469+
''
470+
)
471+
const requiresAuthorization = isMcpAuthorizationRequired(error)
472+
473+
if (requiresAuthorization) {
474+
await this.updateServerAuthorizationRequiredStatus(serverId)
475+
} else {
476+
await this.updateServerStatus(serverId, workspaceId, false, message)
477+
}
478+
443479
return {
444480
verified: false,
445481
toolCount: 0,
446-
requiresAuthorization:
447-
error instanceof McpOauthAuthorizationRequiredError || error instanceof UnauthorizedError,
448-
error: truncate(message, MAX_VERIFICATION_ERROR_LENGTH, ''),
482+
requiresAuthorization,
483+
error: message,
449484
}
450485
}
451486
}

0 commit comments

Comments
 (0)