From 26ba35847deef3afc733a0dc6ad65e69ed9fe48f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 10:28:58 -0700 Subject: [PATCH 1/5] fix(mcp): honor json schema openness in mcp output shapes Objects without additionalProperties: false now keep their declared fields and record that others are allowed, so references to undeclared fields lint clean and resolve empty when missing. Arrays without items accept element paths. Also documents the v2 execute 202 receipt returned after waitTimeoutSeconds. --- apps/docs/openapi-v2-workflows.json | 6 +-- .../executor/utils/block-reference.test.ts | 27 +++++++++++ apps/sim/executor/utils/block-reference.ts | 12 ++++- .../lib/api/contracts/v2/openapi/workflows.ts | 7 +-- .../lib/api/mcp/generated/v2-operations.ts | 2 +- apps/sim/lib/mcp/output-schema.ts | 42 +++++++++++------ apps/sim/lib/workflows/blocks/outputs.ts | 3 ++ .../workflows/editing/dangling-refs.test.ts | 42 ++++++++++++++++- apps/sim/serializer/mcp-output-schema.test.ts | 46 ++++++++++++++++++- 9 files changed, 159 insertions(+), 28 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 386196d06a8..121fedc1399 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3701,7 +3701,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute workflow", - "description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", + "description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, `waitTimeoutSeconds` to get one when a synchronous run outlasts the wait, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.execute", "x-oauth-scope": "api:write", "tags": ["Workflows"], @@ -3891,7 +3891,7 @@ } }, "202": { - "description": "The asynchronous run was queued.", + "description": "The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -14267,7 +14267,7 @@ "required": ["data"], "additionalProperties": false, "title": "Queued workflow execution response", - "description": "Receipt returned for an asynchronous workflow run.", + "description": "Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.", "examples": [ { "data": { diff --git a/apps/sim/executor/utils/block-reference.test.ts b/apps/sim/executor/utils/block-reference.test.ts index db3ee222df5..b8c2840f7d8 100644 --- a/apps/sim/executor/utils/block-reference.test.ts +++ b/apps/sim/executor/utils/block-reference.test.ts @@ -144,6 +144,33 @@ describe('resolveBlockReference', () => { expect(result).toEqual({ value: undefined, blockId: 'block-1' }) }) + it('rejects an undeclared field under a json field that declares its properties', () => { + const ctx = createContext({ + blockData: { 'block-1': { result: { summary: 'hello' } } }, + blockOutputSchemas: { + 'block-1': { result: { type: 'json', properties: { summary: { type: 'string' } } } }, + }, + }) + + expect(resolveBlockReference('start', ['result', 'summary'], ctx)?.value).toBe('hello') + expect(() => resolveBlockReference('start', ['result', 'typo'], ctx)).toThrow( + InvalidFieldError + ) + }) + + it('accepts an undeclared field under an open json field that declares its properties', () => { + const ctx = createContext({ + blockData: { 'block-1': { result: { summary: 'hello' } } }, + blockOutputSchemas: { + 'block-1': { + result: { type: 'json', properties: { summary: { type: 'string' } }, open: true }, + }, + }, + }) + + expect(resolveBlockReference('start', ['result', 'extra'], ctx)?.value).toBeUndefined() + }) + it('should resolve values nested under json field on executed block', () => { const ctx = createContext({ blockData: { 'block-1': { result: { summary: 'hello' } } }, diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 2c2ccca82c2..346f90ae5e6 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -15,6 +15,8 @@ interface SchemaNode { type?: string description?: string properties?: unknown + /** Fields beyond `properties` are allowed, so a path past them is not checked. */ + open?: boolean items?: unknown } @@ -101,6 +103,11 @@ function isOpaqueSchemaNode(value: unknown): boolean { return false } +/** A node declaring its fields but allowing others, so a field it does not name is not an error. */ +function isOpenSchemaNode(value: unknown): boolean { + return asSchemaNode(value)?.open === true +} + /** Whether `pathParts` names a field the declared output schema has, the check a run makes. */ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean { if (!schema || pathParts.length === 0) { @@ -140,7 +147,7 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri if (arrayMatch) { const [, prop] = arrayMatch const fieldDef = lookupField(current, prop) - if (!fieldDef) return false + if (!fieldDef) return isOpenSchemaNode(current) if (isFileType(fieldDef)) { const nextPart = pathParts[i + 1] @@ -194,9 +201,10 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri current = itemField continue } + return isOpenSchemaNode(items) } - return false + return isOpenSchemaNode(current) } return true diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index e6bb6fe056f..3172324a4fb 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -223,7 +223,7 @@ const executeQueuedResponseSchema = documentedSchema( v2ExecuteWorkflowQueuedResponseSchema, 'ExecuteWorkflowQueuedResponse', 'Queued workflow execution response', - 'Receipt returned for an asynchronous workflow run.', + 'Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.', [QUEUED_RUN_EXAMPLE] ) @@ -1194,7 +1194,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.execute, operationId: 'executeWorkflowV2', summary: 'Execute workflow', - description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, + description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, \`waitTimeoutSeconds\` to get one when a synchronous run outlasts the wait, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, errors: [ 'BadRequest', 'Unauthorized', @@ -1218,7 +1218,8 @@ const declaredRoutes = [ additionalContentTypes: ['application/x-ndjson', 'text/event-stream'], }, 202: { - description: 'The asynchronous run was queued.', + description: + 'The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.', headers: ['X-Run-Id', ...RATE_LIMIT_HEADERS], }, }, diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index f56b0e612f6..983a15fe7d7 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -1246,7 +1246,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ExecuteWorkflowContract, summary: 'Execute workflow', description: - 'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', + 'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, `waitTimeoutSeconds` to get one when a synchronous run outlasts the wait, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), }, diff --git a/apps/sim/lib/mcp/output-schema.ts b/apps/sim/lib/mcp/output-schema.ts index 397785d73c3..599baa6494d 100644 --- a/apps/sim/lib/mcp/output-schema.ts +++ b/apps/sim/lib/mcp/output-schema.ts @@ -15,15 +15,21 @@ const MAX_SHAPE_DEPTH = 8 const MAX_SHAPE_NODES = 500 /** - * The structure of a declared output schema: types, properties and array items - * only. Descriptions, enums and examples are dropped because the snapshot is a - * block input, and the executor and lint read every string in a block's inputs - * as a possible `` reference. + * The structure of a declared output schema: types, properties, openness and + * array items only. Descriptions, enums and examples are dropped because the + * snapshot is a block input, and the executor and lint read every string in a + * block's inputs as a possible `` reference. */ export interface McpToolOutputShape { type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'any' - /** Declared fields of a closed object; absent when the object is open or undeclared. */ + /** Declared fields of an object; absent when it declares none, which leaves every field allowed. */ properties?: Record + /** + * The object allows fields beyond `properties`, as JSON Schema reads any object + * not declaring `additionalProperties: false`. A snapshot saved before this was + * recorded lacks it and stays closed until the operation is picked again. + */ + open?: true items?: McpToolOutputShape } @@ -67,23 +73,28 @@ function reduceNode(node: unknown, depth: number, budget: { nodes: number }): Mc switch (declaredType(node)) { case 'object': { - const open = - (node.additionalProperties !== undefined && node.additionalProperties !== false) || - node.patternProperties !== undefined - if (open || !isRecordLike(node.properties)) return { type: 'object' } + if (!isRecordLike(node.properties)) return { type: 'object' } const entries: [string, McpToolOutputShape][] = [] for (const [name, child] of Object.entries(node.properties)) { - /** Past the budget the object stays open: any field resolves, none is saved. */ + /** Past the budget the object is saved untyped: any field resolves, none is declared. */ if (budget.nodes <= 0) return { type: 'object' } entries.push([name, reduceNode(child, depth + 1, budget)]) } - /** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */ - return { type: 'object', properties: Object.fromEntries(entries) } + const closed = node.additionalProperties === false && node.patternProperties === undefined + return { + type: 'object', + /** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */ + properties: Object.fromEntries(entries), + ...(!closed && { open: true }), + } } case 'array': - return isRecordLike(node.items) - ? { type: 'array', items: reduceNode(node.items, depth + 1, budget) } - : { type: 'array' } + return { + type: 'array', + items: isRecordLike(node.items) + ? reduceNode(node.items, depth + 1, budget) + : { type: 'any' }, + } case 'string': return { type: 'string' } case 'number': @@ -139,6 +150,7 @@ function isShape(value: unknown, depth = 0): value is McpToolOutputShape { return false } if (depth > MAX_SHAPE_DEPTH + 1) return false + if (value.open !== undefined && value.open !== true) return false if (value.items !== undefined && !isShape(value.items, depth + 1)) return false if (value.properties === undefined) return true return ( diff --git a/apps/sim/lib/workflows/blocks/outputs.ts b/apps/sim/lib/workflows/blocks/outputs.ts index 5b9eee3a3a1..24a44465568 100644 --- a/apps/sim/lib/workflows/blocks/outputs.ts +++ b/apps/sim/lib/workflows/blocks/outputs.ts @@ -437,6 +437,8 @@ export function getEvaluatorMetricOutputs( interface DeclaredOutputNode { type: PrimitiveValueType properties?: Record + /** Fields beyond `properties` are allowed: a reference to one checks like any `json` path. */ + open?: true items?: DeclaredOutputNode } @@ -449,6 +451,7 @@ function toOutputNode(shape: McpToolOutputShape): DeclaredOutputNode { properties: Object.fromEntries( Object.entries(shape.properties).map(([name, child]) => [name, toOutputNode(child)]) ), + ...(shape.open && { open: true }), } : { type: 'json' } case 'array': diff --git a/apps/sim/lib/workflows/editing/dangling-refs.test.ts b/apps/sim/lib/workflows/editing/dangling-refs.test.ts index 5cec70c9f84..b7ca5986587 100644 --- a/apps/sim/lib/workflows/editing/dangling-refs.test.ts +++ b/apps/sim/lib/workflows/editing/dangling-refs.test.ts @@ -284,8 +284,13 @@ describe('collectDanglingBlockOutputReferences', () => { type: 'object' as const, properties: { role: { type: 'string' }, - person: { type: 'object', properties: { id: { type: 'string' } } }, + person: { + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: false, + }, }, + additionalProperties: false, } function mcpGraph( @@ -366,7 +371,11 @@ describe('collectDanglingBlockOutputReferences', () => { type: 'array', items: { type: 'array', - items: { type: 'object', properties: { id: { type: 'string' } } }, + items: { + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: false, + }, }, }, }, @@ -382,6 +391,35 @@ describe('collectDanglingBlockOutputReferences', () => { expect(finding?.reason).toContain('Declared fields: structuredContent.rows.id') }) + it('accepts an undeclared field of an object the schema leaves open', () => { + const open = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { + role: { type: 'string' }, + person: { type: 'object', properties: { id: { type: 'string' } } }, + }, + additionalProperties: false, + }) + expect( + collectDanglingBlockOutputReferences( + mcpGraph('', open) + ) + ).toEqual([]) + }) + + it('accepts a path inside the elements of an array that declares no items', () => { + const untypedItems = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { rows: { type: 'array' } }, + additionalProperties: false, + }) + expect( + collectDanglingBlockOutputReferences( + mcpGraph('', untypedItems) + ) + ).toEqual([]) + }) + it('keeps flagging structuredContent when the operation declares no schema', () => { for (const snapshot of [ undefined, diff --git a/apps/sim/serializer/mcp-output-schema.test.ts b/apps/sim/serializer/mcp-output-schema.test.ts index 23f4585e01b..a3d8974ef47 100644 --- a/apps/sim/serializer/mcp-output-schema.test.ts +++ b/apps/sim/serializer/mcp-output-schema.test.ts @@ -82,8 +82,50 @@ describe('MCP block output typed from a declared output schema', () => { expect(resolve(declared, ['structuredContent', 'nickname'])).toBeUndefined() }) - it('fails a reference to a field the schema does not declare', () => { - expect(() => resolve(declared, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) + it('fails a reference to a field a closed schema does not declare', () => { + const closed = createMcpToolOutputSnapshot('server-1', 'on_call', { + ...ON_CALL_SCHEMA, + additionalProperties: false, + }) + expect(() => resolve(closed, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) + expect(resolve(closed, ['structuredContent', 'role'])).toBe('sales-ops') + }) + + it('resolves an undeclared field of an object that does not close itself to empty', () => { + expect(resolve(declared, ['structuredContent', 'onCall'])).toBeUndefined() + expect(resolve(declared, ['structuredContent', 'person', 'email'])).toBeUndefined() + }) + + it('keeps the declared fields of an explicitly open object and accepts others', () => { + const open = createMcpToolOutputSnapshot('server-1', 'on_call', { + ...ON_CALL_SCHEMA, + additionalProperties: { type: 'string' }, + }) + const schema = getBlockSchema(serializeMcpBlock(open)) + expect(schema?.structuredContent).toMatchObject({ + properties: { role: { type: 'string' }, person: { type: 'json' } }, + }) + expect(resolve(open, ['structuredContent', 'role'])).toBe('sales-ops') + expect(resolve(open, ['structuredContent', 'onCall'])).toBeUndefined() + }) + + it('resolves a path inside the elements of an array that declares no items', () => { + const untypedItems = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { rows: { type: 'array' } }, + additionalProperties: false, + }) + expect(resolve(untypedItems, ['structuredContent', 'rows', '0', 'id'])).toBeUndefined() + }) + + it('keeps a snapshot saved before openness was recorded closed until it is retaken', () => { + const legacy = { + server: 'server-1', + tool: 'on_call', + schema: { type: 'object', properties: { role: { type: 'string' } } }, + } + expect(resolve(legacy, ['structuredContent', 'role'])).toBe('sales-ops') + expect(() => resolve(legacy, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) }) it('keeps content as declared alongside the typed output', () => { From 49f370c6a891632ded0474fcf18705bfbb81ec86 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 10:46:12 -0700 Subject: [PATCH 2/5] fix(mcp): keep openness flag out of field lookup and close empty objects A declared field named open on an open object resolved to the flag instead of the field. Open nodes now look fields up in their properties only. A closed object declaring no properties now rejects every field instead of saving as untyped. Documents the NDJSON detached frame for waitTimeoutSeconds. --- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/executor/utils/block-reference.ts | 17 +++++++---- .../lib/api/contracts/v2/openapi/workflows.ts | 2 +- .../lib/api/mcp/generated/v2-operations.ts | 2 +- apps/sim/lib/mcp/output-schema.ts | 8 ++--- apps/sim/serializer/mcp-output-schema.test.ts | 29 +++++++++++++++++++ 6 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 121fedc1399..aea9b3fa5c8 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3701,7 +3701,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute workflow", - "description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, `waitTimeoutSeconds` to get one when a synchronous run outlasts the wait, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", + "description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.execute", "x-oauth-scope": "api:write", "tags": ["Workflows"], diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 346f90ae5e6..55aea4a31c8 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -74,15 +74,25 @@ function getProperties(schema: unknown): Record | undefined { : undefined } +/** A node declaring its fields but allowing others, so a field it does not name is not an error. */ +function isOpenSchemaNode(value: unknown): boolean { + return asSchemaNode(value)?.open === true +} + function lookupField(schema: unknown, fieldName: string): unknown | undefined { if (typeof schema !== 'object' || schema === null) return undefined const typed = schema as Record + const props = getProperties(schema) + + /** An open node's fields are its `properties`; its own keys are schema metadata, never fields. */ + if (isOpenSchemaNode(schema)) { + return props && fieldName in props ? props[fieldName] : undefined + } if (fieldName in typed) { return typed[fieldName] } - const props = getProperties(schema) if (props && fieldName in props) { return props[fieldName] } @@ -103,11 +113,6 @@ function isOpaqueSchemaNode(value: unknown): boolean { return false } -/** A node declaring its fields but allowing others, so a field it does not name is not an error. */ -function isOpenSchemaNode(value: unknown): boolean { - return asSchemaNode(value)?.open === true -} - /** Whether `pathParts` names a field the declared output schema has, the check a run makes. */ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean { if (!schema || pathParts.length === 0) { diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 3172324a4fb..b991553fb4b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -1194,7 +1194,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.execute, operationId: 'executeWorkflowV2', summary: 'Execute workflow', - description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, \`waitTimeoutSeconds\` to get one when a synchronous run outlasts the wait, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, + description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, errors: [ 'BadRequest', 'Unauthorized', diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 983a15fe7d7..37f3dca65ef 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -1246,7 +1246,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ExecuteWorkflowContract, summary: 'Execute workflow', description: - 'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, `waitTimeoutSeconds` to get one when a synchronous run outlasts the wait, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', + 'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), }, diff --git a/apps/sim/lib/mcp/output-schema.ts b/apps/sim/lib/mcp/output-schema.ts index 599baa6494d..23c7e2c5249 100644 --- a/apps/sim/lib/mcp/output-schema.ts +++ b/apps/sim/lib/mcp/output-schema.ts @@ -1,4 +1,4 @@ -import { isRecordLike } from '@sim/utils/object' +import { isRecordLike, toRecord } from '@sim/utils/object' import type { McpToolSchema } from '@/lib/mcp/types' /** @@ -73,14 +73,14 @@ function reduceNode(node: unknown, depth: number, budget: { nodes: number }): Mc switch (declaredType(node)) { case 'object': { - if (!isRecordLike(node.properties)) return { type: 'object' } + const closed = node.additionalProperties === false && node.patternProperties === undefined + if (!closed && !isRecordLike(node.properties)) return { type: 'object' } const entries: [string, McpToolOutputShape][] = [] - for (const [name, child] of Object.entries(node.properties)) { + for (const [name, child] of Object.entries(toRecord(node.properties))) { /** Past the budget the object is saved untyped: any field resolves, none is declared. */ if (budget.nodes <= 0) return { type: 'object' } entries.push([name, reduceNode(child, depth + 1, budget)]) } - const closed = node.additionalProperties === false && node.patternProperties === undefined return { type: 'object', /** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */ diff --git a/apps/sim/serializer/mcp-output-schema.test.ts b/apps/sim/serializer/mcp-output-schema.test.ts index a3d8974ef47..027a8cacdc3 100644 --- a/apps/sim/serializer/mcp-output-schema.test.ts +++ b/apps/sim/serializer/mcp-output-schema.test.ts @@ -118,6 +118,35 @@ describe('MCP block output typed from a declared output schema', () => { expect(resolve(untypedItems, ['structuredContent', 'rows', '0', 'id'])).toBeUndefined() }) + it('checks a declared field named like the openness flag as a field', () => { + const named = (additionalProperties?: false) => + createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { + open: { + type: 'object', + properties: { id: { type: 'string' } }, + ...(additionalProperties === false && { additionalProperties }), + }, + }, + ...(additionalProperties === false && { additionalProperties }), + }) + expect(resolve(named(), ['structuredContent', 'open', 'id'])).toBeUndefined() + expect(resolve(named(), ['structuredContent', 'open', 'extra'])).toBeUndefined() + expect(resolve(named(false), ['structuredContent', 'open', 'id'])).toBeUndefined() + expect(() => resolve(named(false), ['structuredContent', 'open', 'extra'])).toThrow( + InvalidFieldError + ) + }) + + it('rejects any field of a closed object that declares none', () => { + const empty = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + additionalProperties: false, + }) + expect(() => resolve(empty, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) + }) + it('keeps a snapshot saved before openness was recorded closed until it is retaken', () => { const legacy = { server: 'server-1', From 6f3aa731c80e6d01c871d78a1bb7c555b2f79ea3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 11:30:27 -0700 Subject: [PATCH 3/5] fix(workflows): offer no child fields for arrays of typed elements The connection panel listed an items child for an array whose elements are a typed node without properties, such as an MCP array of strings or of untyped elements, offering an invalid reference. --- .../hooks/use-block-output-fields.test.ts | 23 +++++++++++++++++++ .../hooks/use-block-output-fields.ts | 7 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts new file mode 100644 index 00000000000..5e1c883bbe1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts @@ -0,0 +1,23 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createFieldFromOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields' + +describe('createFieldFromOutput', () => { + it.each([ + ['untyped elements', { type: 'any' }], + ['primitive elements', { type: 'string' }], + ])('offers no child fields for an array of %s', (_, items) => { + const field = createFieldFromOutput('rows', { type: 'array', items }) + expect(field.children).toBeUndefined() + }) + + it('offers the declared fields of object elements', () => { + const field = createFieldFromOutput('rows', { + type: 'array', + items: { type: 'object', properties: { id: { type: 'string' } } }, + }) + expect(field.children?.map((child) => child.name)).toEqual(['id']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts index f079448eea9..ea25eac853c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts @@ -35,6 +35,11 @@ const extractChildFields = (prop: any): SchemaField[] | undefined => { return extractNestedFields(prop.items.properties) } + /** Typed elements without `properties` (strings, `any`) have no fields to reference. */ + if (isObject(prop.items) && typeof prop.items.type === 'string') { + return undefined + } + if (!('type' in prop)) { return extractNestedFields(prop) } @@ -71,7 +76,7 @@ const extractNestedFields = (properties: Record): SchemaField[] => /** * Creates a schema field from an output definition */ -const createFieldFromOutput = (name: string, output: any): SchemaField => { +export const createFieldFromOutput = (name: string, output: any): SchemaField => { const hasExplicitType = isObject(output) && typeof output.type === 'string' const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string' From e5714ebbadf3bfabf95bb4b3adab5049ae87a8e3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 13:12:35 -0700 Subject: [PATCH 4/5] fix(workflows): scope the typed-elements check to arrays and type the field helper --- .../w/[workflowId]/hooks/use-block-output-fields.test.ts | 8 ++++++++ .../w/[workflowId]/hooks/use-block-output-fields.ts | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts index 5e1c883bbe1..ff78b3b6b0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts @@ -20,4 +20,12 @@ describe('createFieldFromOutput', () => { }) expect(field.children?.map((child) => child.name)).toEqual(['id']) }) + + it('keeps the fields of an untyped object that has a field named items', () => { + const field = createFieldFromOutput('page', { + items: { type: 'string' }, + total: { type: 'number' }, + }) + expect(field.children?.map((child) => child.name)).toEqual(['items', 'total']) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts index ea25eac853c..8a46bc6e735 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts @@ -12,7 +12,8 @@ const RESERVED_KEYS = new Set(['type', 'description']) /** * Checks if a property is an object type */ -const isObject = (prop: any): boolean => prop && typeof prop === 'object' +const isObject = (prop: unknown): prop is Record => + prop !== null && typeof prop === 'object' /** * Gets a subblock value from the store @@ -36,7 +37,7 @@ const extractChildFields = (prop: any): SchemaField[] | undefined => { } /** Typed elements without `properties` (strings, `any`) have no fields to reference. */ - if (isObject(prop.items) && typeof prop.items.type === 'string') { + if (prop.type === 'array' && isObject(prop.items) && typeof prop.items.type === 'string') { return undefined } @@ -76,7 +77,7 @@ const extractNestedFields = (properties: Record): SchemaField[] => /** * Creates a schema field from an output definition */ -export const createFieldFromOutput = (name: string, output: any): SchemaField => { +export const createFieldFromOutput = (name: string, output: unknown): SchemaField => { const hasExplicitType = isObject(output) && typeof output.type === 'string' const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string' From 746c5ed0c190da87c521e980b0dab2049101d56f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 13:30:22 -0700 Subject: [PATCH 5/5] chore(lint): shrink the explicit-any baseline for the output fields hook --- scripts/check-explicit-any.baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index b8b4e30d6ec..ca374a5b8da 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -63,7 +63,7 @@ "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts": 2, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts": 2, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx": 1, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts": 7, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts": 6, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts": 1, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts": 1, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts": 1,