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}`
: (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
let conversationHistory = context?.conversationHistory;
if (!conversationHistory && params.sessionFile) {
@ -275,12 +266,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
// Combine built-in tools with client tools
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.
// This ensures Moltbot sessions are stored separately from CLI sessions.
const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId);

View File

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

View File

@ -119,7 +119,8 @@ describe("tool-bridge", () => {
expect(typeof zodSchema.parse).toBe("function");
expect(typeof zodSchema.safeParse).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", () => {
@ -645,4 +646,182 @@ describe("tool-bridge", () => {
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 { TSchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import * as z from "zod/v4-mini";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { normalizeToolName } from "../tool-policy.js";
@ -36,85 +37,136 @@ import type {
const log = createSubsystemLogger("agents/claude-agent-sdk/tool-bridge");
// ---------------------------------------------------------------------------
// Schema conversion: TypeBox → Zod-compatible schema
// Schema conversion: TypeBox → Zod v4 Mini schema
// ---------------------------------------------------------------------------
/**
* Interface for Zod-compatible schema that the MCP SDK expects.
* The SDK checks for `parse` and `safeParse` methods to identify valid schemas.
*/
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 };
}
// Use z.ZodMiniType as the base type for all Zod Mini schemas
// biome-ignore lint/suspicious/noExplicitAny: Zod Mini type system is complex; any allows proper schema composition
type ZodMiniSchema = z.ZodMiniType<any, any>;
/**
* Wrap a TypeBox schema to make it Zod-compatible for the MCP SDK.
*
* 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.
* Convert a TypeBox property schema to a Zod v4 Mini schema.
* This recursive converter handles common TypeBox/JSON Schema types.
*/
export function createZodCompatibleSchema(typeboxSchema: TSchema): ZodCompatibleSchema {
return {
parse(data: unknown): unknown {
// Decode handles coercion (string→number, etc.) and throws on failure
return Value.Decode(typeboxSchema, data);
},
function convertTypeBoxPropertyToZod(propSchema: TSchema): ZodMiniSchema {
const type = (propSchema as { type?: string }).type;
safeParse(
data: unknown,
): { success: true; data: unknown } | { success: false; error: unknown } {
try {
// First check if valid
if (!Value.Check(typeboxSchema, data)) {
// Get the first validation error for a useful message
const errors = [...Value.Errors(typeboxSchema, data)];
const firstError = errors[0];
return {
success: false,
error: {
message: firstError?.message ?? "Validation failed",
path: firstError?.path ?? "",
issues: errors.map((e) => ({ message: e.message, path: e.path })),
},
};
}
// Decode to apply any transformations
const decoded = Value.Decode(typeboxSchema, data);
return { success: true, data: decoded };
} catch (err) {
return {
success: false,
error: err instanceof Error ? { message: err.message } : { message: String(err) },
};
// Handle based on JSON Schema type
switch (type) {
case "string": {
return z.string();
}
case "number":
case "integer": {
return z.number();
}
case "boolean": {
return z.boolean();
}
case "array": {
const items = (propSchema as { items?: TSchema }).items;
const itemSchema = items ? convertTypeBoxPropertyToZod(items) : z.unknown();
return z.array(itemSchema);
}
case "object": {
// Nested object - recursively convert
const nestedProps = (propSchema as { properties?: Record<string, TSchema> }).properties;
const nestedRequired = (propSchema as { required?: string[] }).required ?? [];
if (nestedProps) {
const shape = buildZodShapeFromTypeBox(nestedProps, nestedRequired);
return z.object(shape);
}
// 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(
data: unknown,
): Promise<{ success: true; data: unknown } | { success: false; error: unknown }> {
return this.safeParse(data);
},
const constValue = (propSchema as { const?: unknown }).const;
if (constValue !== undefined) {
return z.literal(constValue as string | number | boolean);
}
// Mimic Zod v3's internal structure so isZodSchemaInstance returns true
_def: {
typeName: "ZodObject",
description: (typeboxSchema as { description?: string }).description,
},
};
// Fallback to unknown for unrecognized types
return z.unknown();
}
}
}
/**
* 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.
*
@ -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
// ---------------------------------------------------------------------------