fix: schema issues w/zod and mcp tool bridge
This commit is contained in:
parent
7bdbb444e6
commit
800c211582
@ -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);
|
||||
|
||||
@ -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(", ")})`
|
||||
// : ""),
|
||||
// );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user