From 64b382eb49d0604edffb8b7bd277586d69c7f11b Mon Sep 17 00:00:00 2001 From: aadamgough Date: Fri, 9 Jan 2026 18:57:07 -0800 Subject: [PATCH 1/5] ui improvement --- .../deploy-modal/components/mcp/mcp.tsx | 189 ++++++++------- .../workflow-mcp-servers.tsx | 223 +++++++++++++++++- .../settings-modal/settings-modal.tsx | 13 +- 3 files changed, 331 insertions(+), 94 deletions(-) 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..62db3ab1a5 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 @@ -173,7 +173,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 +184,20 @@ export function McpDeploy({ return ids }, [servers, serverToolsMap]) + const [pendingSelectedServerIds, setPendingSelectedServerIds] = useState(null) + + const selectedServerIds = pendingSelectedServerIds ?? actualServerIds + + useEffect(() => { + 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 +255,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 +275,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,74 +294,19 @@ 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 - - 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 }) - } - } - - if (toolsToUpdate.length === 0) return + if (selectedServerIds.length === 0) return onSubmittingChange?.(true) try { - for (const { serverId, toolId } of toolsToUpdate) { - await updateToolMutation.mutateAsync({ - workspaceId, - serverId, - toolId, - toolName: toolName.trim(), - toolDescription: toolDescription.trim() || undefined, - parameterSchema, - }) - } - // Update saved values after successful save (triggers re-render → hasChanges becomes false) - setSavedValues({ - toolName, - toolDescription, - parameterDescriptions: { ...parameterDescriptions }, - }) - onCanSaveChange?.(false) - onSubmittingChange?.(false) - } catch (error) { - logger.error('Failed to save tool configuration:', error) - onSubmittingChange?.(false) - } - }, [ - toolName, - toolDescription, - parameterDescriptions, - parameterSchema, - servers, - serverToolsMap, - workspaceId, - updateToolMutation, - onSubmittingChange, - onCanSaveChange, - ]) - - const serverOptions: ComboboxOption[] = useMemo(() => { - return servers.map((server) => ({ - label: server.name, - value: server.id, - })) - }, [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)) + 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)) for (const serverId of toAdd) { setPendingServerChanges((prev) => new Set(prev).add(serverId)) @@ -342,11 +319,8 @@ export function McpDeploy({ 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) @@ -371,9 +345,6 @@ export function McpDeploy({ delete next[serverId] return next }) - refetchServers() - } catch (error) { - logger.error('Failed to remove tool:', error) } finally { setPendingServerChanges((prev) => { const next = new Set(prev) @@ -383,21 +354,69 @@ export function McpDeploy({ } } } - }, - [ - selectedServerIds, - serverToolsMap, - toolName, - toolDescription, - workspaceId, - workflowId, - parameterSchema, - addToolMutation, - deleteToolMutation, - refetchServers, - onAddedToServer, - ] - ) + + for (const serverId of toUpdate) { + const toolInfo = serverToolsMap[serverId] + if (toolInfo?.tool) { + await updateToolMutation.mutateAsync({ + workspaceId, + serverId, + toolId: toolInfo.tool.id, + toolName: toolName.trim(), + toolDescription: toolDescription.trim() || undefined, + parameterSchema, + }) + } + } + + refetchServers() + + setPendingSelectedServerIds(null) + setSavedValues({ + toolName, + toolDescription, + parameterDescriptions: { ...parameterDescriptions }, + }) + onCanSaveChange?.(false) + onSubmittingChange?.(false) + } catch (error) { + logger.error('Failed to save tool configuration:', error) + onSubmittingChange?.(false) + } + }, [ + toolName, + toolDescription, + parameterDescriptions, + parameterSchema, + servers, + serverToolsMap, + workspaceId, + workflowId, + selectedServerIds, + actualServerIds, + addToolMutation, + deleteToolMutation, + updateToolMutation, + refetchServers, + onSubmittingChange, + onCanSaveChange, + onAddedToServer, + ]) + + const serverOptions: ComboboxOption[] = useMemo(() => { + return servers.map((server) => ({ + label: server.name, + value: server.id, + })) + }, [servers]) + + /** + * 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 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..4d2f6bf6dd 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, ChevronDown, Clipboard, Plus, Search, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -16,6 +16,11 @@ import { ModalContent, ModalFooter, ModalHeader, + Popover, + PopoverContent, + PopoverItem, + PopoverScrollArea, + PopoverTrigger, Textarea, } from '@/components/emcn' import { Input, Skeleton } from '@/components/ui' @@ -36,6 +41,146 @@ 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 +} + +/** + * A tag-input style workflow selector with dropdown. + * Shows selected workflows as removable tags inside the input container. + */ +function WorkflowTagSelect({ + workflows, + selectedIds, + onSelectionChange, + isLoading = false, + disabled = false, +}: WorkflowTagSelectProps) { + const [open, setOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + + const availableWorkflows = useMemo(() => { + return workflows.filter((w) => !selectedIds.includes(w.id)) + }, [workflows, selectedIds]) + + const filteredWorkflows = useMemo(() => { + if (!searchQuery.trim()) return availableWorkflows + const query = searchQuery.toLowerCase() + return availableWorkflows.filter((w) => w.name.toLowerCase().includes(query)) + }, [availableWorkflows, searchQuery]) + + const handleSelect = (id: string) => { + onSelectionChange([...selectedIds, id]) + setSearchQuery('') + } + + const handleRemove = (id: string) => { + onSelectionChange(selectedIds.filter((selectedId) => selectedId !== id)) + } + + const isEmpty = workflows.length === 0 + + return ( + + +
!disabled && !isEmpty && setOpen(true)} + > + {selectedIds.length === 0 ? ( + + {isEmpty ? 'No deployed workflows available' : 'Select deployed workflows...'} + + ) : ( +
+ {selectedIds.map((id) => { + const workflow = workflows.find((w) => w.id === id) + return ( +
+ {workflow?.name || id} + +
+ ) + })} +
+ )} + +
+
+ +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setOpen(false) + setSearchQuery('') + } + }} + /> +
+ + {isLoading ? ( +
+ Loading... +
+ ) : filteredWorkflows.length === 0 ? ( +
+ {searchQuery + ? 'No matching workflows found' + : availableWorkflows.length === 0 + ? 'All workflows have been added' + : 'No deployed workflows found'} +
+ ) : ( +
+ {filteredWorkflows.map((workflow) => ( + handleSelect(workflow.id)} + className='cursor-pointer rounded-[4px] px-[6px] py-[6px] font-medium font-sans text-sm hover:bg-[var(--border-1)]' + > + {workflow.name} + + ))} +
+ )} +
+
+
+ ) +} + interface ServerDetailViewProps { workspaceId: string serverId: string @@ -178,6 +323,17 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro +
+ + Authentication Header + +
+ + X-API-Key: {''} + +
+
+
@@ -439,25 +595,41 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro ) } +interface WorkflowMcpServersProps { + /** Key that when changed resets the component to list view */ + 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: 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()) + // Reset to list view when resetKey changes (triggered by clicking sidebar item) + useEffect(() => { + if (resetKey !== undefined) { + setSelectedServerId(null) + } + }, [resetKey]) + const filteredServers = useMemo(() => { if (!searchTerm.trim()) return servers const search = searchTerm.toLowerCase() @@ -466,6 +638,7 @@ export function WorkflowMcpServers() { const resetForm = useCallback(() => { setFormData({ name: '' }) + setSelectedWorkflowIds([]) setShowAddForm(false) }, []) @@ -473,10 +646,24 @@ export function WorkflowMcpServers() { if (!formData.name.trim()) return try { - await createServerMutation.mutateAsync({ + const server = await createServerMutation.mutateAsync({ workspaceId, name: formData.name.trim(), }) + + // Add selected workflows as tools + if (selectedWorkflowIds.length > 0 && server?.id) { + await Promise.all( + selectedWorkflowIds.map((workflowId) => + addToolMutation.mutateAsync({ + workspaceId, + serverId: server.id, + workflowId, + }) + ) + ) + } + resetForm() } catch (err) { logger.error('Failed to create server:', err) @@ -544,7 +731,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. +

+ + + + +
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..4e3d992b77 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,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const handleSectionChange = useCallback( (sectionId: SettingsSection) => { - if (sectionId === activeSection) return + // For workflow-mcp-servers, clicking again should reset to list view + if (sectionId === activeSection) { + if (sectionId === 'workflow-mcp-servers') { + setWorkflowMcpResetKey((prev) => prev + 1) + } + return + } if (activeSection === 'environment' && environmentBeforeLeaveHandler.current) { environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId)) @@ -470,7 +477,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { {activeSection === 'copilot' && } {activeSection === 'mcp' && } {activeSection === 'custom-tools' && } - {activeSection === 'workflow-mcp-servers' && } + {activeSection === 'workflow-mcp-servers' && ( + + )} From 4622b05674defa812dede3041c5dc1bbd5b891ed Mon Sep 17 00:00:00 2001 From: aadamgough Date: Fri, 9 Jan 2026 19:05:11 -0800 Subject: [PATCH 2/5] fixed component and removed comments --- .../workflow-mcp-servers.tsx | 176 ++++++------------ 1 file changed, 56 insertions(+), 120 deletions(-) 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 4d2f6bf6dd..98e8788779 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, ChevronDown, Clipboard, Plus, Search, X } from 'lucide-react' +import { Check, Clipboard, Plus, Search, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -16,11 +16,6 @@ import { ModalContent, ModalFooter, ModalHeader, - Popover, - PopoverContent, - PopoverItem, - PopoverScrollArea, - PopoverTrigger, Textarea, } from '@/components/emcn' import { Input, Skeleton } from '@/components/ui' @@ -50,8 +45,8 @@ interface WorkflowTagSelectProps { } /** - * A tag-input style workflow selector with dropdown. - * Shows selected workflows as removable tags inside the input container. + * Multi-select workflow selector using Combobox. + * Shows selected workflows as removable badges inside the trigger. */ function WorkflowTagSelect({ workflows, @@ -60,124 +55,68 @@ function WorkflowTagSelect({ isLoading = false, disabled = false, }: WorkflowTagSelectProps) { - const [open, setOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState('') + const options: ComboboxOption[] = useMemo(() => { + return workflows.map((w) => ({ + label: w.name, + value: w.id, + })) + }, [workflows]) - const availableWorkflows = useMemo(() => { - return workflows.filter((w) => !selectedIds.includes(w.id)) + const selectedWorkflows = useMemo(() => { + return workflows.filter((w) => selectedIds.includes(w.id)) }, [workflows, selectedIds]) - const filteredWorkflows = useMemo(() => { - if (!searchQuery.trim()) return availableWorkflows - const query = searchQuery.toLowerCase() - return availableWorkflows.filter((w) => w.name.toLowerCase().includes(query)) - }, [availableWorkflows, searchQuery]) - - const handleSelect = (id: string) => { - onSelectionChange([...selectedIds, id]) - setSearchQuery('') + const handleRemove = (e: React.MouseEvent, id: string) => { + e.preventDefault() + e.stopPropagation() + onSelectionChange(selectedIds.filter((i) => i !== id)) } - const handleRemove = (id: string) => { - onSelectionChange(selectedIds.filter((selectedId) => selectedId !== 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 ( - - -
!disabled && !isEmpty && setOpen(true)} - > - {selectedIds.length === 0 ? ( - - {isEmpty ? 'No deployed workflows available' : 'Select deployed workflows...'} - - ) : ( -
- {selectedIds.map((id) => { - const workflow = workflows.find((w) => w.id === id) - return ( -
- {workflow?.name || id} - -
- ) - })} -
- )} - -
-
- -
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setOpen(false) - setSearchQuery('') - } - }} - /> -
- - {isLoading ? ( -
- Loading... -
- ) : filteredWorkflows.length === 0 ? ( -
- {searchQuery - ? 'No matching workflows found' - : availableWorkflows.length === 0 - ? 'All workflows have been added' - : 'No deployed workflows found'} -
- ) : ( -
- {filteredWorkflows.map((workflow) => ( - handleSelect(workflow.id)} - className='cursor-pointer rounded-[4px] px-[6px] py-[6px] font-medium font-sans text-sm hover:bg-[var(--border-1)]' - > - {workflow.name} - - ))} -
- )} -
-
-
+ ) } @@ -596,7 +535,6 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro } interface WorkflowMcpServersProps { - /** Key that when changed resets the component to list view */ resetKey?: number } @@ -623,7 +561,6 @@ export function WorkflowMcpServers({ resetKey }: WorkflowMcpServersProps) { const [serverToDelete, setServerToDelete] = useState(null) const [deletingServers, setDeletingServers] = useState>(new Set()) - // Reset to list view when resetKey changes (triggered by clicking sidebar item) useEffect(() => { if (resetKey !== undefined) { setSelectedServerId(null) @@ -651,7 +588,6 @@ export function WorkflowMcpServers({ resetKey }: WorkflowMcpServersProps) { name: formData.name.trim(), }) - // Add selected workflows as tools if (selectedWorkflowIds.length > 0 && server?.id) { await Promise.all( selectedWorkflowIds.map((workflowId) => From b7a3a4a37f383413d9fb31a261f3b2f053762167 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Fri, 9 Jan 2026 19:07:03 -0800 Subject: [PATCH 3/5] Removed comment --- .../sidebar/components/settings-modal/settings-modal.tsx | 1 - 1 file changed, 1 deletion(-) 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 4e3d992b77..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 @@ -246,7 +246,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const handleSectionChange = useCallback( (sectionId: SettingsSection) => { - // For workflow-mcp-servers, clicking again should reset to list view if (sectionId === activeSection) { if (sectionId === 'workflow-mcp-servers') { setWorkflowMcpResetKey((prev) => prev + 1) From 210bf41ffea97d185b7e78c0ca0df5e671f4ebdc Mon Sep 17 00:00:00 2001 From: aadamgough Date: Fri, 9 Jan 2026 19:42:08 -0800 Subject: [PATCH 4/5] added validation and greptile comments --- .../mcp/workflow-servers/validate/route.ts | 44 +++++++ .../deploy-modal/components/mcp/mcp.tsx | 124 +++++++++++------- .../workflow-mcp-servers.tsx | 78 ++++++++--- .../sim/hooks/queries/workflow-mcp-servers.ts | 35 +++-- 4 files changed, 205 insertions(+), 76 deletions(-) create mode 100644 apps/sim/app/api/mcp/workflow-servers/validate/route.ts 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 62db3ab1a5..7dcff03842 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,7 @@ export function McpDeploy({ }) const [parameterDescriptions, setParameterDescriptions] = useState>({}) const [pendingServerChanges, setPendingServerChanges] = useState>(new Set()) + const [saveError, setSaveError] = useState(null) const parameterSchema = useMemo( () => generateParameterSchema(inputFormat, parameterDescriptions), @@ -302,25 +303,60 @@ export function McpDeploy({ if (selectedServerIds.length === 0) return onSubmittingChange?.(true) - try { - 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)) + setSaveError(null) + + 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)) + + const errors: string[] = [] + + 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, + }) + 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 toAdd) { + for (const serverId of toRemove) { + const toolInfo = serverToolsMap[serverId] + if (toolInfo?.tool) { setPendingServerChanges((prev) => new Set(prev).add(serverId)) try { - await addToolMutation.mutateAsync({ + await deleteToolMutation.mutateAsync({ workspaceId, serverId, - workflowId, - toolName: toolName.trim(), - toolDescription: toolDescription.trim() || undefined, - parameterSchema, + toolId: toolInfo.tool.id, + }) + setServerToolsMap((prev) => { + const next = { ...prev } + delete next[serverId] + return next }) - 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 remove from "${serverName}"`) + logger.error(`Failed to remove tool from server ${serverId}:`, error) } finally { setPendingServerChanges((prev) => { const next = new Set(prev) @@ -329,35 +365,13 @@ export function McpDeploy({ }) } } + } - 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 - }) - } 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) { + 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, @@ -366,11 +380,25 @@ export function McpDeploy({ 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 + }) } } + } - refetchServers() + refetchServers() + if (errors.length > 0) { + setSaveError(errors.join('. ')) + } else { setPendingSelectedServerIds(null) setSavedValues({ toolName, @@ -378,11 +406,9 @@ export function McpDeploy({ parameterDescriptions: { ...parameterDescriptions }, }) onCanSaveChange?.(false) - onSubmittingChange?.(false) - } catch (error) { - logger.error('Failed to save tool configuration:', error) - onSubmittingChange?.(false) } + + onSubmittingChange?.(false) }, [ toolName, toolDescription, @@ -582,11 +608,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 98e8788779..5d63c8a12b 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 @@ -204,6 +204,8 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro return availableWorkflows.find((w) => w.id === selectedWorkflowId) }, [availableWorkflows, selectedWorkflowId]) + const selectedWorkflowInvalid = selectedWorkflow && selectedWorkflow.hasStartBlock === false + if (isLoading) { return (
@@ -502,7 +504,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'}

@@ -523,7 +530,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro @@ -560,6 +567,7 @@ export function WorkflowMcpServers({ resetKey }: WorkflowMcpServersProps) { 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) { @@ -573,37 +581,63 @@ export function WorkflowMcpServers({ resetKey }: WorkflowMcpServersProps) { 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 === false) + .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 { - const server = await createServerMutation.mutateAsync({ + server = await createServerMutation.mutateAsync({ workspaceId, name: formData.name.trim(), }) + } 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) { - await Promise.all( - selectedWorkflowIds.map((workflowId) => - addToolMutation.mutateAsync({ - workspaceId, - serverId: server.id, - workflowId, - }) - ) - ) + 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) + } } - resetForm() - } catch (err) { - logger.error('Failed to create server:', err) + if (workflowErrors.length > 0) { + setCreateError(`Server created but failed to add workflows: ${workflowErrors.join(', ')}`) + } } + + resetForm() } const handleDeleteServer = async () => { @@ -694,6 +728,13 @@ export function WorkflowMcpServers({ resetKey }: WorkflowMcpServersProps) { disabled={deployedWorkflows.length === 0} /> + {hasInvalidWorkflows && ( +

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

+ )} + + {createError &&

{createError}

}