Skip to content

Commit cf68df9

Browse files
committed
improvement(mcp): align tool discovery timeouts and retries with MCP protocol standard
- Raise tools/list idle timeout 10s -> 30s (derived from per-server config, clamped to max execution timeout) with a 60s hard cap via maxTotalTimeout, matching the SDK's DEFAULT_REQUEST_TIMEOUT_MSEC. Enable resetTimeoutOnProgress with an onprogress handler so slow-but-alive servers are not spuriously failed. - Retry read-only tools/list on transient transport errors (timeout, connection-closed, 429/5xx, session 404/400, network) with jittered backoff. tools/call stays conservative (session-error retry only) since it is not idempotent. The MCP SDK does not retry POST requests, so the app owns this. - Wire client.onerror so out-of-band transport failures are observed, not silently dropped. - Map timeouts to a user-facing message and surface refresh/remove failures via the standard emcn toast instead of swallowing them.
1 parent 87edbc5 commit cf68df9

6 files changed

Lines changed: 150 additions & 11 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useEffect, useRef, useState } from 'react'
4-
import { Badge, Button, Chip, ChipConfirmModal, cn, Tooltip } from '@sim/emcn'
4+
import { Badge, Button, Chip, ChipConfirmModal, cn, Tooltip, toast } from '@sim/emcn'
55
import { ArrowLeft } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
77
import { getErrorMessage } from '@sim/utils/errors'
@@ -224,6 +224,7 @@ export function MCP() {
224224
logger.info(`Removed MCP server: ${serverId}`)
225225
} catch (error) {
226226
logger.error('Failed to remove MCP server:', error)
227+
toast.error('Failed to remove MCP server', { description: getErrorMessage(error) })
227228
} finally {
228229
setDeletingServers((prev) => {
229230
const newSet = new Set(prev)
@@ -301,6 +302,7 @@ export function MCP() {
301302
}
302303
} catch (error) {
303304
logger.error('Failed to refresh MCP server:', error)
305+
toast.error('Failed to refresh MCP server', { description: getErrorMessage(error) })
304306
}
305307
}
306308

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockLogger, mockSdkConnect } = vi.hoisted(() => ({
6+
const { mockLogger, mockSdkConnect, mockSdkListTools } = vi.hoisted(() => ({
77
mockLogger: {
88
debug: vi.fn(),
99
error: vi.fn(),
1010
info: vi.fn(),
1111
warn: vi.fn(),
1212
},
1313
mockSdkConnect: vi.fn().mockResolvedValue(undefined),
14+
mockSdkListTools: vi.fn().mockResolvedValue({ tools: [] }),
1415
}))
1516

1617
vi.mock('@sim/logger', () => ({
@@ -37,7 +38,7 @@ vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
3738
.mockImplementation((_schema: unknown, handler: () => Promise<void>) => {
3839
capturedNotificationHandler = handler
3940
}),
40-
listTools: vi.fn().mockResolvedValue({ tools: [] }),
41+
listTools: mockSdkListTools,
4142
})
4243
}
4344
}
@@ -80,6 +81,7 @@ describe('McpClient notification handler', () => {
8081
capturedNotificationHandler = null
8182
vi.clearAllMocks()
8283
mockSdkConnect.mockResolvedValue(undefined)
84+
mockSdkListTools.mockResolvedValue({ tools: [] })
8385
})
8486

8587
it('fires onToolsChanged when a notification arrives while connected', async () => {
@@ -152,6 +154,26 @@ describe('McpClient notification handler', () => {
152154
expect(mockSdkConnect).toHaveBeenCalledWith(expect.anything(), { timeout: 30_000 })
153155
})
154156

157+
it('bounds tools/list with an idle timeout, hard cap, and progress reset', async () => {
158+
const client = new McpClient({
159+
config: createConfig(),
160+
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
161+
})
162+
163+
await client.connect()
164+
await client.listTools()
165+
166+
expect(mockSdkListTools).toHaveBeenCalledWith(
167+
undefined,
168+
expect.objectContaining({
169+
timeout: 30_000,
170+
maxTotalTimeout: 60_000,
171+
resetTimeoutOnProgress: true,
172+
onprogress: expect.any(Function),
173+
})
174+
)
175+
})
176+
155177
it('logs connection diagnostics without header values', async () => {
156178
const client = new McpClient({
157179
config: {

apps/sim/lib/mcp/client.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,17 @@ export class McpClient {
104104
capabilities: {},
105105
}
106106
)
107+
108+
// The SDK invokes onerror for out-of-band transport failures (SSE
109+
// disconnects, exhausted reconnects) that are not tied to an in-flight
110+
// request. Without a handler these are silently dropped, so wire one for
111+
// observability. See @modelcontextprotocol/sdk Protocol.onerror.
112+
this.client.onerror = (error) => {
113+
logger.warn(`MCP transport error for ${this.config.name}`, {
114+
serverId: this.config.id,
115+
error: sanitizeForLogging(getErrorMessage(error, 'Unknown transport error'), 200),
116+
})
117+
}
107118
}
108119

109120
/**
@@ -217,9 +228,34 @@ export class McpClient {
217228
throw new McpConnectionError('Not connected to server', this.config.name)
218229
}
219230

231+
const configuredTimeout = this.config.timeout
232+
const idleTimeoutMs = Math.min(
233+
configuredTimeout !== undefined && Number.isFinite(configuredTimeout) && configuredTimeout > 0
234+
? Math.floor(configuredTimeout)
235+
: MCP_CLIENT_CONSTANTS.LIST_TOOLS_TIMEOUT_MS,
236+
getMaxExecutionTimeout()
237+
)
238+
const maxTotalTimeoutMs = Math.max(
239+
idleTimeoutMs,
240+
MCP_CLIENT_CONSTANTS.LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS
241+
)
242+
220243
try {
221244
const result: ListToolsResult = await this.client.listTools(undefined, {
222-
timeout: MCP_CLIENT_CONSTANTS.LIST_TOOLS_TIMEOUT_MS,
245+
// `timeout` bounds silence between progress notifications; `maxTotalTimeout`
246+
// is the hard ceiling regardless of progress. `resetTimeoutOnProgress` only
247+
// takes effect when an `onprogress` callback is supplied (the SDK sets the
248+
// request progressToken only then), so both are passed together.
249+
timeout: idleTimeoutMs,
250+
maxTotalTimeout: maxTotalTimeoutMs,
251+
resetTimeoutOnProgress: true,
252+
onprogress: (progress) => {
253+
logger.debug(`Tool discovery progress from ${this.config.name}`, {
254+
serverId: this.config.id,
255+
progress: progress.progress,
256+
total: progress.total,
257+
})
258+
},
223259
})
224260

225261
if (!result.tools || !Array.isArray(result.tools)) {

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,11 @@ describe('McpService.discoverTools per-server caching', () => {
400400

401401
it('successful discoverServerTools clears the negative cache', async () => {
402402
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
403-
mockListTools.mockRejectedValueOnce(new Error('Request timed out'))
403+
// A timeout is transient/retryable, so it must fail every attempt to reach
404+
// the persisted-failure path.
405+
mockListTools
406+
.mockRejectedValueOnce(new Error('Request timed out'))
407+
.mockRejectedValueOnce(new Error('Request timed out'))
404408

405409
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
406410
'Request timed out'
@@ -450,7 +454,9 @@ describe('McpService.discoverTools per-server caching', () => {
450454
statusConfig: { consecutiveFailures: 0, lastSuccessfulDiscovery: null },
451455
}),
452456
])
453-
mockListTools.mockRejectedValueOnce(new Error('Request timed out'))
457+
mockListTools
458+
.mockRejectedValueOnce(new Error('Request timed out'))
459+
.mockRejectedValueOnce(new Error('Request timed out'))
454460

455461
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
456462
'Request timed out'
@@ -459,12 +465,25 @@ describe('McpService.discoverTools per-server caching', () => {
459465
expect(mockUpdateSet).toHaveBeenCalledWith(
460466
expect.objectContaining({
461467
connectionStatus: 'disconnected',
462-
lastError: 'Request timed out',
468+
// Raw SDK timeout text is mapped to a user-facing message before persisting.
469+
lastError: 'The MCP server took too long to respond and timed out',
463470
statusConfig: { consecutiveFailures: 1, lastSuccessfulDiscovery: null },
464471
})
465472
)
466473
})
467474

475+
it('retries a transient tools/list timeout and succeeds on the second attempt', async () => {
476+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
477+
mockListTools
478+
.mockRejectedValueOnce(new Error('Request timed out'))
479+
.mockResolvedValueOnce([tool('a1', 'mcp-a')])
480+
481+
const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)
482+
483+
expect(tools.map((t) => t.name)).toEqual(['a1'])
484+
expect(mockListTools).toHaveBeenCalledTimes(2)
485+
})
486+
468487
it('persists and negative-caches per-server UnauthorizedError for headers auth', async () => {
469488
const reflectedCredential = 'Bearer static-secret-for-server-discovery'
470489
mockGetWorkspaceServersRows.mockResolvedValue([

apps/sim/lib/mcp/service.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
22
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
3+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
34
import { db } from '@sim/db'
45
import { mcpServers } from '@sim/db/schema'
56
import { createLogger } from '@sim/logger'
67
import { getErrorMessage } from '@sim/utils/errors'
78
import { sleep } from '@sim/utils/helpers'
9+
import { backoffWithJitter } from '@sim/utils/retry'
810
import { and, eq, isNull, lte, or } from 'drizzle-orm'
911
import { isTest } from '@/lib/core/config/env-flags'
1012
import { generateRequestId } from '@/lib/core/utils/request'
@@ -85,9 +87,54 @@ function getDiscoveryFailureMessage(
8587
if (authType !== 'oauth' && error instanceof UnauthorizedError) {
8688
return 'Authentication failed'
8789
}
90+
if (isTimeoutError(error)) {
91+
return 'The MCP server took too long to respond and timed out'
92+
}
8893
return getErrorMessage(error, fallback)
8994
}
9095

96+
function isTimeoutError(error: unknown): boolean {
97+
if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) {
98+
return true
99+
}
100+
return getErrorMessage(error, '').toLowerCase().includes('timed out')
101+
}
102+
103+
/**
104+
* Transient transport failures that a read-only `tools/list` may safely retry.
105+
* `tools/list` is idempotent, so re-issuing it after a timeout, dropped
106+
* connection, or 5xx/429 cannot double-apply side effects — unlike `tools/call`,
107+
* which is never retried on these. OAuth-authorization and terminal 4xx errors
108+
* are intentionally excluded: they need re-auth or a config fix, not a retry.
109+
* The MCP TypeScript SDK does not retry POST requests itself (only SSE streams),
110+
* so the app owns this.
111+
*/
112+
function isRetryableDiscoveryError(error: unknown): boolean {
113+
if (isTimeoutError(error)) return true
114+
if (error instanceof McpError) {
115+
return error.code === ErrorCode.ConnectionClosed
116+
}
117+
if (error instanceof StreamableHTTPError) {
118+
// 404/400 = stale/malformed session id: retrying rebuilds the client with no
119+
// session id, which re-initializes per MCP spec. 429/5xx = transient upstream.
120+
const code = error.code
121+
return (
122+
code === 404 ||
123+
code === 400 ||
124+
code === 429 ||
125+
(typeof code === 'number' && code >= 500 && code <= 599)
126+
)
127+
}
128+
const message = getErrorMessage(error, '').toLowerCase()
129+
return (
130+
message.includes('econnreset') ||
131+
message.includes('socket hang up') ||
132+
message.includes('etimedout') ||
133+
message.includes('fetch failed') ||
134+
message.includes('network')
135+
)
136+
}
137+
91138
class McpService {
92139
private cacheAdapter: McpCacheStorageAdapter
93140
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT
@@ -772,12 +819,12 @@ class McpService {
772819
await client.disconnect()
773820
}
774821
} catch (error) {
775-
if (this.isSessionError(error) && attempt < maxRetries - 1) {
822+
if (isRetryableDiscoveryError(error) && attempt < maxRetries - 1) {
776823
logger.warn(
777-
`[${requestId}] Session error discovering tools from server ${serverId}, retrying (attempt ${attempt + 1}):`,
824+
`[${requestId}] Transient error discovering tools from server ${serverId}, retrying (attempt ${attempt + 1}):`,
778825
error
779826
)
780-
await sleep(100)
827+
await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 250, maxMs: 2000 }))
781828
continue
782829
}
783830
// Drop positive cache so a follow-up doesn't return stale tools.

apps/sim/lib/mcp/utils.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,20 @@ export function sanitizeHeaders(
5151
export const MCP_CLIENT_CONSTANTS = {
5252
CLIENT_TIMEOUT: DEFAULT_EXECUTION_TIMEOUT_MS,
5353
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
54-
LIST_TOOLS_TIMEOUT_MS: 10_000,
54+
/**
55+
* Idle timeout for a `tools/list` request. Raised from the former aggressive
56+
* 10s toward the MCP TypeScript SDK's 60s default (`DEFAULT_REQUEST_TIMEOUT_MSEC`)
57+
* so a legitimately slow-to-enumerate server is not spuriously failed with
58+
* `McpError -32001`. Combined with `resetTimeoutOnProgress`, this is the gap
59+
* allowed between progress notifications, not the absolute ceiling.
60+
*/
61+
LIST_TOOLS_TIMEOUT_MS: 30_000,
62+
/**
63+
* Absolute ceiling for a `tools/list` request regardless of progress, mirroring
64+
* the SDK's `maxTotalTimeout` safeguard. Matches the SDK's 60s default so a
65+
* server emitting continuous progress still cannot hang discovery indefinitely.
66+
*/
67+
LIST_TOOLS_MAX_TOTAL_TIMEOUT_MS: 60_000,
5568
FAILURE_CACHE_TTL_MS: 120_000,
5669
} as const
5770

0 commit comments

Comments
 (0)