Skip to content

Commit 23320ee

Browse files
committed
fix(mcp): preserve discovery failure state
Treat static-header 401s as credential failures, keep OAuth failures pending, and prevent failed refreshes or reflected upstream errors from masquerading as connected state.
1 parent c0bd4e4 commit 23320ee

4 files changed

Lines changed: 252 additions & 31 deletions

File tree

apps/sim/app/api/mcp/servers/[id]/refresh/route.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import type { NextRequest } from 'next/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockDiscoverServerTools, mockSelect, mockUpdateSet } = vi.hoisted(() => ({
7+
const { mockClearCache, mockDiscoverServerTools, mockSelect, mockUpdateSet } = vi.hoisted(() => ({
8+
mockClearCache: vi.fn(),
89
mockDiscoverServerTools: vi.fn(),
910
mockSelect: vi.fn(),
1011
mockUpdateSet: vi.fn(),
@@ -41,7 +42,7 @@ vi.mock('@/lib/mcp/middleware', () => ({
4142

4243
vi.mock('@/lib/mcp/service', () => ({
4344
mcpService: {
44-
clearCache: vi.fn(),
45+
clearCache: mockClearCache,
4546
discoverServerTools: mockDiscoverServerTools,
4647
},
4748
}))
@@ -105,4 +106,62 @@ describe('MCP server refresh route', () => {
105106
expect.objectContaining({ connectionStatus: expect.anything() })
106107
)
107108
})
109+
110+
it('reports the discovery failure when status persistence leaves a stale connected row', async () => {
111+
const reflectedSecret = 'Bearer reflected-static-token'
112+
mockDiscoverServerTools.mockRejectedValueOnce(
113+
new Error(`Upstream reflected ${reflectedSecret}`)
114+
)
115+
mockUpdateSet.mockReturnValueOnce({
116+
where: vi.fn().mockReturnValue({
117+
returning: vi.fn().mockResolvedValue([initialServer]),
118+
}),
119+
})
120+
121+
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
122+
method: 'POST',
123+
}) as NextRequest
124+
const response = await POST(request, { params: Promise.resolve({ id: 'server-1' }) })
125+
const body = await response.json()
126+
127+
expect(body.data).toEqual(
128+
expect.objectContaining({
129+
status: 'disconnected',
130+
error: 'Internal server error',
131+
workflowsUpdated: 0,
132+
})
133+
)
134+
expect(JSON.stringify(body)).not.toContain(reflectedSecret)
135+
expect(mockClearCache).not.toHaveBeenCalled()
136+
})
137+
138+
it('preserves a connected status from a newer successful discovery', async () => {
139+
mockDiscoverServerTools.mockRejectedValueOnce(new Error('Connection failed'))
140+
const newerSuccessfulServer = {
141+
...initialServer,
142+
lastConnected: new Date(Date.now() + 60_000),
143+
toolCount: 7,
144+
}
145+
mockUpdateSet.mockReturnValueOnce({
146+
where: vi.fn().mockReturnValue({
147+
returning: vi.fn().mockResolvedValue([newerSuccessfulServer]),
148+
}),
149+
})
150+
151+
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
152+
method: 'POST',
153+
}) as NextRequest
154+
const response = await POST(request, { params: Promise.resolve({ id: 'server-1' }) })
155+
const body = await response.json()
156+
157+
expect(body.data).toEqual(
158+
expect.objectContaining({
159+
status: 'connected',
160+
error: null,
161+
toolCount: 7,
162+
workflowsUpdated: 0,
163+
})
164+
)
165+
expect(mockClearCache).toHaveBeenCalledWith('workspace-1')
166+
})
108167
})

apps/sim/app/api/mcp/servers/[id]/refresh/route.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { db } from '@sim/db'
22
import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { getErrorMessage, toError } from '@sim/utils/errors'
4+
import { toError } from '@sim/utils/errors'
55
import { truncate } from '@sim/utils/string'
66
import { and, eq, inArray, isNull } from 'drizzle-orm'
77
import type { NextRequest } from 'next/server'
@@ -12,6 +12,7 @@ import { withMcpAuth } from '@/lib/mcp/middleware'
1212
import { mcpService } from '@/lib/mcp/service'
1313
import type { McpTool, McpToolSchema } from '@/lib/mcp/types'
1414
import {
15+
categorizeError,
1516
createMcpErrorResponse,
1617
createMcpSuccessResponse,
1718
MCP_TOOL_CORE_PARAMS,
@@ -188,6 +189,7 @@ export const POST = withRouteHandler(
188189
let syncResult: SyncResult = { updatedCount: 0, updatedWorkflowIds: [] }
189190
let discoveredTools: McpTool[] = []
190191
let discoveryError: string | null = null
192+
const discoveryStartedAt = new Date()
191193

192194
try {
193195
discoveredTools = await mcpService.discoverServerTools(
@@ -200,12 +202,10 @@ export const POST = withRouteHandler(
200202
`[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}`
201203
)
202204
} catch (error) {
203-
discoveryError = truncate(
204-
getErrorMessage(error, 'Connection failed').split('\n')[0],
205-
200,
206-
''
207-
)
208-
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
205+
discoveryError = truncate(categorizeError(error).message, 200, '')
206+
logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, {
207+
error: discoveryError,
208+
})
209209
}
210210

211211
if (discoveryError === null) {
@@ -240,10 +240,21 @@ export const POST = withRouteHandler(
240240
toolCount: mcpServers.toolCount,
241241
})
242242

243-
const connectionStatus = refreshedServer?.connectionStatus ?? 'error'
244-
const lastError = refreshedServer ? refreshedServer.lastError : discoveryError
243+
let connectionStatus = refreshedServer?.connectionStatus ?? 'error'
244+
let lastError = refreshedServer ? refreshedServer.lastError : discoveryError
245245
const toolCount = refreshedServer?.toolCount ?? discoveredTools.length
246246

247+
if (discoveryError !== null && connectionStatus === 'connected') {
248+
const newerSuccessWonRace =
249+
refreshedServer?.lastConnected != null &&
250+
refreshedServer.lastConnected > discoveryStartedAt
251+
252+
if (!newerSuccessWonRace) {
253+
connectionStatus = 'disconnected'
254+
lastError = discoveryError
255+
}
256+
}
257+
247258
if (connectionStatus === 'connected') {
248259
await mcpService.clearCache(workspaceId)
249260
}

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

Lines changed: 137 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/**
22
* @vitest-environment node
33
*/
4+
5+
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
6+
import { loggerMock } from '@sim/testing'
47
import { beforeEach, describe, expect, it, vi } from 'vitest'
58

69
const {
@@ -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', () => ({
132135
import { mcpService } from '@/lib/mcp/service'
133136
import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
134137

138+
const mockLogger = vi.mocked(loggerMock.createLogger).mock.results.at(-1)?.value
139+
135140
const WORKSPACE_ID = 'workspace-test'
136141
const 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

Comments
 (0)