Skip to content

Commit 49f370c

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

6 files changed

Lines changed: 47 additions & 13 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
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, `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`.",
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"],

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,25 @@ function getProperties(schema: unknown): Record<string, unknown> | undefined {
7474
: undefined
7575
}
7676

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+
7782
function lookupField(schema: unknown, fieldName: string): unknown | undefined {
7883
if (typeof schema !== 'object' || schema === null) return undefined
7984
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+
}
8091

8192
if (fieldName in typed) {
8293
return typed[fieldName]
8394
}
8495

85-
const props = getProperties(schema)
8696
if (props && fieldName in props) {
8797
return props[fieldName]
8898
}
@@ -103,11 +113,6 @@ function isOpaqueSchemaNode(value: unknown): boolean {
103113
return false
104114
}
105115

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-
111116
/** Whether `pathParts` names a field the declared output schema has, the check a run makes. */
112117
export function isPathInSchema(schema: OutputSchema | undefined, pathParts: string[]): boolean {
113118
if (!schema || pathParts.length === 0) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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, \`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.`,
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',

‎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, `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`.',
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: 4 additions & 4 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
/**
@@ -73,14 +73,14 @@ function reduceNode(node: unknown, depth: number, budget: { nodes: number }): Mc
7373

7474
switch (declaredType(node)) {
7575
case 'object': {
76-
if (!isRecordLike(node.properties)) return { type: 'object' }
76+
const closed = node.additionalProperties === false && node.patternProperties === undefined
77+
if (!closed && !isRecordLike(node.properties)) return { type: 'object' }
7778
const entries: [string, McpToolOutputShape][] = []
78-
for (const [name, child] of Object.entries(node.properties)) {
79+
for (const [name, child] of Object.entries(toRecord(node.properties))) {
7980
/** Past the budget the object is saved untyped: any field resolves, none is declared. */
8081
if (budget.nodes <= 0) return { type: 'object' }
8182
entries.push([name, reduceNode(child, depth + 1, budget)])
8283
}
83-
const closed = node.additionalProperties === false && node.patternProperties === undefined
8484
return {
8585
type: 'object',
8686
/** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */

‎apps/sim/serializer/mcp-output-schema.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,35 @@ describe('MCP block output typed from a declared output schema', () => {
118118
expect(resolve(untypedItems, ['structuredContent', 'rows', '0', 'id'])).toBeUndefined()
119119
})
120120

121+
it('checks a declared field named like the openness flag as a field', () => {
122+
const named = (additionalProperties?: false) =>
123+
createMcpToolOutputSnapshot('server-1', 'on_call', {
124+
type: 'object',
125+
properties: {
126+
open: {
127+
type: 'object',
128+
properties: { id: { type: 'string' } },
129+
...(additionalProperties === false && { additionalProperties }),
130+
},
131+
},
132+
...(additionalProperties === false && { additionalProperties }),
133+
})
134+
expect(resolve(named(), ['structuredContent', 'open', 'id'])).toBeUndefined()
135+
expect(resolve(named(), ['structuredContent', 'open', 'extra'])).toBeUndefined()
136+
expect(resolve(named(false), ['structuredContent', 'open', 'id'])).toBeUndefined()
137+
expect(() => resolve(named(false), ['structuredContent', 'open', 'extra'])).toThrow(
138+
InvalidFieldError
139+
)
140+
})
141+
142+
it('rejects any field of a closed object that declares none', () => {
143+
const empty = createMcpToolOutputSnapshot('server-1', 'on_call', {
144+
type: 'object',
145+
additionalProperties: false,
146+
})
147+
expect(() => resolve(empty, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError)
148+
})
149+
121150
it('keeps a snapshot saved before openness was recorded closed until it is retaken', () => {
122151
const legacy = {
123152
server: 'server-1',

0 commit comments

Comments
 (0)