Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/sim/app/api/mcp/workflow-servers/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const PATCH = withRouteHandler(
)(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ userId, userName, userEmail, workspaceId, requestId, permission },
{ params }
) => {
try {
Expand All @@ -122,6 +122,7 @@ export const PATCH = withRouteHandler(
name: body.name,
description: body.description,
isPublic: body.isPublic,
allowVisibilityChange: permission === 'admin',
})
if (!result.success || !result.server) {
const status = mcpOrchestrationStatus(result.errorCode)
Expand Down
94 changes: 50 additions & 44 deletions apps/sim/app/api/mcp/workflow-servers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,54 +103,60 @@ export const POST = withRouteHandler(
withMcpAuth(
'write',
'deploy.mcp'
)(async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody)
)(
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId, permission }
) => {
try {
const rawBody = await readMcpJsonBodyWithLimit(request)
const parsedBody = createWorkflowMcpServerBodySchema.safeParse(rawBody)

if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}

if (!parsedBody.success) {
return createMcpErrorResponse(parsedBody.error, 'Invalid request format', 400)
}
const body = parsedBody.data

const body = parsedBody.data

logger.info(`[${requestId}] Creating workflow MCP server:`, {
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
})

const result = await performCreateWorkflowMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
name: body.name,
description: body.description,
isPublic: body.isPublic,
workflowIds: body.workflowIds,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to create workflow MCP server'),
result.error || 'Failed to create workflow MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}
logger.info(`[${requestId}] Creating workflow MCP server:`, {
name: body.name,
workspaceId,
workflowIds: body.workflowIds,
})

const { server } = result
const addedTools = result.addedTools || []
const result = await performCreateWorkflowMcpServer({
workspaceId,
userId,
actorName: userName,
actorEmail: userEmail,
name: body.name,
description: body.description,
isPublic: body.isPublic,
allowVisibilityChange: permission === 'admin',
workflowIds: body.workflowIds,
})
if (!result.success || !result.server) {
return createMcpErrorResponse(
new Error(result.error || 'Failed to create workflow MCP server'),
result.error || 'Failed to create workflow MCP server',
mcpOrchestrationStatus(result.errorCode)
)
}

logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`
)
const { server } = result
const addedTools = result.addedTools || []

return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500)
logger.info(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${server.id})`
)

return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
const bodyErrorResponse = mcpBodyReadErrorResponse(error, request)
if (bodyErrorResponse) return bodyErrorResponse
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
return createMcpErrorResponse(toError(error), 'Failed to create workflow MCP server', 500)
}
}
})
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ interface CreateWorkflowMcpServerModalProps {
onOpenChange: (open: boolean) => void
workspaceId: string
workflowOptions?: ComboboxOption[]
canChangeVisibility: boolean
}

export function CreateWorkflowMcpServerModal({
open,
onOpenChange,
workspaceId,
workflowOptions,
canChangeVisibility,
}: CreateWorkflowMcpServerModalProps) {
const createServerMutation = useCreateWorkflowMcpServer()

Expand Down Expand Up @@ -118,6 +120,7 @@ export function CreateWorkflowMcpServerModal({
<ChipButtonGroup
value={formData.isPublic ? 'public' : 'private'}
onValueChange={(value) => setFormData({ ...formData, isPublic: value === 'public' })}
disabled={!canChangeVisibility}
>
<ChipButtonGroupItem value='private'>Private</ChipButtonGroupItem>
<ChipButtonGroupItem value='public'>Public</ChipButtonGroupItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const logger = createLogger('WorkflowMcpServers')

interface ServerDetailViewProps {
canManage: boolean
canChangeVisibility: boolean
workspaceId: string
serverId: string
onBack: () => void
Expand All @@ -80,6 +81,7 @@ type McpClientType = 'sim' | 'cursor' | 'codex' | 'claude-code' | 'claude-deskto

function ServerDetailView({
canManage,
canChangeVisibility,
workspaceId,
serverId,
onBack,
Expand Down Expand Up @@ -854,6 +856,7 @@ function ServerDetailView({
<ChipButtonGroup
value={editServerIsPublic ? 'public' : 'private'}
onValueChange={(value) => setEditServerIsPublic(value === 'public')}
disabled={!canChangeVisibility}
>
<ChipButtonGroupItem value='private'>Private</ChipButtonGroupItem>
<ChipButtonGroupItem value='public'>Public</ChipButtonGroupItem>
Expand Down Expand Up @@ -1002,6 +1005,7 @@ export function WorkflowMcpServers() {
<>
<ServerDetailView
canManage={canAdmin}
canChangeVisibility={workspacePermissions.canAdmin}
workspaceId={workspaceId}
serverId={selectedServerId}
onBack={() => {
Expand Down Expand Up @@ -1094,6 +1098,7 @@ export function WorkflowMcpServers() {
onOpenChange={setShowAddModal}
workspaceId={workspaceId}
workflowOptions={workflowOptions}
canChangeVisibility={workspacePermissions.canAdmin}
/>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from '@/lib/mcp/workflow-tool-schema'
import type { InputFormatField } from '@/lib/workflows/types'
import { useWorkflowScope } from '@/app/workspace/[workspaceId]/providers/workflow-scope-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal'
import {
useAddWorkflowMcpTool,
Expand Down Expand Up @@ -133,6 +134,7 @@ export function McpDeploy({
onActiveServerChange,
}: McpDeployProps) {
const { workspaceId } = useWorkflowScope()
const { canAdmin } = useUserPermissionsContext()
const [showCreateModal, setShowCreateModal] = useState(false)

const { data: servers = [], isLoading: isLoadingServers } = useWorkflowMcpServers(workspaceId)
Expand Down Expand Up @@ -513,6 +515,7 @@ export function McpDeploy({
open={showCreateModal}
onOpenChange={setShowCreateModal}
workspaceId={workspaceId}
canChangeVisibility={canAdmin}
/>
</>
)
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/mcp/application/workflow-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ export const createWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCas
async execute({ principal, input, context }) {
const result = await performCreateWorkflowMcpServer({
...input,
// Safe only because the operation's `minimumRole` is `admin`.
allowVisibilityChange: true,
workspaceId: context.workspaceId,
userId: attribution(principal, context.billedAccountUserId),
projectLegacyAudit: false,
Expand Down Expand Up @@ -259,6 +261,8 @@ export const updateWorkflowMcpDeploymentServer = defineAuthorizedWorkspaceUseCas
async execute({ principal, input, context }) {
const result = await performUpdateWorkflowMcpServer({
...input,
// Safe only because the operation's `minimumRole` is `admin`.
allowVisibilityChange: true,
workspaceId: context.workspaceId,
userId: attribution(principal, context.billedAccountUserId),
projectLegacyAudit: false,
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('workflow MCP lifecycle orchestration', () => {
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
allowVisibilityChange: false,
name: 'Too Many Tools',
workflowIds: Array.from(
{ length: MAX_MCP_TOOLS_PER_SERVER + 1 },
Expand All @@ -47,6 +48,7 @@ describe('workflow MCP lifecycle orchestration', () => {
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
allowVisibilityChange: false,
name: 'Duplicate Tools',
workflowIds: ['wf-1', 'wf-1'],
})
Expand Down Expand Up @@ -86,6 +88,7 @@ describe('workflow MCP lifecycle orchestration', () => {
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
allowVisibilityChange: false,
name: 'Server',
workflowIds: ['wf-1'],
})
Expand Down Expand Up @@ -121,6 +124,7 @@ describe('workflow MCP lifecycle orchestration', () => {
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
allowVisibilityChange: false,
name: 'Server',
workflowIds: ['wf-1'],
})
Expand Down Expand Up @@ -158,6 +162,7 @@ describe('workflow MCP lifecycle orchestration', () => {
const result = await performCreateWorkflowMcpServer({
workspaceId: 'workspace-1',
userId: 'user-1',
allowVisibilityChange: false,
name: 'Server',
workflowIds: ['wf-1'],
})
Expand Down
52 changes: 46 additions & 6 deletions apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,16 @@ interface ActorMetadata {
publishEffects?: boolean
}

export interface PerformCreateWorkflowMcpServerParams extends ActorMetadata {
interface VisibilityAuthority {
/**
* Whether the caller may set `isPublic`. A public server answers
* `/api/mcp/serve/{serverId}` with no Sim credential, so this is reserved for
* workspace admins.
*/
allowVisibilityChange: boolean
}

export interface PerformCreateWorkflowMcpServerParams extends ActorMetadata, VisibilityAuthority {
workspaceId: string
userId: string
name: string
Expand All @@ -80,7 +89,7 @@ export interface PerformCreateWorkflowMcpServerResult {
addedTools?: Array<{ workflowId: string; toolName: string }>
}

export interface PerformUpdateWorkflowMcpServerParams extends ActorMetadata {
export interface PerformUpdateWorkflowMcpServerParams extends ActorMetadata, VisibilityAuthority {
serverId: string
workspaceId: string
userId: string
Expand Down Expand Up @@ -339,6 +348,14 @@ async function validateWorkflowMcpServerMembershipBudget(
export async function performCreateWorkflowMcpServer(
params: PerformCreateWorkflowMcpServerParams
): Promise<PerformCreateWorkflowMcpServerResult> {
if (params.isPublic && !params.allowVisibilityChange) {
return {
success: false,
error: 'Only workspace admins can create a public workflow MCP server',
errorCode: 'forbidden',
}
}

try {
const name = params.name.trim()
const workflowIds = params.workflowIds || []
Expand Down Expand Up @@ -558,24 +575,47 @@ export async function performUpdateWorkflowMcpServer(

if (params.name !== undefined) updateData.name = params.name.trim()
if (params.description !== undefined) updateData.description = params.description?.trim() || null
if (params.isPublic !== undefined) updateData.isPublic = params.isPublic
const lockedVisibility = params.allowVisibilityChange ? undefined : params.isPublic
if (params.allowVisibilityChange && params.isPublic !== undefined) {
updateData.isPublic = params.isPublic
}

const updatedFields = auditUpdatedFields(updateData)
const serverInWorkspace = and(
eq(workflowMcpServer.id, params.serverId),
eq(workflowMcpServer.workspaceId, params.workspaceId),
isNull(workflowMcpServer.deletedAt)
)

try {
const [server] = await db
.update(workflowMcpServer)
.set(updateData)
.where(
and(
eq(workflowMcpServer.id, params.serverId),
eq(workflowMcpServer.workspaceId, params.workspaceId),
isNull(workflowMcpServer.deletedAt)
serverInWorkspace,
lockedVisibility !== undefined
? eq(workflowMcpServer.isPublic, lockedVisibility)
: undefined
)
)
.returning()

if (!server) {
if (lockedVisibility !== undefined) {
const [existing] = await db
.select({ id: workflowMcpServer.id })
.from(workflowMcpServer)
.where(serverInWorkspace)
.limit(1)
if (existing) {
return {
success: false,
error: 'Only workspace admins can change whether a workflow MCP server is public',
errorCode: 'forbidden',
}
}
}
return { success: false, error: 'Server not found', errorCode: 'not_found' }
}

Expand Down
Loading
Loading