|
1 | 1 | import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' |
2 | 2 | import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js' |
| 3 | +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' |
3 | 4 | import { db } from '@sim/db' |
4 | 5 | import { mcpServers } from '@sim/db/schema' |
5 | 6 | import { createLogger } from '@sim/logger' |
6 | 7 | import { getErrorMessage } from '@sim/utils/errors' |
7 | 8 | import { sleep } from '@sim/utils/helpers' |
| 9 | +import { backoffWithJitter } from '@sim/utils/retry' |
8 | 10 | import { and, eq, isNull, lte, or } from 'drizzle-orm' |
9 | 11 | import { isTest } from '@/lib/core/config/env-flags' |
10 | 12 | import { generateRequestId } from '@/lib/core/utils/request' |
@@ -85,9 +87,54 @@ function getDiscoveryFailureMessage( |
85 | 87 | if (authType !== 'oauth' && error instanceof UnauthorizedError) { |
86 | 88 | return 'Authentication failed' |
87 | 89 | } |
| 90 | + if (isTimeoutError(error)) { |
| 91 | + return 'The MCP server took too long to respond and timed out' |
| 92 | + } |
88 | 93 | return getErrorMessage(error, fallback) |
89 | 94 | } |
90 | 95 |
|
| 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 | + |
91 | 138 | class McpService { |
92 | 139 | private cacheAdapter: McpCacheStorageAdapter |
93 | 140 | private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT |
@@ -772,12 +819,12 @@ class McpService { |
772 | 819 | await client.disconnect() |
773 | 820 | } |
774 | 821 | } catch (error) { |
775 | | - if (this.isSessionError(error) && attempt < maxRetries - 1) { |
| 822 | + if (isRetryableDiscoveryError(error) && attempt < maxRetries - 1) { |
776 | 823 | 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}):`, |
778 | 825 | error |
779 | 826 | ) |
780 | | - await sleep(100) |
| 827 | + await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 250, maxMs: 2000 })) |
781 | 828 | continue |
782 | 829 | } |
783 | 830 | // Drop positive cache so a follow-up doesn't return stale tools. |
|
0 commit comments