Skip to content

Commit 75bb19c

Browse files
committed
Address PR review feedback (#5754)
- Retry successful status publication across metadata-only races - Preserve superseded and unavailable discovery semantics
1 parent 8dc843a commit 75bb19c

2 files changed

Lines changed: 92 additions & 66 deletions

File tree

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

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -875,26 +875,42 @@ describe('McpService.discoverTools per-server caching', () => {
875875
expect(cacheStore.has(serverKey)).toBe(false)
876876
})
877877

878-
it('keeps valid live tools when a metadata-only edit wins the database CAS', async () => {
878+
it('publishes successful discovery after repeated metadata-only renames win the database CAS', async () => {
879879
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
880-
mockGetWorkspaceServersRows.mockResolvedValueOnce([dbRow('mcp-a', 'A')]).mockResolvedValueOnce([
881-
dbRow('mcp-a', 'Renamed A', {
882-
description: 'Updated display-only description',
883-
updatedAt: new Date('2026-01-01T00:00:01Z'),
884-
}),
885-
])
886-
mockListTools.mockResolvedValueOnce([tool('still-valid', 'mcp-a')])
887-
mockUpdateSet.mockReturnValueOnce({
888-
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),
880+
const original = dbRow('mcp-a', 'A')
881+
const renamed = dbRow('mcp-a', 'Renamed A', {
882+
description: 'Updated display-only description',
883+
updatedAt: new Date('2026-01-01T00:00:01Z'),
889884
})
885+
const renamedAgain = dbRow('mcp-a', 'Renamed A Again', {
886+
description: 'Another display-only description',
887+
updatedAt: new Date('2026-01-01T00:00:02Z'),
888+
})
889+
mockGetWorkspaceServersRows
890+
.mockResolvedValueOnce([original])
891+
.mockResolvedValueOnce([renamed])
892+
.mockResolvedValueOnce([renamedAgain])
893+
.mockResolvedValue([renamedAgain])
894+
mockListTools.mockResolvedValueOnce([tool('still-valid', 'mcp-a')])
895+
mockUpdateReturning
896+
.mockResolvedValueOnce([])
897+
.mockResolvedValueOnce([])
898+
.mockResolvedValueOnce([{ id: 'mcp-a' }])
890899

891900
await expect(
892901
mcpService.discoverServerToolsWithMetadata(USER_ID, 'mcp-a', WORKSPACE_ID, true)
893902
).resolves.toEqual({
894903
tools: [tool('still-valid', 'mcp-a')],
895-
state: 'unavailable',
904+
state: 'published',
896905
})
897906

907+
const successfulStatusWrites = mockUpdateSet.mock.calls
908+
.map(([update]) => update)
909+
.filter((update) => update.connectionStatus === 'connected')
910+
expect(successfulStatusWrites).toHaveLength(3)
911+
expect(successfulStatusWrites.at(-1)).toEqual(
912+
expect.objectContaining({ connectionStatus: 'connected', toolCount: 1 })
913+
)
898914
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('still-valid', 'mcp-a')])
899915
expect(mockCacheAdapter.applyMutationIfCurrent).toHaveBeenLastCalledWith(
900916
serverKey,
@@ -904,6 +920,26 @@ describe('McpService.discoverTools per-server caching', () => {
904920
)
905921
})
906922

923+
it('keeps valid live tools when successful status publication retries are exhausted', async () => {
924+
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
925+
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
926+
mockListTools.mockResolvedValueOnce([tool('still-valid', 'mcp-a')])
927+
mockUpdateReturning.mockResolvedValue([])
928+
929+
await expect(
930+
mcpService.discoverServerToolsWithMetadata(USER_ID, 'mcp-a', WORKSPACE_ID, true)
931+
).resolves.toEqual({
932+
tools: [tool('still-valid', 'mcp-a')],
933+
state: 'unavailable',
934+
})
935+
936+
const successfulStatusWrites = mockUpdateSet.mock.calls
937+
.map(([update]) => update)
938+
.filter((update) => update.connectionStatus === 'connected')
939+
expect(successfulStatusWrites).toHaveLength(4)
940+
expect(cacheStore.get(serverKey)?.tools).toEqual([tool('still-valid', 'mcp-a')])
941+
})
942+
907943
it('publishes a failed discovery after repeated metadata-only renames win the database CAS', async () => {
908944
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
909945
const failureKey = `${serverKey}:failure`

apps/sim/lib/mcp/service.ts

Lines changed: 45 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const STATUS_UPDATE_CAS_ATTEMPTS = 3
7272
const STATUS_METADATA_RACE_RETRY_ATTEMPTS = 3
7373

7474
type CacheMutationResult = 'applied' | 'superseded' | 'unavailable'
75-
type SuccessfulPublicationResult = 'published' | Exclude<CacheMutationResult, 'applied'>
75+
type StatusPublicationResult = 'published' | Exclude<CacheMutationResult, 'applied'>
7676

7777
export type McpServerDiscoveryState =
7878
| 'cached'
@@ -763,7 +763,7 @@ class McpService {
763763
config: McpServerConfig,
764764
mutation: CacheMutation | null,
765765
tools: McpTool[]
766-
): Promise<SuccessfulPublicationResult> {
766+
): Promise<StatusPublicationResult> {
767767
const cacheApplied = await this.applyServerCacheMutation(
768768
workspaceId,
769769
config.id,
@@ -786,75 +786,64 @@ class McpService {
786786
})
787787
if (statusApplied) return 'published'
788788

789-
// A connection-config edit advances mutation ownership, while metadata-only
790-
// edits only bump updatedAt. Probe ownership without changing cache state:
791-
// superseded results must reload the winner, but metadata races can keep
792-
// and return these valid live tools without publishing stale DB status.
793-
const ownership = await this.applyServerCacheMutation(
789+
const retryResult = await this.retryStatusPublicationAfterMetadataRace(
794790
workspaceId,
795-
config.id,
791+
config,
796792
mutation,
797-
null,
798-
[]
793+
(configUpdatedAt) =>
794+
this.updateServerStatus(config.id, workspaceId, {
795+
outcome: 'connected',
796+
toolCount: tools.length,
797+
configUpdatedAt,
798+
publicationOrder: new Date(mutation.id),
799+
})
799800
)
800-
if (ownership === 'superseded') return 'superseded'
801-
802-
const currentConfig = await this.getServerConfig(config.id, workspaceId)
803-
if (
804-
!currentConfig ||
805-
!config.discoveryRevision ||
806-
currentConfig.discoveryRevision !== config.discoveryRevision
807-
) {
801+
if (retryResult === 'superseded') {
808802
await this.applyServerCacheMutation(workspaceId, config.id, mutation, null, [
809803
serverCacheKey(workspaceId, config.id),
810804
failureCacheKey(workspaceId, config.id),
811805
])
812-
return 'superseded'
813806
}
814-
return 'unavailable'
815-
}
816-
817-
private async getCurrentConfigForOwnedDiscoveryMutation(
818-
workspaceId: string,
819-
config: McpServerConfig,
820-
mutation: CacheMutation
821-
): Promise<McpServerConfig | null> {
822-
const ownership = await this.applyServerCacheMutation(
823-
workspaceId,
824-
config.id,
825-
mutation,
826-
null,
827-
[]
828-
)
829-
if (ownership !== 'applied') return null
830-
831-
const currentConfig = await this.getServerConfig(config.id, workspaceId)
832-
if (
833-
!currentConfig ||
834-
!config.discoveryRevision ||
835-
currentConfig.discoveryRevision !== config.discoveryRevision
836-
) {
837-
return null
838-
}
839-
return currentConfig
807+
return retryResult
840808
}
841809

842810
private async retryStatusPublicationAfterMetadataRace(
843811
workspaceId: string,
844812
config: McpServerConfig,
845813
mutation: CacheMutation,
846814
publishStatus: (configUpdatedAt: string) => Promise<boolean>
847-
): Promise<boolean> {
815+
): Promise<StatusPublicationResult> {
848816
for (let attempt = 0; attempt < STATUS_METADATA_RACE_RETRY_ATTEMPTS; attempt++) {
849-
const currentConfig = await this.getCurrentConfigForOwnedDiscoveryMutation(
817+
const ownership = await this.applyServerCacheMutation(
850818
workspaceId,
851-
config,
852-
mutation
819+
config.id,
820+
mutation,
821+
null,
822+
[]
853823
)
854-
if (!currentConfig) return false
855-
if (await publishStatus(currentConfig.updatedAt!)) return true
824+
if (ownership === 'superseded') return 'superseded'
825+
if (ownership === 'unavailable') return 'unavailable'
826+
827+
let currentConfig: McpServerConfig | null
828+
try {
829+
currentConfig = await this.getServerConfig(config.id, workspaceId)
830+
} catch (error) {
831+
logger.warn(`Failed to reread server ${config.id} for status publication`, {
832+
workspaceId,
833+
error: getMcpSafeErrorDiagnostics(error),
834+
})
835+
return 'unavailable'
836+
}
837+
if (
838+
!currentConfig ||
839+
!config.discoveryRevision ||
840+
currentConfig.discoveryRevision !== config.discoveryRevision
841+
) {
842+
return 'superseded'
843+
}
844+
if (await publishStatus(currentConfig.updatedAt!)) return 'published'
856845
}
857-
return false
846+
return 'unavailable'
858847
}
859848

860849
private async publishFailedDiscovery(
@@ -891,7 +880,7 @@ class McpService {
891880
// that affects discovery. Keep the failed publication only while this
892881
// mutation still owns the cache and the connection configuration is
893882
// unchanged, then retry the status CAS against the row's current token.
894-
const retriedStatusApplied = await this.retryStatusPublicationAfterMetadataRace(
883+
const retryResult = await this.retryStatusPublicationAfterMetadataRace(
895884
workspaceId,
896885
config,
897886
mutation,
@@ -903,7 +892,7 @@ class McpService {
903892
publicationOrder: new Date(mutation.id),
904893
})
905894
)
906-
if (retriedStatusApplied) return true
895+
if (retryResult === 'published') return true
907896

908897
// Do not leave a negative-cache entry for a failure that lost the
909898
// database publication CAS.
@@ -938,13 +927,14 @@ class McpService {
938927
// Metadata-only edits share discovery state with the original row. Retry
939928
// only while this mutation still owns the cache and the discovery-relevant
940929
// configuration has not changed.
941-
return this.retryStatusPublicationAfterMetadataRace(
930+
const retryResult = await this.retryStatusPublicationAfterMetadataRace(
942931
workspaceId,
943932
config,
944933
mutation,
945934
(configUpdatedAt) =>
946935
this.markServerOauthPending(config.id, workspaceId, configUpdatedAt, new Date(mutation.id))
947936
)
937+
return retryResult === 'published'
948938
}
949939

950940
async discoverTools(

0 commit comments

Comments
 (0)