fix: schema issues w/zod and mcp tool bridge

This commit is contained in:
David Garson 2026-01-28 21:30:38 -07:00
parent 7bdbb444e6
commit 800c211582
4 changed files with 306 additions and 104 deletions

View File

@ -214,15 +214,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
? `${params.provider ?? "anthropic"}/${params.model}` ? `${params.provider ?? "anthropic"}/${params.model}`
: (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default"); : (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default");
log.debug("Starting CCSDK Agent session", {
sessionId: params.sessionId,
runId: params.runId,
provider: providerConfig.name,
model: effectiveModel,
agentId,
thinkLevel: params.thinkLevel,
});
// Load conversation history from session file if not provided // Load conversation history from session file if not provided
let conversationHistory = context?.conversationHistory; let conversationHistory = context?.conversationHistory;
if (!conversationHistory && params.sessionFile) { if (!conversationHistory && params.sessionFile) {
@ -275,12 +266,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
// Combine built-in tools with client tools // Combine built-in tools with client tools
const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted]; const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted];
log.debug("Built tools for CCSDK run", {
builtInCount: builtInTools.length,
clientToolCount: clientToolsConverted.length,
totalCount: tools.length,
});
// Resolve CCSDK config directory for this agent's session storage. // Resolve CCSDK config directory for this agent's session storage.
// This ensures Moltbot sessions are stored separately from CLI sessions. // This ensures Moltbot sessions are stored separately from CLI sessions.
const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId); const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId);

View File

@ -630,12 +630,12 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
}; };
} }
log.debug( // log.debug(
`Bridged ${bridgeResult.toolCount} tools to MCP server "${mcpServerName}"` + // `Exposed ${bridgeResult.toolCount} tools to MCP server "${mcpServerName}"` +
(bridgeResult.skippedTools.length > 0 // (bridgeResult.skippedTools.length > 0
? ` (skipped: ${bridgeResult.skippedTools.join(", ")})` // ? ` (skipped: ${bridgeResult.skippedTools.join(", ")})`
: ""), // : ""),
); // );
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@ -119,7 +119,8 @@ describe("tool-bridge", () => {
expect(typeof zodSchema.parse).toBe("function"); expect(typeof zodSchema.parse).toBe("function");
expect(typeof zodSchema.safeParse).toBe("function"); expect(typeof zodSchema.safeParse).toBe("function");
expect(typeof zodSchema.safeParseAsync).toBe("function"); expect(typeof zodSchema.safeParseAsync).toBe("function");
expect(zodSchema._def.typeName).toBe("ZodObject"); // Zod v4 Mini uses _zod instead of _def
expect((zodSchema as any)._zod).toBeDefined();
}); });
it("parse returns data for valid input", () => { it("parse returns data for valid input", () => {
@ -645,4 +646,182 @@ describe("tool-bridge", () => {
expect(result.skippedTools).toContain("bad_tool"); expect(result.skippedTools).toContain("bad_tool");
}); });
}); });
describe("tool call error handling", () => {
const mockExtra = { signal: new AbortController().signal };
it("includes tool name in error message when tool throws", async () => {
const mockExecute = vi.fn().mockRejectedValue(new Error("Database connection failed"));
const tool = {
name: "database-query",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({ query: "SELECT *" }, mockExtra);
expect(result.isError).toBe(true);
expect(result.content[0].type).toBe("text");
expect((result.content[0] as { text: string }).text).toContain("database_query");
expect((result.content[0] as { text: string }).text).toContain("Database connection failed");
});
it("handles non-Error thrown values", async () => {
const mockExecute = vi.fn().mockRejectedValue("string error message");
const tool = {
name: "string-error-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({}, mockExtra);
expect(result.isError).toBe(true);
expect((result.content[0] as { text: string }).text).toContain("string error message");
});
it("handles undefined/null thrown values", async () => {
const mockExecute = vi.fn().mockRejectedValue(undefined);
const tool = {
name: "undefined-error-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({}, mockExtra);
expect(result.isError).toBe(true);
expect(result.content.length).toBeGreaterThan(0);
});
it("converts tool_error blocks from result to MCP error format", () => {
const result: AgentToolResult<unknown> = {
content: [
{ type: "tool_error", error: "Validation failed: missing required field 'name'" },
],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.isError).toBe(true);
expect(mcpResult.content).toHaveLength(1);
expect((mcpResult.content[0] as { text: string }).text).toBe(
"Validation failed: missing required field 'name'",
);
});
it("preserves error details from multiple error blocks", () => {
const result: AgentToolResult<unknown> = {
content: [
{ type: "tool_error", error: "Error 1: Invalid input" },
{ type: "tool_error", error: "Error 2: Permission denied" },
],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.isError).toBe(true);
expect(mcpResult.content).toHaveLength(2);
});
it("marks result as error when any block has error field", () => {
const result: AgentToolResult<unknown> = {
content: [
{ type: "text", text: "Partial success" },
{ type: "warning", error: "But something went wrong" },
],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.isError).toBe(true);
});
});
describe("Zod schema conversion", () => {
it("converts TypeBox string property to Zod string", () => {
const typeboxSchema = Type.Object({
name: Type.String(),
});
const zodSchema = createZodCompatibleSchema(typeboxSchema);
// Should successfully parse valid input
const parseResult = zodSchema.safeParse({ name: "test" });
expect(parseResult.success).toBe(true);
});
it("converts TypeBox number property to Zod number", () => {
const typeboxSchema = Type.Object({
count: Type.Number(),
});
const zodSchema = createZodCompatibleSchema(typeboxSchema);
const parseResult = zodSchema.safeParse({ count: 42 });
expect(parseResult.success).toBe(true);
});
it("converts TypeBox boolean property to Zod boolean", () => {
const typeboxSchema = Type.Object({
active: Type.Boolean(),
});
const zodSchema = createZodCompatibleSchema(typeboxSchema);
const parseResult = zodSchema.safeParse({ active: true });
expect(parseResult.success).toBe(true);
});
it("converts TypeBox array property to Zod array", () => {
const typeboxSchema = Type.Object({
items: Type.Array(Type.String()),
});
const zodSchema = createZodCompatibleSchema(typeboxSchema);
const parseResult = zodSchema.safeParse({ items: ["a", "b", "c"] });
expect(parseResult.success).toBe(true);
});
it("converts TypeBox optional property to Zod optional", () => {
const typeboxSchema = Type.Object({
required: Type.String(),
optional: Type.Optional(Type.String()),
});
const zodSchema = createZodCompatibleSchema(typeboxSchema);
// Should work without optional field
const parseResult = zodSchema.safeParse({ required: "value" });
expect(parseResult.success).toBe(true);
});
it("rejects invalid input with Zod validation errors", () => {
const typeboxSchema = Type.Object({
name: Type.String(),
count: Type.Number(),
});
const zodSchema = createZodCompatibleSchema(typeboxSchema);
// Pass wrong types
const parseResult = zodSchema.safeParse({ name: 123, count: "not a number" });
expect(parseResult.success).toBe(false);
});
it("has _zod property for MCP SDK compatibility", () => {
const typeboxSchema = Type.Object({
value: Type.String(),
});
const zodSchema = createZodCompatibleSchema(typeboxSchema);
// Zod v4 Mini schemas have _zod property that MCP SDK checks
expect((zodSchema as any)._zod).toBeDefined();
});
});
}); });

View File

@ -20,6 +20,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox"; import type { TSchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value"; import { Value } from "@sinclair/typebox/value";
import * as z from "zod/v4-mini";
import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createSubsystemLogger } from "../../logging/subsystem.js";
import { normalizeToolName } from "../tool-policy.js"; import { normalizeToolName } from "../tool-policy.js";
@ -36,85 +37,136 @@ import type {
const log = createSubsystemLogger("agents/claude-agent-sdk/tool-bridge"); const log = createSubsystemLogger("agents/claude-agent-sdk/tool-bridge");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Schema conversion: TypeBox → Zod-compatible schema // Schema conversion: TypeBox → Zod v4 Mini schema
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** // Use z.ZodMiniType as the base type for all Zod Mini schemas
* Interface for Zod-compatible schema that the MCP SDK expects. // biome-ignore lint/suspicious/noExplicitAny: Zod Mini type system is complex; any allows proper schema composition
* The SDK checks for `parse` and `safeParse` methods to identify valid schemas. type ZodMiniSchema = z.ZodMiniType<any, any>;
*/
export interface ZodCompatibleSchema {
/** Synchronous parse - throws on validation failure */
parse(data: unknown): unknown;
/** Safe synchronous parse - returns success/error result */
safeParse(data: unknown): { success: true; data: unknown } | { success: false; error: unknown };
/** Async version of safeParse (used by MCP SDK) */
safeParseAsync(
data: unknown,
): Promise<{ success: true; data: unknown } | { success: false; error: unknown }>;
/** Mark this as a Zod-like schema instance (SDK checks for _def or _zod) */
_def: { typeName: string; description?: string };
}
/** /**
* Wrap a TypeBox schema to make it Zod-compatible for the MCP SDK. * Convert a TypeBox property schema to a Zod v4 Mini schema.
* * This recursive converter handles common TypeBox/JSON Schema types.
* The MCP SDK's McpServer.tool() checks `isZodTypeLike()` which requires:
* - `parse` method (function)
* - `safeParse` method (function)
*
* This wrapper uses TypeBox's Value module to perform validation.
*/ */
export function createZodCompatibleSchema(typeboxSchema: TSchema): ZodCompatibleSchema { function convertTypeBoxPropertyToZod(propSchema: TSchema): ZodMiniSchema {
return { const type = (propSchema as { type?: string }).type;
parse(data: unknown): unknown {
// Decode handles coercion (string→number, etc.) and throws on failure
return Value.Decode(typeboxSchema, data);
},
safeParse( // Handle based on JSON Schema type
data: unknown, switch (type) {
): { success: true; data: unknown } | { success: false; error: unknown } { case "string": {
try { return z.string();
// First check if valid }
if (!Value.Check(typeboxSchema, data)) { case "number":
// Get the first validation error for a useful message case "integer": {
const errors = [...Value.Errors(typeboxSchema, data)]; return z.number();
const firstError = errors[0]; }
return { case "boolean": {
success: false, return z.boolean();
error: { }
message: firstError?.message ?? "Validation failed", case "array": {
path: firstError?.path ?? "", const items = (propSchema as { items?: TSchema }).items;
issues: errors.map((e) => ({ message: e.message, path: e.path })), const itemSchema = items ? convertTypeBoxPropertyToZod(items) : z.unknown();
}, return z.array(itemSchema);
}; }
} case "object": {
// Decode to apply any transformations // Nested object - recursively convert
const decoded = Value.Decode(typeboxSchema, data); const nestedProps = (propSchema as { properties?: Record<string, TSchema> }).properties;
return { success: true, data: decoded }; const nestedRequired = (propSchema as { required?: string[] }).required ?? [];
} catch (err) { if (nestedProps) {
return { const shape = buildZodShapeFromTypeBox(nestedProps, nestedRequired);
success: false, return z.object(shape);
error: err instanceof Error ? { message: err.message } : { message: String(err) }, }
}; // Object without defined properties - use passthrough
return z.record(z.string(), z.unknown());
}
case "null": {
return z.null();
}
default: {
// Check for enum/const
const enumValues = (propSchema as { enum?: string[] }).enum;
if (enumValues && Array.isArray(enumValues)) {
// Create a union of literals for enum
if (enumValues.length === 1) {
return z.literal(enumValues[0]);
}
const literals = enumValues.map((v) => z.literal(v));
// TypeScript requires explicit tuple type assertion for z.union
return z.union(literals as unknown as [ZodMiniSchema, ZodMiniSchema, ...ZodMiniSchema[]]);
} }
},
async safeParseAsync( const constValue = (propSchema as { const?: unknown }).const;
data: unknown, if (constValue !== undefined) {
): Promise<{ success: true; data: unknown } | { success: false; error: unknown }> { return z.literal(constValue as string | number | boolean);
return this.safeParse(data); }
},
// Mimic Zod v3's internal structure so isZodSchemaInstance returns true // Fallback to unknown for unrecognized types
_def: { return z.unknown();
typeName: "ZodObject", }
description: (typeboxSchema as { description?: string }).description, }
},
};
} }
/**
* Build a Zod shape object from TypeBox properties.
*/
function buildZodShapeFromTypeBox(
properties: Record<string, TSchema>,
requiredProps: string[],
): Record<string, ZodMiniSchema> {
const requiredSet = new Set(requiredProps);
const shape: Record<string, ZodMiniSchema> = {};
for (const [propName, propSchema] of Object.entries(properties)) {
let zodProp = convertTypeBoxPropertyToZod(propSchema);
// Wrap in optional if not required
if (!requiredSet.has(propName)) {
zodProp = z.optional(zodProp);
}
shape[propName] = zodProp;
}
return shape;
}
/**
* Convert a TypeBox object schema to a Zod v4 Mini object schema.
*
* The MCP SDK natively supports Zod v4 Mini schemas. By converting TypeBox
* to real Zod schemas, we get proper JSON Schema generation for Claude
* and validation that matches what the SDK expects.
*/
export function convertTypeBoxToZod(typeboxSchema: TSchema): ZodMiniSchema {
const properties = (typeboxSchema as { properties?: Record<string, TSchema> }).properties;
const requiredProps = (typeboxSchema as { required?: string[] }).required ?? [];
if (!properties || typeof properties !== "object") {
// No properties defined - return a permissive object schema
return z.object({});
}
const shape = buildZodShapeFromTypeBox(properties, requiredProps);
return z.object(shape);
}
/**
* Create a Zod v4 Mini schema from a tool's TypeBox parameters.
* Returns undefined if the tool has no parameters.
*/
export function extractZodSchema(tool: AnyAgentTool): ZodMiniSchema | undefined {
const schema = tool.parameters as TSchema | undefined;
if (!schema || typeof schema !== "object") {
return undefined;
}
return convertTypeBoxToZod(schema);
}
// Legacy exports for backward compatibility
export type ZodCompatibleSchema = ZodMiniSchema;
export const createZodCompatibleSchema = convertTypeBoxToZod;
export const extractZodCompatibleSchema = extractZodSchema;
/** /**
* Extract a clean JSON Schema object from a TypeBox schema. * Extract a clean JSON Schema object from a TypeBox schema.
* *
@ -136,20 +188,6 @@ export function extractJsonSchema(tool: AnyAgentTool): Record<string, unknown> {
} }
} }
/**
* Create a Zod-compatible schema from a tool's TypeBox parameters.
* Falls back to a permissive schema if the tool has no parameters.
*/
export function extractZodCompatibleSchema(tool: AnyAgentTool): ZodCompatibleSchema | undefined {
const schema = tool.parameters as TSchema | undefined;
if (!schema || typeof schema !== "object") {
// Return undefined to indicate no input schema
// The MCP SDK will then call handler(extra) instead of handler(args, extra)
return undefined;
}
return createZodCompatibleSchema(schema);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Result conversion: AgentToolResult → McpCallToolResult // Result conversion: AgentToolResult → McpCallToolResult
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------