diff --git a/AGENTS.md b/AGENTS.md index 8a9517e..2a253ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Don't manually adjust formatting — just run `pnpm lint:fix` if needed. - Tool names: `snake_case` (e.g., `search_catalog`) - Tool components: PascalCase + `Tool` suffix (e.g., `SearchCatalogTool`) - Handlers return `CallToolResult` with a `content` array — always (including error results with `isError: true`) -- Peer deps: React ≥ 18, React DOM ≥ 18, Zod ≥ 3. Only runtime dep is `zod-to-json-schema` +- Peer deps: React ≥ 18, React DOM ≥ 18, Zod `^3.25.0 || ^4.0.0` (3.25 is the floor because it ships `zod/v4/core`, including `toJSONSchema`). `zod-to-json-schema` is only for classic Zod 3 *instances* (no `_zod`); those are not Zod 4 schemas, so native `toJSONSchema` cannot walk them. - Warnings use `warnOnce()` — dev-only, fires once per key to avoid console spam ## Boundaries diff --git a/package.json b/package.json index 64df521..e45b24d 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0", - "zod": ">=3.0.0" + "zod": "^3.25.0 || ^4.0.0" }, "dependencies": { "zod-to-json-schema": "^3.24.1" @@ -80,7 +80,7 @@ "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^3.2.7", - "zod": "^3.25.76" + "zod": "^4.4.3" }, "lint-staged": { "*.{ts,tsx}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a2bfa1..e53c556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ importers: dependencies: zod-to-json-schema: specifier: ^3.24.1 - version: 3.25.2(zod@3.25.76) + version: 3.25.2(zod@4.4.3) devDependencies: '@biomejs/biome': specifier: ^2.0.0 @@ -57,8 +57,8 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/node@26.2.0)(jsdom@26.1.0)(yaml@2.9.0) zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.4.3 + version: 4.4.3 examples/native-harness: dependencies: @@ -157,7 +157,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.30.0 - version: 1.30.0(zod@3.25.76) + version: 1.30.0(zod@4.4.3) ws: specifier: ^8.21.3 version: 8.21.3 @@ -2521,6 +2521,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@adobe/css-tools@4.5.0': {} @@ -3071,7 +3074,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@modelcontextprotocol/sdk@1.30.0(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': dependencies: '@hono/node-server': 2.1.0(hono@4.13.1) ajv: 8.20.0 @@ -3088,8 +3091,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) transitivePeerDependencies: - supports-color @@ -4541,8 +4544,10 @@ snapshots: yaml@2.9.0: {} - zod-to-json-schema@3.25.2(zod@3.25.76): + zod-to-json-schema@3.25.2(zod@4.4.3): dependencies: - zod: 3.25.76 + zod: 4.4.3 zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/src/__tests__/smoke.test.tsx b/src/__tests__/smoke.test.tsx index 2b2217d..a90f0de 100644 --- a/src/__tests__/smoke.test.tsx +++ b/src/__tests__/smoke.test.tsx @@ -24,7 +24,7 @@ function AvailabilityDisplay() { return {available ? "yes" : "no"}; } -type ToolConfig = McpToolConfigZod | McpToolConfigJsonSchema; +type ToolConfig = McpToolConfigZod | McpToolConfigJsonSchema; function SmokeToolComponent({ config }: { config: ToolConfig }) { const { state } = useMcpTool(config as McpToolConfigJsonSchema); diff --git a/src/hooks/__tests__/useMcpTool.test.tsx b/src/hooks/__tests__/useMcpTool.test.tsx index f93a092..706c20c 100644 --- a/src/hooks/__tests__/useMcpTool.test.tsx +++ b/src/hooks/__tests__/useMcpTool.test.tsx @@ -3,6 +3,7 @@ import { StrictMode } from "react"; import { renderToString } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import * as z3 from "zod/v3"; import { _resetPolyfillConsumerCount, WebMCPProvider } from "../../context"; import { cleanupPolyfill } from "../../polyfill"; import type { CallToolResult, McpToolConfigJsonSchema, McpToolConfigZod } from "../../types"; @@ -38,7 +39,7 @@ async function waitForRegistration() { type ExecuteFn = ReturnType["execute"]; type ResetFn = ReturnType["reset"]; -type ToolConfig = McpToolConfigZod | McpToolConfigJsonSchema; +type ToolConfig = McpToolConfigZod | McpToolConfigJsonSchema; // ─── Test component ────────────────────────────────────────────── @@ -110,6 +111,43 @@ describe("registration lifecycle", () => { expect(schema.required).toContain("name"); }); + it("registers and validates Zod 3 schemas on both execution paths", async () => { + const executeRef = { current: null } as React.MutableRefObject; + + renderWithProvider( + makeResult(`hello ${name}`), + }} + onExecuteRef={executeRef} + />, + ); + + await waitForRegistration(); + + const tools = navigator.modelContextTesting?.listTools() ?? []; + const schema = JSON.parse(tools[0].inputSchema ?? "{}"); + expect(schema.properties.name.type).toBe("string"); + + let directResult: CallToolResult | undefined; + await act(async () => { + directResult = await executeRef.current?.({ name: "world" }); + }); + expect(directResult?.content[0]).toMatchObject({ type: "text", text: "hello world" }); + + let externalResultJson: string | null | undefined; + await act(async () => { + externalResultJson = await navigator.modelContextTesting?.executeTool( + "greet", + JSON.stringify({ name: "x" }), + ); + }); + expect(JSON.parse(externalResultJson ?? "{}")).toMatchObject({ isError: true }); + }); + it("registers tool with JSON Schema on mount", async () => { renderWithProvider( (); @@ -46,13 +51,13 @@ const INITIAL_STATE: ToolExecutionState = { executionCount: 0, }; -export function useMcpTool(config: McpToolConfigZod): UseMcpToolReturn; +export function useMcpTool( + config: McpToolConfigZod, +): UseMcpToolReturn; export function useMcpTool(config: McpToolConfigJsonSchema): UseMcpToolReturn; -export function useMcpTool( - config: McpToolConfigZod | McpToolConfigJsonSchema, -): UseMcpToolReturn { +export function useMcpTool(config: McpToolConfigZod | McpToolConfigJsonSchema): UseMcpToolReturn { const ctx = useContext(WebMCPContext); if (ctx === MISSING_PROVIDER) { warnOnce( @@ -61,16 +66,16 @@ export function useMcpTool( ); } - const isZodPath = "input" in config && config.input instanceof z.ZodObject; + const isZodPath = "input" in config && isZodObjectSchema(config.input); const inputFingerprint = schemaFingerprint( isZodPath - ? (config as McpToolConfigZod).input + ? (config as McpToolConfigZod).input : (config as McpToolConfigJsonSchema).inputSchema, ); const outputFingerprint = schemaFingerprint( isZodPath - ? (config as McpToolConfigZod).output + ? (config as McpToolConfigZod).output : (config as McpToolConfigJsonSchema).outputSchema, ); const annotationsFingerprint = config.annotations ? JSON.stringify(config.annotations) : ""; @@ -105,12 +110,10 @@ export function useMcpTool( try { let validatedInput: Record = input ?? {}; const currentConfig = configRef.current; - const currentIsZod = "input" in currentConfig && currentConfig.input instanceof z.ZodObject; + const currentIsZod = "input" in currentConfig && isZodObjectSchema(currentConfig.input); if (currentIsZod) { - validatedInput = (currentConfig as McpToolConfigZod).input.parse( - validatedInput, - ); + validatedInput = parseZodInput((currentConfig as McpToolConfigZod).input, validatedInput); } const result = await handlerRef.current(validatedInput as Record); @@ -161,14 +164,14 @@ export function useMcpTool( const mc = document.modelContext; const cfg = configRef.current; const ownerToken = Symbol(cfg.name); - const zodPath = "input" in cfg && cfg.input instanceof z.ZodObject; + const zodPath = "input" in cfg && isZodObjectSchema(cfg.input); // Compute resolved schemas inside effect body to avoid per-render allocation const resolvedInputSchema = zodPath - ? zodToInputSchema((cfg as McpToolConfigZod).input) + ? zodToInputSchema((cfg as McpToolConfigZod).input) : (cfg as McpToolConfigJsonSchema).inputSchema; - const zodOutput = zodPath ? (cfg as McpToolConfigZod).output : undefined; + const zodOutput = zodPath ? (cfg as McpToolConfigZod).output : undefined; const resolvedOutputSchema = zodPath ? zodOutput ? zodToInputSchema(zodOutput) @@ -191,11 +194,10 @@ export function useMcpTool( try { let validatedArgs = args; const currentConfig = configRef.current; - const currentIsZod = - "input" in currentConfig && currentConfig.input instanceof z.ZodObject; + const currentIsZod = "input" in currentConfig && isZodObjectSchema(currentConfig.input); if (currentIsZod) { - validatedArgs = (currentConfig as McpToolConfigZod).input.parse(args); + validatedArgs = parseZodInput((currentConfig as McpToolConfigZod).input, args); } const result = await handlerRef.current(validatedArgs as Record); diff --git a/src/types.ts b/src/types.ts index c92ee06..c240cee 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; -import type { z } from "zod"; +import type * as z3 from "zod/v3"; +import type * as z4 from "zod/v4/core"; export type MaybePromise = T | Promise; @@ -87,12 +88,22 @@ interface McpToolConfigBase { onError?: (error: Error) => void; } -export interface McpToolConfigZod extends McpToolConfigBase { - input: z.ZodObject; +export type ZodObjectSchema = z3.AnyZodObject | z4.$ZodObject; + +/** Parsed schema output — same semantics as Zod's `z.infer`. */ +export type ZodParsed = T extends z4.$ZodType + ? z4.output + : T extends z3.ZodTypeAny + ? z3.infer + : never; + +export interface McpToolConfigZod + extends McpToolConfigBase { + input: T; inputSchema?: never; - output?: z.ZodObject; + output?: ZodObjectSchema; outputSchema?: never; - handler: (args: z.infer>) => MaybePromise; + handler: (args: ZodParsed) => MaybePromise; } export interface McpToolConfigJsonSchema extends McpToolConfigBase { diff --git a/src/utils/__tests__/schema.test.ts b/src/utils/__tests__/schema.test.ts index 18e732b..9d232f4 100644 --- a/src/utils/__tests__/schema.test.ts +++ b/src/utils/__tests__/schema.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { z } from "zod"; +import * as z3 from "zod/v3"; +import * as z4 from "zod/v4"; +import * as zm from "zod/v4/mini"; import type { InputSchema } from "../../types"; import { schemaFingerprint, zodToInputSchema } from "../schema"; +const z = z3; + // ─── zodToInputSchema ──────────────────────────────────────────── describe("zodToInputSchema", () => { @@ -140,6 +144,50 @@ describe("zodToInputSchema", () => { expect(result.properties?.work).toHaveProperty("type", "object"); expect(result.properties?.work).not.toHaveProperty("$ref"); }); + + it("converts Zod 4 schemas with descriptions and constraints", () => { + const schema = z4.object({ + emails: z4.array(z4.email()).min(1).max(25).describe("Email addresses to invite."), + }); + const result = zodToInputSchema(schema); + + expect(result).toMatchObject({ + type: "object", + properties: { + emails: { + type: "array", + minItems: 1, + maxItems: 25, + description: "Email addresses to invite.", + items: { type: "string", format: "email" }, + }, + }, + required: ["emails"], + }); + expect(result).not.toHaveProperty("$schema"); + }); + + it("converts Zod 4 Mini schemas", () => { + const schema = zm.object({ name: zm.string() }); + const result = zodToInputSchema(schema); + + expect(result).toMatchObject({ + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }); + }); + + it("inlines shared Zod 4 sub-schemas instead of emitting $ref", () => { + const address = z4.object({ street: z4.string(), zip: z4.string() }); + const schema = z4.object({ home: address, work: address }); + const result = zodToInputSchema(schema); + + expect(result.properties?.home).toHaveProperty("type", "object"); + expect(result.properties?.work).toHaveProperty("type", "object"); + expect(result.properties?.work).not.toHaveProperty("$ref"); + expect(result).not.toHaveProperty("$defs"); + }); }); // ─── schemaFingerprint ─────────────────────────────────────────── diff --git a/src/utils/schema.ts b/src/utils/schema.ts index 270aff8..ccbc5a2 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -1,23 +1,50 @@ -import { z } from "zod"; +import * as z3 from "zod/v3"; +import * as z4 from "zod/v4/core"; import { zodToJsonSchema } from "zod-to-json-schema"; -import type { InputSchema } from "../types"; +import type { InputSchema, ZodObjectSchema, ZodParsed } from "../types"; + +export function isZodObjectSchema(schema: unknown): schema is ZodObjectSchema { + if (typeof schema !== "object" || schema === null) { + return false; + } + + if ("_zod" in schema) { + return (schema as z4.$ZodType)._zod.def.type === "object"; + } + + return ( + "_def" in schema && + (schema as z3.ZodTypeAny)._def.typeName === z3.ZodFirstPartyTypeKind.ZodObject + ); +} + +export function parseZodInput(schema: T, input: unknown): ZodParsed { + if ("_zod" in schema) { + return z4.parse(schema, input) as ZodParsed; + } + + return schema.parse(input) as ZodParsed; +} // Strips the `$schema` key since MCP doesn't use it. -export function zodToInputSchema(zodObject: z.ZodObject): InputSchema { - const jsonSchema = zodToJsonSchema(zodObject, { $refStrategy: "none" }) as Record< - string, - unknown - >; +export function zodToInputSchema(zodObject: ZodObjectSchema): InputSchema { + const jsonSchema = ( + "_zod" in zodObject + ? z4.toJSONSchema(zodObject, { + io: "input", + target: "draft-7", + reused: "inline", + }) + : zodToJsonSchema(zodObject, { $refStrategy: "none" }) + ) as Record; delete jsonSchema.$schema; return jsonSchema as InputSchema; } // Stable string for useEffect deps — same structure = same fingerprint regardless of reference. -export function schemaFingerprint( - schema: InputSchema | z.ZodObject | undefined, -): string { +export function schemaFingerprint(schema: InputSchema | ZodObjectSchema | undefined): string { if (schema === undefined) return ""; - if (schema instanceof z.ZodObject) { + if (isZodObjectSchema(schema)) { return JSON.stringify(zodToInputSchema(schema)); } try { diff --git a/tsup.config.ts b/tsup.config.ts index c684b32..4526860 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ splitting: false, sourcemap: true, clean: true, - external: ["react", "react-dom", "zod", "zod-to-json-schema"], + external: ["react", "react-dom", "zod", "zod/v3", "zod/v4/core", "zod-to-json-schema"], treeshake: true, minify: false, banner: { js: '"use client";' },