Skip to content

Commit c49185e

Browse files
fix(mcp): restrict MCP server destination changes to admins
1 parent ea88578 commit c49185e

10 files changed

Lines changed: 238 additions & 9 deletions

File tree

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ 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 { mockPerformUpdateMcpServer } = vi.hoisted(() => ({
5+
const { mockPermission, mockPerformUpdateMcpServer } = vi.hoisted(() => ({
6+
mockPermission: { current: 'admin' },
67
mockPerformUpdateMcpServer: vi.fn(),
78
}))
89

@@ -28,7 +29,7 @@ vi.mock('@/lib/mcp/middleware', () => ({
2829
userEmail: 'test@example.com',
2930
workspaceId: 'workspace-1',
3031
requestId: 'request-1',
31-
permission: 'admin',
32+
permission: mockPermission.current,
3233
},
3334
routeContext
3435
),
@@ -54,6 +55,7 @@ function updateRequest() {
5455
describe('MCP server PATCH route', () => {
5556
beforeEach(() => {
5657
resetDbChainMock()
58+
mockPermission.current = 'admin'
5759
})
5860

5961
afterAll(() => {
@@ -87,4 +89,22 @@ describe('MCP server PATCH route', () => {
8789
expect(body.data.server.oauthClientSecret).toBeUndefined()
8890
expect(body.data.server.hasOauthClientSecret).toBe(true)
8991
})
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+
})
90110
})

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const PATCH = withRouteHandler(
3030
)(
3131
async (
3232
request: NextRequest,
33-
{ userId, userName, userEmail, workspaceId, requestId },
33+
{ userId, userName, userEmail, workspaceId, requestId, permission },
3434
{ params }
3535
) => {
3636
try {
@@ -59,6 +59,7 @@ export const PATCH = withRouteHandler(
5959
actorName: userName,
6060
actorEmail: userEmail,
6161
serverId,
62+
allowDestinationChange: permission === 'admin',
6263
name: body.name,
6364
description: body.description,
6465
transport: body.transport,

‎apps/sim/lib/mcp/application/operations.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ const EXPECTED_CAPABILITIES: Record<keyof typeof mcpServerOperations, string> =
132132
register: 'mcp_tools.use',
133133
update: 'mcp_tools.use',
134134
reconfigure: 'mcp_tools.use',
135+
changeDestination: 'mcp_tools.use',
135136
delete: 'mcp_tools.use',
136137
discoverTools: 'mcp_tools.use',
137138
executeTool: 'mcp_tools.use',

‎apps/sim/lib/mcp/application/operations.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,15 @@ export const mcpServerOperations = {
209209
capability: 'mcp_tools.use',
210210
...ALL_PRINCIPAL_POLICY,
211211
}),
212+
/** Pointing a server at another host or path; deployed workflows pin servers by id. */
213+
changeDestination: defineWorkspaceOperation({
214+
id: 'mcp_servers.change_destination',
215+
oauthScope: 'api:write',
216+
minimumRole: 'admin',
217+
workspaceApiKey: 'deny',
218+
capability: 'mcp_tools.use',
219+
...HUMAN_PRINCIPAL_POLICY,
220+
}),
212221
delete: defineWorkspaceOperation({
213222
id: 'mcp_servers.delete',
214223
oauthScope: 'api:write',

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

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
workspaceUploadsMockFns,
1313
} from '@sim/testing/mocks/workspace-uploads.mock'
1414
import { beforeEach, describe, expect, it, vi } from 'vitest'
15+
import { InsufficientWorkspacePermissionsError } from '@/lib/core/application'
1516

1617
const { events, hoisted } = vi.hoisted(() => ({
1718
events: [] as string[],
1819
hoisted: {
1920
idState: vi.fn(),
2021
create: vi.fn(),
22+
update: vi.fn(),
2123
effects: vi.fn(),
2224
getServer: vi.fn(),
2325
listServers: vi.fn(),
@@ -31,7 +33,7 @@ vi.mock('@/lib/mcp/orchestration', () => ({
3133
applyMcpServerMutationEffects: hoisted.effects,
3234
createMcpServer: hoisted.create,
3335
deleteMcpServer: vi.fn(),
34-
updateMcpServer: vi.fn(),
36+
updateMcpServer: hoisted.update,
3537
}))
3638
vi.mock('@/lib/mcp/queries', () => ({
3739
getMcpServerIdState: hoisted.idState,
@@ -45,6 +47,7 @@ import {
4547
discoverMcpServerToolsUseCase,
4648
discoverMcpToolsUseCase,
4749
getMcpServerUseCase,
50+
reconfigureMcpServerUseCase,
4851
} from '@/lib/mcp/application/use-cases'
4952

5053
const mocks = {
@@ -109,6 +112,50 @@ describe('MCP server application use cases', () => {
109112
mocks.discoverServerTools.mockResolvedValue([])
110113
})
111114

115+
it('refuses a writer pointing a server at a different host before writing', async () => {
116+
await expect(
117+
reconfigureMcpServerUseCase.execute({
118+
principal: { kind: 'session', userId: 'user-1' },
119+
input: {
120+
workspaceId: workspace.workspaceId,
121+
serverId: server.id,
122+
url: 'https://other-host.example.com/mcp',
123+
},
124+
})
125+
).rejects.toBeInstanceOf(InsufficientWorkspacePermissionsError)
126+
127+
expect(mocks.update).not.toHaveBeenCalled()
128+
})
129+
130+
it('lets a writer change only the query string, and an admin change the host', async () => {
131+
mocks.update.mockResolvedValue({ success: true, server, configurationChanged: true })
132+
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+
)
144+
145+
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+
)
157+
})
158+
112159
it('resolves a selected organization server through canonical scope and current permissions', async () => {
113160
mocks.loadContext.mockResolvedValue({ ...workspace, workspaceOrganizationId: 'org-1' })
114161
const args = {

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { AuditAction, AuditResourceType } from '@sim/audit'
22
import { resolvePrincipalAttribution } from '@sim/auth/principal'
33
import { getPostgresErrorCode } from '@sim/utils/errors'
44
import type { CursorKey, ListSortOrder } from '@/lib/api/list-query'
5-
import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application'
5+
import {
6+
authorizeWorkspaceOperation,
7+
defineAuthorizedWorkspaceUseCase,
8+
ForbiddenOperationError,
9+
} from '@/lib/core/application'
610
import { OrchestrationError } from '@/lib/core/orchestration/types'
711
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
812
import {
@@ -37,7 +41,7 @@ import {
3741
import { mcpService } from '@/lib/mcp/service'
3842
import { compileMcpToolSchema } from '@/lib/mcp/tool-schema'
3943
import type { McpAuthType } from '@/lib/mcp/types'
40-
import { generateMcpServerId } from '@/lib/mcp/utils'
44+
import { generateMcpServerId, isSameMcpServerDestination } from '@/lib/mcp/utils'
4145

4246
type McpServerTransport = McpServerRow['transport']
4347
type McpWriteSource = 'api' | 'settings' | 'tool_input'
@@ -386,13 +390,26 @@ async function updateMcpServer(args: {
386390
'This MCP server is managed from its Credential Group settings'
387391
)
388392
}
393+
const changesDestination =
394+
args.input.url !== undefined &&
395+
!!args.context.server.url &&
396+
!isSameMcpServerDestination(args.context.server.url, args.input.url)
397+
if (changesDestination) {
398+
await authorizeWorkspaceOperation(
399+
args.principal,
400+
mcpServerOperations.changeDestination,
401+
args.context,
402+
authorizationOptions
403+
)
404+
}
389405
const attribution = resolvePrincipalAttribution(args.principal, {
390406
workspaceBillingOwnerUserId: args.context.billedAccountUserId,
391407
})
392408
const result = await updateMcpServerRecord({
393409
workspaceId: args.context.workspaceId,
394410
userId: attribution.attributedUserId,
395411
serverId: args.context.server.id,
412+
allowDestinationChange: changesDestination,
396413
name: args.input.name,
397414
description: args.input.description,
398415
transport: args.input.transport,

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

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ vi.mock('@/lib/mcp/domain-check', () => ({
2929
}))
3030
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
3131
vi.mock('@/lib/mcp/service', () => mcpServiceMock)
32-
vi.mock('@/lib/mcp/utils', () => ({ generateMcpServerId: mockGenerateMcpServerId }))
32+
vi.mock('@/lib/mcp/utils', () => ({
33+
generateMcpServerId: mockGenerateMcpServerId,
34+
isSameMcpServerDestination: (a: string, b: string) => {
35+
const destination = (url: string) => {
36+
const parsed = new URL(url)
37+
return `${parsed.origin}${parsed.pathname}`
38+
}
39+
return destination(a) === destination(b)
40+
},
41+
}))
3342
vi.mock('@/lib/posthog/server', () => posthogServerMock)
3443

3544
import {
@@ -74,6 +83,7 @@ describe('MCP server lifecycle orchestration', () => {
7483
workspaceId: 'workspace-1',
7584
userId: 'user-1',
7685
serverId: 'server-1',
86+
allowDestinationChange: false,
7787
oauthClientId: 'client-1',
7888
oauthClientIdProvided: true,
7989
})
@@ -116,6 +126,7 @@ describe('MCP server lifecycle orchestration', () => {
116126
workspaceId: 'workspace-1',
117127
userId: 'user-1',
118128
serverId: 'server-1',
129+
allowDestinationChange: false,
119130
authType: 'headers',
120131
})
121132

@@ -163,6 +174,7 @@ describe('MCP server lifecycle orchestration', () => {
163174
workspaceId: 'workspace-1',
164175
userId: 'user-1',
165176
serverId: 'server-1',
177+
allowDestinationChange: false,
166178
headers: { authorization: 'Bearer rotated' },
167179
})
168180

@@ -225,6 +237,90 @@ describe('MCP server lifecycle orchestration', () => {
225237
expect(mockRevokeOauthTokens).toHaveBeenCalledWith('server-1', 'workspace-1')
226238
})
227239

240+
it('refuses a non-admin pointing an existing server at a different host', async () => {
241+
dbChainMockFns.limit.mockResolvedValueOnce([
242+
{
243+
url: 'https://example.com/mcp',
244+
authType: 'headers',
245+
headers: {},
246+
oauthClientId: null,
247+
oauthClientSecret: null,
248+
},
249+
])
250+
251+
const result = await performUpdateMcpServer({
252+
workspaceId: 'workspace-1',
253+
userId: 'user-1',
254+
serverId: 'server-1',
255+
allowDestinationChange: false,
256+
url: 'https://other-host.example.com/mcp',
257+
})
258+
259+
expect(result).toMatchObject({ success: false, errorCode: 'forbidden' })
260+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
261+
expect(mockRevokeOauthTokens).not.toHaveBeenCalled()
262+
})
263+
264+
it('lets an admin point an existing server at a different host', 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+
id: 'server-1',
277+
workspaceId: 'workspace-1',
278+
name: 'Example',
279+
transport: 'streamable-http',
280+
url: 'https://new.example.com/mcp',
281+
authType: 'headers',
282+
},
283+
])
284+
285+
const result = await performUpdateMcpServer({
286+
workspaceId: 'workspace-1',
287+
userId: 'user-1',
288+
serverId: 'server-1',
289+
allowDestinationChange: true,
290+
url: 'https://new.example.com/mcp',
291+
})
292+
293+
expect(result.success).toBe(true)
294+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
295+
expect.objectContaining({ url: 'https://new.example.com/mcp' })
296+
)
297+
})
298+
299+
it('refuses a registration whose id collides with a server at a different host', async () => {
300+
mockGenerateMcpServerId.mockReturnValue('server-1')
301+
dbChainMockFns.limit.mockResolvedValueOnce([
302+
{
303+
id: 'server-1',
304+
deletedAt: null,
305+
url: 'https://example.com/mcp',
306+
authType: 'headers',
307+
oauthClientId: null,
308+
oauthClientSecret: null,
309+
},
310+
])
311+
312+
const result = await performCreateMcpServer({
313+
workspaceId: 'workspace-1',
314+
userId: 'user-1',
315+
name: 'Example',
316+
url: 'https://other-host.example.com/collide',
317+
authType: 'headers',
318+
})
319+
320+
expect(result).toMatchObject({ success: false, errorCode: 'conflict' })
321+
expect(dbChainMockFns.set).not.toHaveBeenCalled()
322+
})
323+
228324
it('registers a new server as disconnected rather than stamping a connection it never made', async () => {
229325
mockGenerateMcpServerId.mockReturnValue('server-1')
230326
dbChainMockFns.limit.mockResolvedValueOnce([])

0 commit comments

Comments
 (0)