Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/sim/executor/utils/start-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ describe('start-block utilities', () => {
value: [
{ name: 'count', type: 'number' },
{ name: 'payload', type: 'object' },
{ name: 'meta', type: 'json' },
],
},
},
Expand All @@ -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')
})
})
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/executor/utils/start-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isInternalFileUrl,
parseInternalFileUrl,
} from '@/lib/uploads/utils/file'
import { canonicalInputFormatFieldType } from '@/lib/workflows/input-format-schema'
import {
classifyStartBlockType,
resolveStartCandidates,
Expand Down Expand Up @@ -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': {
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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([
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/lib/mcp/orchestration/workflow-mcp-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions apps/sim/lib/workflows/editing/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
43 changes: 42 additions & 1 deletion apps/sim/lib/workflows/editing/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand Down
36 changes: 35 additions & 1 deletion apps/sim/lib/workflows/input-format-schema.ts
Original file line number Diff line number Diff line change
@@ -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<string, InputFormatFieldType>([
...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(),
Expand Down
7 changes: 5 additions & 2 deletions apps/sim/lib/workflows/input-format.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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[]'
}

/**
Expand Down
22 changes: 19 additions & 3 deletions apps/sim/lib/workflows/input-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[].',
})
)
})
})
31 changes: 23 additions & 8 deletions apps/sim/lib/workflows/input-schema.ts
Original file line number Diff line number Diff line change
@@ -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':
Expand All @@ -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<string, z.ZodType> = {}
Expand All @@ -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(
Comment thread
waleedlatif1 marked this conversation as resolved.
'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(', ')}.`
)
Comment thread
waleedlatif1 marked this conversation as resolved.
}

const schema = fieldTypeToSchema(field.type).describe(
const schema = fieldTypeToSchema(type).describe(
Comment thread
waleedlatif1 marked this conversation as resolved.
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:<mime>;base64,...",name}, or URL {type:"url",data:"https://...",name}. Stored metadata and access are rechecked when the workflow runs.'
: name)
)
Expand Down
14 changes: 14 additions & 0 deletions apps/sim/lib/workflows/triggers/run-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading