Skip to content

Commit eb9b603

Browse files
committed
Preserve MCP tools across metadata races (#5754)
Probe mutation ownership after database CAS misses, keep valid live tools for metadata-only edits, and invalidate ownership for transport and auth-type changes.
1 parent 29bb919 commit eb9b603

3 files changed

Lines changed: 50 additions & 10 deletions

File tree

apps/sim/lib/mcp/orchestration/server-lifecycle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,8 @@ export async function performUpdateMcpServer(
384384
const shouldClearCache =
385385
urlChanged ||
386386
credsChanged ||
387+
params.transport !== undefined ||
388+
params.authType !== undefined ||
387389
params.enabled !== undefined ||
388390
params.headers !== undefined ||
389391
params.timeout !== undefined ||

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -798,8 +798,18 @@ describe('McpService.discoverTools per-server caching', () => {
798798
it('does not return or cache tools discovered from a stale server configuration', async () => {
799799
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
800800
mockListTools.mockResolvedValueOnce([tool('stale-tool', 'mcp-a')])
801+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
801802
mockUpdateSet.mockReturnValueOnce({
802-
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),
803+
where: vi.fn().mockReturnValue({
804+
returning: vi.fn().mockImplementation(async () => {
805+
// Connection-changing edits invalidate both cache entries and advance
806+
// mutation ownership after updating the database row.
807+
await mockCacheAdapter.beginMutation(serverKey)
808+
cacheStore.delete(serverKey)
809+
cacheStore.delete(`${serverKey}:failure`)
810+
return []
811+
}),
812+
}),
803813
})
804814

805815
const tools = await mcpService.discoverTools(USER_ID, WORKSPACE_ID, true)
@@ -815,7 +825,31 @@ describe('McpService.discoverTools per-server caching', () => {
815825
},
816826
[`workspace:${WORKSPACE_ID}:server:mcp-a:failure`]
817827
)
818-
expect(cacheStore.has(`workspace:${WORKSPACE_ID}:server:mcp-a`)).toBe(false)
828+
expect(cacheStore.has(serverKey)).toBe(false)
829+
})
830+
831+
it('keeps valid live tools when a metadata-only edit wins the database CAS', async () => {
832+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
833+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
834+
mockListTools.mockResolvedValueOnce([tool('still-valid', 'mcp-a')])
835+
mockUpdateSet.mockReturnValueOnce({
836+
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),
837+
})
838+
839+
await expect(
840+
mcpService.discoverServerToolsWithMetadata(USER_ID, 'mcp-a', WORKSPACE_ID, true)
841+
).resolves.toEqual({
842+
tools: [tool('still-valid', 'mcp-a')],
843+
state: 'unavailable',
844+
})
845+
846+
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('still-valid', 'mcp-a')])
847+
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenLastCalledWith(
848+
serverKey,
849+
expect.any(Number),
850+
null,
851+
[]
852+
)
819853
})
820854

821855
it('supersedes an older discovery before it can publish status', async () => {

apps/sim/lib/mcp/service.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -701,14 +701,18 @@ class McpService {
701701
})
702702
if (statusApplied) return 'published'
703703

704-
// A config change or newer discovery won the database CAS after the cache
705-
// mutation. Remove this result only if its mutation is still current; a
706-
// newer cache publisher must never be disturbed.
707-
await this.applyServerCacheMutation(workspaceId, config.id, mutation, null, [
708-
serverCacheKey(workspaceId, config.id),
709-
failureCacheKey(workspaceId, config.id),
710-
])
711-
return 'superseded'
704+
// A connection-config edit advances mutation ownership, while metadata-only
705+
// edits only bump updatedAt. Probe ownership without changing cache state:
706+
// superseded results must reload the winner, but metadata races can keep
707+
// and return these valid live tools without publishing stale DB status.
708+
const ownership = await this.applyServerCacheMutation(
709+
workspaceId,
710+
config.id,
711+
mutation,
712+
null,
713+
[]
714+
)
715+
return ownership === 'superseded' ? 'superseded' : 'unavailable'
712716
}
713717

714718
private async publishFailedDiscovery(

0 commit comments

Comments
 (0)