Skip to content

Commit 3296f1f

Browse files
committed
Address PR review feedback (#5754)
- Fall back safely when ordered MCP cache invalidation is unavailable - Surface sanitized concrete refresh errors without masking OAuth-pending state
1 parent d3c3d09 commit 3296f1f

4 files changed

Lines changed: 90 additions & 3 deletions

File tree

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
import type { NextRequest } from 'next/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { McpOauthAuthorizationRequiredError } from '@/lib/mcp/types'
67

78
const { mockClearCache, mockDiscoverServerTools, mockSelect, mockUpdate, mockUpdateSet } =
89
vi.hoisted(() => ({
@@ -56,6 +57,7 @@ const initialServer = {
5657
workspaceId: 'workspace-1',
5758
name: 'OAuth Server',
5859
url: 'https://example.com/mcp',
60+
authType: 'oauth',
5961
connectionStatus: 'connected',
6062
lastError: null,
6163
lastConnected: new Date('2026-01-01T00:00:00.000Z'),
@@ -93,7 +95,9 @@ describe('MCP server refresh route', () => {
9395
})
9496

9597
it('preserves the service-persisted OAuth pending status', async () => {
96-
mockDiscoverServerTools.mockRejectedValueOnce(new Error('OAuth authorization required'))
98+
mockDiscoverServerTools.mockRejectedValueOnce(
99+
new McpOauthAuthorizationRequiredError('server-1', 'OAuth Server')
100+
)
97101

98102
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
99103
method: 'POST',
@@ -112,6 +116,25 @@ describe('MCP server refresh route', () => {
112116
)
113117
})
114118

119+
it('reports a sanitized discovery timeout when persistence leaves disconnected without an error', async () => {
120+
mockDiscoverServerTools.mockRejectedValueOnce(new Error('upstream request timeout'))
121+
122+
const request = new Request('http://localhost/api/mcp/servers/server-1/refresh', {
123+
method: 'POST',
124+
}) as NextRequest
125+
const response = await POST(request, { params: Promise.resolve({ id: 'server-1' }) })
126+
const body = await response.json()
127+
128+
expect(body.data).toEqual(
129+
expect.objectContaining({
130+
status: 'disconnected',
131+
error: 'Request timed out',
132+
toolCount: 0,
133+
workflowsUpdated: 0,
134+
})
135+
)
136+
})
137+
115138
it('reports the discovery failure when status persistence leaves a stale connected row', async () => {
116139
const reflectedSecret = 'Bearer reflected-static-token'
117140
mockDiscoverServerTools.mockRejectedValueOnce(

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
12
import { db } from '@sim/db'
23
import { mcpServers, workflow, workflowBlocks } from '@sim/db/schema'
34
import { createLogger } from '@sim/logger'
@@ -10,7 +11,11 @@ import { validationErrorResponse } from '@/lib/api/server'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1112
import { withMcpAuth } from '@/lib/mcp/middleware'
1213
import { type McpServerDiscoveryState, mcpService } from '@/lib/mcp/service'
13-
import type { McpTool, McpToolSchema } from '@/lib/mcp/types'
14+
import {
15+
McpOauthAuthorizationRequiredError,
16+
type McpTool,
17+
type McpToolSchema,
18+
} from '@/lib/mcp/types'
1419
import {
1520
categorizeError,
1621
createMcpErrorResponse,
@@ -190,6 +195,7 @@ export const POST = withRouteHandler(
190195
let discoveredTools: McpTool[] = []
191196
let discoveryState: McpServerDiscoveryState | null = null
192197
let discoveryError: string | null = null
198+
let oauthAuthorizationRequired = false
193199

194200
try {
195201
const discovery = await mcpService.discoverServerToolsWithMetadata(
@@ -204,6 +210,10 @@ export const POST = withRouteHandler(
204210
`[${requestId}] Discovered ${discoveredTools.length} tools from server ${serverId}`
205211
)
206212
} catch (error) {
213+
oauthAuthorizationRequired =
214+
server.authType === 'oauth' &&
215+
(error instanceof McpOauthAuthorizationRequiredError ||
216+
error instanceof UnauthorizedError)
207217
discoveryError = truncate(categorizeError(error).message, 200, '')
208218
logger.warn(`[${requestId}] Failed to connect to server ${serverId}`, {
209219
error: discoveryError,
@@ -282,6 +292,14 @@ export const POST = withRouteHandler(
282292
lastError = discoveryError
283293
toolCount = 0
284294
}
295+
} else if (
296+
discoveryError !== null &&
297+
connectionStatus === 'disconnected' &&
298+
lastError === null &&
299+
!oauthAuthorizationRequired
300+
) {
301+
lastError = discoveryError
302+
toolCount = 0
285303
}
286304

287305
return createMcpSuccessResponse({

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,49 @@ describe('McpService.discoverTools per-server caching', () => {
737737
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(failureKey)
738738
})
739739

740+
it('best-effort deletes both cache keys and publishes the barrier when ordered invalidation fails', async () => {
741+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
742+
const failureKey = `${serverKey}:failure`
743+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
744+
cacheStore.set(serverKey, {
745+
tools: [tool('stale-tool', 'mcp-a')],
746+
expiry: Date.now() + 60_000,
747+
})
748+
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
749+
mockCacheAdapter.applyMutationIfCurrent.mockRejectedValueOnce(
750+
new Error('atomic invalidation unavailable')
751+
)
752+
753+
await mcpService.clearCache(WORKSPACE_ID)
754+
755+
const mutationId = mockCacheAdapter.applyMutationIfCurrent.mock.calls[0][1]
756+
expect(cacheStore.has(serverKey)).toBe(false)
757+
expect(cacheStore.has(failureKey)).toBe(false)
758+
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(serverKey)
759+
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(failureKey)
760+
expect(mockUpdateSet).toHaveBeenCalledWith({
761+
toolCount: 0,
762+
lastToolsRefresh: new Date(mutationId),
763+
})
764+
})
765+
766+
it('preserves a newer cache winner when invalidation is superseded', async () => {
767+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
768+
const failureKey = `${serverKey}:failure`
769+
const winner = tool('winner-tool', 'mcp-a')
770+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
771+
cacheStore.set(serverKey, { tools: [winner], expiry: Date.now() + 60_000 })
772+
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
773+
mockCacheAdapter.applyMutationIfCurrent.mockResolvedValueOnce(false)
774+
775+
await mcpService.clearCache(WORKSPACE_ID)
776+
777+
expect(cacheStore.get(serverKey)?.tools).toEqual([winner])
778+
expect(cacheStore.has(failureKey)).toBe(true)
779+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
780+
expect(mockUpdateSet).not.toHaveBeenCalled()
781+
})
782+
740783
it('does not negative-cache OAuth-required errors', async () => {
741784
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
742785
mockListTools.mockRejectedValueOnce(new McpOauthAuthorizationRequiredError('mcp-a', 'A'))

apps/sim/lib/mcp/service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,10 @@ class McpService {
734734
null,
735735
[serverCacheKey(workspaceId, serverId), failureCacheKey(workspaceId, serverId)]
736736
)
737-
if (cacheApplied !== 'applied') return
737+
if (cacheApplied === 'superseded') return
738+
if (cacheApplied === 'unavailable') {
739+
await this.bestEffortInvalidateServerCache(workspaceId, serverId)
740+
}
738741

739742
await this.markServerCacheInvalidated(serverId, workspaceId, new Date(mutation.id))
740743
}

0 commit comments

Comments
 (0)