mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
fix(mcp): missing isDeployed in contract breaking settings, parameter overrides lack of clarity (#5164)
* fix(mcp): missing isDeployed in contract breaking settings, parameter overrides lack of clarity * address comments * address ux concern * address stray 404 * address stale fallback based on live state * fix * fix more things * simplify state mgmt * add tooltip for server selection
This commit is contained in:
@@ -82,8 +82,8 @@ Once your workflow is deployed, you can expose it as an MCP tool:
|
||||
/>
|
||||
</div>
|
||||
|
||||
3. Configure the tool name and description
|
||||
4. Add descriptions for each parameter (helps AI understand inputs)
|
||||
3. Configure the tool name and description (the description defaults to the workflow's description)
|
||||
4. Review the parameter descriptions (prefilled from your Start block inputs) and override any per tool
|
||||
5. Select which MCP servers to add it to
|
||||
6. Click **Save Tool**
|
||||
|
||||
@@ -97,10 +97,14 @@ The workflow must be deployed before it can be added as an MCP tool.
|
||||
Use lowercase letters, numbers, and underscores. The name should be descriptive and follow MCP naming conventions (e.g., `search_documents`, `send_email`).
|
||||
|
||||
### Description
|
||||
Write a clear description of what the tool does. This helps AI assistants understand when to use the tool.
|
||||
Write a clear description of what the tool does. This helps AI assistants understand when to use the tool. If you leave it blank, the tool falls back to the workflow's description.
|
||||
|
||||
### Parameters
|
||||
Your workflow's input format fields become tool parameters. Add descriptions to each parameter to help AI assistants provide correct values.
|
||||
Your workflow's input format fields become tool parameters. Each parameter's description defaults to the description set on that input in the Start block; edit it here to override it for this tool (the Start block stays the default source). Descriptions help AI assistants provide correct values.
|
||||
|
||||
<Callout type="info">
|
||||
Parameter descriptions live on your Start block inputs. A deployed tool picks up Start block changes the next time you redeploy the workflow; use a per-tool override only when a tool needs a different description than the default.
|
||||
</Callout>
|
||||
|
||||
## Connecting MCP Clients
|
||||
|
||||
@@ -220,7 +224,7 @@ Workflows execute using the same deployment version as API calls, ensuring consi
|
||||
{ question: "How do I authenticate MCP client connections?", answer: "Include your API key in the X-API-Key header when connecting via mcp-remote or other HTTP-based MCP transports. The server validates authentication using hybrid auth that supports both session-based and API key-based access." },
|
||||
{ question: "Can I add the same workflow to multiple MCP servers?", answer: "Yes. When configuring a workflow as an MCP tool, you can select multiple MCP servers to add it to. Each server exposes its own URL, so you can organize tools into different servers for different use cases or clients." },
|
||||
{ question: "What naming conventions should I follow for tool names?", answer: "Use lowercase letters, numbers, and underscores only. The name should be descriptive and follow MCP naming conventions, such as search_documents or send_email. This helps AI assistants understand and correctly invoke your tools." },
|
||||
{ question: "How are workflow inputs mapped to MCP tool parameters?", answer: "Your workflow's input format fields automatically become MCP tool parameters. You can add descriptions to each parameter in the MCP configuration to help AI assistants understand what values to provide." },
|
||||
{ question: "How are workflow inputs mapped to MCP tool parameters?", answer: "Your workflow's input format fields automatically become MCP tool parameters. Each parameter's description defaults to the description set on that input in the Start block, and you can override it per tool in the MCP configuration to help AI assistants understand what values to provide." },
|
||||
]} />
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
MCP_TOOL_BRIDGE_ACTOR_HEADER,
|
||||
MCP_TOOL_BRIDGE_HEADER,
|
||||
} from '@/lib/mcp/constants'
|
||||
import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServeAPI')
|
||||
@@ -601,8 +602,11 @@ async function handleToolsList(
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
workflowName: workflow.name,
|
||||
workflowDescription: workflow.description,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.innerJoin(workflow, eq(workflowMcpTool.workflowId, workflow.id))
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.serverId, serverId),
|
||||
@@ -629,7 +633,10 @@ async function handleToolsList(
|
||||
)
|
||||
return {
|
||||
name: tool.toolName,
|
||||
description: tool.toolDescription || `Execute workflow: ${tool.toolName}`,
|
||||
description:
|
||||
tool.toolDescription?.trim() ||
|
||||
getMeaningfulWorkflowDescription(tool.workflowDescription, tool.workflowName) ||
|
||||
`Execute workflow: ${tool.toolName}`,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: schema?.properties || {},
|
||||
|
||||
@@ -114,6 +114,7 @@ export const PATCH = withRouteHandler(
|
||||
actorEmail: userEmail,
|
||||
toolName: body.toolName,
|
||||
toolDescription: body.toolDescription,
|
||||
parameterDescriptionOverrides: body.parameterDescriptionOverrides,
|
||||
parameterSchema: body.parameterSchema,
|
||||
})
|
||||
if (!result.success || !result.tool) {
|
||||
|
||||
@@ -64,6 +64,7 @@ export const GET = withRouteHandler(
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterSchema: workflowMcpTool.parameterSchema,
|
||||
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
|
||||
createdAt: workflowMcpTool.createdAt,
|
||||
updatedAt: workflowMcpTool.updatedAt,
|
||||
workflowName: workflow.name,
|
||||
@@ -122,6 +123,7 @@ export const POST = withRouteHandler(
|
||||
workflowId: body.workflowId,
|
||||
toolName: body.toolName,
|
||||
toolDescription: body.toolDescription,
|
||||
parameterDescriptionOverrides: body.parameterDescriptionOverrides,
|
||||
parameterSchema: body.parameterSchema,
|
||||
})
|
||||
if (!result.success || !result.tool) {
|
||||
|
||||
+18
-33
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Check, Clipboard, Plus, Server } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useParams, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -186,7 +186,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolToView.id,
|
||||
toolDescription: editingDescription.trim() || undefined,
|
||||
toolDescription: editingDescription.trim(),
|
||||
parameterSchema: updatedSchema,
|
||||
})
|
||||
setToolToView(null)
|
||||
@@ -395,7 +395,9 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='inline-flex'>
|
||||
<Chip disabled>Add Workflows</Chip>
|
||||
<Chip leftIcon={Plus} variant='primary' disabled>
|
||||
Add Workflows
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
@@ -403,7 +405,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Chip onClick={() => setShowAddWorkflow(true)} disabled={!canAddWorkflow}>
|
||||
<Chip
|
||||
leftIcon={Plus}
|
||||
variant='primary'
|
||||
onClick={() => setShowAddWorkflow(true)}
|
||||
disabled={!canAddWorkflow}
|
||||
>
|
||||
Add Workflows
|
||||
</Chip>
|
||||
)}
|
||||
@@ -430,34 +437,9 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
<div className='min-h-[300px] pt-4'>
|
||||
{activeServerTab === 'workflows' && (
|
||||
<div className='flex flex-col gap-4.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
Workflows
|
||||
</span>
|
||||
{showAddDisabledTooltip ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='inline-flex'>
|
||||
<Chip leftIcon={Plus} variant='primary' disabled>
|
||||
Add Workflow
|
||||
</Chip>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
All deployed workflows have been added to this server.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Chip
|
||||
leftIcon={Plus}
|
||||
variant='primary'
|
||||
onClick={() => setShowAddWorkflow(true)}
|
||||
disabled={!canAddWorkflow}
|
||||
>
|
||||
Add Workflow
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>
|
||||
Workflows
|
||||
</span>
|
||||
|
||||
{tools.length === 0 ? (
|
||||
<p className='text-[var(--text-muted)] text-sm'>
|
||||
@@ -915,6 +897,7 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
|
||||
export function WorkflowMcpServers() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const { data: servers = [], isLoading, error } = useWorkflowMcpServers(workspaceId)
|
||||
const { data: deployedWorkflows = [], isLoading: isLoadingWorkflows } =
|
||||
@@ -923,7 +906,9 @@ export function WorkflowMcpServers() {
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null)
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(() =>
|
||||
searchParams.get('mcpServerId')
|
||||
)
|
||||
const [serverToDelete, setServerToDelete] = useState<WorkflowMcpServer | null>(null)
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
|
||||
|
||||
|
||||
+189
-129
@@ -13,10 +13,15 @@ import {
|
||||
Skeleton,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { generateParameterSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import {
|
||||
extractDescriptionOverrides,
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
getMeaningfulWorkflowDescription,
|
||||
sanitizeToolName,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal'
|
||||
import {
|
||||
@@ -28,8 +33,7 @@ import {
|
||||
type WorkflowMcpServer,
|
||||
type WorkflowMcpTool,
|
||||
} from '@/hooks/queries/workflow-mcp-servers'
|
||||
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('McpToolDeploy')
|
||||
|
||||
@@ -49,9 +53,13 @@ interface McpDeployProps {
|
||||
workflowName: string
|
||||
workflowDescription?: string | null
|
||||
isDeployed: boolean
|
||||
deployedState?: WorkflowState | null
|
||||
isLoadingDeployedState: boolean
|
||||
onAddedToServer?: () => void
|
||||
onSubmittingChange?: (submitting: boolean) => void
|
||||
onCanSaveChange?: (canSave: boolean) => void
|
||||
onSaveDisabledReasonChange?: (reason: string | null) => void
|
||||
onActiveServerChange?: (serverId: string | null) => void
|
||||
}
|
||||
|
||||
function haveSameServerSelection(a: string[], b: string[]): boolean {
|
||||
@@ -60,16 +68,33 @@ function haveSameServerSelection(a: string[], b: string[]): boolean {
|
||||
return a.every((id) => bSet.has(id))
|
||||
}
|
||||
|
||||
function haveSameParameterDescriptions(
|
||||
a: Record<string, string>,
|
||||
b: Record<string, string>
|
||||
): boolean {
|
||||
function haveSameOverrides(a: Record<string, string>, b: Record<string, string>): boolean {
|
||||
const aKeys = Object.keys(a)
|
||||
const bKeys = Object.keys(b)
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
return aKeys.every((key) => a[key] === b[key])
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the edited descriptions to the sparse set that actually overrides the Start-block
|
||||
* defaults: a real value that differs from both the field name and the field's Start-block
|
||||
* description. Mirrors the server's extractDescriptionOverrides so the deploy-modal and
|
||||
* Settings/legacy write paths agree on what counts as an override.
|
||||
*/
|
||||
function computeDescriptionOverrides(
|
||||
descriptions: Record<string, string>,
|
||||
startBlockDescriptions: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const overrides: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(descriptions)) {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed && trimmed !== name && trimmed !== (startBlockDescriptions[name] ?? '').trim()) {
|
||||
overrides[name] = trimmed
|
||||
}
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to query tools for a single server and report back via callback.
|
||||
*/
|
||||
@@ -82,14 +107,14 @@ function ServerToolsQuery({
|
||||
workspaceId: string
|
||||
server: WorkflowMcpServer
|
||||
workflowId: string
|
||||
onData: (serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => void
|
||||
onData: (serverId: string, tool: WorkflowMcpTool | null) => void
|
||||
}) {
|
||||
const { data: tools, isLoading } = useWorkflowMcpTools(workspaceId, server.id)
|
||||
const { data: tools } = useWorkflowMcpTools(workspaceId, server.id)
|
||||
|
||||
useEffect(() => {
|
||||
const tool = tools?.find((t) => t.workflowId === workflowId) || null
|
||||
onData(server.id, tool, isLoading)
|
||||
}, [tools, isLoading, workflowId, server.id, onData])
|
||||
onData(server.id, tool)
|
||||
}, [tools, workflowId, server.id, onData])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -99,9 +124,13 @@ export function McpDeploy({
|
||||
workflowName,
|
||||
workflowDescription,
|
||||
isDeployed,
|
||||
deployedState,
|
||||
isLoadingDeployedState,
|
||||
onAddedToServer,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
onSaveDisabledReasonChange,
|
||||
onActiveServerChange,
|
||||
}: McpDeployProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -112,53 +141,38 @@ export function McpDeploy({
|
||||
const deleteToolMutation = useDeleteWorkflowMcpTool()
|
||||
const updateToolMutation = useUpdateWorkflowMcpTool()
|
||||
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
|
||||
const starterBlockId = useMemo(() => {
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const blockType = (block as { type?: string }).type
|
||||
if (blockType && isInputDefinitionTrigger(blockType)) {
|
||||
return blockId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [blocks])
|
||||
|
||||
const subBlockValues = useSubBlockStore(
|
||||
(state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES
|
||||
)
|
||||
|
||||
// The MCP tool is built from the DEPLOYED Start block and the server materializes overrides
|
||||
// against it; the form waits for the deployed-state load below, so it works off the deployed
|
||||
// snapshot (never the live editor), keeping its defaults and override classification in lockstep
|
||||
// with what MCP clients receive.
|
||||
const inputFormat = useMemo((): NormalizedField[] => {
|
||||
if (!starterBlockId) return []
|
||||
|
||||
const storeValue = subBlockValues[starterBlockId]?.inputFormat
|
||||
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
|
||||
if (normalized.length > 0) return normalized
|
||||
|
||||
const startBlock = blocks[starterBlockId]
|
||||
const blockValue = startBlock?.subBlocks?.inputFormat?.value
|
||||
return normalizeInputFormatValue(blockValue) as NormalizedField[]
|
||||
}, [starterBlockId, subBlockValues, blocks])
|
||||
const deployedBlocks = deployedState?.blocks
|
||||
if (!deployedBlocks) return []
|
||||
return (extractInputFormatFromBlocks(deployedBlocks as Record<string, unknown>) ??
|
||||
[]) as NormalizedField[]
|
||||
}, [deployedState])
|
||||
|
||||
const [toolName, setToolName] = useState(() => sanitizeToolName(workflowName))
|
||||
const [toolDescription, setToolDescription] = useState(() => {
|
||||
const normalizedDesc = workflowDescription?.toLowerCase().trim()
|
||||
const isDefaultDescription =
|
||||
!workflowDescription ||
|
||||
workflowDescription === workflowName ||
|
||||
normalizedDesc === 'new workflow' ||
|
||||
normalizedDesc === 'your first workflow - start building here!'
|
||||
|
||||
return isDefaultDescription ? '' : workflowDescription
|
||||
})
|
||||
const [toolDescription, setToolDescription] = useState('')
|
||||
const workflowDescriptionFallback = getMeaningfulWorkflowDescription(
|
||||
workflowDescription,
|
||||
workflowName
|
||||
)
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
const [pendingServerChanges, setPendingServerChanges] = useState<Set<string>>(() => new Set())
|
||||
const [saveErrors, setSaveErrors] = useState<string[]>([])
|
||||
|
||||
const parameterSchema = useMemo(
|
||||
() => generateParameterSchema(inputFormat, parameterDescriptions),
|
||||
[inputFormat, parameterDescriptions]
|
||||
const startBlockDescriptions = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
for (const field of inputFormat) {
|
||||
map[field.name] = field.description?.trim() ?? ''
|
||||
}
|
||||
return map
|
||||
}, [inputFormat])
|
||||
|
||||
const parameterDescriptionOverrides = useMemo(
|
||||
() => computeDescriptionOverrides(parameterDescriptions, startBlockDescriptions),
|
||||
[parameterDescriptions, startBlockDescriptions]
|
||||
)
|
||||
|
||||
const toolNameError = useMemo(() => {
|
||||
@@ -173,31 +187,24 @@ export function McpDeploy({
|
||||
return null
|
||||
}, [toolName])
|
||||
|
||||
const [serverToolsMap, setServerToolsMap] = useState<
|
||||
Record<string, { tool: WorkflowMcpTool | null; isLoading: boolean }>
|
||||
>({})
|
||||
const [serverToolsMap, setServerToolsMap] = useState<Record<string, WorkflowMcpTool | null>>({})
|
||||
|
||||
const handleServerToolData = useCallback(
|
||||
(serverId: string, tool: WorkflowMcpTool | null, isLoading: boolean) => {
|
||||
setServerToolsMap((prev) => {
|
||||
const existing = prev[serverId]
|
||||
if (existing?.tool?.id === tool?.id && existing?.isLoading === isLoading) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[serverId]: { tool, isLoading },
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
const handleServerToolData = useCallback((serverId: string, tool: WorkflowMcpTool | null) => {
|
||||
setServerToolsMap((prev) => {
|
||||
if (prev[serverId]?.id === tool?.id) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[serverId]: tool,
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectedServerIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
if (serverToolsMap[server.id]) {
|
||||
ids.push(server.id)
|
||||
}
|
||||
}
|
||||
@@ -207,67 +214,66 @@ export function McpDeploy({
|
||||
const [savedValues, setSavedValues] = useState<{
|
||||
toolName: string
|
||||
toolDescription: string
|
||||
parameterDescriptions: Record<string, string>
|
||||
descriptions: Record<string, string>
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (savedValues) return
|
||||
// Seed once the deployed snapshot is ready — present, or its load settled without data. With the
|
||||
// deployed base the legacy migration classifies overrides against what the server serves; on a
|
||||
// failed fetch we still seed so existing tools stay editable instead of silently un-saveable.
|
||||
if (savedValues || (isLoadingDeployedState && !deployedState)) return
|
||||
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
const initialToolName = toolInfo.tool.toolName
|
||||
|
||||
const loadedDescription = toolInfo.tool.toolDescription || ''
|
||||
const normalizedLoadedDesc = loadedDescription.toLowerCase().trim()
|
||||
const isDefaultDescription =
|
||||
!loadedDescription ||
|
||||
loadedDescription === workflowName ||
|
||||
normalizedLoadedDesc === 'new workflow' ||
|
||||
normalizedLoadedDesc === 'your first workflow - start building here!'
|
||||
const initialToolDescription = isDefaultDescription ? '' : loadedDescription
|
||||
|
||||
const schema = toolInfo.tool.parameterSchema as Record<string, unknown> | undefined
|
||||
const properties = schema?.properties as
|
||||
| Record<string, { description?: string }>
|
||||
| undefined
|
||||
const initialParameterDescriptions: Record<string, string> = {}
|
||||
if (properties) {
|
||||
for (const [name, prop] of Object.entries(properties)) {
|
||||
if (
|
||||
prop.description &&
|
||||
prop.description !== name &&
|
||||
prop.description !== 'Array of file objects'
|
||||
) {
|
||||
initialParameterDescriptions[name] = prop.description
|
||||
}
|
||||
}
|
||||
}
|
||||
const existingTool = serverToolsMap[server.id]
|
||||
if (existingTool) {
|
||||
const initialToolName = existingTool.toolName
|
||||
const initialToolDescription = existingTool.toolDescription ?? ''
|
||||
const storedOverrides = existingTool.parameterDescriptionOverrides ?? {}
|
||||
// Tools created before the overrides column kept custom descriptions only in the stored
|
||||
// schema; derive them (dropping field-name and Start-block-default values) so opening and
|
||||
// saving the form never wipes descriptions that were never migrated to the column.
|
||||
const initialOverrides =
|
||||
Object.keys(storedOverrides).length > 0
|
||||
? storedOverrides
|
||||
: extractDescriptionOverrides(
|
||||
existingTool.parameterSchema,
|
||||
generateToolInputSchema(inputFormat)
|
||||
)
|
||||
|
||||
setToolName(initialToolName)
|
||||
setToolDescription(initialToolDescription)
|
||||
setParameterDescriptions(initialParameterDescriptions)
|
||||
setParameterDescriptions(initialOverrides)
|
||||
setSavedValues({
|
||||
toolName: initialToolName,
|
||||
toolDescription: initialToolDescription,
|
||||
parameterDescriptions: initialParameterDescriptions,
|
||||
descriptions: initialOverrides,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [servers, serverToolsMap, workflowName, savedValues])
|
||||
}, [servers, serverToolsMap, inputFormat, isLoadingDeployedState, deployedState, savedValues])
|
||||
|
||||
const selectedServerIdsForForm = draftSelectedServerIds ?? selectedServerIds
|
||||
|
||||
const hasToolConfigurationChanges = useMemo(() => {
|
||||
if (!savedValues) return false
|
||||
if (toolName !== savedValues.toolName) return true
|
||||
if (toolDescription !== savedValues.toolDescription) return true
|
||||
if (!haveSameParameterDescriptions(parameterDescriptions, savedValues.parameterDescriptions)) {
|
||||
if (toolDescription.trim() !== savedValues.toolDescription.trim()) return true
|
||||
const savedOverrides = computeDescriptionOverrides(
|
||||
savedValues.descriptions,
|
||||
startBlockDescriptions
|
||||
)
|
||||
if (!haveSameOverrides(parameterDescriptionOverrides, savedOverrides)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [toolName, toolDescription, parameterDescriptions, savedValues])
|
||||
}, [
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptionOverrides,
|
||||
savedValues,
|
||||
startBlockDescriptions,
|
||||
])
|
||||
const hasServerSelectionChanges = useMemo(
|
||||
() => !haveSameServerSelection(selectedServerIdsForForm, selectedServerIds),
|
||||
[selectedServerIdsForForm, selectedServerIds]
|
||||
@@ -276,10 +282,28 @@ export function McpDeploy({
|
||||
hasServerSelectionChanges ||
|
||||
(hasToolConfigurationChanges && selectedServerIdsForForm.length > 0)
|
||||
|
||||
// Explain the greyed Save when the tool name is valid but no server is chosen (and none is saved
|
||||
// to remove) — the one disabled state with no inline guidance beside the field.
|
||||
const saveDisabledReason = useMemo(() => {
|
||||
if (!toolName.trim() || toolNameError) return null
|
||||
if (selectedServerIdsForForm.length === 0 && selectedServerIds.length === 0) {
|
||||
return 'Select a server to save this tool'
|
||||
}
|
||||
return null
|
||||
}, [toolName, toolNameError, selectedServerIdsForForm, selectedServerIds])
|
||||
|
||||
useEffect(() => {
|
||||
onCanSaveChange?.(hasChanges && !!toolName.trim() && !toolNameError)
|
||||
}, [hasChanges, toolName, toolNameError, onCanSaveChange])
|
||||
|
||||
useEffect(() => {
|
||||
onSaveDisabledReasonChange?.(saveDisabledReason)
|
||||
}, [saveDisabledReason, onSaveDisabledReasonChange])
|
||||
|
||||
useEffect(() => {
|
||||
onActiveServerChange?.(selectedServerIdsForForm[0] ?? null)
|
||||
}, [selectedServerIdsForForm, onActiveServerChange])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!toolName.trim() || toolNameError) return
|
||||
|
||||
@@ -291,11 +315,17 @@ export function McpDeploy({
|
||||
|
||||
if (toAdd.size === 0 && toRemove.length === 0 && !shouldUpdateExisting) return
|
||||
|
||||
const trimmedDescription = toolDescription.trim()
|
||||
const toolDescriptionForSave =
|
||||
trimmedDescription && trimmedDescription !== (workflowDescriptionFallback ?? '')
|
||||
? trimmedDescription
|
||||
: ''
|
||||
|
||||
onSubmittingChange?.(true)
|
||||
setSaveErrors([])
|
||||
try {
|
||||
const errors: string[] = []
|
||||
const addedEntries: Record<string, { tool: WorkflowMcpTool; isLoading: boolean }> = {}
|
||||
const addedEntries: Record<string, WorkflowMcpTool> = {}
|
||||
const removedIds: string[] = []
|
||||
|
||||
for (const serverId of toAdd) {
|
||||
@@ -306,10 +336,10 @@ export function McpDeploy({
|
||||
serverId,
|
||||
workflowId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
toolDescription: toolDescriptionForSave,
|
||||
parameterDescriptionOverrides,
|
||||
})
|
||||
addedEntries[serverId] = { tool: addedTool, isLoading: false }
|
||||
addedEntries[serverId] = addedTool
|
||||
onAddedToServer?.()
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`)
|
||||
} catch (error) {
|
||||
@@ -326,15 +356,15 @@ export function McpDeploy({
|
||||
}
|
||||
|
||||
for (const serverId of toRemove) {
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (!toolInfo?.tool) continue
|
||||
const existingTool = serverToolsMap[serverId]
|
||||
if (!existingTool) continue
|
||||
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
toolId: existingTool.id,
|
||||
})
|
||||
removedIds.push(serverId)
|
||||
} catch (error) {
|
||||
@@ -353,22 +383,41 @@ export function McpDeploy({
|
||||
if (shouldUpdateExisting) {
|
||||
for (const serverId of selectedServerIdsForForm) {
|
||||
if (toAdd.has(serverId)) continue
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (!toolInfo?.tool) continue
|
||||
const existingTool = serverToolsMap[serverId]
|
||||
if (!existingTool) continue
|
||||
|
||||
try {
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
toolId: existingTool.id,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
toolDescription: toolDescriptionForSave,
|
||||
parameterDescriptionOverrides,
|
||||
})
|
||||
} catch (error) {
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to update on ${serverName}`)
|
||||
logger.error(`Failed to update tool on server ${serverId}:`, error)
|
||||
// The tool can be removed out-of-band (undeploying a workflow deletes its MCP tools), so
|
||||
// a stale-cache update may hit a missing tool — re-create it instead of failing the save.
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
try {
|
||||
const recreated = await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
workflowId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescriptionForSave,
|
||||
parameterDescriptionOverrides,
|
||||
})
|
||||
addedEntries[serverId] = recreated
|
||||
} catch (recreateError) {
|
||||
errors.push(`Failed to update on ${serverName}`)
|
||||
logger.error(`Failed to re-add tool on server ${serverId}:`, recreateError)
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to update on ${serverName}`)
|
||||
logger.error(`Failed to update tool on server ${serverId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,7 +436,7 @@ export function McpDeploy({
|
||||
setSavedValues({
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions: { ...parameterDescriptions },
|
||||
descriptions: { ...parameterDescriptions },
|
||||
})
|
||||
onCanSaveChange?.(false)
|
||||
}
|
||||
@@ -429,7 +478,7 @@ export function McpDeploy({
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingServers) {
|
||||
if (isLoadingServers || (isLoadingDeployedState && !deployedState)) {
|
||||
return (
|
||||
<div className='-mx-1 space-y-4 px-1'>
|
||||
<div className='space-y-3'>
|
||||
@@ -517,7 +566,11 @@ export function McpDeploy({
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder='Describe what this tool does...'
|
||||
placeholder={
|
||||
workflowDescriptionFallback
|
||||
? `Defaults to the workflow description: ${workflowDescriptionFallback}`
|
||||
: 'Describe what this tool does...'
|
||||
}
|
||||
className='min-h-[100px] resize-none'
|
||||
value={toolDescription}
|
||||
onChange={(e) => setToolDescription(e.target.value)}
|
||||
@@ -529,6 +582,9 @@ export function McpDeploy({
|
||||
<Label className='mb-[6.5px] block pl-0.5 font-medium text-[var(--text-primary)] text-small'>
|
||||
Parameters ({inputFormat.length})
|
||||
</Label>
|
||||
<p className='mb-[6.5px] pl-0.5 text-[var(--text-secondary)] text-xs'>
|
||||
Descriptions default to your Start block inputs; edit to override for this tool.
|
||||
</p>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{inputFormat.map((field) => (
|
||||
<div
|
||||
@@ -549,14 +605,18 @@ export function McpDeploy({
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<Label className='text-small'>Description</Label>
|
||||
<ChipInput
|
||||
value={parameterDescriptions[field.name] || ''}
|
||||
value={
|
||||
parameterDescriptions[field.name] ??
|
||||
startBlockDescriptions[field.name] ??
|
||||
''
|
||||
}
|
||||
onChange={(e) =>
|
||||
setParameterDescriptions((prev) => ({
|
||||
...prev,
|
||||
[field.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Enter description for ${field.name}`}
|
||||
placeholder={startBlockDescriptions[field.name] || `Describe ${field.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-9
@@ -20,6 +20,7 @@ import {
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
|
||||
@@ -141,6 +142,8 @@ export function DeployModal({
|
||||
const [undeployTargetWorkflowId, setUndeployTargetWorkflowId] = useState<string | null>(null)
|
||||
const [mcpToolSubmitting, setMcpToolSubmitting] = useState(false)
|
||||
const [mcpToolCanSave, setMcpToolCanSave] = useState(false)
|
||||
const [mcpToolSaveDisabledReason, setMcpToolSaveDisabledReason] = useState<string | null>(null)
|
||||
const [mcpActiveServerId, setMcpActiveServerId] = useState<string | null>(null)
|
||||
const [a2aSubmitting, setA2aSubmitting] = useState(false)
|
||||
const [a2aCanSave, setA2aCanSave] = useState(false)
|
||||
const [a2aNeedsRepublish, setA2aNeedsRepublish] = useState(false)
|
||||
@@ -668,8 +671,12 @@ export function DeployModal({
|
||||
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||
workflowDescription={workflowMetadata?.description}
|
||||
isDeployed={isDeployed}
|
||||
deployedState={deployedState}
|
||||
isLoadingDeployedState={isLoadingDeployedState}
|
||||
onSubmittingChange={setMcpToolSubmitting}
|
||||
onCanSaveChange={setMcpToolCanSave}
|
||||
onSaveDisabledReasonChange={setMcpToolSaveDisabledReason}
|
||||
onActiveServerChange={setMcpActiveServerId}
|
||||
/>
|
||||
)}
|
||||
</GatedTabContent>
|
||||
@@ -769,18 +776,32 @@ export function DeployModal({
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
onClick={() => navigateToSettings({ section: 'workflow-mcp-servers' })}
|
||||
onClick={() =>
|
||||
navigateToSettings({
|
||||
section: 'workflow-mcp-servers',
|
||||
mcpServerId: mcpActiveServerId ?? undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
onClick={handleMcpToolFormSubmit}
|
||||
disabled={mcpToolSubmitting || !mcpToolCanSave}
|
||||
>
|
||||
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
|
||||
</Button>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='tertiary'
|
||||
onClick={handleMcpToolFormSubmit}
|
||||
disabled={mcpToolSubmitting || !mcpToolCanSave}
|
||||
>
|
||||
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
{mcpToolSaveDisabledReason && (
|
||||
<Tooltip.Content>{mcpToolSaveDisabledReason}</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
|
||||
@@ -262,7 +262,7 @@ interface AddWorkflowMcpToolParams {
|
||||
workflowId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterSchema?: Record<string, unknown>
|
||||
parameterDescriptionOverrides?: Record<string, string>
|
||||
}
|
||||
|
||||
export function useAddWorkflowMcpTool() {
|
||||
@@ -275,12 +275,12 @@ export function useAddWorkflowMcpTool() {
|
||||
workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
parameterDescriptionOverrides,
|
||||
}: AddWorkflowMcpToolParams) => {
|
||||
const data = await requestJson(createWorkflowMcpToolContract, {
|
||||
params: { id: serverId },
|
||||
query: { workspaceId },
|
||||
body: { workflowId, toolName, toolDescription, parameterSchema },
|
||||
body: { workflowId, toolName, toolDescription, parameterDescriptionOverrides },
|
||||
})
|
||||
|
||||
logger.info(`Added tool to workflow MCP server: ${serverId}`)
|
||||
@@ -311,6 +311,11 @@ interface UpdateWorkflowMcpToolParams {
|
||||
toolId: string
|
||||
toolName?: string
|
||||
toolDescription?: string
|
||||
parameterDescriptionOverrides?: Record<string, string>
|
||||
/**
|
||||
* Full edited schema sent by the Settings edit modal, which has no Start-block context to compute
|
||||
* sparse overrides; the server diffs it against the deployed base to extract the overrides.
|
||||
*/
|
||||
parameterSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export const workflowMcpToolSchema = z
|
||||
toolName: z.string(),
|
||||
toolDescription: z.string().nullable(),
|
||||
parameterSchema: z.record(z.string(), z.unknown()),
|
||||
parameterDescriptionOverrides: z.record(z.string(), z.string()).optional(),
|
||||
createdAt: dateStringSchema,
|
||||
updatedAt: dateStringSchema,
|
||||
workflowName: z.string().nullable().optional(),
|
||||
@@ -96,6 +97,8 @@ export const createWorkflowMcpToolBodySchema = z
|
||||
workflowId: z.string().min(1),
|
||||
toolName: z.string().optional(),
|
||||
toolDescription: z.string().optional(),
|
||||
parameterDescriptionOverrides: z.record(z.string(), z.string()).optional(),
|
||||
/** Legacy full schema accepted during the override-model rollout; prefer parameterDescriptionOverrides. */
|
||||
parameterSchema: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
@@ -105,6 +108,8 @@ export const updateWorkflowMcpToolBodySchema = z
|
||||
workspaceId: z.string().optional(),
|
||||
toolName: z.string().optional(),
|
||||
toolDescription: z.string().nullable().optional(),
|
||||
parameterDescriptionOverrides: z.record(z.string(), z.string()).optional(),
|
||||
/** Legacy full schema accepted during the override-model rollout; prefer parameterDescriptionOverrides. */
|
||||
parameterSchema: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -219,6 +219,7 @@ export const workflowListItemSchema = z.object({
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
locked: z.boolean(),
|
||||
isDeployed: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const createWorkflowBodySchema = z.object({
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
performUpdateWorkflowMcpTool,
|
||||
} from '@/lib/mcp/orchestration'
|
||||
import { getDeployedWorkflowInputFormat } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { generateParameterSchema, sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import {
|
||||
applyDescriptionOverrides,
|
||||
generateToolInputSchema,
|
||||
getMeaningfulWorkflowDescription,
|
||||
sanitizeToolName,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import {
|
||||
performChatDeploy,
|
||||
performChatUndeploy,
|
||||
@@ -603,22 +608,25 @@ export async function executeDeployMcp(
|
||||
params.toolName || workflowRecord.name || `workflow_${workflowId}`
|
||||
)
|
||||
const toolDescription =
|
||||
params.toolDescription ||
|
||||
workflowRecord.description ||
|
||||
params.toolDescription?.trim() ||
|
||||
getMeaningfulWorkflowDescription(workflowRecord.description, workflowRecord.name) ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
/**
|
||||
* Build the parameter schema exactly as the deploy modal does: overlay the
|
||||
* caller-supplied per-parameter descriptions onto the workflow's deployed
|
||||
* input format, then generate the schema. Names/types/required come from the
|
||||
* workflow's input trigger — this tool only sets descriptions.
|
||||
* Parameter names/types come from the workflow's deployed input trigger; this tool only sets
|
||||
* per-parameter descriptions, sent as sparse overrides. The materialized schema is echoed in the
|
||||
* response for the model's reference.
|
||||
*/
|
||||
const inputFormat = await getDeployedWorkflowInputFormat(workflowId)
|
||||
const parameterDescriptions = Object.fromEntries(
|
||||
const parameterDescriptionOverrides = Object.fromEntries(
|
||||
(params.parameterDescriptions ?? [])
|
||||
.filter((entry) => entry && typeof entry.name === 'string' && entry.name.trim() !== '')
|
||||
.map((entry) => [entry.name, entry.description ?? ''])
|
||||
.map((entry) => [entry.name.trim(), (entry.description ?? '').trim()])
|
||||
.filter(([, description]) => description !== '')
|
||||
)
|
||||
const parameterSchema = applyDescriptionOverrides(
|
||||
generateToolInputSchema(inputFormat),
|
||||
parameterDescriptionOverrides
|
||||
)
|
||||
const parameterSchema = generateParameterSchema(inputFormat, parameterDescriptions)
|
||||
const baseUrl = getBaseUrl()
|
||||
const mcpServerUrl = `${baseUrl}/api/mcp/serve/${serverId}`
|
||||
const apiEndpoint = buildWorkflowApiEndpoint(baseUrl, workflowId)
|
||||
@@ -633,7 +641,7 @@ export async function executeDeployMcp(
|
||||
userId: context.userId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
parameterDescriptionOverrides,
|
||||
})
|
||||
if (!updateResult.success || !updateResult.tool) {
|
||||
return { success: false, error: updateResult.error || 'Failed to update MCP tool' }
|
||||
@@ -695,7 +703,7 @@ export async function executeDeployMcp(
|
||||
workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
parameterDescriptionOverrides,
|
||||
})
|
||||
if (!createResult.success || !createResult.tool) {
|
||||
return { success: false, error: createResult.error || 'Failed to deploy MCP tool' }
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
addMcpToolMetadataUsageRow,
|
||||
createMcpToolMetadataUsageRow,
|
||||
getMcpServerToolMetadataUsageRows,
|
||||
getMcpToolDescriptionForStorage,
|
||||
getMcpToolMetadataSizes,
|
||||
getMcpToolMetadataUsageFromRows,
|
||||
type McpToolMetadataUsage,
|
||||
@@ -29,7 +28,12 @@ import {
|
||||
validateMcpToolMetadataForStorage,
|
||||
} from '@/lib/mcp/tool-limits'
|
||||
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import {
|
||||
applyDescriptionOverrides,
|
||||
extractDescriptionOverrides,
|
||||
pruneOverridesToSchema,
|
||||
sanitizeToolName,
|
||||
} from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
const logger = createLogger('WorkflowMcpOrchestration')
|
||||
@@ -110,6 +114,8 @@ export interface PerformCreateWorkflowMcpToolParams extends ActorMetadata {
|
||||
workflowId: string
|
||||
toolName?: string
|
||||
toolDescription?: string | null
|
||||
parameterDescriptionOverrides?: Record<string, string>
|
||||
/** Legacy full schema accepted during rollout; converted to overrides. */
|
||||
parameterSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -127,6 +133,8 @@ export interface PerformUpdateWorkflowMcpToolParams extends ActorMetadata {
|
||||
userId: string
|
||||
toolName?: string
|
||||
toolDescription?: string | null
|
||||
parameterDescriptionOverrides?: Record<string, string>
|
||||
/** Legacy full schema accepted during rollout; converted to overrides. */
|
||||
parameterSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -156,6 +164,7 @@ interface PreparedWorkflowMcpTool {
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterSchema: unknown
|
||||
parameterDescriptionOverrides: Record<string, string>
|
||||
}
|
||||
|
||||
interface WorkflowMcpToolWorkflowRecord {
|
||||
@@ -213,18 +222,23 @@ async function prepareWorkflowMcpTool(params: {
|
||||
workflowRecord: WorkflowMcpToolWorkflowRecord
|
||||
toolName?: string
|
||||
toolDescription?: string | null
|
||||
parameterSchema?: Record<string, unknown>
|
||||
parameterDescriptionOverrides?: Record<string, string>
|
||||
legacyParameterSchema?: Record<string, unknown>
|
||||
}): Promise<PreparedWorkflowMcpTool> {
|
||||
const { workflowRecord } = params
|
||||
const toolName = sanitizeToolName(params.toolName?.trim() || workflowRecord.name)
|
||||
const toolDescription =
|
||||
params.toolDescription !== undefined
|
||||
? params.toolDescription?.trim() || `Execute ${workflowRecord.name} workflow`
|
||||
: getMcpToolDescriptionForStorage(workflowRecord.description, workflowRecord.name)
|
||||
const parameterSchema =
|
||||
params.parameterSchema && Object.keys(params.parameterSchema).length > 0
|
||||
? params.parameterSchema
|
||||
: await generateParameterSchemaForWorkflow(workflowRecord.id)
|
||||
params.toolDescription !== undefined ? params.toolDescription?.trim() || null : null
|
||||
|
||||
const baseSchema = await generateParameterSchemaForWorkflow(workflowRecord.id)
|
||||
const requestedOverrides =
|
||||
params.parameterDescriptionOverrides ??
|
||||
(params.legacyParameterSchema
|
||||
? extractDescriptionOverrides(params.legacyParameterSchema, baseSchema)
|
||||
: {})
|
||||
const parameterDescriptionOverrides = pruneOverridesToSchema(requestedOverrides, baseSchema)
|
||||
const parameterSchema = applyDescriptionOverrides(baseSchema, parameterDescriptionOverrides)
|
||||
|
||||
const metadataLimitError = validateMcpToolMetadataForStorage({
|
||||
toolName,
|
||||
toolDescription,
|
||||
@@ -239,6 +253,7 @@ async function prepareWorkflowMcpTool(params: {
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
parameterDescriptionOverrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +486,7 @@ export async function performCreateWorkflowMcpServer(
|
||||
toolName: preparedTool.toolName,
|
||||
toolDescription: preparedTool.toolDescription,
|
||||
parameterSchema: preparedTool.parameterSchema,
|
||||
parameterDescriptionOverrides: preparedTool.parameterDescriptionOverrides,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -690,9 +706,11 @@ export async function performCreateWorkflowMcpTool(
|
||||
workflowRecord,
|
||||
toolName: params.toolName,
|
||||
toolDescription: params.toolDescription,
|
||||
parameterSchema: params.parameterSchema,
|
||||
parameterDescriptionOverrides: params.parameterDescriptionOverrides,
|
||||
legacyParameterSchema: params.parameterSchema,
|
||||
})
|
||||
const { toolName, toolDescription, parameterSchema } = preparedTool
|
||||
const { toolName, toolDescription, parameterSchema, parameterDescriptionOverrides } =
|
||||
preparedTool
|
||||
|
||||
const toolId = generateId()
|
||||
const tool = await db.transaction(async (tx) => {
|
||||
@@ -805,6 +823,7 @@ export async function performCreateWorkflowMcpTool(
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema,
|
||||
parameterDescriptionOverrides,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -880,7 +899,35 @@ export async function performUpdateWorkflowMcpTool(
|
||||
if (params.toolDescription !== undefined) {
|
||||
updateData.toolDescription = params.toolDescription?.trim() || null
|
||||
}
|
||||
if (params.parameterSchema !== undefined) updateData.parameterSchema = params.parameterSchema
|
||||
|
||||
const overridesProvided =
|
||||
params.parameterDescriptionOverrides !== undefined || params.parameterSchema !== undefined
|
||||
if (overridesProvided) {
|
||||
const [toolRow] = await db
|
||||
.select({ workflowId: workflowMcpTool.workflowId })
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowMcpTool.id, params.toolId),
|
||||
eq(workflowMcpTool.serverId, params.serverId),
|
||||
isNull(workflowMcpTool.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (!toolRow) return { success: false, error: 'Tool not found', errorCode: 'not_found' }
|
||||
|
||||
const baseSchema = await generateParameterSchemaForWorkflow(toolRow.workflowId)
|
||||
const overrides = pruneOverridesToSchema(
|
||||
params.parameterDescriptionOverrides ??
|
||||
(params.parameterSchema
|
||||
? extractDescriptionOverrides(params.parameterSchema, baseSchema)
|
||||
: {}),
|
||||
baseSchema
|
||||
)
|
||||
updateData.parameterDescriptionOverrides = overrides
|
||||
updateData.parameterSchema = applyDescriptionOverrides(baseSchema, overrides)
|
||||
}
|
||||
|
||||
const updatedFields = Object.keys(updateData).filter((key) => key !== 'updatedAt')
|
||||
|
||||
const tool = await db.transaction(async (tx) => {
|
||||
|
||||
@@ -155,17 +155,6 @@ export async function getMcpServerToolMetadataUsageRows(
|
||||
}))
|
||||
}
|
||||
|
||||
export function getMcpToolDescriptionForStorage(
|
||||
description: string | null | undefined,
|
||||
workflowName: string
|
||||
): string {
|
||||
const trimmed = description?.trim()
|
||||
if (trimmed && utf8Size(trimmed) <= MAX_MCP_TOOL_DESCRIPTION_BYTES) {
|
||||
return trimmed
|
||||
}
|
||||
return `Execute ${workflowName} workflow`
|
||||
}
|
||||
|
||||
export function validateMcpToolMetadataForStorage(metadata: {
|
||||
toolName?: string | null
|
||||
toolDescription?: string | null
|
||||
|
||||
@@ -20,7 +20,12 @@ import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-util
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { mcpPubSub } from './pubsub'
|
||||
import { extractInputFormatFromBlocks, generateToolInputSchema } from './workflow-tool-schema'
|
||||
import {
|
||||
applyDescriptionOverrides,
|
||||
extractInputFormatFromBlocks,
|
||||
generateToolInputSchema,
|
||||
pruneOverridesToSchema,
|
||||
} from './workflow-tool-schema'
|
||||
|
||||
const logger = createLogger('WorkflowMcpSync')
|
||||
|
||||
@@ -41,6 +46,7 @@ interface WorkflowMcpToolSyncRow {
|
||||
serverId: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
parameterDescriptionOverrides: Record<string, string>
|
||||
}
|
||||
|
||||
interface ServerMetadataUsageState {
|
||||
@@ -60,6 +66,7 @@ async function listWorkflowMcpToolSyncPage(
|
||||
serverId: workflowMcpTool.serverId,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.where(
|
||||
@@ -214,7 +221,7 @@ export async function syncMcpToolsForWorkflow(
|
||||
if (schemaLimitError) {
|
||||
throw new Error(schemaLimitError)
|
||||
}
|
||||
const parameterSchema = generatedParameterSchema
|
||||
const baseParameterSchema = generatedParameterSchema
|
||||
|
||||
const affectedServerIds = new Set<string>()
|
||||
const lockedServers = await collectWorkflowMcpToolServerIds(tx, workflowId)
|
||||
@@ -258,26 +265,22 @@ export async function syncMcpToolsForWorkflow(
|
||||
if (!usageState) {
|
||||
throw new Error(`Missing locked MCP server usage state for server ${serverId}`)
|
||||
}
|
||||
const schemaToolIds: string[] = []
|
||||
const emptySchemaToolIds: string[] = []
|
||||
|
||||
for (const tool of serverTools) {
|
||||
const existingUsage = subtractMcpToolMetadataUsageRow(
|
||||
usageState.serverUsage,
|
||||
usageState.usageByToolId.get(tool.id)
|
||||
)
|
||||
const prunedOverrides = pruneOverridesToSchema(
|
||||
tool.parameterDescriptionOverrides,
|
||||
baseParameterSchema
|
||||
)
|
||||
const mergedSchema = applyDescriptionOverrides(baseParameterSchema, prunedOverrides)
|
||||
const shouldUseEmptySchema = exceedsMcpServerToolMetadataBudget(existingUsage, {
|
||||
toolName: tool.toolName,
|
||||
toolDescription: tool.toolDescription,
|
||||
parameterSchema,
|
||||
parameterSchema: mergedSchema,
|
||||
})
|
||||
const schemaForTool = shouldUseEmptySchema ? EMPTY_SCHEMA : parameterSchema
|
||||
|
||||
if (shouldUseEmptySchema) {
|
||||
emptySchemaToolIds.push(tool.id)
|
||||
} else {
|
||||
schemaToolIds.push(tool.id)
|
||||
}
|
||||
const schemaForTool = shouldUseEmptySchema ? EMPTY_SCHEMA : mergedSchema
|
||||
|
||||
const updatedUsageRow = createMcpToolMetadataUsageRow({
|
||||
id: tool.id,
|
||||
@@ -287,26 +290,15 @@ export async function syncMcpToolsForWorkflow(
|
||||
})
|
||||
usageState.usageByToolId.set(tool.id, updatedUsageRow)
|
||||
usageState.serverUsage = addMcpToolMetadataUsageRow(existingUsage, updatedUsageRow)
|
||||
}
|
||||
|
||||
if (schemaToolIds.length > 0) {
|
||||
await tx
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema,
|
||||
parameterSchema: schemaForTool,
|
||||
parameterDescriptionOverrides: prunedOverrides,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(inArray(workflowMcpTool.id, schemaToolIds))
|
||||
}
|
||||
|
||||
if (emptySchemaToolIds.length > 0) {
|
||||
await tx
|
||||
.update(workflowMcpTool)
|
||||
.set({
|
||||
parameterSchema: EMPTY_SCHEMA,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(inArray(workflowMcpTool.id, emptySchemaToolIds))
|
||||
.where(eq(workflowMcpTool.id, tool.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,21 +188,97 @@ export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToo
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an MCP tool parameter schema from an input format, overlaying
|
||||
* caller-supplied per-parameter descriptions (keyed by field name) before
|
||||
* generating the JSON Schema. This is the single source of truth shared by the
|
||||
* deploy modal UI and the copilot `deploy_mcp` tool so both produce identical
|
||||
* schemas for the same inputs.
|
||||
* Overlay sparse per-parameter description overrides onto a base schema produced by
|
||||
* `generateToolInputSchema`. Only keys present in both `overrides` and the base are applied;
|
||||
* overrides for fields no longer in the Start block are ignored. An empty override falls back to
|
||||
* the field name, matching the base converter's "no description" behavior.
|
||||
*/
|
||||
export function generateParameterSchema(
|
||||
inputFormat: InputFormatField[],
|
||||
descriptions: Record<string, string>
|
||||
export function applyDescriptionOverrides(
|
||||
baseSchema: Record<string, unknown>,
|
||||
overrides: Record<string, string> | null | undefined
|
||||
): Record<string, unknown> {
|
||||
const fieldsWithDescriptions = inputFormat.map((field) => ({
|
||||
...field,
|
||||
description: field.name ? descriptions[field.name]?.trim() || undefined : undefined,
|
||||
}))
|
||||
return { ...generateToolInputSchema(fieldsWithDescriptions) }
|
||||
if (!overrides || Object.keys(overrides).length === 0) return baseSchema
|
||||
const baseProperties = baseSchema.properties as Record<string, McpToolProperty> | undefined
|
||||
if (!baseProperties) return baseSchema
|
||||
|
||||
const properties: Record<string, McpToolProperty> = {}
|
||||
for (const [name, property] of Object.entries(baseProperties)) {
|
||||
const override = overrides[name]
|
||||
properties[name] =
|
||||
typeof override === 'string'
|
||||
? { ...property, description: override.trim() || name }
|
||||
: property
|
||||
}
|
||||
|
||||
return { ...baseSchema, properties }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop override entries whose parameter no longer exists in the base schema, so the stored override
|
||||
* map never accumulates or resurrects descriptions for removed Start-block inputs.
|
||||
*/
|
||||
export function pruneOverridesToSchema(
|
||||
overrides: Record<string, string>,
|
||||
baseSchema: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
const baseProperties = (baseSchema.properties ?? {}) as Record<string, unknown>
|
||||
const pruned: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(overrides)) {
|
||||
if (name in baseProperties) pruned[name] = value
|
||||
}
|
||||
return pruned
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the sparse description-override map between a full schema and the Start-block base: keep
|
||||
* only fields whose description is a real custom value (present, not equal to the field name, and
|
||||
* different from the base). Used to migrate a legacy full `parameterSchema` payload into overrides
|
||||
* during the transition window.
|
||||
*/
|
||||
export function extractDescriptionOverrides(
|
||||
schema: Record<string, unknown> | null | undefined,
|
||||
baseSchema: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
const overrides: Record<string, string> = {}
|
||||
const schemaProperties = schema?.properties as
|
||||
| Record<string, { description?: unknown }>
|
||||
| undefined
|
||||
if (!schemaProperties) return overrides
|
||||
const baseProperties = (baseSchema.properties ?? {}) as Record<string, McpToolProperty>
|
||||
|
||||
for (const [name, property] of Object.entries(schemaProperties)) {
|
||||
if (!(name in baseProperties)) continue
|
||||
const description = typeof property?.description === 'string' ? property.description.trim() : ''
|
||||
if (!description || description === name) continue
|
||||
const baseDescription =
|
||||
typeof baseProperties[name]?.description === 'string'
|
||||
? (baseProperties[name].description as string)
|
||||
: ''
|
||||
if (description !== baseDescription) overrides[name] = description
|
||||
}
|
||||
|
||||
return overrides
|
||||
}
|
||||
|
||||
const DEFAULT_WORKFLOW_DESCRIPTIONS = new Set([
|
||||
'new workflow',
|
||||
'your first workflow - start building here!',
|
||||
])
|
||||
|
||||
/**
|
||||
* Returns the workflow description when it is a real, user-meaningful value, or `null` for empty or
|
||||
* placeholder defaults (so callers can fall back to a derived description). Shared by the serve
|
||||
* layer and the deploy UI so both treat the same values as "no description".
|
||||
*/
|
||||
export function getMeaningfulWorkflowDescription(
|
||||
description: string | null | undefined,
|
||||
workflowName?: string | null
|
||||
): string | null {
|
||||
const trimmed = description?.trim()
|
||||
if (!trimmed) return null
|
||||
if (DEFAULT_WORKFLOW_DESCRIPTIONS.has(trimmed.toLowerCase())) return null
|
||||
if (workflowName && trimmed === workflowName.trim()) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ const listColumns = {
|
||||
updatedAt: workflow.updatedAt,
|
||||
archivedAt: workflow.archivedAt,
|
||||
locked: workflow.locked,
|
||||
isDeployed: workflow.isDeployed,
|
||||
} as const
|
||||
|
||||
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
||||
@@ -39,6 +40,7 @@ type WorkflowListRow = {
|
||||
updatedAt: Date
|
||||
archivedAt: Date | null
|
||||
locked: boolean
|
||||
isDeployed: boolean
|
||||
}
|
||||
|
||||
/** Normalizes timestamp columns to ISO strings to honor the `WorkflowListItem` wire contract. */
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow_mcp_tool" ADD COLUMN "parameter_description_overrides" json DEFAULT '{}'::json NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1723,6 +1723,13 @@
|
||||
"when": 1781985478354,
|
||||
"tag": "0246_convert_grandfathered_all_ws_permission_groups",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 247,
|
||||
"version": "7",
|
||||
"when": 1782092169888,
|
||||
"tag": "0247_workflow_mcp_tool_param_desc_overrides",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2531,6 +2531,10 @@ export const workflowMcpTool = pgTable(
|
||||
toolName: text('tool_name').notNull(),
|
||||
toolDescription: text('tool_description'),
|
||||
parameterSchema: json('parameter_schema').notNull().default('{}'),
|
||||
parameterDescriptionOverrides: json('parameter_description_overrides')
|
||||
.$type<Record<string, string>>()
|
||||
.notNull()
|
||||
.default(sql`'{}'::json`),
|
||||
archivedAt: timestamp('archived_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
|
||||
Reference in New Issue
Block a user