Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions typescript/.changeset/fix-recursive-zod-schema-refs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@coinbase/agentkit": patch
"@coinbase/agentkit-model-context-protocol": patch
---

fix: resolve $ref pointers from recursive Zod schemas for LLM function-calling APIs

Added `resolveJsonSchemaRefs()` utility that inlines `$ref` pointers produced by `zodToJsonSchema()` and `z.toJSONSchema()` when converting recursive or shared Zod types. Integrated into the MCP framework extension so schemas are resolved transparently. Also exported from `@coinbase/agentkit` for use with LangChain and Vercel AI SDK extensions.
1 change: 1 addition & 0 deletions typescript/agentkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./agentkit";
export * from "./wallet-providers";
export * from "./action-providers";
export * from "./network";
export { resolveJsonSchemaRefs } from "./resolveJsonSchemaRefs";
184 changes: 184 additions & 0 deletions typescript/agentkit/src/resolveJsonSchemaRefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { resolveJsonSchemaRefs } from "./resolveJsonSchemaRefs";

describe("resolveJsonSchemaRefs", () => {
it("returns schema unchanged when no $ref present", () => {
const schema = {
type: "object",
properties: {
name: { type: "string" },
},
};
expect(resolveJsonSchemaRefs(schema)).toEqual(schema);
});

it("inlines a simple $ref from $defs", () => {
const schema = {
$ref: "#/$defs/Address",
$defs: {
Address: {
type: "object",
properties: {
street: { type: "string" },
},
},
},
};
expect(resolveJsonSchemaRefs(schema)).toEqual({
type: "object",
properties: {
street: { type: "string" },
},
});
});

it("inlines a simple $ref from definitions", () => {
const schema = {
$ref: "#/definitions/Item",
definitions: {
Item: {
type: "object",
properties: { id: { type: "number" } },
},
},
};
expect(resolveJsonSchemaRefs(schema)).toEqual({
type: "object",
properties: { id: { type: "number" } },
});
});

it("inlines recursive $ref up to maxDepth", () => {
// Simulates zodToJsonSchema output for:
// z.object({ value: z.string(), children: z.lazy(() => schema).array() })
const schema = {
$ref: "#/$defs/TreeNode",
$defs: {
TreeNode: {
type: "object",
properties: {
value: { type: "string" },
children: {
type: "array",
items: { $ref: "#/$defs/TreeNode" },
},
},
},
},
};

const result = resolveJsonSchemaRefs(schema, 2);

// Depth 0: TreeNode inlined
expect(result.type).toBe("object");
expect(result.properties.value).toEqual({ type: "string" });

// Depth 1: children[].items -> TreeNode inlined again
expect(result.properties.children.items.type).toBe("object");
expect(result.properties.children.items.properties.value).toEqual({ type: "string" });

// Depth 2: hit maxDepth, replaced with permissive empty schema
expect(result.properties.children.items.properties.children.items).toEqual({});
});

it("handles shared sub-schemas referenced multiple times in a union", () => {
// Reproduces the exact scenario from issue #815: a sub-schema used twice
// in a union causes zodToJsonSchema to emit $ref for both occurrences
const schema = {
type: "object",
properties: {
value: {
anyOf: [
{ type: "string" },
{ $ref: "#/$defs/SubSchema" },
{ type: "array", items: { $ref: "#/$defs/SubSchema" } },
],
},
},
$defs: {
SubSchema: {
type: "object",
additionalProperties: {
anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
},
},
},
};

const result = resolveJsonSchemaRefs(schema);

// Both $ref occurrences should be inlined
expect(result.properties.value.anyOf[1]).toEqual({
type: "object",
additionalProperties: {
anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
},
});
expect(result.properties.value.anyOf[2].items).toEqual({
type: "object",
additionalProperties: {
anyOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
},
});
});

it("strips $defs and definitions from output", () => {
const schema = {
type: "object",
properties: {
child: { $ref: "#/$defs/Child" },
},
$defs: {
Child: { type: "object", properties: { name: { type: "string" } } },
},
};
const result = resolveJsonSchemaRefs(schema);
expect(result.$defs).toBeUndefined();
expect(result.definitions).toBeUndefined();
expect(result.properties.child).toEqual({
type: "object",
properties: { name: { type: "string" } },
});
});

it("passes through primitive schemas unchanged", () => {
expect(resolveJsonSchemaRefs({ type: "string" })).toEqual({ type: "string" });
expect(resolveJsonSchemaRefs({ type: "number" })).toEqual({ type: "number" });
});

it("preserves unrecognized $ref paths", () => {
const schema = { $ref: "https://example.com/schema.json" };
expect(resolveJsonSchemaRefs(schema)).toEqual(schema);
});

it("handles null and undefined values in schema nodes", () => {
const schema = {
type: "object",
properties: {
a: null,
b: { type: "string", default: null },
},
};
expect(resolveJsonSchemaRefs(schema)).toEqual(schema);
});

it("uses default maxDepth of 5", () => {
// Build a schema that chains 6 levels of $ref
const schema = {
$ref: "#/$defs/L0",
$defs: {
L0: { type: "object", properties: { next: { $ref: "#/$defs/L0" } } },
},
};

const result = resolveJsonSchemaRefs(schema);

// Walk 5 levels deep -- each should be resolved
let node = result;
for (let i = 0; i < 4; i++) {
expect(node.type).toBe("object");
node = node.properties.next;
}
// At depth 5, should be empty schema
expect(node.properties.next).toEqual({});
});
});
84 changes: 84 additions & 0 deletions typescript/agentkit/src/resolveJsonSchemaRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

/**
* Resolves `$ref` pointers in a JSON Schema by inlining their definitions.
*
* `zodToJsonSchema()` and `z.toJSONSchema()` emit `$ref` pointers when a Zod
* type is referenced more than once (shared sub-schemas) or is recursive
* (`z.lazy`). Several LLM function-calling APIs -- notably OpenAI -- reject
* schemas that contain `$ref` with errors like "object schema missing
* properties".
*
* This utility post-processes JSON Schema output by inlining every `$ref` up
* to a configurable depth, replacing deeper levels with a permissive empty
* schema (`{}`), and stripping the `$defs`/`definitions` block from the result.
*
* @param schema - A JSON Schema object, typically from `zodToJsonSchema()` or
* `z.toJSONSchema()`
* @param maxDepth - Maximum number of `$ref` expansions per path (default: 5)
* @returns A new JSON Schema with all `$ref` pointers resolved
*
* @example
* ```typescript
* import { resolveJsonSchemaRefs } from "@coinbase/agentkit";
* import { zodToJsonSchema } from "zod-to-json-schema";
*
* const jsonSchema = zodToJsonSchema(myRecursiveZodSchema);
* const resolved = resolveJsonSchemaRefs(jsonSchema);
* // `resolved` contains no $ref -- safe for OpenAI function calling
* ```
*/
export function resolveJsonSchemaRefs(
schema: Record<string, any>,
maxDepth = 5,
): Record<string, any> {
const definitions = schema.$defs || schema.definitions || {};

/**
* Recursively resolves `$ref` pointers in a JSON Schema node.
*
* @param node - The current schema node to process
* @param depth - Number of `$ref` expansions on the current path
* @returns The resolved schema node with `$ref` pointers inlined
*/
function resolve(node: any, depth: number): any {
if (node == null || typeof node !== "object") {
return node;
}

if (Array.isArray(node)) {
return node.map(item => resolve(item, depth));
}

// Resolve $ref by looking up the definition and inlining it
if (typeof node.$ref === "string") {
const match = node.$ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
if (!match) {
return node;
}

if (depth >= maxDepth) {
return {};
}

const def = definitions[match[1]];
if (!def) {
return node;
}

return resolve(def, depth + 1);
}

// Recurse into object properties, skipping the definitions block
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(node)) {
if (key === "$defs" || key === "definitions") {
continue;
}
result[key] = resolve(value, depth);
}
return result;
}

return resolve(schema, 0);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,52 @@ import { z } from "zod";
import { getMcpTools } from "./index";
import { AgentKit } from "@coinbase/agentkit";

/**
* Standalone ref resolver for test assertions. Mirrors the real utility
* but avoids loading the full `@coinbase/agentkit` module (ESM-only deps).
*
* @param schema - JSON Schema object to resolve
* @param maxDepth - Maximum ref expansion depth
* @returns Resolved schema
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolveJsonSchemaRefs(schema: Record<string, any>, maxDepth = 5): Record<string, any> {
const definitions = schema.$defs || schema.definitions || {};
/**
* Recursively resolves $ref pointers in a schema node.
*
* @param node - Current schema node
* @param depth - Current expansion depth
* @returns Resolved node
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolve(node: any, depth: number): any {
if (node == null || typeof node !== "object") return node;
if (Array.isArray(node)) return node.map(item => resolve(item, depth));
if (typeof node.$ref === "string") {
const match = node.$ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
if (!match) return node;
if (depth >= maxDepth) return {};
const def = definitions[match[1]];
return def ? resolve(def, depth + 1) : node;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(node)) {
if (key === "$defs" || key === "definitions") continue;
result[key] = resolve(value, depth);
}
return result;
}
return resolve(schema, 0);
}

// Mock AgentKit before importing - this prevents loading ES-only dependencies
jest.mock("@coinbase/agentkit", () => ({
AgentKit: {
from: jest.fn(),
},
resolveJsonSchemaRefs,
}));

// Define mock action after imports
Expand All @@ -32,9 +73,39 @@ describe("getMcpTools", () => {

expect(tool.name).toBe(mockAction.name);
expect(tool.description).toBe(mockAction.description);
expect(tool.inputSchema).toStrictEqual(z.toJSONSchema(mockAction.schema));
expect(tool.inputSchema).toStrictEqual(
resolveJsonSchemaRefs(z.toJSONSchema(mockAction.schema) as Record<string, unknown>),
);

const result = await toolHandler("testAction", { test: "data" });
expect(result).toStrictEqual({ content: [{ text: '"Invoked with data"', type: "text" }] });
});

it("should resolve $ref pointers in schemas with shared sub-types", async () => {
// Simulate a schema that produces $ref when converted to JSON Schema
const subSchema = z.object({ value: z.string() });
const actionWithRefs = {
name: "refAction",
description: "Action with shared schema refs",
schema: z.object({
single: subSchema,
list: z.array(subSchema),
}),
invoke: jest.fn(async () => "ok"),
};

(AgentKit.from as jest.Mock).mockImplementationOnce(() => ({
getActions: jest.fn(() => [actionWithRefs]),
}));

const mockAgentKit = await AgentKit.from({});
const { tools } = await getMcpTools(mockAgentKit);

const inputSchema = tools[0].inputSchema as Record<string, unknown>;
// After resolution, there should be no $ref or $defs in the output
const schemaStr = JSON.stringify(inputSchema);
expect(schemaStr).not.toContain("$ref");
expect(schemaStr).not.toContain("$defs");
expect(schemaStr).not.toContain('"definitions"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { z } from "zod";
import { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
import { AgentKit, Action } from "@coinbase/agentkit";
import { AgentKit, Action, resolveJsonSchemaRefs } from "@coinbase/agentkit";

/**
* The AgentKit MCP tools and tool handler
Expand All @@ -28,7 +28,9 @@ export async function getMcpTools(agentKit: AgentKit): Promise<AgentKitMcpTools>
return {
name: action.name,
description: action.description,
inputSchema: z.toJSONSchema(action.schema),
inputSchema: resolveJsonSchemaRefs(
z.toJSONSchema(action.schema) as Record<string, unknown>,
),
} as Tool;
}),
toolHandler: async (name: string, args: unknown) => {
Expand Down
Loading