Skip to content

Commit a865f47

Browse files
fix(mcp): restrict MCP server destination changes to admins (#8629)
* fix(mcp): restrict MCP server destination changes to admins * fix(mcp): compare exact MCP paths and guard concurrent URL changes * fix(mcp): treat setting a URL on a URL-less server as a destination change * fix(mcp): guard re-registration against concurrent URL changes * fix(mcp): narrow re-registration URL before the guarded update * chore(mcp): use absolute import in utils test
1 parent ea88578 commit a865f47

10 files changed

Lines changed: 342 additions & 15 deletions

File tree

‎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: 44 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,46 @@ 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+
128+
it('lets a writer change only the query string, and an admin change the host', async () => {
129+
mocks.update.mockResolvedValue({ success: true, server, configurationChanged: true })
130+
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 } })
141+
142+
mocks.resolvePermission.mockResolvedValue('admin')
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 } })
153+
})
154+
112155
it('resolves a selected organization server through canonical scope and current permissions', async () => {
113156
mocks.loadContext.mockResolvedValue({ ...workspace, workspaceOrganizationId: 'org-1' })
114157
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,

0 commit comments

Comments
 (0)