Skip to content

Commit 26ba358

Browse files
committed
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.
1 parent 06faa07 commit 26ba358

9 files changed

Lines changed: 159 additions & 28 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, `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`.",
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": {

‎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: 10 additions & 2 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

@@ -101,6 +103,11 @@ function isOpaqueSchemaNode(value: unknown): boolean {
101103
return false
102104
}
103105

106+
/** A node declaring its fields but allowing others, so a field it does not name is not an error. */
107+
function isOpenSchemaNode(value: unknown): boolean {
108+
return asSchemaNode(value)?.open === true
109+
}
110+
104111
/** Whether `pathParts` names a field the declared output schema has, the check a run makes. */
105112
export function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
106113
if (!schema || pathParts.length === 0) {
@@ -140,7 +147,7 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri
140147
if (arrayMatch) {
141148
const [, prop] = arrayMatch
142149
const fieldDef = lookupField(current, prop)
143-
if (!fieldDef) return false
150+
if (!fieldDef) return isOpenSchemaNode(current)
144151

145152
if (isFileType(fieldDef)) {
146153
const nextPart = pathParts[i + 1]
@@ -194,9 +201,10 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri
194201
current = itemField
195202
continue
196203
}
204+
return isOpenSchemaNode(items)
197205
}
198206

199-
return false
207+
return isOpenSchemaNode(current)
200208
}
201209

202210
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, \`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.`,
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, `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`.',
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: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
if (!isRecordLike(node.properties)) return { type: 'object' }
7477
const entries: [string, McpToolOutputShape][] = []
7578
for (const [name, child] of Object.entries(node.properties)) {
76-
/** Past the budget the object stays open: any field resolves, none is saved. */
79+
/** Past the budget the object is saved untyped: any field resolves, none is declared. */
7780
if (budget.nodes <= 0) return { type: 'object' }
7881
entries.push([name, reduceNode(child, depth + 1, budget)])
7982
}
80-
/** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */
81-
return { type: 'object', properties: Object.fromEntries(entries) }
83+
const closed = node.additionalProperties === false && node.patternProperties === undefined
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':

‎apps/sim/lib/workflows/editing/dangling-refs.test.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,13 @@ describe('collectDanglingBlockOutputReferences', () => {
284284
type: 'object' as const,
285285
properties: {
286286
role: { type: 'string' },
287-
person: { type: 'object', properties: { id: { type: 'string' } } },
287+
person: {
288+
type: 'object',
289+
properties: { id: { type: 'string' } },
290+
additionalProperties: false,
291+
},
288292
},
293+
additionalProperties: false,
289294
}
290295

291296
function mcpGraph(
@@ -366,7 +371,11 @@ describe('collectDanglingBlockOutputReferences', () => {
366371
type: 'array',
367372
items: {
368373
type: 'array',
369-
items: { type: 'object', properties: { id: { type: 'string' } } },
374+
items: {
375+
type: 'object',
376+
properties: { id: { type: 'string' } },
377+
additionalProperties: false,
378+
},
370379
},
371380
},
372381
},
@@ -382,6 +391,35 @@ describe('collectDanglingBlockOutputReferences', () => {
382391
expect(finding?.reason).toContain('Declared fields: structuredContent.rows.id')
383392
})
384393

394+
it('accepts an undeclared field of an object the schema leaves open', () => {
395+
const open = createMcpToolOutputSnapshot('server-1', 'on_call', {
396+
type: 'object',
397+
properties: {
398+
role: { type: 'string' },
399+
person: { type: 'object', properties: { id: { type: 'string' } } },
400+
},
401+
additionalProperties: false,
402+
})
403+
expect(
404+
collectDanglingBlockOutputReferences(
405+
mcpGraph('<oncall.structuredContent.person.email>', open)
406+
)
407+
).toEqual([])
408+
})
409+
410+
it('accepts a path inside the elements of an array that declares no items', () => {
411+
const untypedItems = createMcpToolOutputSnapshot('server-1', 'on_call', {
412+
type: 'object',
413+
properties: { rows: { type: 'array' } },
414+
additionalProperties: false,
415+
})
416+
expect(
417+
collectDanglingBlockOutputReferences(
418+
mcpGraph('<oncall.structuredContent.rows.0.id>', untypedItems)
419+
)
420+
).toEqual([])
421+
})
422+
385423
it('keeps flagging structuredContent when the operation declares no schema', () => {
386424
for (const snapshot of [
387425
undefined,

0 commit comments

Comments
 (0)