Skip to content

Commit 10baba5

Browse files
committed
Harden MCP cache fallback diagnostics (#5754)
- fall back to best-effort cache writes when mutation ordering is unavailable\n- share bounded redacted MCP error diagnostics across client and service
1 parent 89dce25 commit 10baba5

5 files changed

Lines changed: 173 additions & 32 deletions

File tree

apps/sim/lib/mcp/client.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import {
99
ToolListChangedNotificationSchema,
1010
} from '@modelcontextprotocol/sdk/types.js'
1111
import { createLogger } from '@sim/logger'
12-
import { describeError, getErrorMessage } from '@sim/utils/errors'
12+
import { getErrorMessage } from '@sim/utils/errors'
1313
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1414
import { createPinnedFetch } from '@/lib/core/security/input-validation.server'
15-
import { sanitizeForLogging } from '@/lib/core/security/redaction'
15+
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
1616
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
1717
import {
1818
type McpClientOptions,
@@ -60,16 +60,6 @@ function classifyConnectionOutcome(
6060
return 'error'
6161
}
6262

63-
function getSafeErrorDiagnostics(error: unknown) {
64-
const describedError = describeError(error)
65-
return {
66-
name: sanitizeForLogging(describedError.name, 100),
67-
code: describedError.code ? sanitizeForLogging(describedError.code, 100) : undefined,
68-
errno: describedError.errno ? sanitizeForLogging(describedError.errno, 100) : undefined,
69-
syscall: describedError.syscall ? sanitizeForLogging(describedError.syscall, 100) : undefined,
70-
}
71-
}
72-
7363
interface McpClientConnectOptions {
7464
isCancelled?: () => boolean
7565
}
@@ -127,7 +117,7 @@ export class McpClient {
127117
serverId: this.config.id,
128118
phase: 'transport',
129119
sessionIdPresent: Boolean(this.transport.sessionId),
130-
error: getSafeErrorDiagnostics(error),
120+
error: getMcpSafeErrorDiagnostics(error),
131121
})
132122
}
133123
}
@@ -200,7 +190,7 @@ export class McpClient {
200190
logger.error(`Failed to connect to MCP server ${this.config.name}`, {
201191
...diagnostics,
202192
durationMs: Date.now() - startedAt,
203-
error: getSafeErrorDiagnostics(error),
193+
error: getMcpSafeErrorDiagnostics(error),
204194
outcome,
205195
})
206196
if (outcome === 'authorization_required') {
@@ -287,7 +277,7 @@ export class McpClient {
287277
idleTimeoutMs,
288278
maxTotalTimeoutMs,
289279
sessionIdPresent: Boolean(this.transport.sessionId),
290-
error: getSafeErrorDiagnostics(error),
280+
error: getMcpSafeErrorDiagnostics(error),
291281
})
292282
throw error
293283
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { describe, expect, it } from 'vitest'
6+
import { getMcpSafeErrorDiagnostics } from './error-diagnostics'
7+
8+
describe('getMcpSafeErrorDiagnostics', () => {
9+
it('redacts and truncates structural fields without emitting message text', () => {
10+
const secret = 'sk-abcdefghijklmnopqrstuvwxyz1234567890'
11+
const error = Object.assign(new Error(`message contains ${secret}`), {
12+
name: `Bearer ${secret}`,
13+
code: `code-${'c'.repeat(200)}`,
14+
errno: `api_key: "${secret}"`,
15+
syscall: `Bearer ${secret}`,
16+
sessionId: secret,
17+
})
18+
19+
const diagnostics = getMcpSafeErrorDiagnostics(error)
20+
21+
expect(diagnostics).toEqual({
22+
name: expect.any(String),
23+
code: expect.any(String),
24+
errno: expect.any(String),
25+
syscall: expect.any(String),
26+
})
27+
expect(diagnostics).not.toHaveProperty('message')
28+
expect(diagnostics).not.toHaveProperty('causeChain')
29+
expect(diagnostics).not.toHaveProperty('sessionId')
30+
for (const value of Object.values(diagnostics)) {
31+
expect(value).not.toContain(secret)
32+
expect(value?.length).toBeLessThanOrEqual(100)
33+
}
34+
expect(diagnostics.name).toContain('[REDACTED]')
35+
expect(diagnostics.errno).toContain('[REDACTED]')
36+
expect(diagnostics.syscall).toContain('[REDACTED]')
37+
expect(diagnostics.code).toHaveLength(100)
38+
})
39+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describeError } from '@sim/utils/errors'
2+
import { sanitizeForLogging } from '@/lib/core/security/redaction'
3+
4+
const MAX_DIAGNOSTIC_FIELD_LENGTH = 100
5+
6+
export interface McpSafeErrorDiagnostics {
7+
name: string
8+
code: string | undefined
9+
errno: string | undefined
10+
syscall: string | undefined
11+
}
12+
13+
/** Returns bounded structural error fields without messages, causes, or session identifiers. */
14+
export function getMcpSafeErrorDiagnostics(error: unknown): McpSafeErrorDiagnostics {
15+
const described = describeError(error)
16+
const sanitizeOptional = (value: string | undefined) =>
17+
value === undefined ? undefined : sanitizeForLogging(value, MAX_DIAGNOSTIC_FIELD_LENGTH)
18+
19+
return {
20+
name: sanitizeForLogging(described.name, MAX_DIAGNOSTIC_FIELD_LENGTH),
21+
code: sanitizeOptional(described.code),
22+
errno: sanitizeOptional(described.errno),
23+
syscall: sanitizeOptional(described.syscall),
24+
}
25+
}

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,67 @@ describe('McpService.discoverTools per-server caching', () => {
505505
expect(mockListTools).not.toHaveBeenCalled()
506506
})
507507

508+
it('falls back to unordered cache writes when mutation ordering is unavailable', async () => {
509+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
510+
const failureKey = `${serverKey}:failure`
511+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
512+
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
513+
mockCacheAdapter.beginMutation.mockRejectedValueOnce(new Error('cache ordering unavailable'))
514+
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
515+
516+
const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
517+
518+
expect(tools).toEqual([tool('a1', 'mcp-a')])
519+
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('a1', 'mcp-a')])
520+
expect(cacheStore.has(failureKey)).toBe(false)
521+
expect(mockCacheAdapter.set).toHaveBeenCalledWith(
522+
serverKey,
523+
[tool('a1', 'mcp-a')],
524+
expect.any(Number)
525+
)
526+
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(failureKey)
527+
expect(mockUpdateSet).toHaveBeenCalledWith(
528+
expect.objectContaining({ connectionStatus: 'connected', toolCount: 1 })
529+
)
530+
})
531+
532+
it('publishes database status when both ordered and fallback cache writes fail', async () => {
533+
const reflectedCredential = 'opaque-cache-provider-message'
534+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
535+
mockCacheAdapter.beginMutation.mockRejectedValueOnce(new Error(reflectedCredential))
536+
mockCacheAdapter.set.mockRejectedValueOnce(new Error(reflectedCredential))
537+
mockCacheAdapter.delete.mockRejectedValueOnce(new Error(reflectedCredential))
538+
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
539+
540+
await expect(
541+
mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
542+
).resolves.toEqual([tool('a1', 'mcp-a')])
543+
544+
expect(mockUpdateSet).toHaveBeenCalledWith(
545+
expect.objectContaining({ connectionStatus: 'connected', toolCount: 1 })
546+
)
547+
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
548+
})
549+
550+
it('falls back to unordered deletes when cache invalidation cannot be ordered', async () => {
551+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
552+
const failureKey = `${serverKey}:failure`
553+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
554+
cacheStore.set(serverKey, {
555+
tools: [tool('stale-tool', 'mcp-a')],
556+
expiry: Date.now() + 60_000,
557+
})
558+
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
559+
mockCacheAdapter.beginMutation.mockRejectedValueOnce(new Error('cache ordering unavailable'))
560+
561+
await mcpService.clearCache(WORKSPACE_ID)
562+
563+
expect(cacheStore.has(serverKey)).toBe(false)
564+
expect(cacheStore.has(failureKey)).toBe(false)
565+
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(serverKey)
566+
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(failureKey)
567+
})
568+
508569
it('does not negative-cache OAuth-required errors', async () => {
509570
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
510571
mockListTools.mockRejectedValueOnce(new McpOauthAuthorizationRequiredError('mcp-a', 'A'))

apps/sim/lib/mcp/service.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
44
import { db } from '@sim/db'
55
import { mcpServers } from '@sim/db/schema'
66
import { createLogger } from '@sim/logger'
7-
import { describeError, getErrorMessage } from '@sim/utils/errors'
7+
import { getErrorMessage } from '@sim/utils/errors'
88
import { sleep } from '@sim/utils/helpers'
99
import { backoffWithJitter } from '@sim/utils/retry'
1010
import { and, eq, gte, isNull, lt, lte, or } from 'drizzle-orm'
@@ -17,6 +17,7 @@ import {
1717
validateMcpDomain,
1818
validateMcpServerSsrf,
1919
} from '@/lib/mcp/domain-check'
20+
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
2021
import {
2122
getOrCreateOauthRow,
2223
loadPreregisteredClient,
@@ -144,16 +145,6 @@ function getDiscoveryFailureMessage(
144145
return fallback === 'Unknown error' ? 'Connection failed' : fallback
145146
}
146147

147-
function getSafeErrorDiagnostics(error: unknown) {
148-
const described = describeError(error)
149-
return {
150-
name: described.name,
151-
code: described.code,
152-
errno: described.errno,
153-
syscall: described.syscall,
154-
}
155-
}
156-
157148
function isTimeoutError(error: unknown): boolean {
158149
if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) {
159150
return true
@@ -202,7 +193,7 @@ class McpService {
202193
this.unsubscribeConnectionManager = mcpConnectionManager.subscribe((event) => {
203194
this.invalidateServerCache(event.workspaceId, event.serverId).catch((error) => {
204195
logger.warn(`Failed to invalidate cache for ${event.serverName} on listChanged`, {
205-
error: getSafeErrorDiagnostics(error),
196+
error: getMcpSafeErrorDiagnostics(error),
206197
})
207198
})
208199
})
@@ -519,7 +510,43 @@ class McpService {
519510
setEntry: McpCacheMutationSet | null,
520511
deleteKeys: string[]
521512
): Promise<boolean> {
522-
if (!mutation) return true
513+
if (!mutation) {
514+
const operations = [
515+
...(setEntry
516+
? [
517+
{
518+
kind: 'set',
519+
run: () => this.cacheAdapter.set(setEntry.key, setEntry.tools, setEntry.ttlMs),
520+
},
521+
]
522+
: []),
523+
...deleteKeys.map((key) => ({
524+
kind: 'delete',
525+
run: () => this.cacheAdapter.delete(key),
526+
})),
527+
]
528+
const results = await Promise.allSettled(
529+
operations.map(({ run }) => Promise.resolve().then(run))
530+
)
531+
const failedOperations = results.flatMap((result, index) =>
532+
result.status === 'rejected'
533+
? [
534+
{
535+
kind: operations[index].kind,
536+
error: getMcpSafeErrorDiagnostics(result.reason),
537+
},
538+
]
539+
: []
540+
)
541+
if (failedOperations.length > 0) {
542+
logger.warn(`Failed to update cache fallback for server ${serverId}`, {
543+
workspaceId,
544+
failedOperations,
545+
})
546+
}
547+
// Cache availability must not block the authoritative database status.
548+
return true
549+
}
523550
try {
524551
return await this.cacheAdapter.applyMutationIfCurrent(
525552
mutation.scopeKey,
@@ -530,7 +557,7 @@ class McpService {
530557
} catch (error) {
531558
logger.warn(`Failed to atomically update cache for server ${serverId}`, {
532559
workspaceId,
533-
error: getSafeErrorDiagnostics(error),
560+
error: getMcpSafeErrorDiagnostics(error),
534561
})
535562
return true
536563
}
@@ -591,15 +618,14 @@ class McpService {
591618
return { scopeKey, id: await this.cacheAdapter.beginMutation(scopeKey) }
592619
} catch (error) {
593620
logger.warn(`Failed to order cache mutation for server ${serverId}`, {
594-
error: getSafeErrorDiagnostics(error),
621+
error: getMcpSafeErrorDiagnostics(error),
595622
})
596623
return null
597624
}
598625
}
599626

600627
private async invalidateServerCache(workspaceId: string, serverId: string): Promise<void> {
601628
const mutation = await this.beginServerCacheMutation(workspaceId, serverId)
602-
if (!mutation) return
603629
await this.applyServerCacheMutation(workspaceId, serverId, mutation, null, [
604630
serverCacheKey(workspaceId, serverId),
605631
failureCacheKey(workspaceId, serverId),
@@ -982,7 +1008,7 @@ class McpService {
9821008
if (isRetryableDiscoveryError(error) && attempt < maxRetries - 1) {
9831009
logger.warn(
9841010
`[${requestId}] Transient error discovering tools from server ${serverId}, retrying (attempt ${attempt + 1})`,
985-
{ error: getSafeErrorDiagnostics(error) }
1011+
{ error: getMcpSafeErrorDiagnostics(error) }
9861012
)
9871013
await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 250, maxMs: 2000 }))
9881014
continue

0 commit comments

Comments
 (0)