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";' },