Skip to content

Commit d4bdbe8

Browse files
committed
Verify MCP config revisions after status races (#5754)
Hash connection-affecting server fields and recheck the fresh revision after database CAS misses so metadata edits retain valid tools while URL, credential, and transport changes fail closed.
1 parent c1764a1 commit d4bdbe8

3 files changed

Lines changed: 80 additions & 16 deletions

File tree

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

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -796,20 +796,16 @@ describe('McpService.discoverTools per-server caching', () => {
796796
})
797797

798798
it('does not return or cache tools discovered from a stale server configuration', async () => {
799-
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
799+
mockGetWorkspaceServersRows.mockResolvedValueOnce([dbRow('mcp-a', 'A')]).mockResolvedValueOnce([
800+
dbRow('mcp-a', 'A', {
801+
url: 'https://changed-config.example.com/mcp',
802+
updatedAt: new Date('2026-01-01T00:00:01Z'),
803+
}),
804+
])
800805
mockListTools.mockResolvedValueOnce([tool('stale-tool', 'mcp-a')])
801806
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
802807
mockUpdateSet.mockReturnValueOnce({
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-
}),
808+
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),
813809
})
814810

815811
const tools = await mcpService.discoverTools(USER_ID, WORKSPACE_ID, true)
@@ -830,7 +826,12 @@ describe('McpService.discoverTools per-server caching', () => {
830826

831827
it('keeps valid live tools when a metadata-only edit wins the database CAS', async () => {
832828
const serverKey = `workspace:${WORKSPACE_ID}:server:mcp-a`
833-
mockGetWorkspaceServersRows.mockResolvedValue([dbRow('mcp-a', 'A')])
829+
mockGetWorkspaceServersRows.mockResolvedValueOnce([dbRow('mcp-a', 'A')]).mockResolvedValueOnce([
830+
dbRow('mcp-a', 'Renamed A', {
831+
description: 'Updated display-only description',
832+
updatedAt: new Date('2026-01-01T00:00:01Z'),
833+
}),
834+
])
834835
mockListTools.mockResolvedValueOnce([tool('still-valid', 'mcp-a')])
835836
mockUpdateSet.mockReturnValueOnce({
836837
where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }),

apps/sim/lib/mcp/service.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'node:crypto'
12
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
23
import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
@@ -90,6 +91,7 @@ type DiscoveryOutcome =
9091
kind: 'fetched'
9192
tools: McpTool[]
9293
resolvedConfig: McpServerConfig
94+
sourceConfig: McpServerConfig
9395
resolvedIP: string | null
9496
mutation: CacheMutation | null
9597
}
@@ -110,6 +112,42 @@ interface CacheMutation {
110112
id: number
111113
}
112114

115+
interface DiscoveryRevisionInput {
116+
transport: string | null
117+
url: string | null
118+
authType: string | null
119+
oauthClientId: string | null
120+
oauthClientSecret: string | null
121+
headers: unknown
122+
timeout: number | null
123+
retries: number | null
124+
enabled: boolean
125+
}
126+
127+
function getDiscoveryRevision(config: DiscoveryRevisionInput): string {
128+
const headers =
129+
config.headers && typeof config.headers === 'object' && !Array.isArray(config.headers)
130+
? Object.entries(config.headers as Record<string, unknown>).sort(([left], [right]) =>
131+
left.localeCompare(right)
132+
)
133+
: []
134+
return createHash('sha256')
135+
.update(
136+
JSON.stringify({
137+
transport: config.transport,
138+
url: config.url,
139+
authType: config.authType,
140+
oauthClientId: config.oauthClientId,
141+
oauthClientSecret: config.oauthClientSecret,
142+
headers,
143+
timeout: config.timeout,
144+
retries: config.retries,
145+
enabled: config.enabled,
146+
})
147+
)
148+
.digest('hex')
149+
}
150+
113151
type ServerStatusUpdate =
114152
| {
115153
outcome: 'connected'
@@ -275,6 +313,7 @@ class McpService {
275313
enabled: server.enabled,
276314
createdAt: server.createdAt.toISOString(),
277315
updatedAt: server.updatedAt.toISOString(),
316+
discoveryRevision: getDiscoveryRevision(server),
278317
}
279318
}
280319

@@ -305,6 +344,7 @@ class McpService {
305344
enabled: server.enabled,
306345
createdAt: server.createdAt.toISOString(),
307346
updatedAt: server.updatedAt.toISOString(),
347+
discoveryRevision: getDiscoveryRevision(server),
308348
}))
309349
.filter((config) => isMcpDomainAllowed(config.url))
310350
}
@@ -712,7 +752,21 @@ class McpService {
712752
null,
713753
[]
714754
)
715-
return ownership === 'superseded' ? 'superseded' : 'unavailable'
755+
if (ownership === 'superseded') return 'superseded'
756+
757+
const currentConfig = await this.getServerConfig(config.id, workspaceId)
758+
if (
759+
!currentConfig ||
760+
!config.discoveryRevision ||
761+
currentConfig.discoveryRevision !== config.discoveryRevision
762+
) {
763+
await this.applyServerCacheMutation(workspaceId, config.id, mutation, null, [
764+
serverCacheKey(workspaceId, config.id),
765+
failureCacheKey(workspaceId, config.id),
766+
])
767+
return 'superseded'
768+
}
769+
return 'unavailable'
716770
}
717771

718772
private async publishFailedDiscovery(
@@ -827,7 +881,14 @@ class McpService {
827881
logger.debug(
828882
`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`
829883
)
830-
return { kind: 'fetched', tools, resolvedConfig, resolvedIP, mutation }
884+
return {
885+
kind: 'fetched',
886+
tools,
887+
resolvedConfig,
888+
sourceConfig: config,
889+
resolvedIP,
890+
mutation,
891+
}
831892
} finally {
832893
await client.disconnect()
833894
}
@@ -861,7 +922,7 @@ class McpService {
861922
if (outcome.kind === 'fetched') {
862923
const publication = await this.publishSuccessfulDiscovery(
863924
workspaceId,
864-
outcome.resolvedConfig,
925+
outcome.sourceConfig,
865926
outcome.mutation,
866927
outcome.tools
867928
)
@@ -1038,7 +1099,7 @@ class McpService {
10381099
logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`)
10391100
const publication = await this.publishSuccessfulDiscovery(
10401101
workspaceId,
1041-
resolvedConfig,
1102+
config,
10421103
mutation,
10431104
tools
10441105
)

apps/sim/lib/mcp/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface McpServerConfig {
3030
statusConfig?: McpServerStatusConfig
3131
createdAt?: string
3232
updatedAt?: string
33+
/** Internal hash of fields that affect discovery; excludes display-only metadata. */
34+
discoveryRevision?: string
3335
}
3436

3537
export interface McpVersionInfo {

0 commit comments

Comments
 (0)