Skip to content

Commit 80da6da

Browse files
committed
Fail MCP discovery closed without cache ownership (#5754)
Retry cache mutation ownership before publishing discovery state, avoid unordered publication that older writers can supersede, and keep explicit invalidation best effort.
1 parent 10baba5 commit 80da6da

2 files changed

Lines changed: 148 additions & 62 deletions

File tree

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

Lines changed: 106 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ 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 () => {
508+
it('retries mutation ownership before publishing discovery state', async () => {
509509
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
510510
const failureKey = `${serverKey}:failure`
511511
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
@@ -518,36 +518,123 @@ describe('McpService.discoverTools per-server caching', () => {
518518
expect(tools).toEqual([tool('a1', 'mcp-a')])
519519
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('a1', 'mcp-a')])
520520
expect(cacheStore.has(failureKey)).toBe(false)
521-
expect(mockCacheAdapter.set).toHaveBeenCalledWith(
521+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(2)
522+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledWith(
522523
serverKey,
523-
[tool('a1', 'mcp-a')],
524-
expect.any(Number)
524+
expect.any(Number),
525+
{ key: serverKey, tools: [tool('a1', 'mcp-a')], ttlMs: expect.any(Number) },
526+
[failureKey]
525527
)
526-
expect(mockCacheAdapter.delete).toHaveBeenCalledWith(failureKey)
528+
expect(mockCacheAdapter.set).not.toHaveBeenCalled()
529+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
527530
expect(mockUpdateSet).toHaveBeenCalledWith(
528531
expect.objectContaining({ connectionStatus: 'connected', toolCount: 1 })
529532
)
530533
})
531534

532-
it('publishes database status when both ordered and fallback cache writes fail', async () => {
535+
it('keeps an older ordered publisher from superseding a retry-acquired mutation', async () => {
536+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
537+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
538+
539+
let resolveOlder: ((tools: ReturnType<typeof tool>[]) => void) | undefined
540+
mockListTools
541+
.mockReturnValueOnce(
542+
new Promise((resolve) => {
543+
resolveOlder = resolve
544+
})
545+
)
546+
.mockResolvedValueOnce([tool('new-tool', 'mcp-a')])
547+
548+
const older = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, false)
549+
await vi.waitFor(() => expect(mockListTools).toHaveBeenCalledTimes(1))
550+
551+
mockCacheAdapter.beginMutation.mockRejectedValueOnce(new Error('transient ordering failure'))
552+
const newer = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
553+
await expect(newer).resolves.toEqual([tool('new-tool', 'mcp-a')])
554+
555+
resolveOlder?.([tool('old-tool', 'mcp-a')])
556+
await expect(older).resolves.toEqual([])
557+
558+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(3)
559+
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('new-tool', 'mcp-a')])
560+
expect(mockUpdateSet).toHaveBeenCalledTimes(1)
561+
})
562+
563+
it('fails a newer discovery closed while an older mutation remains the owner', async () => {
564+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
565+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
566+
567+
let resolveOlder: ((tools: ReturnType<typeof tool>[]) => void) | undefined
568+
mockListTools
569+
.mockReturnValueOnce(
570+
new Promise((resolve) => {
571+
resolveOlder = resolve
572+
})
573+
)
574+
.mockResolvedValueOnce([tool('unowned-new-tool', 'mcp-a')])
575+
576+
const older = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, false)
577+
await vi.waitFor(() => expect(mockListTools).toHaveBeenCalledTimes(1))
578+
579+
mockCacheAdapter.beginMutation
580+
.mockRejectedValueOnce(new Error('ordering unavailable'))
581+
.mockRejectedValueOnce(new Error('ordering unavailable'))
582+
const newer = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
583+
await expect(newer).resolves.toEqual([])
584+
585+
resolveOlder?.([tool('owned-old-tool', 'mcp-a')])
586+
await expect(older).resolves.toEqual([tool('owned-old-tool', 'mcp-a')])
587+
588+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(3)
589+
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('owned-old-tool', 'mcp-a')])
590+
expect(mockCacheAdapter.set).not.toHaveBeenCalled()
591+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
592+
expect(mockUpdateSet).toHaveBeenCalledTimes(1)
593+
})
594+
595+
it('fails discovery publication closed when mutation ownership stays unavailable', async () => {
533596
const reflectedCredential = 'opaque-cache-provider-message'
534597
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))
598+
mockCacheAdapter.beginMutation
599+
.mockRejectedValueOnce(new Error(reflectedCredential))
600+
.mockRejectedValueOnce(new Error(reflectedCredential))
538601
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
539602

540603
await expect(
541604
mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
542-
).resolves.toEqual([tool('a1', 'mcp-a')])
605+
).resolves.toEqual([])
543606

544-
expect(mockUpdateSet).toHaveBeenCalledWith(
545-
expect.objectContaining({ connectionStatus: 'connected', toolCount: 1 })
607+
expect(mockCacheAdapter.applyMutationIfCurrent).not.toHaveBeenCalled()
608+
expect(mockCacheAdapter.set).not.toHaveBeenCalled()
609+
expect(mockCacheAdapter.delete).not.toHaveBeenCalled()
610+
expect(mockUpdateSet).not.toHaveBeenCalled()
611+
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
612+
})
613+
614+
it('fails discovery publication closed when the atomic cache transition fails', async () => {
615+
const reflectedCredential = 'opaque-atomic-cache-provider-message'
616+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
617+
mockCacheAdapter.applyMutationIfCurrent.mockRejectedValueOnce(new Error(reflectedCredential))
618+
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
619+
620+
await expect(
621+
mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
622+
).resolves.toEqual([])
623+
624+
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(1)
625+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledTimes(1)
626+
expect(mockUpdateSet).not.toHaveBeenCalled()
627+
expect(mockLogger?.warn).toHaveBeenCalledWith(
628+
'Failed to atomically update cache for server mcp-a',
629+
expect.objectContaining({
630+
workspaceId: WORKSPACE_ID,
631+
error: expect.objectContaining({ name: 'Error' }),
632+
})
546633
)
547634
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
548635
})
549636

550-
it('falls back to unordered deletes when cache invalidation cannot be ordered', async () => {
637+
it('best-effort deletes both cache keys when invalidation cannot be ordered', async () => {
551638
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
552639
const failureKey = `${serverKey}:failure`
553640
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
@@ -556,7 +643,9 @@ describe('McpService.discoverTools per-server caching', () => {
556643
expiry: Date.now() + 60_000,
557644
})
558645
cacheStore.set(failureKey, { tools: [], expiry: Date.now() + 60_000 })
559-
mockCacheAdapter.beginMutation.mockRejectedValueOnce(new Error('cache ordering unavailable'))
646+
mockCacheAdapter.beginMutation
647+
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
648+
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
560649

561650
await mcpService.clearCache(WORKSPACE_ID)
562651

@@ -655,13 +744,10 @@ describe('McpService.discoverTools per-server caching', () => {
655744
expect(cacheStore.has(`workspace:${WORKSPACE_ID}:server:mcp-a`)).toBe(false)
656745
})
657746

658-
it('orders status publication by discovery start when an older run completes first', async () => {
747+
it('supersedes an older discovery before it can publish status', async () => {
659748
vi.useFakeTimers({ toFake: ['Date'] })
660749
try {
661750
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
662-
mockCacheAdapter.beginMutation
663-
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
664-
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
665751

666752
let resolveOlder: ((tools: ReturnType<typeof tool>[]) => void) | undefined
667753
let resolveNewer: ((tools: ReturnType<typeof tool>[]) => void) | undefined
@@ -689,7 +775,7 @@ describe('McpService.discoverTools per-server caching', () => {
689775

690776
vi.setSystemTime(new Date('2026-02-01T00:00:02.000Z'))
691777
resolveOlder?.([tool('old-tool', 'mcp-a')])
692-
await expect(older).resolves.toEqual([tool('old-tool', 'mcp-a')])
778+
await expect(older).resolves.toEqual([])
693779

694780
vi.setSystemTime(new Date('2026-02-01T00:00:03.000Z'))
695781
resolveNewer?.([tool('new-tool', 'mcp-a')])
@@ -699,7 +785,7 @@ describe('McpService.discoverTools per-server caching', () => {
699785
.map(([update]) => update)
700786
.filter((update) => update.connectionStatus === 'connected')
701787
.map((update) => update.lastToolsRefresh)
702-
expect(publishedRefreshTimes).toEqual([olderStartedAt, newerStartedAt])
788+
expect(publishedRefreshTimes).toEqual([newerStartedAt])
703789
} finally {
704790
vi.useRealTimers()
705791
}

apps/sim/lib/mcp/service.ts

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function getTimestampMillisecondBounds(timestamp: string): {
6666
}
6767

6868
const FAILURE_CACHE_SENTINEL: McpTool[] = []
69+
const CACHE_MUTATION_BEGIN_ATTEMPTS = 2
6970

7071
type DiscoveryOutcome =
7172
| { kind: 'cached'; tools: McpTool[] }
@@ -511,41 +512,11 @@ class McpService {
511512
deleteKeys: string[]
512513
): Promise<boolean> {
513514
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
515+
// An unordered fallback cannot safely publish discovery state. An older
516+
// ordered mutation could overwrite it and then lose the database CAS,
517+
// while an unguarded delete could erase a newer publisher's result.
518+
// Fail closed so cache and database publication remain one ordered unit.
519+
return false
549520
}
550521
try {
551522
return await this.cacheAdapter.applyMutationIfCurrent(
@@ -559,7 +530,7 @@ class McpService {
559530
workspaceId,
560531
error: getMcpSafeErrorDiagnostics(error),
561532
})
562-
return true
533+
return false
563534
}
564535
}
565536

@@ -614,18 +585,47 @@ class McpService {
614585
serverId: string
615586
): Promise<CacheMutation | null> {
616587
const scopeKey = serverCacheKey(workspaceId, serverId)
617-
try {
618-
return { scopeKey, id: await this.cacheAdapter.beginMutation(scopeKey) }
619-
} catch (error) {
620-
logger.warn(`Failed to order cache mutation for server ${serverId}`, {
621-
error: getMcpSafeErrorDiagnostics(error),
588+
let lastError: unknown
589+
for (let attempt = 0; attempt < CACHE_MUTATION_BEGIN_ATTEMPTS; attempt++) {
590+
try {
591+
return { scopeKey, id: await this.cacheAdapter.beginMutation(scopeKey) }
592+
} catch (error) {
593+
lastError = error
594+
}
595+
}
596+
logger.warn(`Failed to order cache mutation for server ${serverId}`, {
597+
attempts: CACHE_MUTATION_BEGIN_ATTEMPTS,
598+
error: getMcpSafeErrorDiagnostics(lastError),
599+
})
600+
return null
601+
}
602+
603+
private async bestEffortInvalidateServerCache(
604+
workspaceId: string,
605+
serverId: string
606+
): Promise<void> {
607+
const keys = [serverCacheKey(workspaceId, serverId), failureCacheKey(workspaceId, serverId)]
608+
const results = await Promise.allSettled(keys.map((key) => this.cacheAdapter.delete(key)))
609+
const failures = results.flatMap((result) =>
610+
result.status === 'rejected' ? [getMcpSafeErrorDiagnostics(result.reason)] : []
611+
)
612+
if (failures.length > 0) {
613+
logger.warn(`Failed best-effort cache invalidation for server ${serverId}`, {
614+
workspaceId,
615+
failures,
622616
})
623-
return null
624617
}
625618
}
626619

627620
private async invalidateServerCache(workspaceId: string, serverId: string): Promise<void> {
628621
const mutation = await this.beginServerCacheMutation(workspaceId, serverId)
622+
if (!mutation) {
623+
// During a total cache-backend outage there is no cross-process token we
624+
// can advance, so this can only be best effort. Deleting both keys still
625+
// prevents stale reads whenever the backend accepts ordinary commands.
626+
await this.bestEffortInvalidateServerCache(workspaceId, serverId)
627+
return
628+
}
629629
await this.applyServerCacheMutation(workspaceId, serverId, mutation, null, [
630630
serverCacheKey(workspaceId, serverId),
631631
failureCacheKey(workspaceId, serverId),

0 commit comments

Comments
 (0)