From b61c955d3f9c32db219d04e9012f17feb714e27f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Oct 2026 13:00:20 -0700 Subject: [PATCH 1/3] fix(workflows): accept json input type and return 400 for unsupported input types A Start/API trigger input field typed "json" (the name blocks use for objects) made manual and copilot runs fail with a 500, and broke MCP schema sync on deploy, because the shared run schema threw a plain Error on any type outside its list. Treat json as object and files as file[] everywhere the type is read, raise OrchestrationError('validation') for reserved names and unsupported types so callers answer 400 naming the field and allowed types, and canonicalize or reject input-format row types at write time. --- apps/sim/executor/utils/start-block.test.ts | 3 ++ apps/sim/executor/utils/start-block.ts | 3 +- .../lib/workflows/editing/validation.test.ts | 31 +++++++++++++ apps/sim/lib/workflows/editing/validation.ts | 43 ++++++++++++++++++- apps/sim/lib/workflows/input-format-schema.ts | 36 +++++++++++++++- apps/sim/lib/workflows/input-format.ts | 7 ++- apps/sim/lib/workflows/input-schema.test.ts | 22 ++++++++-- apps/sim/lib/workflows/input-schema.ts | 31 +++++++++---- 8 files changed, 160 insertions(+), 16 deletions(-) diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 9f5887ecf7b..758cbe2116c 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -430,6 +430,7 @@ describe('start-block utilities', () => { value: [ { name: 'count', type: 'number' }, { name: 'payload', type: 'object' }, + { name: 'meta', type: 'json' }, ], }, }, @@ -446,12 +447,14 @@ describe('start-block utilities', () => { workflowInput: { count: '5', payload: '{"event":"push"}', + meta: '{"source":"api"}', extra: 'untouched', }, }) expect(output.count).toBe(5) expect(output.payload).toEqual({ event: 'push' }) + expect(output.meta).toEqual({ source: 'api' }) expect(output.extra).toBe('untouched') }) }) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index e2efde6d84a..c3dea3bd3ab 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -7,6 +7,7 @@ import { isInternalFileUrl, parseInternalFileUrl, } from '@/lib/uploads/utils/file' +import { canonicalInputFormatFieldType } from '@/lib/workflows/input-format-schema' import { classifyStartBlockType, resolveStartCandidates, @@ -245,7 +246,7 @@ export function coerceValue(type: string | null | undefined, value: unknown): un return value } - switch (type) { + switch (type ? (canonicalInputFormatFieldType(type) ?? type) : type) { case 'string': return typeof value === 'string' ? value : String(value) case 'number': { diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 085cae64679..88e2e942077 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -345,6 +345,37 @@ describe('validateInputsForBlock', () => { ).toBe(false) }) + describe('input-format', () => { + const config = { id: 'inputFormat', type: 'input-format' as const } + const validate = (value: unknown) => + validateValueForSubBlockType(config, value, 'inputFormat', 'start_trigger', 'start-1') + + it('stores type aliases as their canonical type and keeps untyped rows', () => { + expect( + validate([ + { name: 'payload', type: 'json' }, + { name: 'documents', type: 'files' }, + { name: 'message' }, + ]) + ).toEqual({ + valid: true, + value: [ + { name: 'payload', type: 'object' }, + { name: 'documents', type: 'file[]' }, + { name: 'message' }, + ], + }) + }) + + it('rejects a type no workflow input can declare, listing the allowed types', () => { + const result = validate([{ name: 'count', type: 'integer' }]) + expect(result.valid).toBe(false) + expect(result.error?.error).toBe( + 'Invalid type "integer" for inputFormat[0] - use one of: string, number, boolean, object, array, file[]' + ) + }) + }) + describe('model-fallback-list', () => { const config = { id: 'fallbackModels', type: 'model-fallback-list' as const } const validate = (value: unknown) => diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index a816ec9f8d0..5fcab91ce65 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -24,6 +24,10 @@ import { type SelectorReference, validateSelectorIds, } from '@/lib/workflows/editing/selector-validator' +import { + canonicalInputFormatFieldType, + INPUT_FORMAT_FIELD_TYPES, +} from '@/lib/workflows/input-format-schema' import { containsReference } from '@/lib/workflows/sanitization/references' import { getSkillById } from '@/lib/workflows/skills/operations' import { @@ -639,7 +643,44 @@ export function validateValueForSubBlockType( return { valid: true, value } } - case 'input-format': + case 'input-format': { + if (!Array.isArray(value)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid ${type} value for field "${fieldName}" - expected an array`, + }, + } + } + const rows: unknown[] = [] + for (const [index, row] of value.entries()) { + if (!isPlainRecord(row) || row.type === undefined) { + rows.push(row) + continue + } + const fieldType = + typeof row.type === 'string' ? canonicalInputFormatFieldType(row.type) : null + if (!fieldType) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid type ${JSON.stringify(row.type)} for ${fieldName}[${index}] - use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}`, + }, + } + } + rows.push(fieldType === row.type ? row : { ...row, type: fieldType }) + } + return { valid: true, value: rows } + } + case 'table': { // Should be an array if (!Array.isArray(value)) { diff --git a/apps/sim/lib/workflows/input-format-schema.ts b/apps/sim/lib/workflows/input-format-schema.ts index 1347ee0a32d..1f22315e482 100644 --- a/apps/sim/lib/workflows/input-format-schema.ts +++ b/apps/sim/lib/workflows/input-format-schema.ts @@ -1,10 +1,44 @@ import { z } from 'zod' +/** The input field types the editor offers and workflow runs accept. */ +export const INPUT_FORMAT_FIELD_TYPES = [ + 'string', + 'number', + 'boolean', + 'object', + 'array', + 'file[]', +] as const + +export type InputFormatFieldType = (typeof INPUT_FORMAT_FIELD_TYPES)[number] + +/** + * Every spelling a stored field's `type` may use, mapped to its canonical type: + * `json` is the name blocks use for object params and outputs, `files` the + * legacy MCP alias. + */ +const INPUT_FORMAT_FIELD_TYPES_BY_NAME = new Map([ + ...INPUT_FORMAT_FIELD_TYPES.map((type) => [type, type] as const), + ['json', 'object'], + ['files', 'file[]'], +]) + +/** + * Resolves a stored field's `type` to its canonical type, or `null` when it + * names none. A field without a type is a string field. + */ +export function canonicalInputFormatFieldType( + type: string | undefined +): InputFormatFieldType | null { + if (type === undefined) return 'string' + return INPUT_FORMAT_FIELD_TYPES_BY_NAME.get(type) ?? null +} + /** Stored fields shared by the input editor and catalog authoring contract. */ export const inputFormatFieldStateSchema = z.object({ id: z.string(), name: z.string(), - type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'file[]']), + type: z.enum(INPUT_FORMAT_FIELD_TYPES), value: z.string(), description: z.string().optional(), collapsed: z.boolean(), diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts index dd0682ab302..5d25b48c18f 100644 --- a/apps/sim/lib/workflows/input-format.ts +++ b/apps/sim/lib/workflows/input-format.ts @@ -1,6 +1,9 @@ import { generateId } from '@sim/utils/id' import { isInternalFileUrl, parseInternalFileUrl } from '@/lib/uploads/utils/file' -import type { InputFormatFieldState } from '@/lib/workflows/input-format-schema' +import { + canonicalInputFormatFieldType, + type InputFormatFieldState, +} from '@/lib/workflows/input-format-schema' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' import type { UserFile } from '@/executor/types' @@ -48,7 +51,7 @@ export function createDefaultInputFormatField(): InputFormatFieldState { /** Whether a field is a canonical file array or its legacy MCP alias. */ export function isFileFieldType(type: string | null | undefined): boolean { - return type === 'file[]' || type === 'files' + return typeof type === 'string' && canonicalInputFormatFieldType(type) === 'file[]' } /** diff --git a/apps/sim/lib/workflows/input-schema.test.ts b/apps/sim/lib/workflows/input-schema.test.ts index f48566cad7a..77a408be3b7 100644 --- a/apps/sim/lib/workflows/input-schema.test.ts +++ b/apps/sim/lib/workflows/input-schema.test.ts @@ -108,9 +108,25 @@ describe('workflow input schemas', () => { expect(z.object(generateWorkflowInputShape(fields)).parse(input)).toEqual(input) }) - it('rejects unsupported type names instead of advertising strings', () => { - expect(() => generateToolInputSchema([{ name: 'files', type: 'unsupported' }])).toThrow( - 'Unsupported workflow input type "unsupported"' + it('accepts the json and files aliases as their canonical types', () => { + const shape = z.object( + generateWorkflowInputShape([ + { name: 'payload', type: 'json' }, + { name: 'documents', type: 'files' }, + ]) + ) + expect(shape.safeParse({ payload: { a: 1 }, documents: [file] }).success).toBe(true) + expect(shape.safeParse({ payload: 'text', documents: [file] }).success).toBe(false) + }) + + it('rejects unsupported type names as a validation failure naming the field', () => { + expect(() => generateToolInputSchema([{ name: 'count', type: 'unsupported' }])).toThrow( + expect.objectContaining({ + name: 'OrchestrationError', + code: 'validation', + message: + 'Workflow input "count" has unsupported type "unsupported". Use one of: string, number, boolean, object, array, file[].', + }) ) }) }) diff --git a/apps/sim/lib/workflows/input-schema.ts b/apps/sim/lib/workflows/input-schema.ts index a824e013604..f4fed3364c0 100644 --- a/apps/sim/lib/workflows/input-schema.ts +++ b/apps/sim/lib/workflows/input-schema.ts @@ -1,11 +1,16 @@ import { z } from 'zod' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { workflowFileInputSchema } from '@/lib/workflows/input-file-schema' +import { + canonicalInputFormatFieldType, + INPUT_FORMAT_FIELD_TYPES, + type InputFormatFieldType, +} from '@/lib/workflows/input-format-schema' import type { InputFormatField } from '@/lib/workflows/types' import { isSafeKey } from '@/tools/safe-assign' -function fieldTypeToSchema(type: string | undefined): z.ZodType { +function fieldTypeToSchema(type: InputFormatFieldType): z.ZodType { switch (type) { - case undefined: case 'string': return z.string() case 'number': @@ -16,17 +21,17 @@ function fieldTypeToSchema(type: string | undefined): z.ZodType { return z.record(z.string(), z.unknown()) case 'array': return z.array(z.unknown()) - case 'files': case 'file[]': return z.array(workflowFileInputSchema) - default: - throw new Error(`Unsupported workflow input type "${type}"`) } } /** * Shared by workflow run validation and MCP discovery so the advertised input * types match execution. The executor supplies configured defaults at run time. + * + * @throws OrchestrationError `validation` when a field name is reserved or its + * type is not one a workflow input can declare. */ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.ZodRawShape { const shape: Record = {} @@ -35,12 +40,22 @@ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.Z const name = field.name?.trim() if (!name) continue if (!isSafeKey(name)) { - throw new Error(`Workflow input name "${name}" is reserved. Rename this input field.`) + throw new OrchestrationError( + 'validation', + `Workflow input name "${name}" is reserved. Rename this input field.` + ) + } + const type = canonicalInputFormatFieldType(field.type) + if (!type) { + throw new OrchestrationError( + 'validation', + `Workflow input "${name}" has unsupported type "${field.type}". Use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}.` + ) } - const schema = fieldTypeToSchema(field.type).describe( + const schema = fieldTypeToSchema(type).describe( field.description?.trim() || - (field.type === 'file[]' || field.type === 'files' + (type === 'file[]' ? 'Files: use an existing workspace file ID/key, a file upload {type:"file",data:"data:;base64,...",name}, or URL {type:"url",data:"https://...",name}. Stored metadata and access are rechecked when the workflow runs.' : name) ) From fc88c0c661a555db75b0a05632a226b0c529198a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Oct 2026 13:05:46 -0700 Subject: [PATCH 2/3] fix(workflows): sample aliased input types as their canonical type in mock payloads --- .../sim/lib/workflows/triggers/run-options.test.ts | 14 ++++++++++++++ apps/sim/lib/workflows/triggers/run-options.ts | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/triggers/run-options.test.ts b/apps/sim/lib/workflows/triggers/run-options.test.ts index 458c9ab28f0..327a9d49552 100644 --- a/apps/sim/lib/workflows/triggers/run-options.test.ts +++ b/apps/sim/lib/workflows/triggers/run-options.test.ts @@ -115,6 +115,20 @@ describe('file inputs through workflow run options', () => { }) }) +describe('mock payloads for aliased field types', () => { + it('samples a json field as an object the run validation accepts', () => { + const [option] = resolveTriggerRunOptions({ + start: { + type: 'start_trigger', + name: 'Start', + subBlocks: { inputFormat: { value: [{ name: 'payload', type: 'json' }] } }, + }, + }) + expect(option.mockPayload).toEqual({ payload: {} }) + expect(validateTriggerInput(option, option.mockPayload)).toEqual({ ok: true }) + }) +}) + describe('workflow input discovery schemas', () => { it('matches optional defaults and describes stored-file and upload alternatives', () => { const [option] = resolveTriggerRunOptions({ diff --git a/apps/sim/lib/workflows/triggers/run-options.ts b/apps/sim/lib/workflows/triggers/run-options.ts index 39d3b418408..bb02b73f856 100644 --- a/apps/sim/lib/workflows/triggers/run-options.ts +++ b/apps/sim/lib/workflows/triggers/run-options.ts @@ -7,6 +7,7 @@ import { normalizeInputFormatValue, parseInputFormatFiles, } from '@/lib/workflows/input-format' +import { canonicalInputFormatFieldType } from '@/lib/workflows/input-format-schema' import { generateWorkflowInputShape } from '@/lib/workflows/input-schema' import { getLegacyStarterMode, @@ -128,7 +129,7 @@ function triggerOutputsToJsonSchema(outputs: Record): Record Date: Fri, 23 Oct 2026 13:17:02 -0700 Subject: [PATCH 3/3] fix(mcp): answer an unpublishable workflow input as a validation failure --- .../workflow-mcp-lifecycle.test.ts | 32 +++++++++++++++++++ .../orchestration/workflow-mcp-lifecycle.ts | 10 +++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts index f16629523de..8acdb15775d 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts @@ -11,11 +11,13 @@ vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ generateParameterSchemaForWorkflow: vi.fn().mockResolvedValue({ type: 'object', properties: {} }), })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants' import { performCreateWorkflowMcpServer, performUpdateWorkflowMcpTool, } from '@/lib/mcp/orchestration/workflow-mcp-lifecycle' +import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { hasValidStartBlock } from '@/lib/workflows/triggers/utils.server' describe('workflow MCP lifecycle orchestration', () => { @@ -97,6 +99,36 @@ describe('workflow MCP lifecycle orchestration', () => { expect(dbChainMockFns.insert).not.toHaveBeenCalled() }) + it('answers an unpublishable workflow input as a validation failure naming the field', async () => { + const message = + 'Workflow input "payload" has unsupported type "integer". Use one of: string, number.' + vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true) + vi.mocked(generateParameterSchemaForWorkflow).mockRejectedValueOnce( + new OrchestrationError('validation', message) + ) + dbChainMockFns.where.mockResolvedValueOnce([ + { + id: 'wf-1', + name: 'Workflow', + description: null, + isDeployed: true, + workspaceId: 'workspace-1', + deployedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + const result = await performCreateWorkflowMcpServer({ + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'Server', + workflowIds: ['wf-1'], + }) + + expect(result).toEqual({ success: false, error: message, errorCode: 'validation' }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + it('rejects workflow MCP server fan-out above the per-workflow limit', async () => { vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true) dbChainMockFns.where.mockResolvedValueOnce([ diff --git a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts index bc6f114da3c..5615e4942b3 100644 --- a/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts +++ b/apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts @@ -3,6 +3,7 @@ import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, ne, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES, @@ -232,7 +233,14 @@ async function prepareWorkflowMcpTool(params: { const toolDescription = params.toolDescription !== undefined ? params.toolDescription?.trim() || null : null - const baseSchema = await generateParameterSchemaForWorkflow(workflowRecord.id) + const baseSchema = await generateParameterSchemaForWorkflow(workflowRecord.id).catch( + (error: unknown) => { + if (error instanceof OrchestrationError && error.code === 'validation') { + throw new WorkflowMcpExpectedError(error.message, 'validation') + } + throw error + } + ) const requestedOverrides = params.parameterDescriptionOverrides ?? (params.legacyParameterSchema