Skip to content

Commit 3b6be5c

Browse files
authored
fix(mcp): require workspace admin to change workflow MCP server visibility (#9576)
1 parent a041519 commit 3b6be5c

9 files changed

Lines changed: 246 additions & 51 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const PATCH = withRouteHandler(
9797
)(
9898
async (
9999
request: NextRequest,
100-
{ userId, userName, userEmail, workspaceId, requestId },
100+
{ userId, userName, userEmail, workspaceId, requestId, permission },
101101
{ params }
102102
) => {
103103
try {
@@ -122,6 +122,7 @@ export const PATCH = withRouteHandler(
122122
name: body.name,
123123
description: body.description,
124124
isPublic: body.isPublic,
125+
allowVisibilityChange: permission === 'admin',
125126
})
126127
if (!result.success || !result.server) {
127128
const status = mcpOrchestrationStatus(result.errorCode)

‎apps/sim/app/api/mcp/workflow-servers/route.ts‎

Lines changed: 50 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -103,54 +103,60 @@ export const POST = withRouteHandler(
103103
withMcpAuth(
104104
'write',
105105
'deploy.mcp'
106-
)(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
107-
try {
108-
const rawBody = await readMcpJsonBodyWithLimit(request)
109-
const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody)
106+
)(
107+
async (
108+
request: NextRequest,
109+
{ userId, userName, userEmail, workspaceId, requestId, permission }
110+
) => {
111+
try {
112+
const rawBody = await readMcpJsonBodyWithLimit(request)
113+
const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody)
114+
115+
if (!parsedBody.success) {
116+
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
117+
}
110118

111-
if (!parsedBody.success) {
112-
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
113-
}
119+
const body = parsedBody.data
114120

115-
const body = parsedBody.data
116-
117-
logger.info(`[${requestId}] Creating workflow MCP server:`, {
118-
name: body.name,
119-
workspaceId,
120-
workflowIds: body.workflowIds,
121-
})
122-
123-
const result = await performCreateWorkflowMcpServer({
124-
workspaceId,
125-
userId,
126-
actorName: userName,
127-
actorEmail: userEmail,
128-
name: body.name,
129-
description: body.description,
130-
isPublic: body.isPublic,
131-
workflowIds: body.workflowIds,
132-
})
133-
if (!result.success || !result.server) {
134-
return createMcpErrorResponse(
135-
new Error(result.error || 'Failed to create workflow MCP server'),
136-
result.error || 'Failed to create workflow MCP server',
137-
mcpOrchestrationStatus(result.errorCode)
138-
)
139-
}
121+
logger.info(`[${requestId}] Creating workflow MCP server:`, {
122+
name: body.name,
123+
workspaceId,
124+
workflowIds: body.workflowIds,
125+
})
140126

141-
const { server } = result
142-
const addedTools = result.addedTools || []
127+
const result = await performCreateWorkflowMcpServer({
128+
workspaceId,
129+
userId,
130+
actorName: userName,
131+
actorEmail: userEmail,
132+
name: body.name,
133+
description: body.description,
134+
isPublic: body.isPublic,
135+
allowVisibilityChange: permission === 'admin',
136+
workflowIds: body.workflowIds,
137+
})
138+
if (!result.success || !result.server) {
139+
return createMcpErrorResponse(
140+
new Error(result.error || 'Failed to create workflow MCP server'),
141+
result.error || 'Failed to create workflow MCP server',
142+
mcpOrchestrationStatus(result.errorCode)
143+
)
144+
}
143145

144-
logger.info(
145-
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`
146-
)
146+
const { server } = result
147+
const addedTools = result.addedTools || []
147148

148-
return createMcpSuccessResponse({ server, addedTools }, 201)
149-
} catch (error) {
150-
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
151-
if (bodyErrorResponse) return bodyErrorResponse
152-
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
153-
return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500)
149+
logger.info(
150+
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`
151+
)
152+
153+
return createMcpSuccessResponse({ server, addedTools }, 201)
154+
} catch (error) {
155+
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
156+
if (bodyErrorResponse) return bodyErrorResponse
157+
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
158+
return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500)
159+
}
154160
}
155-
})
161+
)
156162
)

‎apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ interface CreateWorkflowMcpServerModalProps {
2929
onOpenChange: (open: boolean) => void
3030
workspaceId: string
3131
workflowOptions?: ComboboxOption[]
32+
canChangeVisibility: boolean
3233
}
3334

3435
export function CreateWorkflowMcpServerModal({
3536
open,
3637
onOpenChange,
3738
workspaceId,
3839
workflowOptions,
40+
canChangeVisibility,
3941
}: CreateWorkflowMcpServerModalProps) {
4042
const createServerMutation = useCreateWorkflowMcpServer()
4143

@@ -118,6 +120,7 @@ export function CreateWorkflowMcpServerModal({
118120
<ChipButtonGroup
119121
value={formData.isPublic ? 'public' : 'private'}
120122
onValueChange={(value) => setFormData({ ...formData, isPublic: value === 'public' })}
123+
disabled={!canChangeVisibility}
121124
>
122125
<ChipButtonGroupItem value='private'>Private</ChipButtonGroupItem>
123126
<ChipButtonGroupItem value='public'>Public</ChipButtonGroupItem>

‎apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const logger = createLogger('WorkflowMcpServers')
6767

6868
interface ServerDetailViewProps {
6969
canManage: boolean
70+
canChangeVisibility: boolean
7071
workspaceId: string
7172
serverId: string
7273
onBack: () => void
@@ -80,6 +81,7 @@ type McpClientType = 'sim' | 'cursor' | 'codex' | 'claude-code' | 'claude-deskto
8081

8182
function ServerDetailView({
8283
canManage,
84+
canChangeVisibility,
8385
workspaceId,
8486
serverId,
8587
onBack,
@@ -854,6 +856,7 @@ function ServerDetailView({
854856
<ChipButtonGroup
855857
value={editServerIsPublic ? 'public' : 'private'}
856858
onValueChange={(value) => setEditServerIsPublic(value === 'public')}
859+
disabled={!canChangeVisibility}
857860
>
858861
<ChipButtonGroupItem value='private'>Private</ChipButtonGroupItem>
859862
<ChipButtonGroupItem value='public'>Public</ChipButtonGroupItem>
@@ -1002,6 +1005,7 @@ export function WorkflowMcpServers() {
10021005
<>
10031006
<ServerDetailView
10041007
canManage={canAdmin}
1008+
canChangeVisibility={workspacePermissions.canAdmin}
10051009
workspaceId={workspaceId}
10061010
serverId={selectedServerId}
10071011
onBack={() => {
@@ -1094,6 +1098,7 @@ export function WorkflowMcpServers() {
10941098
onOpenChange={setShowAddModal}
10951099
workspaceId={workspaceId}
10961100
workflowOptions={workflowOptions}
1101+
canChangeVisibility={workspacePermissions.canAdmin}
10971102
/>
10981103
)}
10991104

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '@/lib/mcp/workflow-tool-schema'
2424
import type { InputFormatField } from '@/lib/workflows/types'
2525
import { useWorkflowScope } from '@/app/workspace/[workspaceId]/providers/workflow-scope-provider'
26+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
2627
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal'
2728
import {
2829
useAddWorkflowMcpTool,
@@ -133,6 +134,7 @@ export function McpDeploy({
133134
onActiveServerChange,
134135
}: McpDeployProps) {
135136
const { workspaceId } = useWorkflowScope()
137+
const { canAdmin } = useUserPermissionsContext()
136138
const [showCreateModal, setShowCreateModal] = useState(false)
137139

138140
const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
@@ -513,6 +515,7 @@ export function McpDeploy({
513515
open={showCreateModal}
514516
onOpenChange={setShowCreateModal}
515517
workspaceId={workspaceId}
518+
canChangeVisibility={canAdmin}
516519
/>
517520
</>
518521
)

‎apps/sim/lib/mcp/application/workflow-deployments.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ export const createWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCas
211211
async execute({ principal, input, context }) {
212212
const result = await performCreateWorkflowMcpServer({
213213
...input,
214+
// Safe only because the operation's `minimumRole` is `admin`.
215+
allowVisibilityChange: true,
214216
workspaceId: context.workspaceId,
215217
userId: attribution(principal, context.billedAccountUserId),
216218
projectLegacyAudit: false,
@@ -259,6 +261,8 @@ export const updateWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCas
259261
async execute({ principal, input, context }) {
260262
const result = await performUpdateWorkflowMcpServer({
261263
...input,
264+
// Safe only because the operation's `minimumRole` is `admin`.
265+
allowVisibilityChange: true,
262266
workspaceId: context.workspaceId,
263267
userId: attribution(principal, context.billedAccountUserId),
264268
projectLegacyAudit: false,

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('workflow MCP lifecycle orchestration', () => {
2929
const result = await performCreateWorkflowMcpServer({
3030
workspaceId: 'workspace-1',
3131
userId: 'user-1',
32+
allowVisibilityChange: false,
3233
name: 'Too Many Tools',
3334
workflowIds: Array.from(
3435
{ length: MAX_MCP_TOOLS_PER_SERVER + 1 },
@@ -47,6 +48,7 @@ describe('workflow MCP lifecycle orchestration', () => {
4748
const result = await performCreateWorkflowMcpServer({
4849
workspaceId: 'workspace-1',
4950
userId: 'user-1',
51+
allowVisibilityChange: false,
5052
name: 'Duplicate Tools',
5153
workflowIds: ['wf-1', 'wf-1'],
5254
})
@@ -86,6 +88,7 @@ describe('workflow MCP lifecycle orchestration', () => {
8688
const result = await performCreateWorkflowMcpServer({
8789
workspaceId: 'workspace-1',
8890
userId: 'user-1',
91+
allowVisibilityChange: false,
8992
name: 'Server',
9093
workflowIds: ['wf-1'],
9194
})
@@ -121,6 +124,7 @@ describe('workflow MCP lifecycle orchestration', () => {
121124
const result = await performCreateWorkflowMcpServer({
122125
workspaceId: 'workspace-1',
123126
userId: 'user-1',
127+
allowVisibilityChange: false,
124128
name: 'Server',
125129
workflowIds: ['wf-1'],
126130
})
@@ -158,6 +162,7 @@ describe('workflow MCP lifecycle orchestration', () => {
158162
const result = await performCreateWorkflowMcpServer({
159163
workspaceId: 'workspace-1',
160164
userId: 'user-1',
165+
allowVisibilityChange: false,
161166
name: 'Server',
162167
workflowIds: ['wf-1'],
163168
})

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

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,16 @@ interface ActorMetadata {
6363
publishEffects?: boolean
6464
}
6565

66-
export interface PerformCreateWorkflowMcpServerParams extends ActorMetadata {
66+
interface VisibilityAuthority {
67+
/**
68+
* Whether the caller may set `isPublic`. A public server answers
69+
* `/api/mcp/serve/{serverId}` with no Sim credential, so this is reserved for
70+
* workspace admins.
71+
*/
72+
allowVisibilityChange: boolean
73+
}
74+
75+
export interface PerformCreateWorkflowMcpServerParams extends ActorMetadata, VisibilityAuthority {
6776
workspaceId: string
6877
userId: string
6978
name: string
@@ -80,7 +89,7 @@ export interface PerformCreateWorkflowMcpServerResult {
8089
addedTools?: Array<{ workflowId: string; toolName: string }>
8190
}
8291

83-
export interface PerformUpdateWorkflowMcpServerParams extends ActorMetadata {
92+
export interface PerformUpdateWorkflowMcpServerParams extends ActorMetadata, VisibilityAuthority {
8493
serverId: string
8594
workspaceId: string
8695
userId: string
@@ -339,6 +348,14 @@ async function validateWorkflowMcpServerMembershipBudget(
339348
export async function performCreateWorkflowMcpServer(
340349
params: PerformCreateWorkflowMcpServerParams
341350
): Promise<PerformCreateWorkflowMcpServerResult> {
351+
if (params.isPublic && !params.allowVisibilityChange) {
352+
return {
353+
success: false,
354+
error: 'Only workspace admins can create a public workflow MCP server',
355+
errorCode: 'forbidden',
356+
}
357+
}
358+
342359
try {
343360
const name = params.name.trim()
344361
const workflowIds = params.workflowIds || []
@@ -558,24 +575,47 @@ export async function performUpdateWorkflowMcpServer(
558575

559576
if (params.name !== undefined) updateData.name = params.name.trim()
560577
if (params.description !== undefined) updateData.description = params.description?.trim() || null
561-
if (params.isPublic !== undefined) updateData.isPublic = params.isPublic
578+
const lockedVisibility = params.allowVisibilityChange ? undefined : params.isPublic
579+
if (params.allowVisibilityChange && params.isPublic !== undefined) {
580+
updateData.isPublic = params.isPublic
581+
}
562582

563583
const updatedFields = auditUpdatedFields(updateData)
584+
const serverInWorkspace = and(
585+
eq(workflowMcpServer.id, params.serverId),
586+
eq(workflowMcpServer.workspaceId, params.workspaceId),
587+
isNull(workflowMcpServer.deletedAt)
588+
)
564589

565590
try {
566591
const [server] = await db
567592
.update(workflowMcpServer)
568593
.set(updateData)
569594
.where(
570595
and(
571-
eq(workflowMcpServer.id, params.serverId),
572-
eq(workflowMcpServer.workspaceId, params.workspaceId),
573-
isNull(workflowMcpServer.deletedAt)
596+
serverInWorkspace,
597+
lockedVisibility !== undefined
598+
? eq(workflowMcpServer.isPublic, lockedVisibility)
599+
: undefined
574600
)
575601
)
576602
.returning()
577603

578604
if (!server) {
605+
if (lockedVisibility !== undefined) {
606+
const [existing] = await db
607+
.select({ id: workflowMcpServer.id })
608+
.from(workflowMcpServer)
609+
.where(serverInWorkspace)
610+
.limit(1)
611+
if (existing) {
612+
return {
613+
success: false,
614+
error: 'Only workspace admins can change whether a workflow MCP server is public',
615+
errorCode: 'forbidden',
616+
}
617+
}
618+
}
579619
return { success: false, error: 'Server not found', errorCode: 'not_found' }
580620
}
581621

0 commit comments

Comments
 (0)