diff --git a/apps/sim/app/api/mcp/workflow-servers/validate/route.ts b/apps/sim/app/api/mcp/workflow-servers/validate/route.ts new file mode 100644 index 0000000000..86e64412fc --- /dev/null +++ b/apps/sim/app/api/mcp/workflow-servers/validate/route.ts @@ -0,0 +1,44 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' + +const logger = createLogger('ValidateMcpWorkflowsAPI') + +/** + * POST /api/mcp/workflow-servers/validate + * Validates if workflows have valid start blocks for MCP usage + */ +export async function POST(request: Request) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { workflowIds } = body + + if (!Array.isArray(workflowIds) || workflowIds.length === 0) { + return NextResponse.json({ error: 'workflowIds must be a non-empty array' }, { status: 400 }) + } + + const results: Record = {} + + for (const workflowId of workflowIds) { + try { + const state = await loadWorkflowFromNormalizedTables(workflowId) + results[workflowId] = hasValidStartBlockInState(state) + } catch (error) { + logger.warn(`Failed to validate workflow ${workflowId}:`, error) + results[workflowId] = false + } + } + + return NextResponse.json({ data: results }) + } catch (error) { + logger.error('Failed to validate workflows for MCP:', error) + return NextResponse.json({ error: 'Failed to validate workflows' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx index 53b7c114e5..9eb313c231 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/mcp/mcp.tsx @@ -147,6 +147,8 @@ export function McpDeploy({ }) const [parameterDescriptions, setParameterDescriptions] = useState>({}) const [pendingServerChanges, setPendingServerChanges] = useState>(new Set()) + const [saveError, setSaveError] = useState(null) + const isSavingRef = useRef(false) const parameterSchema = useMemo( () => generateParameterSchema(inputFormat, parameterDescriptions), @@ -173,7 +175,7 @@ export function McpDeploy({ [] ) - const selectedServerIds = useMemo(() => { + const actualServerIds = useMemo(() => { const ids: string[] = [] for (const server of servers) { const toolInfo = serverToolsMap[server.id] @@ -184,6 +186,21 @@ export function McpDeploy({ return ids }, [servers, serverToolsMap]) + const [pendingSelectedServerIds, setPendingSelectedServerIds] = useState(null) + + const selectedServerIds = pendingSelectedServerIds ?? actualServerIds + + useEffect(() => { + if (isSavingRef.current) return + if (pendingSelectedServerIds !== null) { + const pendingSet = new Set(pendingSelectedServerIds) + const actualSet = new Set(actualServerIds) + if (pendingSet.size === actualSet.size && [...pendingSet].every((id) => actualSet.has(id))) { + setPendingSelectedServerIds(null) + } + } + }, [actualServerIds, pendingSelectedServerIds]) + const hasLoadedInitialData = useRef(false) useEffect(() => { @@ -241,7 +258,17 @@ export function McpDeploy({ }, [toolName, toolDescription, parameterDescriptions, savedValues]) const hasDeployedTools = selectedServerIds.length > 0 + + const hasServerSelectionChanges = useMemo(() => { + if (pendingSelectedServerIds === null) return false + const pendingSet = new Set(pendingSelectedServerIds) + const actualSet = new Set(actualServerIds) + if (pendingSet.size !== actualSet.size) return true + return ![...pendingSet].every((id) => actualSet.has(id)) + }, [pendingSelectedServerIds, actualServerIds]) + const hasChanges = useMemo(() => { + if (hasServerSelectionChanges && selectedServerIds.length > 0) return true if (!savedValues || !hasDeployedTools) return false if (toolName !== savedValues.toolName) return true if (toolDescription !== savedValues.toolDescription) return true @@ -251,7 +278,15 @@ export function McpDeploy({ return true } return false - }, [toolName, toolDescription, parameterDescriptions, hasDeployedTools, savedValues]) + }, [ + toolName, + toolDescription, + parameterDescriptions, + hasDeployedTools, + savedValues, + hasServerSelectionChanges, + selectedServerIds.length, + ]) useEffect(() => { onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim()) @@ -262,45 +297,121 @@ export function McpDeploy({ }, [servers.length, onHasServersChange]) /** - * Save tool configuration to all deployed servers + * Save tool configuration to all selected servers. + * This handles both adding to new servers and updating existing tools. */ const handleSave = useCallback(async () => { if (!toolName.trim()) return + if (selectedServerIds.length === 0) return - const toolsToUpdate: Array<{ serverId: string; toolId: string }> = [] - for (const server of servers) { - const toolInfo = serverToolsMap[server.id] - if (toolInfo?.tool) { - toolsToUpdate.push({ serverId: server.id, toolId: toolInfo.tool.id }) - } - } + isSavingRef.current = true + onSubmittingChange?.(true) + setSaveError(null) - if (toolsToUpdate.length === 0) return + const actualSet = new Set(actualServerIds) + const toAdd = selectedServerIds.filter((id) => !actualSet.has(id)) + const toRemove = actualServerIds.filter((id) => !selectedServerIds.includes(id)) + const toUpdate = selectedServerIds.filter((id) => actualSet.has(id)) - onSubmittingChange?.(true) - try { - for (const { serverId, toolId } of toolsToUpdate) { - await updateToolMutation.mutateAsync({ + const errors: string[] = [] + + for (const serverId of toAdd) { + setPendingServerChanges((prev) => new Set(prev).add(serverId)) + try { + await addToolMutation.mutateAsync({ workspaceId, serverId, - toolId, + workflowId, toolName: toolName.trim(), toolDescription: toolDescription.trim() || undefined, parameterSchema, }) + onAddedToServer?.() + logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`) + } catch (error) { + const serverName = servers.find((s) => s.id === serverId)?.name || serverId + errors.push(`Failed to add to "${serverName}"`) + logger.error(`Failed to add tool to server ${serverId}:`, error) + } finally { + setPendingServerChanges((prev) => { + const next = new Set(prev) + next.delete(serverId) + return next + }) + } + } + + for (const serverId of toRemove) { + const toolInfo = serverToolsMap[serverId] + if (toolInfo?.tool) { + setPendingServerChanges((prev) => new Set(prev).add(serverId)) + try { + await deleteToolMutation.mutateAsync({ + workspaceId, + serverId, + toolId: toolInfo.tool.id, + }) + setServerToolsMap((prev) => { + const next = { ...prev } + delete next[serverId] + return next + }) + } catch (error) { + const serverName = servers.find((s) => s.id === serverId)?.name || serverId + errors.push(`Failed to remove from "${serverName}"`) + logger.error(`Failed to remove tool from server ${serverId}:`, error) + } finally { + setPendingServerChanges((prev) => { + const next = new Set(prev) + next.delete(serverId) + return next + }) + } + } + } + + for (const serverId of toUpdate) { + const toolInfo = serverToolsMap[serverId] + if (toolInfo?.tool) { + setPendingServerChanges((prev) => new Set(prev).add(serverId)) + try { + await updateToolMutation.mutateAsync({ + workspaceId, + serverId, + toolId: toolInfo.tool.id, + toolName: toolName.trim(), + toolDescription: toolDescription.trim() || undefined, + parameterSchema, + }) + } catch (error) { + const serverName = servers.find((s) => s.id === serverId)?.name || serverId + errors.push(`Failed to update "${serverName}"`) + logger.error(`Failed to update tool on server ${serverId}:`, error) + } finally { + setPendingServerChanges((prev) => { + const next = new Set(prev) + next.delete(serverId) + return next + }) + } } - // Update saved values after successful save (triggers re-render → hasChanges becomes false) + } + + if (errors.length > 0) { + setSaveError(errors.join('. ')) + } else { + refetchServers() + setPendingSelectedServerIds(null) setSavedValues({ toolName, toolDescription, parameterDescriptions: { ...parameterDescriptions }, }) onCanSaveChange?.(false) - onSubmittingChange?.(false) - } catch (error) { - logger.error('Failed to save tool configuration:', error) - onSubmittingChange?.(false) } + + isSavingRef.current = false + onSubmittingChange?.(false) }, [ toolName, toolDescription, @@ -309,9 +420,16 @@ export function McpDeploy({ servers, serverToolsMap, workspaceId, + workflowId, + selectedServerIds, + actualServerIds, + addToolMutation, + deleteToolMutation, updateToolMutation, + refetchServers, onSubmittingChange, onCanSaveChange, + onAddedToServer, ]) const serverOptions: ComboboxOption[] = useMemo(() => { @@ -321,83 +439,13 @@ export function McpDeploy({ })) }, [servers]) - const handleServerSelectionChange = useCallback( - async (newSelectedIds: string[]) => { - if (!toolName.trim()) return - - const currentIds = new Set(selectedServerIds) - const newIds = new Set(newSelectedIds) - - const toAdd = newSelectedIds.filter((id) => !currentIds.has(id)) - const toRemove = selectedServerIds.filter((id) => !newIds.has(id)) - - for (const serverId of toAdd) { - setPendingServerChanges((prev) => new Set(prev).add(serverId)) - try { - await addToolMutation.mutateAsync({ - workspaceId, - serverId, - workflowId, - toolName: toolName.trim(), - toolDescription: toolDescription.trim() || undefined, - parameterSchema, - }) - refetchServers() - onAddedToServer?.() - logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`) - } catch (error) { - logger.error('Failed to add tool:', error) - } finally { - setPendingServerChanges((prev) => { - const next = new Set(prev) - next.delete(serverId) - return next - }) - } - } - - for (const serverId of toRemove) { - const toolInfo = serverToolsMap[serverId] - if (toolInfo?.tool) { - setPendingServerChanges((prev) => new Set(prev).add(serverId)) - try { - await deleteToolMutation.mutateAsync({ - workspaceId, - serverId, - toolId: toolInfo.tool.id, - }) - setServerToolsMap((prev) => { - const next = { ...prev } - delete next[serverId] - return next - }) - refetchServers() - } catch (error) { - logger.error('Failed to remove tool:', error) - } finally { - setPendingServerChanges((prev) => { - const next = new Set(prev) - next.delete(serverId) - return next - }) - } - } - } - }, - [ - selectedServerIds, - serverToolsMap, - toolName, - toolDescription, - workspaceId, - workflowId, - parameterSchema, - addToolMutation, - deleteToolMutation, - refetchServers, - onAddedToServer, - ] - ) + /** + * Handle server selection change - only updates local state. + * Actual add/remove operations happen when user clicks Save. + */ + const handleServerSelectionChange = useCallback((newSelectedIds: string[]) => { + setPendingSelectedServerIds(newSelectedIds) + }, []) const selectedServersLabel = useMemo(() => { const count = selectedServerIds.length @@ -563,11 +611,7 @@ export function McpDeploy({ )} - {addToolMutation.isError && ( -

- {addToolMutation.error?.message || 'Failed to add tool'} -

- )} + {saveError &&

{saveError}

} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx index 8736bf59d0..3d1daa87d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { Check, Clipboard, Plus, Search } from 'lucide-react' +import { Check, Clipboard, Plus, Search, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -36,13 +36,108 @@ import { FormField, McpServerSkeleton } from '../mcp/components' const logger = createLogger('WorkflowMcpServers') +interface WorkflowTagSelectProps { + workflows: { id: string; name: string }[] + selectedIds: string[] + onSelectionChange: (ids: string[]) => void + isLoading?: boolean + disabled?: boolean +} + +/** + * Multi-select workflow selector using Combobox. + * Shows selected workflows as removable badges inside the trigger. + */ +function WorkflowTagSelect({ + workflows, + selectedIds, + onSelectionChange, + isLoading = false, + disabled = false, +}: WorkflowTagSelectProps) { + const options: ComboboxOption[] = useMemo(() => { + return workflows.map((w) => ({ + label: w.name, + value: w.id, + })) + }, [workflows]) + + const selectedWorkflows = useMemo(() => { + return workflows.filter((w) => selectedIds.includes(w.id)) + }, [workflows, selectedIds]) + + const validSelectedIds = useMemo(() => { + const workflowIds = new Set(workflows.map((w) => w.id)) + return selectedIds.filter((id) => workflowIds.has(id)) + }, [workflows, selectedIds]) + + const handleRemove = (e: React.MouseEvent, id: string) => { + e.preventDefault() + e.stopPropagation() + onSelectionChange(selectedIds.filter((i) => i !== id)) + } + + const overlayContent = useMemo(() => { + if (selectedWorkflows.length === 0) { + return null + } + + return ( +
+ {selectedWorkflows.slice(0, 2).map((w) => ( + handleRemove(e, w.id)} + > + {w.name} + + + ))} + {selectedWorkflows.length > 2 && ( + + +{selectedWorkflows.length - 2} + + )} +
+ ) + }, [selectedWorkflows, selectedIds]) + + const isEmpty = workflows.length === 0 + + if (isLoading) { + return + } + + return ( + + ) +} + interface ServerDetailViewProps { workspaceId: string serverId: string onBack: () => void + onToolsChanged?: () => void } -function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewProps) { +function ServerDetailView({ + workspaceId, + serverId, + onBack, + onToolsChanged, +}: ServerDetailViewProps) { const { data, isLoading, error, refetch } = useWorkflowMcpServer(workspaceId, serverId) const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = useDeployedWorkflows(workspaceId) @@ -81,6 +176,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro toolId: toolToDelete.id, }) setToolToDelete(null) + onToolsChanged?.() } catch (err) { logger.error('Failed to delete tool:', err) } @@ -97,6 +193,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro setShowAddWorkflow(false) setSelectedWorkflowId(null) refetch() + onToolsChanged?.() } catch (err) { logger.error('Failed to add workflow:', err) } @@ -120,6 +217,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return availableWorkflows.find((w) => w.id === selectedWorkflowId) }, [availableWorkflows, selectedWorkflowId]) + const selectedWorkflowInvalid = selectedWorkflow && selectedWorkflow.hasStartBlock !== true + if (isLoading) { return (
@@ -178,6 +277,17 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
+
+ + Authentication Header + +
+ + X-API-Key: {''} + +
+
+
@@ -407,7 +517,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro ) : undefined } /> - {addToolMutation.isError && ( + {selectedWorkflowInvalid && ( +

+ Workflow must have a Start block to be used as an MCP tool +

+ )} + {addToolMutation.isError && !selectedWorkflowInvalid && (

{addToolMutation.error?.message || 'Failed to add workflow'}

@@ -428,7 +543,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro @@ -439,24 +554,44 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro ) } +interface WorkflowMcpServersProps { + resetKey?: number +} + /** * MCP Servers settings component. * Allows users to create and manage MCP servers that expose workflows as tools. */ -export function WorkflowMcpServers() { +export function WorkflowMcpServers({ resetKey }: WorkflowMcpServersProps) { const params = useParams() const workspaceId = params.workspaceId as string - const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId) + const { + data: servers = [], + isLoading, + error, + refetch: refetchServers, + } = useWorkflowMcpServers(workspaceId) + const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } = + useDeployedWorkflows(workspaceId) const createServerMutation = useCreateWorkflowMcpServer() + const addToolMutation = useAddWorkflowMcpTool() const deleteServerMutation = useDeleteWorkflowMcpServer() const [searchTerm, setSearchTerm] = useState('') const [showAddForm, setShowAddForm] = useState(false) const [formData, setFormData] = useState({ name: '' }) + const [selectedWorkflowIds, setSelectedWorkflowIds] = useState([]) const [selectedServerId, setSelectedServerId] = useState(null) const [serverToDelete, setServerToDelete] = useState(null) const [deletingServers, setDeletingServers] = useState>(new Set()) + const [createError, setCreateError] = useState(null) + + useEffect(() => { + if (resetKey !== undefined) { + setSelectedServerId(null) + } + }, [resetKey]) const filteredServers = useMemo(() => { if (!searchTerm.trim()) return servers @@ -464,23 +599,64 @@ export function WorkflowMcpServers() { return servers.filter((server) => server.name.toLowerCase().includes(search)) }, [servers, searchTerm]) + const invalidWorkflows = useMemo(() => { + return selectedWorkflowIds + .map((id) => deployedWorkflows.find((w) => w.id === id)) + .filter((w) => w && w.hasStartBlock !== true) + .map((w) => w!.name) + }, [selectedWorkflowIds, deployedWorkflows]) + + const hasInvalidWorkflows = invalidWorkflows.length > 0 + const resetForm = useCallback(() => { setFormData({ name: '' }) + setSelectedWorkflowIds([]) setShowAddForm(false) + setCreateError(null) }, []) const handleCreateServer = async () => { if (!formData.name.trim()) return + setCreateError(null) + + let server: WorkflowMcpServer | undefined try { - await createServerMutation.mutateAsync({ + server = await createServerMutation.mutateAsync({ workspaceId, name: formData.name.trim(), }) - resetForm() } catch (err) { logger.error('Failed to create server:', err) + setCreateError(err instanceof Error ? err.message : 'Failed to create server') + return + } + + if (selectedWorkflowIds.length > 0 && server?.id) { + const workflowErrors: string[] = [] + + for (const workflowId of selectedWorkflowIds) { + try { + await addToolMutation.mutateAsync({ + workspaceId, + serverId: server.id, + workflowId, + }) + } catch (err) { + const workflowName = + deployedWorkflows.find((w) => w.id === workflowId)?.name || workflowId + workflowErrors.push(workflowName) + logger.error(`Failed to add workflow ${workflowId} to server:`, err) + } + } + + if (workflowErrors.length > 0) { + setCreateError(`Server created but failed to add workflows: ${workflowErrors.join(', ')}`) + return + } } + + resetForm() } const handleDeleteServer = async () => { @@ -516,6 +692,7 @@ export function WorkflowMcpServers() { workspaceId={workspaceId} serverId={selectedServerId} onBack={() => setSelectedServerId(null)} + onToolsChanged={refetchServers} /> ) } @@ -544,7 +721,11 @@ export function WorkflowMcpServers() { {shouldShowForm && !isLoading && (
-
+
+

+ Create an MCP server to expose your deployed workflows as tools. +

+ -
+

+ Select deployed workflows to add to this MCP server. Each workflow will be available + as a tool. +

+ + + + {hasInvalidWorkflows && ( +

+ Workflow must have a Start block to be used as an MCP tool +

+ )} + + {createError &&

{createError}

} + +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index bdebb7eeb3..438f3ffc46 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -162,6 +162,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const [activeSection, setActiveSection] = useState('general') const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore() const [pendingMcpServerId, setPendingMcpServerId] = useState(null) + const [workflowMcpResetKey, setWorkflowMcpResetKey] = useState(0) const { data: session } = useSession() const queryClient = useQueryClient() const { data: organizationsData } = useOrganizations() @@ -245,7 +246,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const handleSectionChange = useCallback( (sectionId: SettingsSection) => { - if (sectionId === activeSection) return + if (sectionId === activeSection) { + if (sectionId === 'workflow-mcp-servers') { + setWorkflowMcpResetKey((prev) => prev + 1) + } + return + } if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) { environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId)) @@ -470,7 +476,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { {activeSection === 'copilot' && } {activeSection === 'mcp' && } {activeSection === 'custom-tools' && } - {activeSection === 'workflow-mcp-servers' && } + {activeSection === 'workflow-mcp-servers' && ( + + )} diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.ts b/apps/sim/hooks/queries/workflow-mcp-servers.ts index 7169746b40..cceca4d701 100644 --- a/apps/sim/hooks/queries/workflow-mcp-servers.ts +++ b/apps/sim/hooks/queries/workflow-mcp-servers.ts @@ -11,6 +11,7 @@ export interface DeployedWorkflow { name: string description: string | null isDeployed: boolean + hasStartBlock?: boolean } /** @@ -442,14 +443,32 @@ async function fetchDeployedWorkflows(workspaceId: string): Promise w.isDeployed) - .map((w) => ({ - id: w.id, - name: w.name, - description: w.description, - isDeployed: w.isDeployed, - })) + const deployedWorkflows = data.filter((w) => w.isDeployed) + + let startBlockMap: Record = {} + if (deployedWorkflows.length > 0) { + try { + const validateResponse = await fetch('/api/mcp/workflow-servers/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workflowIds: deployedWorkflows.map((w) => w.id) }), + }) + if (validateResponse.ok) { + const validateData = await validateResponse.json() + startBlockMap = validateData.data || {} + } + } catch (error) { + logger.warn('Failed to validate workflows for MCP:', error) + } + } + + return deployedWorkflows.map((w) => ({ + id: w.id, + name: w.name, + description: w.description, + isDeployed: w.isDeployed, + hasStartBlock: startBlockMap[w.id], + })) } /**