Files
simstudioai--sim/apps/sim/lib/mcp/workflow-tool-schema.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

332 lines
10 KiB
TypeScript

import { z } from 'zod'
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import type { McpToolSchema } from './types'
/**
* Extended property definition for workflow tool schemas.
* More specific than the generic McpToolSchema properties.
*/
export interface McpToolProperty {
[key: string]: unknown
type: string
description?: string
items?: McpToolProperty
properties?: Record<string, McpToolProperty>
}
/**
* Extended MCP tool schema with typed properties (for workflow tool generation).
* Extends the base McpToolSchema with more specific property types.
*/
export interface McpToolInputSchema extends McpToolSchema {
properties: Record<string, McpToolProperty>
}
export interface McpToolDefinition {
name: string
description: string
inputSchema: McpToolInputSchema
}
/**
* File item Zod schema for MCP file inputs.
* This is the single source of truth for file structure.
*/
export const fileItemZodSchema = z.object({
name: z.string().describe('File name'),
data: z.string().describe('Base64 encoded file content'),
mimeType: z.string().describe('MIME type of the file'),
})
/**
* Convert InputFormatField type to Zod schema
*/
function fieldTypeToZod(fieldType: string | undefined, isRequired: boolean): z.ZodTypeAny {
let zodType: z.ZodTypeAny
switch (fieldType) {
case 'string':
zodType = z.string()
break
case 'number':
zodType = z.number()
break
case 'boolean':
zodType = z.boolean()
break
case 'object':
zodType = z.record(z.string(), z.any())
break
case 'array':
zodType = z.array(z.any())
break
case 'files':
zodType = z.array(fileItemZodSchema)
break
default:
zodType = z.string()
}
return isRequired ? zodType : zodType.optional()
}
/**
* Generate Zod schema shape from InputFormatField array.
* This is used directly by the MCP server for tool registration.
*/
export function generateToolZodSchema(inputFormat: InputFormatField[]): z.ZodRawShape | undefined {
if (!inputFormat || inputFormat.length === 0) {
return undefined
}
const shape: Record<string, z.ZodTypeAny> = {}
for (const field of inputFormat) {
if (!field.name) continue
const zodType = fieldTypeToZod(field.type, true)
shape[field.name] = field.name ? zodType.describe(field.name) : zodType
}
return Object.keys(shape).length > 0 ? shape : undefined
}
/**
* Map InputFormatField type to JSON Schema type (for database storage)
*/
function mapFieldTypeToJsonSchemaType(fieldType: string | undefined): string {
switch (fieldType) {
case 'string':
return 'string'
case 'number':
return 'number'
case 'boolean':
return 'boolean'
case 'object':
return 'object'
case 'array':
return 'array'
case 'files':
return 'array'
default:
return 'string'
}
}
/**
* Sanitize a workflow name to be a valid MCP tool name.
* Tool names should be lowercase, alphanumeric with underscores.
*/
export function sanitizeToolName(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9\s_-]/g, '')
.replace(/[\s-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.substring(0, 64) || 'workflow_tool'
)
}
/**
* Generate MCP tool input schema from InputFormatField array.
* This converts the workflow's input format definition to JSON Schema format
* that MCP clients can use to understand tool parameters.
*/
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolInputSchema {
const properties: Record<string, McpToolProperty> = {}
const required: string[] = []
for (const field of inputFormat) {
if (!field.name) continue
const fieldName = field.name
const fieldType = mapFieldTypeToJsonSchemaType(field.type)
const property: McpToolProperty = {
type: fieldType,
// Use custom description if provided, otherwise use field name
description: field.description?.trim() || fieldName,
}
// Handle array types
if (fieldType === 'array') {
if (field.type === 'file[]') {
property.items = {
type: 'object',
properties: {
name: { type: 'string', description: 'File name' },
url: { type: 'string', description: 'File URL' },
type: { type: 'string', description: 'MIME type' },
size: { type: 'number', description: 'File size in bytes' },
},
}
// Use custom description if provided, otherwise use default
if (!field.description?.trim()) {
property.description = 'Array of file objects'
}
} else {
property.items = { type: 'string' }
}
}
properties[fieldName] = property
// All fields are considered required by default
// (in the future, we could add an optional flag to InputFormatField)
required.push(fieldName)
}
return {
type: 'object',
properties,
required: required.length > 0 ? required : undefined,
}
}
/**
* 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 applyDescriptionOverrides(
baseSchema: Record<string, unknown>,
overrides: Record<string, string> | null | undefined
): Record<string, unknown> {
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
}
/**
* Generate a complete MCP tool definition from workflow metadata and input format.
*/
export function generateToolDefinition(
workflowName: string,
workflowDescription: string | undefined | null,
inputFormat: InputFormatField[],
customToolName?: string,
customDescription?: string
): McpToolDefinition {
return {
name: customToolName || sanitizeToolName(workflowName),
description: customDescription || workflowDescription || `Execute ${workflowName} workflow`,
inputSchema: generateToolInputSchema(inputFormat),
}
}
/**
* Extract input format from a workflow's blocks.
* Looks for any valid start block and extracts its inputFormat configuration.
*/
export function extractInputFormatFromBlocks(
blocks: Record<string, unknown>
): InputFormatField[] | null {
// Look for any valid start block
for (const [, block] of Object.entries(blocks)) {
if (!block || typeof block !== 'object') continue
const blockObj = block as Record<string, unknown>
const blockType = blockObj.type as string
if (isInputDefinitionTrigger(blockType)) {
// Try to get inputFormat from subBlocks.inputFormat.value
const subBlocks = blockObj.subBlocks as Record<string, { value?: unknown }> | undefined
const subBlockValue = subBlocks?.inputFormat?.value
// Try legacy config.params.inputFormat
const config = blockObj.config as Record<string, unknown> | undefined
const params = config?.params as Record<string, unknown> | undefined
const paramsValue = params?.inputFormat
const normalized = normalizeInputFormatValue(subBlockValue ?? paramsValue)
return normalized.length > 0 ? normalized : null
}
}
return null
}