Skip to content

Commit 405e858

Browse files
committed
Keep MCP failure counts and live results consistent (#5754)
Recompute consecutive failures after status CAS conflicts, reload winning cached tools for superseded discoveries, and return live tools without unordered publication when cache ordering is unavailable.
1 parent 9f5d554 commit 405e858

2 files changed

Lines changed: 167 additions & 74 deletions

File tree

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

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ describe('McpService.discoverTools per-server caching', () => {
556556
await expect(newer).resolves.toEqual([tool('new-tool', 'mcp-a')])
557557

558558
resolveOlder?.([tool('old-tool', 'mcp-a')])
559-
await expect(older).resolves.toEqual([])
559+
await expect(older).resolves.toEqual([tool('new-tool', 'mcp-a')])
560560

561561
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(3)
562562
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('new-tool', 'mcp-a')])
@@ -613,7 +613,7 @@ describe('McpService.discoverTools per-server caching', () => {
613613
}
614614
})
615615

616-
it('fails a newer discovery closed while an older mutation remains the owner', async () => {
616+
it('returns live tools without publishing when cache ownership is unavailable', async () => {
617617
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
618618
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
619619

@@ -633,7 +633,7 @@ describe('McpService.discoverTools per-server caching', () => {
633633
.mockRejectedValueOnce(new Error('ordering unavailable'))
634634
.mockRejectedValueOnce(new Error('ordering unavailable'))
635635
const newer = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
636-
await expect(newer).resolves.toEqual([])
636+
await expect(newer).resolves.toEqual([tool('unowned-new-tool', 'mcp-a')])
637637

638638
resolveOlder?.([tool('owned-old-tool', 'mcp-a')])
639639
await expect(older).resolves.toEqual([tool('owned-old-tool', 'mcp-a')])
@@ -645,7 +645,7 @@ describe('McpService.discoverTools per-server caching', () => {
645645
expect(mockUpdateSet).toHaveBeenCalledTimes(1)
646646
})
647647

648-
it('fails discovery publication closed when mutation ownership stays unavailable', async () => {
648+
it('returns live tools but skips publication when mutation ownership stays unavailable', async () => {
649649
const reflectedCredential = 'opaque-cache-provider-message'
650650
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
651651
mockCacheAdapter.beginMutation
@@ -655,7 +655,7 @@ describe('McpService.discoverTools per-server caching', () => {
655655

656656
await expect(
657657
mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
658-
).resolves.toEqual([])
658+
).resolves.toEqual([tool('a1', 'mcp-a')])
659659

660660
expect(mockCacheAdapter.applyMutationIfCurrent).not.toHaveBeenCalled()
661661
expect(mockCacheAdapter.set).not.toHaveBeenCalled()
@@ -664,15 +664,30 @@ describe('McpService.discoverTools per-server caching', () => {
664664
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
665665
})
666666

667-
it('fails discovery publication closed when the atomic cache transition fails', async () => {
667+
it('returns bulk live tools without publication when mutation ownership is unavailable', async () => {
668+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
669+
mockCacheAdapter.beginMutation
670+
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
671+
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
672+
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
673+
674+
await expect(mcpService.discoverTools(USER_ID, WORKSPACE_ID, true)).resolves.toEqual([
675+
tool('a1', 'mcp-a'),
676+
])
677+
678+
expect(mockCacheAdapter.applyMutationIfCurrent).not.toHaveBeenCalled()
679+
expect(mockUpdateSet).not.toHaveBeenCalled()
680+
})
681+
682+
it('returns live tools but skips publication when the atomic cache transition fails', async () => {
668683
const reflectedCredential = 'opaque-atomic-cache-provider-message'
669684
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
670685
mockCacheAdapter.applyMutationIfCurrent.mockRejectedValueOnce(new Error(reflectedCredential))
671686
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
672687

673688
await expect(
674689
mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
675-
).resolves.toEqual([])
690+
).resolves.toEqual([tool('a1', 'mcp-a')])
676691

677692
expect(mockCacheAdapter.beginMutation).toHaveBeenCalledTimes(1)
678693
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledTimes(1)
@@ -1049,6 +1064,45 @@ describe('McpService.discoverTools per-server caching', () => {
10491064
)
10501065
})
10511066

1067+
it('recomputes a failure count after a concurrent status update wins the CAS', async () => {
1068+
const beforeSuccess = dbRow('mcp-a', 'A', {
1069+
statusConfig: { consecutiveFailures: 2, lastSuccessfulDiscovery: null },
1070+
})
1071+
const afterSuccess = dbRow('mcp-a', 'A', {
1072+
statusConfig: {
1073+
consecutiveFailures: 0,
1074+
lastSuccessfulDiscovery: '2030-02-01T00:00:00.000Z',
1075+
},
1076+
})
1077+
mockGetWorkspaceServersRows
1078+
.mockResolvedValueOnce([beforeSuccess])
1079+
.mockResolvedValueOnce([beforeSuccess])
1080+
.mockResolvedValueOnce([afterSuccess])
1081+
mockUpdateReturning.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'mcp-a' }])
1082+
mockListTools.mockRejectedValueOnce(new Error('Connection refused'))
1083+
1084+
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
1085+
'Connection refused'
1086+
)
1087+
1088+
const failureUpdates = mockUpdateSet.mock.calls
1089+
.map(([update]) => update)
1090+
.filter((update) => update.lastError === 'Connection failed')
1091+
expect(failureUpdates).toEqual([
1092+
expect.objectContaining({
1093+
connectionStatus: 'error',
1094+
statusConfig: { consecutiveFailures: 3, lastSuccessfulDiscovery: null },
1095+
}),
1096+
expect.objectContaining({
1097+
connectionStatus: 'disconnected',
1098+
statusConfig: {
1099+
consecutiveFailures: 1,
1100+
lastSuccessfulDiscovery: '2030-02-01T00:00:00.000Z',
1101+
},
1102+
}),
1103+
])
1104+
})
1105+
10521106
it('persists OAuth-required discovery as disconnected without a failure error', async () => {
10531107
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
10541108
mockListTools.mockRejectedValueOnce(new McpOauthAuthorizationRequiredError('mcp-a', 'A'))
@@ -1068,12 +1122,13 @@ describe('McpService.discoverTools per-server caching', () => {
10681122
it('does not negative-cache a failure older than a successful discovery', async () => {
10691123
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
10701124
mockListTools.mockRejectedValueOnce(new Error('Older request failed'))
1071-
mockUpdateReturning.mockResolvedValueOnce([])
1125+
mockUpdateReturning.mockResolvedValue([])
10721126

10731127
await expect(mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)).rejects.toThrow(
10741128
'Older request failed'
10751129
)
10761130

1131+
mockUpdateReturning.mockResolvedValue([{ id: 'server-1' }])
10771132
mockListTools.mockResolvedValueOnce([tool('a1', 'mcp-a')])
10781133
const tools = await mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID)
10791134

0 commit comments

Comments
 (0)