Skip to content

Commit 7d1b047

Browse files
authored
fix(workflows): accept json input type and return 400 for unsupported input types (#9342)
* 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. * fix(workflows): sample aliased input types as their canonical type in mock payloads * fix(mcp): answer an unpublishable workflow input as a validation failure
1 parent e5ad3ae commit 7d1b047

12 files changed

Lines changed: 217 additions & 18 deletions

‎apps/sim/executor/utils/start-block.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ describe('start-block utilities', () => {
430430
value: [
431431
{ name: 'count', type: 'number' },
432432
{ name: 'payload', type: 'object' },
433+
{ name: 'meta', type: 'json' },
433434
],
434435
},
435436
},
@@ -446,12 +447,14 @@ describe('start-block utilities', () => {
446447
workflowInput: {
447448
count: '5',
448449
payload: '{"event":"push"}',
450+
meta: '{"source":"api"}',
449451
extra: 'untouched',
450452
},
451453
})
452454

453455
expect(output.count).toBe(5)
454456
expect(output.payload).toEqual({ event: 'push' })
457+
expect(output.meta).toEqual({ source: 'api' })
455458
expect(output.extra).toBe('untouched')
456459
})
457460
})

‎apps/sim/executor/utils/start-block.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isInternalFileUrl,
88
parseInternalFileUrl,
99
} from '@/lib/uploads/utils/file'
10+
import { canonicalInputFormatFieldType } from '@/lib/workflows/input-format-schema'
1011
import {
1112
classifyStartBlockType,
1213
resolveStartCandidates,
@@ -245,7 +246,7 @@ export function coerceValue(type: string | null | undefined, value: unknown): un
245246
return value
246247
}
247248

248-
switch (type) {
249+
switch (type ? (canonicalInputFormatFieldType(type) ?? type) : type) {
249250
case 'string':
250251
return typeof value === 'string' ? value : String(value)
251252
case 'number': {

‎apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({
1111
generateParameterSchemaForWorkflow: vi.fn().mockResolvedValue({ type: 'object', properties: {} }),
1212
}))
1313

14+
import { OrchestrationError } from '@/lib/core/orchestration/types'
1415
import { MAX_MCP_PARAMETER_SCHEMA_BYTES, MAX_MCP_TOOLS_PER_SERVER } from '@/lib/mcp/constants'
1516
import {
1617
performCreateWorkflowMcpServer,
1718
performUpdateWorkflowMcpTool,
1819
} from '@/lib/mcp/orchestration/workflow-mcp-lifecycle'
20+
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
1921
import { hasValidStartBlock } from '@/lib/workflows/triggers/utils.server'
2022

2123
describe('workflow MCP lifecycle orchestration', () => {
@@ -97,6 +99,36 @@ describe('workflow MCP lifecycle orchestration', () => {
9799
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
98100
})
99101

102+
it('answers an unpublishable workflow input as a validation failure naming the field', async () => {
103+
const message =
104+
'Workflow input "payload" has unsupported type "integer". Use one of: string, number.'
105+
vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true)
106+
vi.mocked(generateParameterSchemaForWorkflow).mockRejectedValueOnce(
107+
new OrchestrationError('validation', message)
108+
)
109+
dbChainMockFns.where.mockResolvedValueOnce([
110+
{
111+
id: 'wf-1',
112+
name: 'Workflow',
113+
description: null,
114+
isDeployed: true,
115+
workspaceId: 'workspace-1',
116+
deployedAt: new Date('2026-01-01T00:00:00Z'),
117+
updatedAt: new Date('2026-01-01T00:00:00Z'),
118+
},
119+
])
120+
121+
const result = await performCreateWorkflowMcpServer({
122+
workspaceId: 'workspace-1',
123+
userId: 'user-1',
124+
name: 'Server',
125+
workflowIds: ['wf-1'],
126+
})
127+
128+
expect(result).toEqual({ success: false, error: message, errorCode: 'validation' })
129+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
130+
})
131+
100132
it('rejects workflow MCP server fan-out above the per-workflow limit', async () => {
101133
vi.mocked(hasValidStartBlock).mockResolvedValueOnce(true)
102134
dbChainMockFns.where.mockResolvedValueOnce([

‎apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { db, workflow, workflowMcpServer, workflowMcpTool } from '@sim/db'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
55
import { and, asc, eq, inArray, isNull, ne, sql } from 'drizzle-orm'
6+
import { OrchestrationError } from '@/lib/core/orchestration/types'
67
import type { DbOrTx } from '@/lib/db/types'
78
import {
89
MAX_MCP_SERVER_PARAMETER_SCHEMAS_BYTES,
@@ -232,7 +233,14 @@ async function prepareWorkflowMcpTool(params: {
232233
const toolDescription =
233234
params.toolDescription !== undefined ? params.toolDescription?.trim() || null : null
234235

235-
const baseSchema = await generateParameterSchemaForWorkflow(workflowRecord.id)
236+
const baseSchema = await generateParameterSchemaForWorkflow(workflowRecord.id).catch(
237+
(error: unknown) => {
238+
if (error instanceof OrchestrationError && error.code === 'validation') {
239+
throw new WorkflowMcpExpectedError(error.message, 'validation')
240+
}
241+
throw error
242+
}
243+
)
236244
const requestedOverrides =
237245
params.parameterDescriptionOverrides ??
238246
(params.legacyParameterSchema

‎apps/sim/lib/workflows/editing/validation.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,37 @@ describe('validateInputsForBlock', () => {
345345
).toBe(false)
346346
})
347347

348+
describe('input-format', () => {
349+
const config = { id: 'inputFormat', type: 'input-format' as const }
350+
const validate = (value: unknown) =>
351+
validateValueForSubBlockType(config, value, 'inputFormat', 'start_trigger', 'start-1')
352+
353+
it('stores type aliases as their canonical type and keeps untyped rows', () => {
354+
expect(
355+
validate([
356+
{ name: 'payload', type: 'json' },
357+
{ name: 'documents', type: 'files' },
358+
{ name: 'message' },
359+
])
360+
).toEqual({
361+
valid: true,
362+
value: [
363+
{ name: 'payload', type: 'object' },
364+
{ name: 'documents', type: 'file[]' },
365+
{ name: 'message' },
366+
],
367+
})
368+
})
369+
370+
it('rejects a type no workflow input can declare, listing the allowed types', () => {
371+
const result = validate([{ name: 'count', type: 'integer' }])
372+
expect(result.valid).toBe(false)
373+
expect(result.error?.error).toBe(
374+
'Invalid type "integer" for inputFormat[0] - use one of: string, number, boolean, object, array, file[]'
375+
)
376+
})
377+
})
378+
348379
describe('model-fallback-list', () => {
349380
const config = { id: 'fallbackModels', type: 'model-fallback-list' as const }
350381
const validate = (value: unknown) =>

‎apps/sim/lib/workflows/editing/validation.ts‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import {
2424
type SelectorReference,
2525
validateSelectorIds,
2626
} from '@/lib/workflows/editing/selector-validator'
27+
import {
28+
canonicalInputFormatFieldType,
29+
INPUT_FORMAT_FIELD_TYPES,
30+
} from '@/lib/workflows/input-format-schema'
2731
import { containsReference } from '@/lib/workflows/sanitization/references'
2832
import { getSkillById } from '@/lib/workflows/skills/operations'
2933
import {
@@ -639,7 +643,44 @@ export function validateValueForSubBlockType(
639643
return { valid: true, value }
640644
}
641645

642-
case 'input-format':
646+
case 'input-format': {
647+
if (!Array.isArray(value)) {
648+
return {
649+
valid: false,
650+
error: {
651+
blockId,
652+
blockType,
653+
field: fieldName,
654+
value,
655+
error: `Invalid ${type} value for field "${fieldName}" - expected an array`,
656+
},
657+
}
658+
}
659+
const rows: unknown[] = []
660+
for (const [index, row] of value.entries()) {
661+
if (!isPlainRecord(row) || row.type === undefined) {
662+
rows.push(row)
663+
continue
664+
}
665+
const fieldType =
666+
typeof row.type === 'string' ? canonicalInputFormatFieldType(row.type) : null
667+
if (!fieldType) {
668+
return {
669+
valid: false,
670+
error: {
671+
blockId,
672+
blockType,
673+
field: fieldName,
674+
value,
675+
error: `Invalid type ${JSON.stringify(row.type)} for ${fieldName}[${index}] - use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}`,
676+
},
677+
}
678+
}
679+
rows.push(fieldType === row.type ? row : { ...row, type: fieldType })
680+
}
681+
return { valid: true, value: rows }
682+
}
683+
643684
case 'table': {
644685
// Should be an array
645686
if (!Array.isArray(value)) {

‎apps/sim/lib/workflows/input-format-schema.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,44 @@
11
import { z } from 'zod'
22

3+
/** The input field types the editor offers and workflow runs accept. */
4+
export const INPUT_FORMAT_FIELD_TYPES = [
5+
'string',
6+
'number',
7+
'boolean',
8+
'object',
9+
'array',
10+
'file[]',
11+
] as const
12+
13+
export type InputFormatFieldType = (typeof INPUT_FORMAT_FIELD_TYPES)[number]
14+
15+
/**
16+
* Every spelling a stored field's `type` may use, mapped to its canonical type:
17+
* `json` is the name blocks use for object params and outputs, `files` the
18+
* legacy MCP alias.
19+
*/
20+
const INPUT_FORMAT_FIELD_TYPES_BY_NAME = new Map<string, InputFormatFieldType>([
21+
...INPUT_FORMAT_FIELD_TYPES.map((type) => [type, type] as const),
22+
['json', 'object'],
23+
['files', 'file[]'],
24+
])
25+
26+
/**
27+
* Resolves a stored field's `type` to its canonical type, or `null` when it
28+
* names none. A field without a type is a string field.
29+
*/
30+
export function canonicalInputFormatFieldType(
31+
type: string | undefined
32+
): InputFormatFieldType | null {
33+
if (type === undefined) return 'string'
34+
return INPUT_FORMAT_FIELD_TYPES_BY_NAME.get(type) ?? null
35+
}
36+
337
/** Stored fields shared by the input editor and catalog authoring contract. */
438
export const inputFormatFieldStateSchema = z.object({
539
id: z.string(),
640
name: z.string(),
7-
type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'file[]']),
41+
type: z.enum(INPUT_FORMAT_FIELD_TYPES),
842
value: z.string(),
943
description: z.string().optional(),
1044
collapsed: z.boolean(),

‎apps/sim/lib/workflows/input-format.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { generateId } from '@sim/utils/id'
22
import { isInternalFileUrl, parseInternalFileUrl } from '@/lib/uploads/utils/file'
3-
import type { InputFormatFieldState } from '@/lib/workflows/input-format-schema'
3+
import {
4+
canonicalInputFormatFieldType,
5+
type InputFormatFieldState,
6+
} from '@/lib/workflows/input-format-schema'
47
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
58
import type { InputFormatField } from '@/lib/workflows/types'
69
import type { UserFile } from '@/executor/types'
@@ -48,7 +51,7 @@ export function createDefaultInputFormatField(): InputFormatFieldState {
4851

4952
/** Whether a field is a canonical file array or its legacy MCP alias. */
5053
export function isFileFieldType(type: string | null | undefined): boolean {
51-
return type === 'file[]' || type === 'files'
54+
return typeof type === 'string' && canonicalInputFormatFieldType(type) === 'file[]'
5255
}
5356

5457
/**

‎apps/sim/lib/workflows/input-schema.test.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,25 @@ describe('workflow input schemas', () => {
108108
expect(z.object(generateWorkflowInputShape(fields)).parse(input)).toEqual(input)
109109
})
110110

111-
it('rejects unsupported type names instead of advertising strings', () => {
112-
expect(() => generateToolInputSchema([{ name: 'files', type: 'unsupported' }])).toThrow(
113-
'Unsupported workflow input type "unsupported"'
111+
it('accepts the json and files aliases as their canonical types', () => {
112+
const shape = z.object(
113+
generateWorkflowInputShape([
114+
{ name: 'payload', type: 'json' },
115+
{ name: 'documents', type: 'files' },
116+
])
117+
)
118+
expect(shape.safeParse({ payload: { a: 1 }, documents: [file] }).success).toBe(true)
119+
expect(shape.safeParse({ payload: 'text', documents: [file] }).success).toBe(false)
120+
})
121+
122+
it('rejects unsupported type names as a validation failure naming the field', () => {
123+
expect(() => generateToolInputSchema([{ name: 'count', type: 'unsupported' }])).toThrow(
124+
expect.objectContaining({
125+
name: 'OrchestrationError',
126+
code: 'validation',
127+
message:
128+
'Workflow input "count" has unsupported type "unsupported". Use one of: string, number, boolean, object, array, file[].',
129+
})
114130
)
115131
})
116132
})

‎apps/sim/lib/workflows/input-schema.ts‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { z } from 'zod'
2+
import { OrchestrationError } from '@/lib/core/orchestration/types'
23
import { workflowFileInputSchema } from '@/lib/workflows/input-file-schema'
4+
import {
5+
canonicalInputFormatFieldType,
6+
INPUT_FORMAT_FIELD_TYPES,
7+
type InputFormatFieldType,
8+
} from '@/lib/workflows/input-format-schema'
39
import type { InputFormatField } from '@/lib/workflows/types'
410
import { isSafeKey } from '@/tools/safe-assign'
511

6-
function fieldTypeToSchema(type: string | undefined): z.ZodType {
12+
function fieldTypeToSchema(type: InputFormatFieldType): z.ZodType {
713
switch (type) {
8-
case undefined:
914
case 'string':
1015
return z.string()
1116
case 'number':
@@ -16,17 +21,17 @@ function fieldTypeToSchema(type: string | undefined): z.ZodType {
1621
return z.record(z.string(), z.unknown())
1722
case 'array':
1823
return z.array(z.unknown())
19-
case 'files':
2024
case 'file[]':
2125
return z.array(workflowFileInputSchema)
22-
default:
23-
throw new Error(`Unsupported workflow input type "${type}"`)
2426
}
2527
}
2628

2729
/**
2830
* Shared by workflow run validation and MCP discovery so the advertised input
2931
* types match execution. The executor supplies configured defaults at run time.
32+
*
33+
* @throws OrchestrationError `validation` when a field name is reserved or its
34+
* type is not one a workflow input can declare.
3035
*/
3136
export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.ZodRawShape {
3237
const shape: Record<string, z.ZodType> = {}
@@ -35,12 +40,22 @@ export function generateWorkflowInputShape(inputFormat: InputFormatField[]): z.Z
3540
const name = field.name?.trim()
3641
if (!name) continue
3742
if (!isSafeKey(name)) {
38-
throw new Error(`Workflow input name "${name}" is reserved. Rename this input field.`)
43+
throw new OrchestrationError(
44+
'validation',
45+
`Workflow input name "${name}" is reserved. Rename this input field.`
46+
)
47+
}
48+
const type = canonicalInputFormatFieldType(field.type)
49+
if (!type) {
50+
throw new OrchestrationError(
51+
'validation',
52+
`Workflow input "${name}" has unsupported type "${field.type}". Use one of: ${INPUT_FORMAT_FIELD_TYPES.join(', ')}.`
53+
)
3954
}
4055

41-
const schema = fieldTypeToSchema(field.type).describe(
56+
const schema = fieldTypeToSchema(type).describe(
4257
field.description?.trim() ||
43-
(field.type === 'file[]' || field.type === 'files'
58+
(type === 'file[]'
4459
? 'Files: use an existing workspace file ID/key, a file upload {type:"file",data:"data:<mime>;base64,...",name}, or URL {type:"url",data:"https://...",name}. Stored metadata and access are rechecked when the workflow runs.'
4560
: name)
4661
)

0 commit comments

Comments
 (0)