You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
With any MCP server configured, the first message of a run dies with an error overlay in the TUI:
undefined is not an object (evaluating 'H._zod.parent')
The message only mentions the MCP server, so it reads like a server problem — it isn't. It's cloneDeep dropping zod's internals when getToolSet copies a tool definition, and every MCP tool is a zod schema by the time it gets there.
MCP tools are the only tool definitions whose inputSchema is a live Zod schema — mcp.ts builds them with convertJsonSchemaToZod(inputSchema), while SDK custom tools stay JSON Schema. And zod v4 keeps its internals on a non-enumerable property:
cloneDeep copies own enumerable properties only, so the clone loses _zod — but it keeps the prototype, which is what makes it dangerous: ensureZodSchema checks typeof schema.safeParse === 'function', that still passes, so the broken clone is handed to the AI SDK as a valid schema. It then fails inside zod:
TypeError: undefined is not an object (evaluating 'schema._zod.parent')
at get (zod/v4/core/registries.js:33) // const p = schema._zod.parent
at get description (zod/v4/classic/schemas.js:186) // .description reads the registry
at ensureJsonSchemaCompatible (tools/prompts.ts:47)
at getToolSet (tools/prompts.ts:437)
ensureJsonSchemaCompatible even has a fallback for schemas that can't be converted — but reaching it reads schema.description, which is exactly the call that throws.
Fix
Copy the definition, carry the schema by reference. Schemas are immutable, and ensureZodSchema accepts either a Zod schema or JSON Schema, so nothing downstream changes for SDK custom tools:
Verified directly against getToolSet (no MCP server needed — the definition is what an MCP tool looks like once mcp.ts has converted it):
import{convertJsonSchemaToZod}from'zod-from-json-schema'import{getToolSet}from'./packages/agent-runtime/src/tools/prompts'awaitgetToolSet({toolNames: [],windowedFileReads: false,additionalToolDefinitions: async()=>({'exa__web_search_exa': {inputSchema: convertJsonSchemaToZod({type: 'object',properties: {query: {type: 'string'}},required: ['query'],}),endsAgentStep: true,description: 'Search the web with Exa',},}),agentTools: {},skills: {},})
main: CRASH: undefined is not an object (evaluating 'schema._zod.parent') + the stack above.
With this patch: OK, tools: [ "exa__web_search_exa" ].
In the product the same thing happens end to end: put a mcpServers entry in .agents/mcp.json, start a run, and send a message — the first step loads the MCP tools and the run dies.
The regression test added here fails on main and passes with the patch:
bun test packages/agent-runtime/src/__tests__/prompts-schema-handling.test.ts
main
this patch
that file
15 pass / 3 fail
17 pass / 1 fail
the new test
fails
passes
Verification
The new test fails on main and passes with the patch (load check, not just a green run).
The getToolSet probe above: crash on main, clean with the patch.
bun run --cwd packages/agent-runtime test: the only remaining failure in that package is the pre-existing one below.
bunx prettier --check passes on the test file. prompts.ts was already unformatted on main (prettier wants changes in hasMeaningfulJsonSchema and paramsSection, lines I don't touch), so I left it alone rather than mix unrelated reformatting into this patch.
bun.lock pins zod@4.6.2, which is what I tested against.
Reproduced against a real MCP server, then fixed
Same binary, same machine, same mcpServers entry (Exa over HTTP) — only the build differs:
build
sending a message
before the patch
the TUI shows undefined is not an object (evaluating 'H._zod.parent') and the run dies
with the patch
run completes; MCP tools load and several turns go through
The error is an overlay in the TUI rather than something in the log, since it is thrown while the tool set is assembled, before the step runs. One thing worth knowing when you build this yourself: with MCP configured, this patch alone surfaces a separate crash (“JSON.stringify cannot serialize cyclic structures”) from the run-state clone in #1341 — with both applied the run is clean. That is how I found out the two are independent.
Two tests are already red on main (FYI, not touched)
The public mirror doesn't run the test suite, so these have been sitting there — both in prompts-schema-handling.test.ts, both with the lockfile's zod:
getToolSet handles custom tools with problematic schemas — same root cause as this PR, and this patch repairs it.
buildToolDescription preserves MCP params when schema is represented as allOf — a stale expectation: zod 4.6.2 merges that intersection into a flat object (name and cb_easp both survive, .and() just no longer emits allOf). Changing that assertion is a call about intended output, so I left it out of this PR — happy to send it separately if you want it.
Not in this PR, on purpose
Three other places deep-clone something that can hold a schema. They are fine today, and the difference is why this patch stays narrow:
run-agent-step.ts:154 and tool-executor.ts:678 clone fileContext.customToolDefinitions, but everything in that map is JSON Schema by then: the SDK's custom tools are converted with z.toJSONSchema in sdk/src/run-state.ts before they get there, and the MCP entries are written into a per-step copy that is never written back. There is no live schema for cloneDeep to strip.
sdk/src/run-state.ts:1074 (cloneSessionState) does clone a state whose agent templates hold live schemas, but every consumer converts inside a try/catch (templates/strings.ts:247, lookup-agent-info.ts:85), so a stripped copy degrades to a fallback instead of throwing.
getToolSet is the one place where the copy is followed by an unguarded zod read, which is why it is the one that crashes.
If you'd rather have a single zod-aware clone helper used everywhere, say so and I'll follow up with it.
Scope
Two files, +44/-2: packages/agent-runtime/src/tools/prompts.ts and its test file. No dependency changes, no behavior change for SDK custom tools.
Solid bug report and fix. The root cause analysis is precise and verifiable: cloneDeep only copies own enumerable properties, zod v4 keeps _zod non-enumerable, and the resulting clone still passes the typeof schema.safeParse === 'function' duck-type check while being broken underneath. That's a nasty failure mode because it silently produces a corrupt-but-plausible schema instead of failing fast.
The fix (destructure inputSchema out before cloneDeep, carry it by reference since schemas are immutable) is minimal and targeted at packages/agent-runtime/src/tools/prompts.ts:433. It doesn't touch forbidden paths. The included test in prompts-schema-handling.test.ts reproduces the exact scenario (MCP-style zod schema via convertJsonSchemaToZod) and asserts safeParse actually validates post-clone, which is the right way to test this since the bug is specifically about zod internals, not just object shape.
One nit: clonedDef.inputSchema is now set twice implicitly — once via the spread of restOfDefinition (which excludes it) and once via the explicit inputSchema key when building clonedDef, and then again spread into toolSet[toolName] at the bottom (not shown but implied). Worth double-checking there's no redundant reassignment there, but from the diff it looks correct and non-duplicative.
Good find, appropriately scoped, includes a regression test. Recommend porting.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
With any MCP server configured, the first message of a run dies with an error overlay in the TUI:
The message only mentions the MCP server, so it reads like a server problem — it isn't. It's
cloneDeepdropping zod's internals whengetToolSetcopies a tool definition, and every MCP tool is a zod schema by the time it gets there.Root cause
MCP tools are the only tool definitions whose
inputSchemais a live Zod schema —mcp.tsbuilds them withconvertJsonSchemaToZod(inputSchema), while SDK custom tools stay JSON Schema. And zod v4 keeps its internals on a non-enumerable property:cloneDeepcopies own enumerable properties only, so the clone loses_zod— but it keeps the prototype, which is what makes it dangerous:ensureZodSchemacheckstypeof schema.safeParse === 'function', that still passes, so the broken clone is handed to the AI SDK as a valid schema. It then fails inside zod:ensureJsonSchemaCompatibleeven has a fallback for schemas that can't be converted — but reaching it readsschema.description, which is exactly the call that throws.Fix
Copy the definition, carry the schema by reference. Schemas are immutable, and
ensureZodSchemaaccepts either a Zod schema or JSON Schema, so nothing downstream changes for SDK custom tools:How to reproduce
Verified directly against
getToolSet(no MCP server needed — the definition is what an MCP tool looks like oncemcp.tshas converted it):main:CRASH: undefined is not an object (evaluating 'schema._zod.parent')+ the stack above.OK, tools: [ "exa__web_search_exa" ].In the product the same thing happens end to end: put a
mcpServersentry in.agents/mcp.json, start a run, and send a message — the first step loads the MCP tools and the run dies.The regression test added here fails on
mainand passes with the patch:mainVerification
mainand passes with the patch (load check, not just a green run).getToolSetprobe above: crash onmain, clean with the patch.bun run --cwd packages/agent-runtime test: the only remaining failure in that package is the pre-existing one below.bunx prettier --checkpasses on the test file.prompts.tswas already unformatted onmain(prettier wants changes inhasMeaningfulJsonSchemaandparamsSection, lines I don't touch), so I left it alone rather than mix unrelated reformatting into this patch.bun.lockpinszod@4.6.2, which is what I tested against.Reproduced against a real MCP server, then fixed
Same binary, same machine, same
mcpServersentry (Exa over HTTP) — only the build differs:undefined is not an object (evaluating 'H._zod.parent')and the run diesThe error is an overlay in the TUI rather than something in the log, since it is thrown while the tool set is assembled, before the step runs. One thing worth knowing when you build this yourself: with MCP configured, this patch alone surfaces a separate crash (“JSON.stringify cannot serialize cyclic structures”) from the run-state clone in #1341 — with both applied the run is clean. That is how I found out the two are independent.
Two tests are already red on main (FYI, not touched)
The public mirror doesn't run the test suite, so these have been sitting there — both in
prompts-schema-handling.test.ts, both with the lockfile's zod:getToolSet handles custom tools with problematic schemas— same root cause as this PR, and this patch repairs it.buildToolDescription preserves MCP params when schema is represented as allOf— a stale expectation: zod 4.6.2 merges that intersection into a flat object (nameandcb_easpboth survive,.and()just no longer emitsallOf). Changing that assertion is a call about intended output, so I left it out of this PR — happy to send it separately if you want it.Not in this PR, on purpose
Three other places deep-clone something that can hold a schema. They are fine today, and the difference is why this patch stays narrow:
run-agent-step.ts:154andtool-executor.ts:678clonefileContext.customToolDefinitions, but everything in that map is JSON Schema by then: the SDK's custom tools are converted withz.toJSONSchemainsdk/src/run-state.tsbefore they get there, and the MCP entries are written into a per-step copy that is never written back. There is no live schema forcloneDeepto strip.sdk/src/run-state.ts:1074(cloneSessionState) does clone a state whose agent templates hold live schemas, but every consumer converts inside atry/catch(templates/strings.ts:247,lookup-agent-info.ts:85), so a stripped copy degrades to a fallback instead of throwing.getToolSetis the one place where the copy is followed by an unguarded zod read, which is why it is the one that crashes.If you'd rather have a single zod-aware clone helper used everywhere, say so and I'll follow up with it.
Scope
Two files, +44/-2:
packages/agent-runtime/src/tools/prompts.tsand its test file. No dependency changes, no behavior change for SDK custom tools.