mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
Compare commits
5 Commits
main
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8099e824aa | ||
|
|
210bf41ffe | ||
|
|
b7a3a4a37f | ||
|
|
4622b05674 | ||
|
|
64b382eb49 |
44
apps/sim/app/api/mcp/workflow-servers/validate/route.ts
Normal file
44
apps/sim/app/api/mcp/workflow-servers/validate/route.ts
Normal file
@@ -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<string, boolean> = {}
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,8 @@ export function McpDeploy({
|
||||
})
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
const [pendingServerChanges, setPendingServerChanges] = useState<Set<string>>(new Set())
|
||||
const [saveError, setSaveError] = useState<string | null>(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<string[] | null>(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 })
|
||||
}
|
||||
}
|
||||
|
||||
if (toolsToUpdate.length === 0) return
|
||||
|
||||
isSavingRef.current = true
|
||||
onSubmittingChange?.(true)
|
||||
try {
|
||||
for (const { serverId, toolId } of toolsToUpdate) {
|
||||
await updateToolMutation.mutateAsync({
|
||||
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,
|
||||
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
|
||||
})
|
||||
}
|
||||
// Update saved values after successful save (triggers re-render → hasChanges becomes false)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addToolMutation.isError && (
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
|
||||
{addToolMutation.error?.message || 'Failed to add tool'}
|
||||
</p>
|
||||
)}
|
||||
{saveError && <p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>{saveError}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className='flex items-center gap-[4px] overflow-hidden'>
|
||||
{selectedWorkflows.slice(0, 2).map((w) => (
|
||||
<Badge
|
||||
key={w.id}
|
||||
variant='outline'
|
||||
className='pointer-events-auto cursor-pointer gap-[4px] rounded-[6px] px-[8px] py-[2px] text-[11px]'
|
||||
onMouseDown={(e) => handleRemove(e, w.id)}
|
||||
>
|
||||
{w.name}
|
||||
<X className='h-3 w-3' />
|
||||
</Badge>
|
||||
))}
|
||||
{selectedWorkflows.length > 2 && (
|
||||
<Badge variant='outline' className='rounded-[6px] px-[8px] py-[2px] text-[11px]'>
|
||||
+{selectedWorkflows.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, [selectedWorkflows, selectedIds])
|
||||
|
||||
const isEmpty = workflows.length === 0
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className='h-[34px] w-full rounded-[6px]' />
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
multiSelect
|
||||
multiSelectValues={validSelectedIds}
|
||||
onMultiSelectChange={onSelectionChange}
|
||||
placeholder={isEmpty ? 'No deployed workflows available' : 'Select deployed workflows...'}
|
||||
overlayContent={overlayContent}
|
||||
searchable
|
||||
searchPlaceholder='Search workflows...'
|
||||
disabled={disabled || isEmpty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
@@ -178,6 +277,17 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Authentication Header
|
||||
</span>
|
||||
<div className='rounded-[6px] border bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
<code className='font-mono text-[12px] text-[var(--text-primary)]'>
|
||||
X-API-Key: {'<your-api-key>'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -407,7 +517,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{addToolMutation.isError && (
|
||||
{selectedWorkflowInvalid && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
Workflow must have a Start block to be used as an MCP tool
|
||||
</p>
|
||||
)}
|
||||
{addToolMutation.isError && !selectedWorkflowInvalid && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
{addToolMutation.error?.message || 'Failed to add workflow'}
|
||||
</p>
|
||||
@@ -428,7 +543,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleAddWorkflow}
|
||||
disabled={!selectedWorkflowId || addToolMutation.isPending}
|
||||
disabled={!selectedWorkflowId || selectedWorkflowInvalid || addToolMutation.isPending}
|
||||
>
|
||||
{addToolMutation.isPending ? 'Adding...' : 'Add Workflow'}
|
||||
</Button>
|
||||
@@ -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<string[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(new Set())
|
||||
const [createError, setCreateError] = useState<string | null>(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 && (
|
||||
<div className='rounded-[8px] border p-[10px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex flex-col gap-[12px]'>
|
||||
<p className='text-[12px] text-[var(--text-secondary)] leading-relaxed'>
|
||||
Create an MCP server to expose your deployed workflows as tools.
|
||||
</p>
|
||||
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
@@ -554,16 +735,44 @@ export function WorkflowMcpServers() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className='flex items-center justify-end gap-[8px] pt-[12px]'>
|
||||
<p className='ml-[112px] text-[12px] text-[var(--text-secondary)]'>
|
||||
Select deployed workflows to add to this MCP server. Each workflow will be available
|
||||
as a tool.
|
||||
</p>
|
||||
<FormField label='Workflows'>
|
||||
<WorkflowTagSelect
|
||||
workflows={deployedWorkflows}
|
||||
selectedIds={selectedWorkflowIds}
|
||||
onSelectionChange={setSelectedWorkflowIds}
|
||||
isLoading={isLoadingWorkflows}
|
||||
disabled={deployedWorkflows.length === 0}
|
||||
/>
|
||||
</FormField>
|
||||
{hasInvalidWorkflows && (
|
||||
<p className='ml-[112px] text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
Workflow must have a Start block to be used as an MCP tool
|
||||
</p>
|
||||
)}
|
||||
|
||||
{createError && <p className='text-[12px] text-[var(--text-error)]'>{createError}</p>}
|
||||
|
||||
<div className='flex items-center justify-end gap-[8px] pt-[4px]'>
|
||||
<Button variant='ghost' onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateServer}
|
||||
disabled={!isFormValid || createServerMutation.isPending}
|
||||
disabled={
|
||||
!isFormValid ||
|
||||
hasInvalidWorkflows ||
|
||||
createServerMutation.isPending ||
|
||||
addToolMutation.isPending
|
||||
}
|
||||
variant='tertiary'
|
||||
>
|
||||
{createServerMutation.isPending ? 'Adding...' : 'Add Server'}
|
||||
{createServerMutation.isPending || addToolMutation.isPending
|
||||
? 'Adding...'
|
||||
: 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,6 +162,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(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' && <Copilot />}
|
||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||
{activeSection === 'custom-tools' && <CustomTools />}
|
||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||
{activeSection === 'workflow-mcp-servers' && (
|
||||
<WorkflowMcpServers resetKey={workflowMcpResetKey} />
|
||||
)}
|
||||
</SModalMainBody>
|
||||
</SModalMain>
|
||||
</SModalContent>
|
||||
|
||||
@@ -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<DeployedWork
|
||||
|
||||
const { data }: { data: any[] } = await response.json()
|
||||
|
||||
return data
|
||||
.filter((w) => 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<string, boolean> = {}
|
||||
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],
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user