Skip to content

Commit 9fffbcc

Browse files
authored
fix(mcp): honor json schema openness in mcp output shapes (#9476)
* 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. * 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. * 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. * fix(workflows): scope the typed-elements check to arrays and type the field helper * chore(lint): shrink the explicit-any baseline for the output fields hook
1 parent 3175daa commit 9fffbcc

12 files changed

Lines changed: 236 additions & 34 deletions

File tree

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3701,7 +3701,7 @@
37013701
"post": {
37023702
"operationId": "executeWorkflowV2",
37033703
"summary": "Execute workflow",
3704-
"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`.",
3704+
"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`.",
37053705
"x-sim-operation": "workflows.execute",
37063706
"x-oauth-scope": "api:write",
37073707
"tags": ["Workflows"],
@@ -3891,7 +3891,7 @@
38913891
}
38923892
},
38933893
"202": {
3894-
"description": "The asynchronous run was queued.",
3894+
"description": "The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.",
38953895
"headers": {
38963896
"X-Run-Id": {
38973897
"$ref": "#/components/headers/X-Run-Id"
@@ -14267,7 +14267,7 @@
1426714267
"required": ["data"],
1426814268
"additionalProperties": false,
1426914269
"title": "Queued workflow execution response",
14270-
"description": "Receipt returned for an asynchronous workflow run.",
14270+
"description": "Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.",
1427114271
"examples": [
1427214272
{
1427314273
"data": {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { createFieldFromOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields'
6+
7+
describe('createFieldFromOutput', () => {
8+
it.each([
9+
['untyped elements', { type: 'any' }],
10+
['primitive elements', { type: 'string' }],
11+
])('offers no child fields for an array of %s', (_, items) => {
12+
const field = createFieldFromOutput('rows', { type: 'array', items })
13+
expect(field.children).toBeUndefined()
14+
})
15+
16+
it('offers the declared fields of object elements', () => {
17+
const field = createFieldFromOutput('rows', {
18+
type: 'array',
19+
items: { type: 'object', properties: { id: { type: 'string' } } },
20+
})
21+
expect(field.children?.map((child) => child.name)).toEqual(['id'])
22+
})
23+
24+
it('keeps the fields of an untyped object that has a field named items', () => {
25+
const field = createFieldFromOutput('page', {
26+
items: { type: 'string' },
27+
total: { type: 'number' },
28+
})
29+
expect(field.children?.map((child) => child.name)).toEqual(['items', 'total'])
30+
})
31+
})

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ const RESERVED_KEYS = new Set(['type', 'description'])
1212
/**
1313
* Checks if a property is an object type
1414
*/
15-
const isObject = (prop: any): boolean => prop && typeof prop === 'object'
15+
const isObject = (prop: unknown): prop is Record<string, any> =>
16+
prop !== null && typeof prop === 'object'
1617

1718
/**
1819
* Gets a subblock value from the store
@@ -35,6 +36,11 @@ const extractChildFields = (prop: any): SchemaField[] | undefined => {
3536
return extractNestedFields(prop.items.properties)
3637
}
3738

39+
/** Typed elements without `properties` (strings, `any`) have no fields to reference. */
40+
if (prop.type === 'array' && isObject(prop.items) && typeof prop.items.type === 'string') {
41+
return undefined
42+
}
43+
3844
if (!('type' in prop)) {
3945
return extractNestedFields(prop)
4046
}
@@ -71,7 +77,7 @@ const extractNestedFields = (properties: Record<string, any>): SchemaField[] =>
7177
/**
7278
* Creates a schema field from an output definition
7379
*/
74-
const createFieldFromOutput = (name: string, output: any): SchemaField => {
80+
export const createFieldFromOutput = (name: string, output: unknown): SchemaField => {
7581
const hasExplicitType = isObject(output) && typeof output.type === 'string'
7682
const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string'
7783

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,33 @@ describe('resolveBlockReference', () => {
144144
expect(result).toEqual({ value: undefined, blockId: 'block-1' })
145145
})
146146

147+
it('rejects an undeclared field under a json field that declares its properties', () => {
148+
const ctx = createContext({
149+
blockData: { 'block-1': { result: { summary: 'hello' } } },
150+
blockOutputSchemas: {
151+
'block-1': { result: { type: 'json', properties: { summary: { type: 'string' } } } },
152+
},
153+
})
154+
155+
expect(resolveBlockReference('start', ['result', 'summary'], ctx)?.value).toBe('hello')
156+
expect(() => resolveBlockReference('start', ['result', 'typo'], ctx)).toThrow(
157+
InvalidFieldError
158+
)
159+
})
160+
161+
it('accepts an undeclared field under an open json field that declares its properties', () => {
162+
const ctx = createContext({
163+
blockData: { 'block-1': { result: { summary: 'hello' } } },
164+
blockOutputSchemas: {
165+
'block-1': {
166+
result: { type: 'json', properties: { summary: { type: 'string' } }, open: true },
167+
},
168+
},
169+
})
170+
171+
expect(resolveBlockReference('start', ['result', 'extra'], ctx)?.value).toBeUndefined()
172+
})
173+
147174
it('should resolve values nested under json field on executed block', () => {
148175
const ctx = createContext({
149176
blockData: { 'block-1': { result: { summary: 'hello' } } },

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ interface SchemaNode {
1515
type?: string
1616
description?: string
1717
properties?: unknown
18+
/** Fields beyond `properties` are allowed, so a path past them is not checked. */
19+
open?: boolean
1820
items?: unknown
1921
}
2022

@@ -72,15 +74,25 @@ function getProperties(schema: unknown): Record<string, unknown> | undefined {
7274
: undefined
7375
}
7476

77+
/** A node declaring its fields but allowing others, so a field it does not name is not an error. */
78+
function isOpenSchemaNode(value: unknown): boolean {
79+
return asSchemaNode(value)?.open === true
80+
}
81+
7582
function lookupField(schema: unknown, fieldName: string): unknown | undefined {
7683
if (typeof schema !== 'object' || schema === null) return undefined
7784
const typed = schema as Record<string, unknown>
85+
const props = getProperties(schema)
86+
87+
/** An open node's fields are its `properties`; its own keys are schema metadata, never fields. */
88+
if (isOpenSchemaNode(schema)) {
89+
return props && fieldName in props ? props[fieldName] : undefined
90+
}
7891

7992
if (fieldName in typed) {
8093
return typed[fieldName]
8194
}
8295

83-
const props = getProperties(schema)
8496
if (props && fieldName in props) {
8597
return props[fieldName]
8698
}
@@ -140,7 +152,7 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri
140152
if (arrayMatch) {
141153
const [, prop] = arrayMatch
142154
const fieldDef = lookupField(current, prop)
143-
if (!fieldDef) return false
155+
if (!fieldDef) return isOpenSchemaNode(current)
144156

145157
if (isFileType(fieldDef)) {
146158
const nextPart = pathParts[i + 1]
@@ -194,9 +206,10 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri
194206
current = itemField
195207
continue
196208
}
209+
return isOpenSchemaNode(items)
197210
}
198211

199-
return false
212+
return isOpenSchemaNode(current)
200213
}
201214

202215
return true

‎apps/sim/lib/api/contracts/v2/openapi/workflows.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ const executeQueuedResponseSchema = documentedSchema(
223223
v2ExecuteWorkflowQueuedResponseSchema,
224224
'ExecuteWorkflowQueuedResponse',
225225
'Queued workflow execution response',
226-
'Receipt returned for an asynchronous workflow run.',
226+
'Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.',
227227
[QUEUED_RUN_EXAMPLE]
228228
)
229229

@@ -1194,7 +1194,7 @@ const declaredRoutes = [
11941194
applicationOperation: workflowOperations.execute,
11951195
operationId: 'executeWorkflowV2',
11961196
summary: 'Execute workflow',
1197-
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.`,
1197+
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.`,
11981198
errors: [
11991199
'BadRequest',
12001200
'Unauthorized',
@@ -1218,7 +1218,8 @@ const declaredRoutes = [
12181218
additionalContentTypes: ['application/x-ndjson', 'text/event-stream'],
12191219
},
12201220
202: {
1221-
description: 'The asynchronous run was queued.',
1221+
description:
1222+
'The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.',
12221223
headers: ['X-Run-Id', ...RATE_LIMIT_HEADERS],
12231224
},
12241225
},

‎apps/sim/lib/api/mcp/generated/v2-operations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1246,7 +1246,7 @@ export const V2_MCP_OPERATIONS = {
12461246
contract: v2ExecuteWorkflowContract,
12471247
summary: 'Execute workflow',
12481248
description:
1249-
'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`.',
1249+
'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`.',
12501250
handler: () =>
12511251
import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST),
12521252
},

‎apps/sim/lib/mcp/output-schema.ts‎

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isRecordLike } from '@sim/utils/object'
1+
import { isRecordLike, toRecord } from '@sim/utils/object'
22
import type { McpToolSchema } from '@/lib/mcp/types'
33

44
/**
@@ -15,15 +15,21 @@ const MAX_SHAPE_DEPTH = 8
1515
const MAX_SHAPE_NODES = 500
1616

1717
/**
18-
* The structure of a declared output schema: types, properties and array items
19-
* only. Descriptions, enums and examples are dropped because the snapshot is a
20-
* block input, and the executor and lint read every string in a block's inputs
21-
* as a possible `<block.path>` reference.
18+
* The structure of a declared output schema: types, properties, openness and
19+
* array items only. Descriptions, enums and examples are dropped because the
20+
* snapshot is a block input, and the executor and lint read every string in a
21+
* block's inputs as a possible `<block.path>` reference.
2222
*/
2323
export interface McpToolOutputShape {
2424
type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'any'
25-
/** Declared fields of a closed object; absent when the object is open or undeclared. */
25+
/** Declared fields of an object; absent when it declares none, which leaves every field allowed. */
2626
properties?: Record<string, McpToolOutputShape>
27+
/**
28+
* The object allows fields beyond `properties`, as JSON Schema reads any object
29+
* not declaring `additionalProperties: false`. A snapshot saved before this was
30+
* recorded lacks it and stays closed until the operation is picked again.
31+
*/
32+
open?: true
2733
items?: McpToolOutputShape
2834
}
2935

@@ -67,23 +73,28 @@ function reduceNode(node: unknown, depth: number, budget: { nodes: number }): Mc
6773

6874
switch (declaredType(node)) {
6975
case 'object': {
70-
const open =
71-
(node.additionalProperties !== undefined && node.additionalProperties !== false) ||
72-
node.patternProperties !== undefined
73-
if (open || !isRecordLike(node.properties)) return { type: 'object' }
76+
const closed = node.additionalProperties === false && node.patternProperties === undefined
77+
if (!closed && !isRecordLike(node.properties)) return { type: 'object' }
7478
const entries: [string, McpToolOutputShape][] = []
75-
for (const [name, child] of Object.entries(node.properties)) {
76-
/** Past the budget the object stays open: any field resolves, none is saved. */
79+
for (const [name, child] of Object.entries(toRecord(node.properties))) {
80+
/** Past the budget the object is saved untyped: any field resolves, none is declared. */
7781
if (budget.nodes <= 0) return { type: 'object' }
7882
entries.push([name, reduceNode(child, depth + 1, budget)])
7983
}
80-
/** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */
81-
return { type: 'object', properties: Object.fromEntries(entries) }
84+
return {
85+
type: 'object',
86+
/** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */
87+
properties: Object.fromEntries(entries),
88+
...(!closed && { open: true }),
89+
}
8290
}
8391
case 'array':
84-
return isRecordLike(node.items)
85-
? { type: 'array', items: reduceNode(node.items, depth + 1, budget) }
86-
: { type: 'array' }
92+
return {
93+
type: 'array',
94+
items: isRecordLike(node.items)
95+
? reduceNode(node.items, depth + 1, budget)
96+
: { type: 'any' },
97+
}
8798
case 'string':
8899
return { type: 'string' }
89100
case 'number':
@@ -139,6 +150,7 @@ function isShape(value: unknown, depth = 0): value is McpToolOutputShape {
139150
return false
140151
}
141152
if (depth > MAX_SHAPE_DEPTH + 1) return false
153+
if (value.open !== undefined && value.open !== true) return false
142154
if (value.items !== undefined && !isShape(value.items, depth + 1)) return false
143155
if (value.properties === undefined) return true
144156
return (

‎apps/sim/lib/workflows/blocks/outputs.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,8 @@ export function getEvaluatorMetricOutputs(
437437
interface DeclaredOutputNode {
438438
type: PrimitiveValueType
439439
properties?: Record<string, DeclaredOutputNode>
440+
/** Fields beyond `properties` are allowed: a reference to one checks like any `json` path. */
441+
open?: true
440442
items?: DeclaredOutputNode
441443
}
442444

@@ -449,6 +451,7 @@ function toOutputNode(shape: McpToolOutputShape): DeclaredOutputNode {
449451
properties: Object.fromEntries(
450452
Object.entries(shape.properties).map(([name, child]) => [name, toOutputNode(child)])
451453
),
454+
...(shape.open && { open: true }),
452455
}
453456
: { type: 'json' }
454457
case 'array':

0 commit comments

Comments
 (0)