Skip to content

fix(agent-runtime): keep zod internals when copying tool definitions - #1342

Open
KazenDev wants to merge 1 commit into
CodebuffAI:mainfrom
KazenDev:fix/mcp-tool-schema-copy
Open

fix(agent-runtime): keep zod internals when copying tool definitions#1342
KazenDev wants to merge 1 commit into
CodebuffAI:mainfrom
KazenDev:fix/mcp-tool-schema-copy

Conversation

@KazenDev

@KazenDev KazenDev commented Sep 12, 2026

Copy link
Copy Markdown

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.

Root cause

// packages/agent-runtime/src/tools/prompts.ts:433
const clonedDef = cloneDeep(toolDefinition)

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:

// node_modules/zod/v4/core/core.js:9
const _zodDesc = { value: undefined, enumerable: false };

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:

const { inputSchema, ...restOfDefinition } = toolDefinition
const clonedDef = { ...cloneDeep(restOfDefinition), inputSchema }

How to reproduce

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'

await getToolSet({
  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 schemassame 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant