Skip to content

Commit ce6d0f6

Browse files
committed
Address PR review feedback (#5754)
- preserve typed static-header authentication failures\n- order discovery publication by start time\n- atomically publish tools and failure cache state
1 parent aa654cd commit ce6d0f6

10 files changed

Lines changed: 358 additions & 142 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,13 @@ describe('McpClient notification handler', () => {
277277
securityPolicy: { requireConsent: false, auditLevel: 'basic' },
278278
})
279279

280-
await expect(client.connect()).rejects.toThrow('Static token rejected')
280+
await expect(client.connect()).rejects.toBeInstanceOf(UnauthorizedError)
281281

282282
expect(mockLogger.error).toHaveBeenCalledWith(
283283
expect.stringContaining('Failed to connect'),
284284
expect.objectContaining({ outcome: 'unauthorized' })
285285
)
286+
expect(client.getStatus().lastError).toBe('Authentication failed')
286287
})
287288

288289
it('logs tools/list failures without echoed credentials or session identifiers', async () => {

apps/sim/lib/mcp/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ export class McpClient {
207207
this.connectionStatus.lastError = undefined
208208
throw error
209209
}
210+
if (error instanceof UnauthorizedError) {
211+
this.connectionStatus.lastError = 'Authentication failed'
212+
throw error
213+
}
210214
this.connectionStatus.lastError = errorMessage
211215
throw new McpConnectionError(errorMessage, this.config.name)
212216
}

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

Lines changed: 143 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,24 @@ const {
7070
cacheStore.delete(key)
7171
return true
7272
}),
73+
applyMutationIfCurrent: vi.fn(
74+
async (
75+
scopeKey: string,
76+
mutationId: number,
77+
setEntry: { key: string; tools: unknown[]; ttlMs: number } | null,
78+
deleteKeys: string[]
79+
) => {
80+
if (cacheMutations.get(scopeKey) !== mutationId) return false
81+
if (setEntry) {
82+
cacheStore.set(setEntry.key, {
83+
tools: setEntry.tools,
84+
expiry: Date.now() + setEntry.ttlMs,
85+
})
86+
}
87+
for (const key of deleteKeys) cacheStore.delete(key)
88+
return true
89+
}
90+
),
7391
clear: vi.fn(async () => {
7492
cacheStore.clear()
7593
for (const scopeKey of cacheMutations.keys()) {
@@ -371,7 +389,7 @@ describe('McpService.discoverTools per-server caching', () => {
371389
statusConfig: { consecutiveFailures: 0, lastSuccessfulDiscovery: null },
372390
}),
373391
])
374-
mockListTools.mockRejectedValueOnce(
392+
mockConnect.mockRejectedValueOnce(
375393
new UnauthorizedError(`Rejected Authorization: ${reflectedCredential}`)
376394
)
377395

@@ -386,19 +404,23 @@ describe('McpService.discoverTools per-server caching', () => {
386404
statusConfig: { consecutiveFailures: 1, lastSuccessfulDiscovery: null },
387405
})
388406
)
389-
expect(mockCacheAdapter.setIfCurrentMutation).toHaveBeenCalledWith(
407+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledWith(
390408
`workspace:${WORKSPACE_ID}:server:mcp-a`,
391409
expect.any(Number),
392-
`workspace:${WORKSPACE_ID}:server:mcp-a:failure`,
393-
[],
394-
expect.any(Number)
410+
{
411+
key: `workspace:${WORKSPACE_ID}:server:mcp-a:failure`,
412+
tools: [],
413+
ttlMs: expect.any(Number),
414+
},
415+
[`workspace:${WORKSPACE_ID}:server:mcp-a`]
395416
)
396417
})
397418
expect(JSON.stringify(mockUpdateSet.mock.calls)).not.toContain(reflectedCredential)
398-
expect(JSON.stringify(mockCacheAdapter.setIfCurrentMutation.mock.calls)).not.toContain(
419+
expect(JSON.stringify(mockCacheAdapter.applyMutationIfCurrent.mock.calls)).not.toContain(
399420
reflectedCredential
400421
)
401422
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
423+
expect(mockListTools).not.toHaveBeenCalled()
402424

403425
mockListTools.mockClear()
404426
const second = await mcpService.discoverTools(USER_ID, WORKSPACE_ID)
@@ -421,12 +443,11 @@ describe('McpService.discoverTools per-server caching', () => {
421443
})
422444
)
423445
})
424-
expect(mockCacheAdapter.setIfCurrentMutation).not.toHaveBeenCalledWith(
446+
expect(mockCacheAdapter.applyMutationIfCurrent).not.toHaveBeenCalledWith(
425447
expect.anything(),
426448
expect.anything(),
427-
`workspace:${WORKSPACE_ID}:server:mcp-a:failure`,
428-
[],
429-
expect.any(Number)
449+
expect.objectContaining({ key: `workspace:${WORKSPACE_ID}:server:mcp-a:failure` }),
450+
expect.anything()
430451
)
431452

432453
mockResolveEnvVars.mockClear()
@@ -543,13 +564,107 @@ describe('McpService.discoverTools per-server caching', () => {
543564
const tools = await mcpService.discoverTools(USER_ID, WORKSPACE_ID, true)
544565

545566
expect(tools).toEqual([])
546-
expect(mockCacheAdapter.setIfCurrentMutation).not.toHaveBeenCalledWith(
547-
expect.anything(),
567+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledWith(
548568
expect.anything(),
549-
`workspace:${WORKSPACE_ID}:server:mcp-a`,
550569
expect.anything(),
551-
expect.any(Number)
570+
{
571+
key: `workspace:${WORKSPACE_ID}:server:mcp-a`,
572+
tools: [tool('stale-tool', 'mcp-a')],
573+
ttlMs: expect.any(Number),
574+
},
575+
[`workspace:${WORKSPACE_ID}:server:mcp-a:failure`]
552576
)
577+
expect(cacheStore.has(`workspace:${WORKSPACE_ID}:server:mcp-a`)).toBe(false)
578+
})
579+
580+
it('orders status publication by discovery start when an older run completes first', async () => {
581+
vi.useFakeTimers({ toFake: ['Date'] })
582+
try {
583+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
584+
mockCacheAdapter.beginMutation
585+
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
586+
.mockRejectedValueOnce(new Error('cache ordering unavailable'))
587+
588+
let resolveOlder: ((tools: ReturnType<typeof tool>[]) => void) | undefined
589+
let resolveNewer: ((tools: ReturnType<typeof tool>[]) => void) | undefined
590+
mockListTools
591+
.mockReturnValueOnce(
592+
new Promise((resolve) => {
593+
resolveOlder = resolve
594+
})
595+
)
596+
.mockReturnValueOnce(
597+
new Promise((resolve) => {
598+
resolveNewer = resolve
599+
})
600+
)
601+
602+
const olderStartedAt = new Date('2026-02-01T00:00:00.000Z')
603+
vi.setSystemTime(olderStartedAt)
604+
const older = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, false)
605+
await vi.waitFor(() => expect(mockListTools).toHaveBeenCalledTimes(1))
606+
607+
const newerStartedAt = new Date('2026-02-01T00:00:01.000Z')
608+
vi.setSystemTime(newerStartedAt)
609+
const newer = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, true)
610+
await vi.waitFor(() => expect(mockListTools).toHaveBeenCalledTimes(2))
611+
612+
vi.setSystemTime(new Date('2026-02-01T00:00:02.000Z'))
613+
resolveOlder?.([tool('old-tool', 'mcp-a')])
614+
await expect(older).resolves.toEqual([tool('old-tool', 'mcp-a')])
615+
616+
vi.setSystemTime(new Date('2026-02-01T00:00:03.000Z'))
617+
resolveNewer?.([tool('new-tool', 'mcp-a')])
618+
await expect(newer).resolves.toEqual([tool('new-tool', 'mcp-a')])
619+
620+
const publishedRefreshTimes = mockUpdateSet.mock.calls
621+
.map(([update]) => update)
622+
.filter((update) => update.connectionStatus === 'connected')
623+
.map((update) => update.lastToolsRefresh)
624+
expect(publishedRefreshTimes).toEqual([olderStartedAt, newerStartedAt])
625+
} finally {
626+
vi.useRealTimers()
627+
}
628+
})
629+
630+
it('does not write status after its cache mutation is superseded', async () => {
631+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
632+
633+
let resolveList: ((tools: ReturnType<typeof tool>[]) => void) | undefined
634+
mockListTools.mockReturnValueOnce(
635+
new Promise((resolve) => {
636+
resolveList = resolve
637+
})
638+
)
639+
640+
let releaseOlderCacheMutation: (() => void) | undefined
641+
const olderCacheMutationGate = new Promise<void>((resolve) => {
642+
releaseOlderCacheMutation = resolve
643+
})
644+
const defaultApply = mockCacheAdapter.applyMutationIfCurrent.getMockImplementation()
645+
mockCacheAdapter.applyMutationIfCurrent.mockImplementationOnce(
646+
async (
647+
scopeKey: string,
648+
mutationId: number,
649+
setEntry: { key: string; tools: unknown[]; ttlMs: number } | null,
650+
deleteKeys: string[]
651+
) => {
652+
await olderCacheMutationGate
653+
return defaultApply?.(scopeKey, mutationId, setEntry, deleteKeys) ?? false
654+
}
655+
)
656+
657+
const discovery = mcpService.discoverServerTools(USER_ID, 'mcp-a', WORKSPACE_ID, false)
658+
await vi.waitFor(() => expect(mockListTools).toHaveBeenCalledTimes(1))
659+
resolveList?.([tool('superseded-tool', 'mcp-a')])
660+
await vi.waitFor(() => expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenCalledTimes(1))
661+
662+
await mockCacheAdapter.beginMutation(`workspace:${WORKSPACE_ID}:server:mcp-a`)
663+
releaseOlderCacheMutation?.()
664+
665+
await expect(discovery).resolves.toEqual([])
666+
expect(mockUpdateSet).not.toHaveBeenCalled()
667+
expect(cacheStore.has(`workspace:${WORKSPACE_ID}:server:mcp-a`)).toBe(false)
553668
})
554669

555670
it('waits for bulk discovery status publication before returning', async () => {
@@ -592,11 +707,16 @@ describe('McpService.discoverTools per-server caching', () => {
592707
const olderFailureCacheGate = new Promise<void>((resolve) => {
593708
releaseOlderFailureCache = resolve
594709
})
595-
const defaultSet = mockCacheAdapter.setIfCurrentMutation.getMockImplementation()
596-
mockCacheAdapter.setIfCurrentMutation.mockImplementationOnce(
597-
async (scopeKey: string, mutationId: number, key: string, tools: unknown[], ttl: number) => {
598-
if (key.endsWith(':failure')) await olderFailureCacheGate
599-
return defaultSet?.(scopeKey, mutationId, key, tools, ttl) ?? false
710+
const defaultApply = mockCacheAdapter.applyMutationIfCurrent.getMockImplementation()
711+
mockCacheAdapter.applyMutationIfCurrent.mockImplementationOnce(
712+
async (
713+
scopeKey: string,
714+
mutationId: number,
715+
setEntry: { key: string; tools: unknown[]; ttlMs: number } | null,
716+
deleteKeys: string[]
717+
) => {
718+
await olderFailureCacheGate
719+
return defaultApply?.(scopeKey, mutationId, setEntry, deleteKeys) ?? false
600720
}
601721
)
602722

@@ -651,7 +771,7 @@ describe('McpService.discoverTools per-server caching', () => {
651771
})
652772
)
653773
expect(JSON.stringify(mockUpdateSet.mock.calls)).not.toContain(reflectedCredential)
654-
expect(JSON.stringify(mockCacheAdapter.setIfCurrentMutation.mock.calls)).not.toContain(
774+
expect(JSON.stringify(mockCacheAdapter.applyMutationIfCurrent.mock.calls)).not.toContain(
655775
reflectedCredential
656776
)
657777
expect(JSON.stringify(mockLogger?.warn.mock.calls)).not.toContain(reflectedCredential)
@@ -677,12 +797,11 @@ describe('McpService.discoverTools per-server caching', () => {
677797
lastError: null,
678798
})
679799
)
680-
expect(mockCacheAdapter.setIfCurrentMutation).not.toHaveBeenCalledWith(
800+
expect(mockCacheAdapter.applyMutationIfCurrent).not.toHaveBeenCalledWith(
681801
expect.anything(),
682802
expect.anything(),
683-
`workspace:${WORKSPACE_ID}:server:mcp-a:failure`,
684-
[],
685-
expect.any(Number)
803+
expect.objectContaining({ key: `workspace:${WORKSPACE_ID}:server:mcp-a:failure` }),
804+
expect.anything()
686805
)
687806

688807
mockResolveEnvVars.mockClear()

0 commit comments

Comments
 (0)