Skip to content

feat(codemode): support AI SDK jsonSchema wrapper + production-harden schema converter#960

Merged
mattzcarey merged 22 commits intomainfrom
feat/mcp-tools-zod-schema
Feb 24, 2026
Merged

feat(codemode): support AI SDK jsonSchema wrapper + production-harden schema converter#960
mattzcarey merged 22 commits intomainfrom
feat/mcp-tools-zod-schema

Conversation

@mattzcarey
Copy link
Copy Markdown
Contributor

@mattzcarey mattzcarey commented Feb 20, 2026

Summary

This PR makes codemode's generateTypes() work with AI SDK jsonSchema() wrappers (from MCP tools) and hardens the entire JSON Schema → TypeScript conversion pipeline so it never crashes regardless of input.

Before: generateTypes() only worked with Zod schemas. MCP tools using jsonSchema() wrappers silently produced unknown types. No protection against recursive schemas, malformed input, or special characters.

After: Full support for both Zod and jsonSchema() wrappers with direct JSON Schema → TypeScript conversion (~6µs vs ~32µs for Zod). The converter handles $ref, circular schemas, deeply nested schemas, tuple types, nullable types, and special characters without crashing.

What changed

packages/codemode/src/types.ts (+568 lines)

Schema detection & extraction:

  • isJsonSchemaWrapper() / extractJsonSchema() — detect and unwrap AI SDK jsonSchema() wrappers (both direct property and symbol-based storage)
  • isZodSchema() — detect Zod schemas via _zod property
  • safeSchemaToTs() — routes to zodToTs or direct conversion based on schema type

Direct JSON Schema → TypeScript converter (jsonSchemaToTypeString):

  • Handles: string, number, integer, boolean, null, object, array, enum, const, anyOf, oneOf, allOf, type arrays (["string", "null"])
  • ConversionContext threading through all recursive calls (root schema, depth counter, visited set, max depth)

Safety & correctness:

  • $ref resolutionresolveRef() handles internal JSON Pointers (#/definitions/..., #/$defs/..., #), JSON Pointer unescaping (~0/~1). External URLs degrade to unknown
  • Depth guard — returns unknown at depth 20
  • Circular reference guardSet<unknown> tracks visited schema objects, returns unknown on cycles
  • Tuple supportprefixItems (JSON Schema 2020-12) and items as array (draft-07) → [T1, T2, ...]
  • OpenAPI 3.0 nullable: trueapplyNullable() applied across all branches including $ref
  • additionalProperties: false — empty object with no additional properties returns {} instead of Record<string, unknown>
  • Empty enumenum: [] produces never instead of invalid TypeScript
  • Object/array enum & const valuesJSON.stringify() instead of String() (which produced [object Object])
  • String escapingescapeStringLiteral() for \n, \r, \t, control chars, U+2028/U+2029 in enum/const values; quoteProp() for property names; escapeJsDoc() for */ in JSDoc
  • Newline normalization\r?\n → spaces in descriptions before JSDoc emission
  • @format as JSDoc hint — JSON Schema format keyword emitted as @format tag; multi-line JSDoc when combined with description
  • Per-tool error isolationtry-catch in generateTypes() loop; one bad tool emits unknown types without crashing the pipeline
  • Null guard on inputSchema — missing schema produces empty param descriptions instead of throwing
  • Null guard in extractDescriptions — guards typeof null === "object" edge case

packages/agents/src/mcp/client.ts (+25/-7)

  • Per-tool error isolation in getAITools() — replaced .map() with for-loop + try-catch; bad tools logged via console.warn and skipped
  • Guard tool.inputSchema — missing schema falls back to { type: "object" }

packages/codemode/src/tests/schema-conversion.test.ts (new, 42 tests)

Group Tests
jsonSchema wrapper basics 8 (object, nested, arrays, enums, required/optional, descriptions, anyOf, output schema)
Zod schema basics 2
$ref resolution 5 ($defs, definitions, unresolvable, external URL, nested chains)
Circular schemas 2 (self-ref, 30-level deep nesting)
Property name safety 3 (control chars, quotes, empty string)
JSDoc safety 2 (*/ in property and tool descriptions)
Tuple support 2 (draft-07 items array, 2020-12 prefixItems)
Nullable 2 (with/without nullable: true)
allOf / oneOf 2 (intersection, 3+ member union)
Enum/const escaping 3 (special chars, null, const escaping)
additionalProperties: false 2 (empty {} vs Record<string, unknown>)
Enum/const object values 3 (object enum, array enum, object const via JSON.stringify)
Multi-line JSDoc @format 2 (desc+format multi-line, format-only single-line)
Newline normalization 2 (property descriptions, tool descriptions)
Codemode declaration 2 (structure, name sanitization)

packages/codemode/src/tests/types.test.ts (new, 24 tests)

Covers: Zod tools, jsonSchema tools, mixed tools, tool name sanitization, description/JSDoc, edge cases (hyphenated names, reserved words, digit-leading names), malformed input (null/undefined/string inputSchema), error isolation (throwing tool doesn't break others).

Stress testing

Tested against 51 schemas from real-world MCP servers and adversarial inputs:

Real MCP servers: Cloudflare Workers, DynamoDB, Docker, Filesystem, Playwright, Kubernetes, Home Assistant, Obsidian, Stagehand

Adversarial inputs: Self-referencing $ref, 25-level nesting, circular A→B→A refs, __proto__ property names, JSDoc-breaking */ descriptions, empty enums, external URL refs, boolean schemas, null/undefined/string inputSchema

All 51 pass without crashes.

Performance

Operation Time
MCP getAITools() ~0.25 µs/schema
generateTypes() with jsonSchema ~6 µs
generateTypes() with Zod ~32 µs

Known limitations

  • __proto__ as a property name is silently dropped by the JS engine (object literals only — JSON.parse() from MCP wire protocol is unaffected)
  • External $ref URLs, not, if/then/else, patternProperties are out of scope and degrade to unknown
  • format keyword is emitted as a @format JSDoc hint, not as a refined TypeScript type

Test plan

  • 66 new codemode tests pass (42 schema-conversion + 24 types)
  • 89 total codemode tests pass
  • Formatting: 0 issues
  • Linting (oxlint): 0 errors
  • Typecheck: 0 new errors (pre-existing glm-4.7-flash only)
  • 51 real-world + adversarial schemas pass without crashes

MCP tools returned by getAITools() now have the _zod property on their
inputSchema, which is required for codemode type generation.

Previously, getAITools() used the AI SDK's jsonSchema() function to wrap
MCP tool JSON schemas, which created Schema objects without the _zod
property. This change uses Zod v4's fromJSONSchema() function instead,
which converts JSON schemas to actual Zod schemas that include the _zod
property.

- Add static import of fromJSONSchema from zod
- Replace this.jsonSchema() calls with fromJSONSchema() in getAITools()
- Remove the jsonSchema initialization check (no longer needed)
- Update tests to verify _zod property is present on tool inputSchema
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 20, 2026

🦋 Changeset detected

Latest commit: 0ee5368

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@cloudflare/codemode Patch
agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Feb 20, 2026

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/agents@960
npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/ai-chat@960
npm i https://pkg.pr.new/cloudflare/agents/@cloudflare/codemode@960
npm i https://pkg.pr.new/cloudflare/agents/hono-agents@960

commit: 0ee5368

@mattzcarey mattzcarey marked this pull request as draft February 20, 2026 23:12
@mattzcarey mattzcarey force-pushed the feat/mcp-tools-zod-schema branch from 46ca09f to 9f2be18 Compare February 21, 2026 00:16
- Remove unused json-schema-to-typescript dependency from codemode
- Add comprehensive test for MCP tools with input AND output schemas
- Add comprehensive test for AI SDK tool() with input AND output schemas
- Remove redundant mixed tools test (covered by the two focused tests)
- Both tests verify rich output types, not just 'unknown'
@mattzcarey mattzcarey marked this pull request as ready for review February 21, 2026 00:53
- Fix JSONSchema7 type incompatibility with fromJSONSchema parameter type
- Remove unused JSONSchema7 import
- Simplify test to use ToolDescriptors directly instead of AI SDK tool()
- Fix Text component className prop error in codemode example
- Keep jsonSchema property on MCPClientManager (deprecated but functional)
- ensureJsonSchema() still lazy-loads jsonSchema from AI SDK for compat
- No breaking changes - existing code using manager.jsonSchema still works
@mattzcarey
Copy link
Copy Markdown
Contributor Author

mattzcarey commented Feb 21, 2026

Performance Analysis: jsonSchema() vs fromJSONSchema()

Profiled the performance impact of switching from AI SDK's jsonSchema() to Zod v4's fromJSONSchema():

Metric fromJSONSchema() jsonSchema() Ratio
Simple schema (2 props) 25 µs 0.25 µs ~100x slower
Complex MCP schema 151 µs 0.22 µs ~685x slower
Deeply nested (5 levels) 89 µs 0.26 µs ~342x slower
Memory per schema ~559 KB ~1 KB ~559x more

What this means in practice

For a typical MCP server with 10 tools:

  • fromJSONSchema(): ~1.5ms startup + ~5.5MB memory
  • jsonSchema(): ~0.002ms startup + ~10KB memory

However, this cost is paid once when getAITools() is called, not per-request. After initialization:

  • Validation is fast: ~1 µs per call
  • Schemas are cached in the returned tool definitions

Why we need fromJSONSchema()

  • jsonSchema() creates a thin wrapper without _zod property
  • fromJSONSchema() creates a real Zod schema with _zod property
  • The _zod property is required for zod-to-ts which codemode uses for type generation

Recommendation

keep jsonSchema and build a parser to support it.

@mattzcarey mattzcarey marked this pull request as draft February 21, 2026 01:27
…andling

- Add safeZodToTs() wrapper that returns "unknown" for schemas that can't
  be represented in TypeScript (e.g., transforms) instead of throwing
- Add schema-conversion.test.ts with 165 tests covering all Zod v4 types:
  primitives, literals, enums, objects, arrays, tuples, unions, intersections,
  records, maps, sets, modifiers, readonly, coerce, pipe, transform,
  template literals, lazy/recursive, functions, promises, branded types,
  effects/refinements, string/number validators, fromJSONSchema, and
  sanitizeToolName edge cases
- Test coverage exceeds zod-to-ts package (~25 tests vs 165 tests)
These were used to validate fromJSONSchema() vs jsonSchema() performance:
- fromJSONSchema: ~168µs (creates real Zod schema with _zod property)
- jsonSchema: ~0.25µs (thin wrapper, no _zod property)

The ~680x slower performance is acceptable since schema creation happens
once at startup, not per-request. fromJSONSchema is required for codemode
type generation which needs the _zod property.
Reverts MCP client to use fast jsonSchema wrapper (~0.25µs) instead of
slower fromJSONSchema (~168µs). Type conversion is now handled in the
codemode package's generateTypes function.

Changes to codemode/types.ts:
- Add schema detection: isZodSchema, isJsonSchemaWrapper, isRawJsonSchema
- Add normalizeToZodSchema to convert any schema type to Zod
- Extract JSON schema from AI SDK jsonSchema wrapper via symbol properties
- Use fromJSONSchema internally only during type generation (one-time cost)
- Support: Zod schemas, AI SDK jsonSchema wrapper, raw JSON schemas

Performance:
- MCP getAITools(): ~0.25µs per schema (fast jsonSchema wrapper)
- generateTypes(): ~210µs for complex schemas (acceptable startup cost)

Tests added:
- AI SDK jsonSchema wrapper handling
- Raw JSON Schema object handling
Replace fromJSONSchema-based conversion with direct JSON Schema to
TypeScript string conversion. This avoids creating Zod validators
when we only need type information.

- Add jsonSchemaToTypeString() for direct conversion
- Remove fromJSONSchema import (no longer needed)
- Remove raw JSON Schema handling (only Zod + jsonSchema wrapper)
- Support: objects, arrays, enums, unions, intersections, etc.

Performance:
- jsonSchema wrapper → TypeScript: ~6µs (was ~210µs, 35x faster)
- Zod schema → TypeScript: ~32µs (uses zod-to-ts)
Remove 144 tests that were testing zod-to-ts library behavior.
Keep 21 focused tests for our own code:
- sanitizeToolName (10 tests)
- generateTypes with jsonSchema wrapper (9 tests)  
- generateTypes with Zod schema (2 tests - integration only)
@mattzcarey mattzcarey changed the title fix(mcp): use Zod fromJSONSchema for MCP tool schemas feat(codemode): support AI SDK jsonSchema wrapper in type generation Feb 21, 2026
Add depth/circular reference guards, $ref resolution, string escaping
(control chars, U+2028/U+2029, JSDoc), tuple/nullable support, and
per-tool error isolation so one malformed schema never crashes the
pipeline.
- Apply nullable after resolving $ref so nullable: true on ref
  properties correctly produces `| null`
- Empty enum (enum: []) now produces `never` instead of invalid TS
- Add improvements.md documenting known limitations found during
  stress-testing with 51 real-world MCP schemas
@mattzcarey mattzcarey marked this pull request as ready for review February 23, 2026 14:43
- Remove improvements.md (findings go in PR comment instead)
- Emit @Format JSDoc annotation when a property has a format
  keyword (e.g. email, date-time, uuid) so the info is preserved
  for the LLM without changing the TS type
@mattzcarey
Copy link
Copy Markdown
Contributor Author

Stress Testing Findings

Ran generateTypes against 51 schemas — real-world MCP servers (Cloudflare Workers, DynamoDB, Docker, Filesystem, Playwright, Kubernetes, Home Assistant, Obsidian, Stagehand) plus adversarial inputs. All pass.

Bugs found and fixed

Issue Fix
nullable: true on a $ref property was silently lost applyNullable() now wraps the resolved ref result
Empty enum: [] produced val?: ; (invalid TS) Returns never for empty enums
nullable wasn't applied to anyOf/oneOf/allOf/enum/const branches All branches now go through applyNullable()
escapeStringLiteral missed newlines, control chars, U+2028/U+2029 Character-level loop escapes everything
*/ in descriptions broke JSDoc comments escapeJsDoc() applied to all descriptions
format keyword (email, date-time, uuid) was silently dropped Now emitted as @format JSDoc hint

Known limitations (cannot fix in converter)

  • __proto__ as a property name is silently dropped by the JS engine when using object literals. JSON.parse() (i.e., schemas arriving from MCP wire protocol) is unaffected.

Out of scope (by design, degrade to unknown)

  • External $ref URLs — security risk
  • not, if/then/else — no clean TS equivalent
  • patternProperties — rare, complex
  • dependencies / dependentRequired — conditional requirements can't be expressed in TS types

- Return `{}` for `additionalProperties: false` with no properties
- Use JSON.stringify for object/array enum and const values
- Multi-line JSDoc when both description and @Format are present
- Add null guard in extractDescriptions for propSchema
- Normalize newlines in descriptions to prevent broken JSDoc
@mattzcarey mattzcarey changed the title feat(codemode): support AI SDK jsonSchema wrapper in type generation feat(codemode): support AI SDK jsonSchema wrapper + production-harden schema converter Feb 23, 2026
mattzcarey and others added 2 commits February 23, 2026 15:44
Treat boolean property schemas as concrete TS types: true -> unknown, false -> never, and respect required/optional markers when emitting properties. Add tests covering boolean property schemas, type arrays (e.g. ["string","null"]), integer->number mapping, bare arrays, empty enum -> never, and additionalProperties behavior (both true and typed). Also add a clarifying comment about index signature compatibility when emitting additionalProperties.
@threepointone
Copy link
Copy Markdown
Contributor

Added a few more tests, and addressed this gap:

Boolean property schemas silently dropped
In JSON Schema, { "foo": true } means foo accepts any value, { "foo": false } means foo accepts nothing. These should map to unknown and never, not be skipped entirely. Properties with boolean schemas will vanish from the generated type.


Approving, land if my above thing makes sense to you (look at my commit)

@mattzcarey mattzcarey merged commit 179b8cb into main Feb 24, 2026
4 checks passed
@mattzcarey mattzcarey deleted the feat/mcp-tools-zod-schema branch February 24, 2026 10:28
@github-actions github-actions bot mentioned this pull request Feb 24, 2026
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.

2 participants