Skip to content

Commit b2d3e67

Browse files
fix(mcp): compare exact MCP paths and guard concurrent URL changes
1 parent c49185e commit b2d3e67

4 files changed

Lines changed: 64 additions & 8 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,29 @@ describe('MCP server lifecycle orchestration', () => {
261261
expect(mockRevokeOauthTokens).not.toHaveBeenCalled()
262262
})
263263

264+
it('refuses a non-admin save when the URL changed after it was checked', async () => {
265+
dbChainMockFns.limit.mockResolvedValueOnce([
266+
{
267+
url: 'https://example.com/mcp',
268+
authType: 'headers',
269+
headers: {},
270+
oauthClientId: null,
271+
oauthClientSecret: null,
272+
},
273+
])
274+
dbChainMockFns.returning.mockResolvedValueOnce([])
275+
276+
const result = await performUpdateMcpServer({
277+
workspaceId: 'workspace-1',
278+
userId: 'user-1',
279+
serverId: 'server-1',
280+
allowDestinationChange: false,
281+
url: 'https://example.com/mcp?token=rotated',
282+
})
283+
284+
expect(result).toMatchObject({ success: false, errorCode: 'conflict' })
285+
})
286+
264287
it('lets an admin point an existing server at a different host', async () => {
265288
dbChainMockFns.limit.mockResolvedValueOnce([
266289
{

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,11 +435,12 @@ export async function updateMcpServer(
435435

436436
if (!currentServer) return { success: false, error: 'Server not found', errorCode: 'not_found' }
437437

438+
const checkedUrl =
439+
!params.allowDestinationChange && params.url !== undefined ? currentServer.url : null
438440
if (
439-
!params.allowDestinationChange &&
441+
checkedUrl &&
440442
params.url !== undefined &&
441-
currentServer.url &&
442-
!isSameMcpServerDestination(currentServer.url, params.url)
443+
!isSameMcpServerDestination(checkedUrl, params.url)
443444
) {
444445
return {
445446
success: false,
@@ -505,7 +506,8 @@ export async function updateMcpServer(
505506
and(
506507
eq(mcpServers.id, params.serverId),
507508
eq(mcpServers.workspaceId, params.workspaceId),
508-
isNull(mcpServers.deletedAt)
509+
isNull(mcpServers.deletedAt),
510+
checkedUrl ? eq(mcpServers.url, checkedUrl) : undefined
509511
)
510512
)
511513
.returning()
@@ -518,7 +520,15 @@ export async function updateMcpServer(
518520
return updated
519521
})
520522

521-
if (!server) return { success: false, error: 'Server not found', errorCode: 'not_found' }
523+
if (!server) {
524+
return checkedUrl
525+
? {
526+
success: false,
527+
error: 'The MCP server URL changed while saving; reload and try again',
528+
errorCode: 'conflict',
529+
}
530+
: { success: false, error: 'Server not found', errorCode: 'not_found' }
531+
}
522532

523533
const shouldClearCache =
524534
urlChanged ||

‎apps/sim/lib/mcp/utils.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
generateManagedMcpConnectionId,
1111
generateMcpServerId,
1212
isManagedMcpConnectionId,
13+
isSameMcpServerDestination,
1314
parseMcpToolId,
1415
parseMcpToolTarget,
1516
} from './utils'
@@ -49,6 +50,21 @@ describe('generateMcpServerId', () => {
4950
})
5051
})
5152

53+
describe('isSameMcpServerDestination', () => {
54+
it('ignores only the query string and fragment', () => {
55+
const url = 'https://mcp.example.com/mcp'
56+
expect(isSameMcpServerDestination(url, `${url}?token=abc#x`)).toBe(true)
57+
expect(isSameMcpServerDestination(url, 'https://MCP.example.com/mcp')).toBe(true)
58+
})
59+
60+
it('treats a different host, path case, or trailing slash as a new destination', () => {
61+
const url = 'https://mcp.example.com/mcp'
62+
expect(isSameMcpServerDestination(url, 'https://other.example.com/mcp')).toBe(false)
63+
expect(isSameMcpServerDestination(url, 'https://mcp.example.com/MCP')).toBe(false)
64+
expect(isSameMcpServerDestination(url, `${url}/`)).toBe(false)
65+
})
66+
})
67+
5268
describe('categorizeError', () => {
5369
it.concurrent('returns 401 for McpOauthAuthorizationRequiredError via instanceof', () => {
5470
const error = new McpOauthAuthorizationRequiredError('mcp-a', 'A')

‎apps/sim/lib/mcp/utils.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,18 @@ export function generateMcpServerId(workspaceId: string, url: string): string {
244244
}
245245

246246
/**
247-
* Whether two URLs name the same MCP server destination — the origin and path
248-
* its id is derived from. Only the query string and fragment may differ.
247+
* Whether two URLs name the same MCP server destination: the same origin and
248+
* exact path. Only the query string and fragment may differ — paths can be
249+
* case-sensitive, so this is stricter than the id hash.
249250
*/
250251
export function isSameMcpServerDestination(a: string, b: string): boolean {
251-
return normalizeUrlForHashing(a) === normalizeUrlForHashing(b)
252+
try {
253+
const parsedA = new URL(a)
254+
const parsedB = new URL(b)
255+
return parsedA.origin === parsedB.origin && parsedA.pathname === parsedB.pathname
256+
} catch {
257+
return a === b
258+
}
252259
}
253260

254261
/**

0 commit comments

Comments
 (0)