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:
Vikhyath Mondreti
2026-06-21 21:23:42 -07:00
committed by GitHub
parent f5d42ce452
commit 951ad42a23
20 changed files with 17235 additions and 256 deletions
@@ -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) {
@@ -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())
@@ -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>
@@ -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()
+1
View File
@@ -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) => {
-11
View File
@@ -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
+19 -27
View File
@@ -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))
}
}
+89 -13
View File
@@ -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
}
/**
+2
View File
@@ -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
}
]
}
+4
View File
@@ -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(),