Skip to content

Commit 850a0e1

Browse files
fix(mcp): guard re-registration against concurrent URL changes
1 parent 1fce014 commit 850a0e1

4 files changed

Lines changed: 79 additions & 60 deletions

File tree

‎apps/sim/app/api/mcp/servers/[id]/route.test.ts‎

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { resetDbChainMock } from '@sim/testing'
22
import type { NextRequest } from 'next/server'
33
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
44

5-
const { mockPermission, mockPerformUpdateMcpServer } = vi.hoisted(() => ({
6-
mockPermission: { current: 'admin' },
5+
const { mockPerformUpdateMcpServer } = vi.hoisted(() => ({
76
mockPerformUpdateMcpServer: vi.fn(),
87
}))
98

@@ -29,7 +28,7 @@ vi.mock('@/lib/mcp/middleware', () => ({
2928
userEmail: 'test@example.com',
3029
workspaceId: 'workspace-1',
3130
requestId: 'request-1',
32-
permission: mockPermission.current,
31+
permission: 'admin',
3332
},
3433
routeContext
3534
),
@@ -55,7 +54,6 @@ function updateRequest() {
5554
describe('MCP server PATCH route', () => {
5655
beforeEach(() => {
5756
resetDbChainMock()
58-
mockPermission.current = 'admin'
5957
})
6058

6159
afterAll(() => {
@@ -89,22 +87,4 @@ describe('MCP server PATCH route', () => {
8987
expect(body.data.server.oauthClientSecret).toBeUndefined()
9088
expect(body.data.server.hasOauthClientSecret).toBe(true)
9189
})
92-
93-
it.each([
94-
['admin', true],
95-
['write', false],
96-
])('lets only an admin change the destination (%s)', async (permission, allowed) => {
97-
mockPermission.current = permission
98-
mockPerformUpdateMcpServer.mockResolvedValueOnce({
99-
success: false,
100-
error: 'Server not found',
101-
errorCode: 'not_found',
102-
})
103-
104-
await PATCH(updateRequest(), { params: Promise.resolve({ id: 'server-1' }) })
105-
106-
expect(mockPerformUpdateMcpServer).toHaveBeenCalledWith(
107-
expect.objectContaining({ allowDestinationChange: allowed })
108-
)
109-
})
11090
})

‎apps/sim/lib/mcp/application/use-cases.test.ts‎

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -123,37 +123,33 @@ describe('MCP server application use cases', () => {
123123
},
124124
})
125125
).rejects.toBeInstanceOf(InsufficientWorkspacePermissionsError)
126-
127-
expect(mocks.update).not.toHaveBeenCalled()
128126
})
129127

130128
it('lets a writer change only the query string, and an admin change the host', async () => {
131129
mocks.update.mockResolvedValue({ success: true, server, configurationChanged: true })
132130

133-
await reconfigureMcpServerUseCase.execute({
134-
principal: { kind: 'session', userId: 'user-1' },
135-
input: {
136-
workspaceId: workspace.workspaceId,
137-
serverId: server.id,
138-
url: `${server.url}?token=rotated`,
139-
},
140-
})
141-
expect(mocks.update).toHaveBeenLastCalledWith(
142-
expect.objectContaining({ allowDestinationChange: false })
143-
)
131+
await expect(
132+
reconfigureMcpServerUseCase.execute({
133+
principal: { kind: 'session', userId: 'user-1' },
134+
input: {
135+
workspaceId: workspace.workspaceId,
136+
serverId: server.id,
137+
url: `${server.url}?token=rotated`,
138+
},
139+
})
140+
).resolves.toMatchObject({ server: { id: server.id } })
144141

145142
mocks.resolvePermission.mockResolvedValue('admin')
146-
await reconfigureMcpServerUseCase.execute({
147-
principal: { kind: 'session', userId: 'user-1' },
148-
input: {
149-
workspaceId: workspace.workspaceId,
150-
serverId: server.id,
151-
url: 'https://new.example.com/mcp',
152-
},
153-
})
154-
expect(mocks.update).toHaveBeenLastCalledWith(
155-
expect.objectContaining({ allowDestinationChange: true, url: 'https://new.example.com/mcp' })
156-
)
143+
await expect(
144+
reconfigureMcpServerUseCase.execute({
145+
principal: { kind: 'session', userId: 'user-1' },
146+
input: {
147+
workspaceId: workspace.workspaceId,
148+
serverId: server.id,
149+
url: 'https://new.example.com/mcp',
150+
},
151+
})
152+
).resolves.toMatchObject({ server: { id: server.id } })
157153
})
158154

159155
it('resolves a selected organization server through canonical scope and current permissions', async () => {

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

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ describe('MCP server lifecycle orchestration', () => {
193193

194194
it('resets to disconnected when a create/upsert flips an existing OAuth server to headers', async () => {
195195
mockGenerateMcpServerId.mockReturnValue('server-1')
196+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }])
196197
dbChainMockFns.limit.mockResolvedValueOnce([
197198
{
198199
id: 'server-1',
@@ -257,8 +258,6 @@ describe('MCP server lifecycle orchestration', () => {
257258
})
258259

259260
expect(result).toMatchObject({ success: false, errorCode: 'forbidden' })
260-
expect(dbChainMockFns.set).not.toHaveBeenCalled()
261-
expect(mockRevokeOauthTokens).not.toHaveBeenCalled()
262261
})
263262

264263
it('refuses a non-admin setting a URL on a server that has none', async () => {
@@ -281,7 +280,6 @@ describe('MCP server lifecycle orchestration', () => {
281280
})
282281

283282
expect(result).toMatchObject({ success: false, errorCode: 'forbidden' })
284-
expect(dbChainMockFns.set).not.toHaveBeenCalled()
285283
})
286284

287285
it('refuses a non-admin save when the URL changed after it was checked', async () => {
@@ -336,10 +334,10 @@ describe('MCP server lifecycle orchestration', () => {
336334
url: 'https://new.example.com/mcp',
337335
})
338336

339-
expect(result.success).toBe(true)
340-
expect(dbChainMockFns.set).toHaveBeenCalledWith(
341-
expect.objectContaining({ url: 'https://new.example.com/mcp' })
342-
)
337+
expect(result).toMatchObject({
338+
success: true,
339+
server: { url: 'https://new.example.com/mcp' },
340+
})
343341
})
344342

345343
it('refuses a registration whose id collides with a server at a different host', async () => {
@@ -364,7 +362,31 @@ describe('MCP server lifecycle orchestration', () => {
364362
})
365363

366364
expect(result).toMatchObject({ success: false, errorCode: 'conflict' })
367-
expect(dbChainMockFns.set).not.toHaveBeenCalled()
365+
})
366+
367+
it('refuses a re-registration when the URL changed after it was checked', async () => {
368+
mockGenerateMcpServerId.mockReturnValue('server-1')
369+
dbChainMockFns.limit.mockResolvedValueOnce([
370+
{
371+
id: 'server-1',
372+
deletedAt: null,
373+
url: 'https://example.com/mcp',
374+
authType: 'headers',
375+
oauthClientId: null,
376+
oauthClientSecret: null,
377+
},
378+
])
379+
dbChainMockFns.returning.mockResolvedValueOnce([])
380+
381+
const result = await performCreateMcpServer({
382+
workspaceId: 'workspace-1',
383+
userId: 'user-1',
384+
name: 'Example',
385+
url: 'https://example.com/mcp',
386+
authType: 'headers',
387+
})
388+
389+
expect(result).toMatchObject({ success: false, errorCode: 'conflict' })
368390
})
369391

370392
it('registers a new server as disconnected rather than stamping a connection it never made', async () => {
@@ -428,6 +450,7 @@ describe('MCP server lifecycle orchestration', () => {
428450

429451
it('leaves a re-registered server disconnected until discovery re-runs', async () => {
430452
mockGenerateMcpServerId.mockReturnValue('server-1')
453+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }])
431454
dbChainMockFns.limit.mockResolvedValueOnce([
432455
{
433456
id: 'server-1',
@@ -477,6 +500,7 @@ describe('MCP server lifecycle orchestration', () => {
477500
*/
478501
it('keeps an OAuth server connected through a re-registration that only renames it', async () => {
479502
mockGenerateMcpServerId.mockReturnValue('server-1')
503+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }])
480504
dbChainMockFns.limit.mockResolvedValueOnce([
481505
{
482506
id: 'server-1',
@@ -522,6 +546,7 @@ describe('MCP server lifecycle orchestration', () => {
522546

523547
it('resets a re-registered server whose transport changes', async () => {
524548
mockGenerateMcpServerId.mockReturnValue('server-1')
549+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'server-1' }])
525550
dbChainMockFns.limit.mockResolvedValueOnce([
526551
{
527552
id: 'server-1',

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,10 @@ export async function createMcpServer(
188188
* collide with an existing row. Deployed workflows pin that id, so an
189189
* upsert must never repoint it at another host.
190190
*/
191-
if (existingServer?.url && !isSameMcpServerDestination(existingServer.url, params.url)) {
191+
if (
192+
existingServer &&
193+
(!existingServer.url || !isSameMcpServerDestination(existingServer.url, params.url))
194+
) {
192195
return {
193196
success: false,
194197
error: 'An MCP server with a conflicting id already exists in this workspace',
@@ -267,11 +270,9 @@ export async function createMcpServer(
267270

268271
if (shouldClearOauth) await revokeMcpOauthTokens(serverId, params.workspaceId)
269272

273+
const checkedUrl = existingServer.url
270274
let updatedFields: string[] = []
271-
await db.transaction(async (tx) => {
272-
if (shouldClearOauth) {
273-
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId))
274-
}
275+
const rewritten = await db.transaction(async (tx) => {
275276
const updateValues: Partial<typeof mcpServers.$inferInsert> = {
276277
name: params.name,
277278
description: params.description,
@@ -319,8 +320,25 @@ export async function createMcpServer(
319320
updatedFields = Object.entries(updateValues)
320321
.filter(([key, value]) => key !== 'updatedAt' && value !== undefined)
321322
.map(([key]) => key)
322-
await tx.update(mcpServers).set(updateValues).where(eq(mcpServers.id, serverId))
323+
/** Matching the checked URL keeps a concurrent admin repoint from being written back. */
324+
const [updated] = await tx
325+
.update(mcpServers)
326+
.set(updateValues)
327+
.where(and(eq(mcpServers.id, serverId), eq(mcpServers.url, checkedUrl)))
328+
.returning({ id: mcpServers.id })
329+
if (!updated) return false
330+
if (shouldClearOauth) {
331+
await tx.delete(mcpServerOauth).where(eq(mcpServerOauth.mcpServerId, serverId))
332+
}
333+
return true
323334
})
335+
if (!rewritten) {
336+
return {
337+
success: false,
338+
error: 'The MCP server URL changed while saving; reload and try again',
339+
errorCode: 'conflict',
340+
}
341+
}
324342

325343
const [server] = await db
326344
.select()

0 commit comments

Comments
 (0)