Skip to content

Commit 97a12a1

Browse files
committed
fix(mcp): keep declared fields in deps whole reads and sample __proto__ fields safely
workflows deps now adds an operation's declared fields wherever a consumer reads a structuredContent value whole, beside the paths it reads by name. Trigger mock payloads define sampled fields with Object.fromEntries, so a declared __proto__ field stays a field instead of replacing the sample's prototype.
1 parent d975ede commit 97a12a1

4 files changed

Lines changed: 90 additions & 26 deletions

File tree

‎apps/sim/lib/mothership/agent-cli/engines.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,19 @@ describe('workflows deps', () => {
340340
).toEqual({ structuredContent: { person: { id: null }, onCall: null }, content: null })
341341
})
342342

343+
it('keeps the declared fields of a whole read beside a nested one', async () => {
344+
expect(
345+
await depsMock('<oncall.structuredContent> <oncall.structuredContent.onCall>')
346+
).toEqual({
347+
structuredContent: {
348+
role: null,
349+
person: { id: null },
350+
shifts: [{ start: null }],
351+
onCall: null,
352+
},
353+
})
354+
})
355+
343356
it('keeps the read-path skeleton when the operation declares no schema', async () => {
344357
expect(
345358
await depsMock('return <oncall.structuredContent>', { server: 'server-1', tool: 'on_call' })

‎apps/sim/lib/mothership/agent-cli/engines/deps.ts‎

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -122,38 +122,60 @@ function skeletonFromDeclaredOutput(node: unknown, depth = 0): unknown {
122122
return null
123123
}
124124

125+
/** A dependency path as the segments the mock skeleton is built from. */
126+
function pathSegments(path: string): string[] {
127+
return path
128+
.split('.')
129+
.filter(Boolean)
130+
.flatMap((part) => {
131+
const { property, pathParts } = splitLeadingBracketPath(part)
132+
return [property, ...pathParts]
133+
})
134+
}
135+
125136
/**
126-
* Replaces each null leaf of an MCP block's mocked `structuredContent` with the
127-
* skeleton its operation's declared output schema gives at that path, so a mock
128-
* of a declaring operation starts from the reply's real fields rather than from
129-
* whatever names the consuming block guessed. A leaf the schema does not declare
130-
* stays null.
137+
* Fills an MCP block's mocked `structuredContent` from its operation's declared
138+
* output schema, so a mock of a declaring operation starts from the reply's real
139+
* fields rather than from whatever names the consuming block guessed. Where the
140+
* consumer reads a value whole, every declared field below it is added beside
141+
* the ones it reads by name; an undeclared path it reads stays as read.
131142
*/
132143
function fillDeclaredStructuredContent(
133144
mock: Record<string, unknown>,
134-
block: Record<string, unknown> | undefined
145+
block: Record<string, unknown> | undefined,
146+
paths: readonly string[]
135147
): void {
136148
if (block?.type !== 'mcp' || !Object.hasOwn(mock, MCP_STRUCTURED_CONTENT_OUTPUT)) return
137149
// An invalid mode makes resolveMcpBlockConfig throw, which reads as no typed output.
138150
const modes = toRecord(toRecord(block.data).canonicalModes) as CanonicalModeOverrides
139151
const outputs = getMcpStructuredContentOutputs(toRecord(block.subBlocks), modes)
140152
const declared = outputs?.[MCP_STRUCTURED_CONTENT_OUTPUT]
141153
if (!declared) return
154+
const readWhole = new Set(paths.map((path) => pathSegments(path).join('.')))
142155

143-
const fill = (value: unknown, node: unknown): unknown => {
156+
const fill = (value: unknown, node: unknown, at: string): unknown => {
144157
if (value === null) return skeletonFromDeclaredOutput(node)
145158
if (!isRecordLike(node)) return value
146-
if (Array.isArray(value)) return value.map((item) => fill(item, node.items))
159+
if (Array.isArray(value))
160+
return value.map((item, index) => fill(item, node.items, `${at}.${index}`))
147161
if (!isRecordLike(value) || !isRecordLike(node.properties)) return value
148162
const properties = node.properties
149-
return Object.fromEntries(
150-
Object.entries(value).map(([key, child]) => [
151-
key,
152-
Object.hasOwn(properties, key) ? fill(child, properties[key]) : child,
153-
])
154-
)
163+
const read = Object.entries(value).map(([key, child]): [string, unknown] => [
164+
key,
165+
Object.hasOwn(properties, key) ? fill(child, properties[key], `${at}.${key}`) : child,
166+
])
167+
const added = readWhole.has(at)
168+
? Object.entries(properties)
169+
.filter(([key]) => !Object.hasOwn(value, key))
170+
.map(([key, child]): [string, unknown] => [key, skeletonFromDeclaredOutput(child)])
171+
: []
172+
return Object.fromEntries([...added, ...read])
155173
}
156-
mock[MCP_STRUCTURED_CONTENT_OUTPUT] = fill(mock[MCP_STRUCTURED_CONTENT_OUTPUT], declared)
174+
mock[MCP_STRUCTURED_CONTENT_OUTPUT] = fill(
175+
mock[MCP_STRUCTURED_CONTENT_OUTPUT],
176+
declared,
177+
MCP_STRUCTURED_CONTENT_OUTPUT
178+
)
157179
}
158180

159181
interface DepView {
@@ -330,7 +352,11 @@ export const workflowDepsCommand: AgentCliEngine = {
330352
// parent it never reads at all — run_block refuses to start without them.
331353
const shapes = blockDeps.map((d) => {
332354
const shape = skeletonFromPaths(d.paths ?? [])
333-
fillDeclaredStructuredContent(shape.value, d.blockId ? blocks[d.blockId] : undefined)
355+
fillDeclaredStructuredContent(
356+
shape.value,
357+
d.blockId ? blocks[d.blockId] : undefined,
358+
d.paths ?? []
359+
)
334360
return { name: d.blockName ?? d.blockId ?? d.token, ...shape }
335361
})
336362
const mock: Record<string, Record<string, unknown>> = Object.fromEntries(
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { generateMockPayloadFromOutputsDefinition } from '@/lib/workflows/triggers/mock-payload'
3+
4+
describe('generateMockPayloadFromOutputsDefinition', () => {
5+
it('samples a declared __proto__ field as a field, never as the prototype', () => {
6+
const outputs = JSON.parse(
7+
'{"structuredContent":{"type":"json","properties":{"__proto__":{"type":"json","properties":{"polluted":{"type":"boolean"}}},"role":{"type":"string"}}}}'
8+
)
9+
const sample = generateMockPayloadFromOutputsDefinition(outputs)
10+
const structured = sample.structuredContent as Record<string, unknown>
11+
expect(Object.getPrototypeOf(structured)).toBe(Object.prototype)
12+
expect(Object.keys(structured)).toEqual(['__proto__', 'role'])
13+
expect(Object.hasOwn(structured, '__proto__')).toBe(true)
14+
})
15+
})

‎apps/sim/lib/workflows/triggers/mock-payload.ts‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ function generateMockValue(type: string, _description?: string, fieldName?: stri
4747
}
4848
}
4949

50+
/**
51+
* Samples each declared field as an own property. `fromEntries` defines rather
52+
* than assigns, so a field named `__proto__` (an MCP output schema may declare
53+
* one) stays a field instead of replacing the sample's prototype.
54+
*/
55+
function sampleFields(
56+
fields: Record<string, unknown>,
57+
depth: number,
58+
maxDepth: number
59+
): Record<string, unknown> {
60+
return Object.fromEntries(
61+
Object.entries(fields).map(([key, field]) => [
62+
key,
63+
processOutputField(key, field, depth + 1, maxDepth),
64+
])
65+
)
66+
}
67+
5068
/**
5169
* Recursively processes nested output structures, expanding JSON-Schema-style
5270
* objects/arrays that define `properties` or `items` instead of returning
@@ -75,11 +93,7 @@ function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 1
7593
typedField.properties &&
7694
typeof typedField.properties === 'object'
7795
) {
78-
const nestedObject: Record<string, unknown> = {}
79-
for (const [nestedKey, nestedField] of Object.entries(typedField.properties)) {
80-
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
81-
}
82-
return nestedObject
96+
return sampleFields(typedField.properties, depth, maxDepth)
8397
}
8498

8599
if (typedField.type === 'array' && typedField.items && typeof typedField.items === 'object') {
@@ -91,11 +105,7 @@ function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 1
91105
}
92106

93107
if (isRecordLike(field)) {
94-
const nestedObject: Record<string, unknown> = {}
95-
for (const [nestedKey, nestedField] of Object.entries(field)) {
96-
nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth)
97-
}
98-
return nestedObject
108+
return sampleFields(field, depth, maxDepth)
99109
}
100110

101111
return null

0 commit comments

Comments
 (0)