From bfd026243e50be1caa5e7e7f387030a60fee5c51 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:58:26 +0000 Subject: [PATCH 1/4] fix(server): surface raw-shape schema failures at registration time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw shapes whose field schemas come from a different zod build than the SDK bundles previously registered fine and then crashed tools/list with '[toJSONSchema]: Non-representable type encountered'. normalizeRawShapeSchema now runs the JSON Schema conversion eagerly on the auto-wrap path so the failure is an actionable TypeError at registerTool/registerPrompt. Shapes mixing zod v3 and v4 fields now throw Error('Mixed Zod versions detected in object shape.') — the same registration-time error v1's objectFromShape threw — instead of being misreported as an all-v3 shape. The all-v3 message also gains the fromJsonSchema() alternative. --- .../raw-shape-registration-time-errors.md | 8 ++++ packages/core/src/util/zodCompat.ts | 43 ++++++++++++++--- packages/core/test/util/zodCompat.test.ts | 46 +++++++++++++++++++ packages/server/src/server/mcp.ts | 6 +-- .../server/test/server/mcp.compat.test.ts | 36 +++++++++++++++ 5 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 .changeset/raw-shape-registration-time-errors.md diff --git a/.changeset/raw-shape-registration-time-errors.md b/.changeset/raw-shape-registration-time-errors.md new file mode 100644 index 0000000000..5eb4f32fb9 --- /dev/null +++ b/.changeset/raw-shape-registration-time-errors.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +Raw-shape `inputSchema`/`outputSchema`/`argsSchema` failures now surface at registration time instead of crashing `tools/list` later. Shapes whose field schemas come from a different zod build than the SDK bundles (e.g. an application's own zod 4.0/4.1 instance) previously +registered fine and then threw `[toJSONSchema]: Non-representable type encountered` when listing; they now throw an actionable `TypeError` from `registerTool`/`registerPrompt`. Shapes mixing zod v3 and v4 fields throw `Error('Mixed Zod versions detected in object shape.')` — the +same registration-time error v1 threw — instead of being misreported as all-v3. The all-v3 error message now also mentions the `fromJsonSchema()` alternative. diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts index 3bb208809c..8301ec147c 100644 --- a/packages/core/src/util/zodCompat.ts +++ b/packages/core/src/util/zodCompat.ts @@ -7,7 +7,7 @@ import * as z from 'zod/v4'; import type { StandardSchemaWithJSON } from './standardSchema.js'; -import { isStandardSchema } from './standardSchema.js'; +import { isStandardSchema, standardSchemaToJsonSchema } from './standardSchema.js'; function isZodV4Schema(v: unknown): v is z.ZodType { // `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def` @@ -56,16 +56,45 @@ export function isZodRawShape(obj: unknown): obj is Record { * @internal */ export function normalizeRawShapeSchema( - schema: StandardSchemaWithJSON | Record | undefined + schema: StandardSchemaWithJSON | Record | undefined, + io: 'input' | 'output' = 'input' ): StandardSchemaWithJSON | undefined { if (schema === undefined) return undefined; if (isZodRawShape(schema)) { - return z.object(schema) as StandardSchemaWithJSON; + // Fail at registration time, not serve time. Listing handlers run this + // exact JSON Schema conversion later; if the shape's field schemas come + // from a different zod build than the one the SDK bundles (e.g. the + // application's own zod 4.0/4.1 instance), the bundled converter can + // fail while walking their internals. Without this check, a bad shape + // registers fine and then crashes `tools/list` far from the cause. + let wrapped: StandardSchemaWithJSON; + try { + wrapped = z.object(schema) as StandardSchemaWithJSON; + standardSchemaToJsonSchema(wrapped, io); + } catch (error) { + throw new TypeError( + 'Raw-shape inputSchema/outputSchema/argsSchema could not be converted to JSON Schema. ' + + 'The field schemas likely come from a different zod build than the one the SDK bundles. ' + + "Wrap the shape with your own zod's `z.object({...})` (zod >=4.2), or pass a JSON Schema via `fromJsonSchema()`.", + { cause: error } + ); + } + return wrapped; } - if (typeof schema === 'object' && schema !== null && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) { - throw new TypeError( - 'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.' - ); + if (typeof schema === 'object' && schema !== null && !isStandardSchema(schema)) { + const fields = Object.values(schema); + const hasV3 = fields.some(v => looksLikeZodV3(v)); + if (hasV3 && fields.some(v => isZodV4Schema(v))) { + // v1's zod-compat `objectFromShape` threw this exact error for + // shapes mixing zod versions; keep the message byte-identical so + // code and tests written against v1 keep matching. + throw new Error('Mixed Zod versions detected in object shape.'); + } + if (hasV3) { + throw new TypeError( + 'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), wrap with `z.object({...})` yourself, or pass a JSON Schema via `fromJsonSchema()`.' + ); + } } if (!isStandardSchema(schema)) { throw new TypeError( diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts index cf48be3d3b..948e121ead 100644 --- a/packages/core/test/util/zodCompat.test.ts +++ b/packages/core/test/util/zodCompat.test.ts @@ -87,3 +87,49 @@ describe('normalizeRawShapeSchema', () => { expect(() => normalizeRawShapeSchema(null as never)).toThrow(/must be a Standard Schema/); }); }); + +// Minimal structural mock of a field schema from a *different* zod v4 build +// (e.g. an application's own zod 4.0/4.1 instance): has `_zod` so it is +// detected as a v4 raw-shape field, but its internals are not walkable by the +// SDK-bundled `z.toJSONSchema()`. +function mockForeignZodV4String(): unknown { + return { + _zod: { def: { type: 'string' }, version: { major: 4, minor: 0 } }, + '~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) } + }; +} + +describe('normalizeRawShapeSchema v1-compat dispatch', () => { + test('throws the v1 error for a shape mixing zod versions (exact message)', () => { + const shape = { a: z.string(), b: mockZodV3String() }; + expect(() => normalizeRawShapeSchema(shape as never)).toThrow('Mixed Zod versions detected in object shape.'); + }); + + test('mixed-version error is not misreported as the all-v3 error', () => { + const shape = { a: z.string(), b: mockZodV3String() }; + expect(() => normalizeRawShapeSchema(shape as never)).not.toThrow(/must be Zod v4 schemas/); + }); + + test('fails at normalization time when raw-shape fields cannot be converted to JSON Schema', () => { + // Without the eager check this registers fine and `tools/list` crashes later. + const shape = { a: mockForeignZodV4String() }; + expect(() => normalizeRawShapeSchema(shape as never)).toThrow(/could not be converted to JSON Schema/); + }); + + test('conversion failure preserves the underlying error as cause', () => { + const shape = { a: mockForeignZodV4String() }; + try { + normalizeRawShapeSchema(shape as never); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(TypeError); + expect((e as TypeError).cause).toBeDefined(); + } + }); + + test('wraps and converts a well-formed raw shape for the output position', () => { + const wrapped = normalizeRawShapeSchema({ result: z.string().default('x') }, 'output'); + expect(wrapped).toBeDefined(); + expect(standardSchemaToJsonSchema(wrapped!, 'output').type).toBe('object'); + }); +}); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 40ec8bb1eb..a2becb3864 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -817,8 +817,8 @@ export class McpServer { name, title, description, - normalizeRawShapeSchema(inputSchema), - normalizeRawShapeSchema(outputSchema), + normalizeRawShapeSchema(inputSchema, 'input'), + normalizeRawShapeSchema(outputSchema, 'output'), annotations, undefined, _meta, @@ -893,7 +893,7 @@ export class McpServer { name, title, description, - normalizeRawShapeSchema(argsSchema), + normalizeRawShapeSchema(argsSchema, 'input'), cb as PromptCallback, _meta ); diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 322b615353..5aaaafe5f1 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -121,6 +121,42 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = }); }); +describe('registerTool raw-shape failure modes surface at registration time', () => { + // Structural stand-ins for field schemas from other zod builds. A v3 field + // has `_def.typeName` and no `_zod`; a foreign v4 field has `_zod` but + // internals the SDK-bundled `z.toJSONSchema()` cannot walk. + const v3Field = { + _def: { typeName: 'ZodString', checks: [], coerce: false }, + '~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) }, + parse: (v: unknown) => v + }; + const foreignV4Field = { + _zod: { def: { type: 'string' }, version: { major: 4, minor: 0 } }, + '~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) } + }; + + // These shapes are intentionally invalid, so go through a loosened + // signature — the runtime dispatch in normalizeRawShapeSchema is the + // subject under test, not the overload types. + function registerLoose(server: McpServer, name: string, config: unknown): void { + (server.registerTool as unknown as (n: string, c: unknown, cb: unknown) => unknown)(name, config, async () => ({ content: [] })); + } + + it('throws the v1 mixed-versions error when a raw shape mixes zod versions', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + expect(() => registerLoose(server, 'mixed', { inputSchema: { a: z.string(), b: v3Field } })).toThrow( + 'Mixed Zod versions detected in object shape.' + ); + }); + + it('throws at registerTool, not tools/list, when raw-shape fields cannot convert to JSON Schema', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + expect(() => registerLoose(server, 'foreign', { inputSchema: { a: foreignV4Field } })).toThrow( + /could not be converted to JSON Schema/ + ); + }); +}); + describe('InferRawShape', () => { it('preserves optionality from .optional() as ?: keys', () => { type S = InferRawShape<{ a: z.ZodString; b: z.ZodOptional }>; From 7e9e3a8376feb5bc8bfea4eb6adacfb6b6d1948e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 07:58:26 +0000 Subject: [PATCH 2/4] docs: document raw-shape compatibility failure modes in migration guides --- docs/migration-SKILL.md | 2 +- docs/migration.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..d951eacd40 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -223,7 +223,7 @@ Zod schemas, all callback return types. Note: `callTool()` and `request()` signa The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. **IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with -`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. +`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. A deprecated overload still auto-wraps raw shapes of zod/v4 fields; invalid shapes fail at registration time: zod v3 fields → `TypeError`; mixed v3+v4 fields → `Error('Mixed Zod versions detected in object shape.')` (v1's exact error); fields from a different zod v4 build that the bundled converter cannot walk → `TypeError` (wrap with your own zod ≥4.2 `z.object()`). ### Tools diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..3365a1124c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -304,6 +304,12 @@ This applies to: - `outputSchema` in `registerTool()` - `argsSchema` in `registerPrompt()` +> **Compatibility note:** a deprecated `registerTool`/`registerPrompt` overload still accepts raw shapes at runtime and auto-wraps them with the SDK-bundled `z.object()`. Shapes that cannot work fail at **registration time** (not later at `tools/list`): +> +> - fields from **zod v3** → `TypeError` — wrap with your own `z.object()` from `zod/v4`, or pass JSON Schema via `fromJsonSchema()`; +> - fields mixing **zod v3 and v4** → `Error('Mixed Zod versions detected in object shape.')`, the same error v1 threw for this case; +> - fields from a **different zod v4 build** whose internals the bundled JSON Schema converter cannot walk → `TypeError` at registration — wrap with your own zod ≥4.2 `z.object()` so the schema carries its own converter. + **Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): | Removed | Replacement | From 9242683600703a4d2a11955a01fd0c3d4acffbf1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 08:27:59 +0000 Subject: [PATCH 3/4] test: cover raw-shape failures with a real second zod install and a tools/list round trip The structural mocks proved the guard mechanism but not the real-world claim. A zod-v40 devDependency alias (npm:zod@4.0.17) now reproduces the actual serve-time conversion failure as a registration-time error, a native z.custom field covers the version-independent case, and an InMemoryTransport tools/list round trip verifies auto-wrapped native shapes advertise correct schemas (properties, required, descriptions) so the eager check has no false positives. --- packages/core/package.json | 3 +- packages/core/test/util/zodCompat.test.ts | 35 +++++++++-- packages/server/package.json | 3 +- .../server/test/server/mcp.compat.test.ts | 60 +++++++++++++++++++ pnpm-lock.yaml | 11 ++++ 5 files changed, 106 insertions(+), 6 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index beb46ccb88..3a85fa08b6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -98,6 +98,7 @@ "tsx": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "zod-v40": "npm:zod@4.0.17" } } diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts index 948e121ead..b5d7a2c0eb 100644 --- a/packages/core/test/util/zodCompat.test.ts +++ b/packages/core/test/util/zodCompat.test.ts @@ -88,10 +88,10 @@ describe('normalizeRawShapeSchema', () => { }); }); -// Minimal structural mock of a field schema from a *different* zod v4 build -// (e.g. an application's own zod 4.0/4.1 instance): has `_zod` so it is -// detected as a v4 raw-shape field, but its internals are not walkable by the -// SDK-bundled `z.toJSONSchema()`. +// Minimal structural stand-in for a v4-detected field schema whose internals +// the SDK-bundled `z.toJSONSchema()` cannot walk. Deterministic and +// build-independent; the real-instance equivalent (an actual second zod +// install) is covered in the real-instance tests below. function mockForeignZodV4String(): unknown { return { _zod: { def: { type: 'string' }, version: { major: 4, minor: 0 } }, @@ -133,3 +133,30 @@ describe('normalizeRawShapeSchema v1-compat dispatch', () => { expect(standardSchemaToJsonSchema(wrapped!, 'output').type).toBe('object'); }); }); + +// Real second zod instance (npm alias zod-v40 -> zod@4.0.17): implements +// StandardSchemaV1 but not `~standard.jsonSchema`, like most currently +// installed zod versions. The SDK-bundled converter cannot walk schemas from +// this build, so raw shapes built from it are exactly the input that used to +// register fine and then crash every `tools/list`. +import * as zV40 from 'zod-v40'; + +describe('normalizeRawShapeSchema real-instance coverage', () => { + test('native z.custom raw-shape field fails at normalization time (version-independent construct)', () => { + const shape = { c: z.custom(v => typeof v === 'string') }; + expect(() => normalizeRawShapeSchema(shape as never)).toThrow(/could not be converted to JSON Schema/); + }); + + test('raw shape from a real foreign zod build fails at normalization time, not serve time', () => { + const shape = { a: zV40.z.string(), o: zV40.z.string().optional() }; + try { + normalizeRawShapeSchema(shape as never); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(TypeError); + expect(String(e)).toMatch(/could not be converted to JSON Schema/); + // the underlying converter error is preserved for debugging + expect(String((e as TypeError).cause)).toMatch(/Non-representable type/); + } + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index d7bce70fa4..c5d3334de5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -112,6 +112,7 @@ "tsx": "catalog:devTools", "typescript": "catalog:devTools", "typescript-eslint": "catalog:devTools", - "vitest": "catalog:devTools" + "vitest": "catalog:devTools", + "zod-v40": "npm:zod@4.0.17" } } diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 5aaaafe5f1..39ab792405 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -2,6 +2,7 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; +import * as zV40 from 'zod-v40'; import { McpServer } from '../../src/index.js'; import type { InferRawShape } from '../../src/server/mcp.js'; import { completable } from '../../src/server/completable.js'; @@ -155,6 +156,65 @@ describe('registerTool raw-shape failure modes surface at registration time', () /could not be converted to JSON Schema/ ); }); + + it('throws at registerTool for a raw shape from a real second zod install', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + expect(() => registerLoose(server, 'real-foreign', { inputSchema: { a: zV40.z.string().optional() } })).toThrow( + /could not be converted to JSON Schema/ + ); + }); +}); + +describe('auto-wrapped raw shapes advertise correct schemas over tools/list', () => { + it('round trip: registered raw shape is advertised with properties, required, and descriptions intact', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + server.registerTool( + 'echo', + { inputSchema: { x: z.number().describe('the x value'), opt: z.string().optional() } }, + async ({ x }) => ({ content: [{ type: 'text' as const, text: String(x) }] }) + ); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + const result = responses.find(r => 'id' in r && r.id === 2) as { + result?: { + tools: Array<{ + name: string; + inputSchema: { type: string; properties?: Record; required?: string[] }; + }>; + }; + error?: unknown; + }; + expect(result.error).toBeUndefined(); + const tool = result.result?.tools.find(t => t.name === 'echo'); + expect(tool).toBeDefined(); + expect(tool!.inputSchema.type).toBe('object'); + expect(Object.keys(tool!.inputSchema.properties ?? {}).sort()).toEqual(['opt', 'x']); + expect(tool!.inputSchema.required).toEqual(['x']); + expect(tool!.inputSchema.properties?.x?.description).toBe('the x value'); + + await server.close(); + }); }); describe('InferRawShape', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c13f1ebcde..410640d13b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -711,6 +711,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod-v40: + specifier: npm:zod@4.0.17 + version: zod@4.0.17 packages/middleware/express: dependencies: @@ -1007,6 +1010,9 @@ importers: vitest: specifier: catalog:devTools version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod-v40: + specifier: npm:zod@4.0.17 + version: zod@4.0.17 packages/server-legacy: dependencies: @@ -5319,6 +5325,9 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 + zod@4.0.17: + resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -9360,4 +9369,6 @@ snapshots: dependencies: zod: 4.3.6 + zod@4.0.17: {} + zod@4.3.6: {} From 35303b755e084de68c9e98b023b50f07bf076f9a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 2 Jun 2026 15:19:40 +0000 Subject: [PATCH 4/4] Include the converter error in raw-shape registration failures and fix the zod-v3 remedy in the migration guides --- docs/migration-SKILL.md | 2 +- docs/migration.md | 5 +++-- packages/core/src/util/zodCompat.ts | 20 ++++++++++++-------- packages/core/test/util/zodCompat.test.ts | 4 ++++ 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index d951eacd40..18e2533be9 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -223,7 +223,7 @@ Zod schemas, all callback return types. Note: `callTool()` and `request()` signa The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. **IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with -`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. A deprecated overload still auto-wraps raw shapes of zod/v4 fields; invalid shapes fail at registration time: zod v3 fields → `TypeError`; mixed v3+v4 fields → `Error('Mixed Zod versions detected in object shape.')` (v1's exact error); fields from a different zod v4 build that the bundled converter cannot walk → `TypeError` (wrap with your own zod ≥4.2 `z.object()`). +`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. A deprecated overload still auto-wraps raw shapes of zod/v4 fields; invalid shapes fail at registration time: zod v3 fields → `TypeError` (import the field schemas from `zod/v4` — v4's `z.object()` cannot consume v3 fields — or pass JSON Schema via `fromJsonSchema()`); mixed v3+v4 fields → `Error('Mixed Zod versions detected in object shape.')` (v1's exact error); fields from a different zod v4 build that the bundled converter cannot walk → `TypeError` (wrap with your own zod ≥4.2 `z.object()`); fields not representable in JSON Schema such as `z.custom()` → `TypeError` carrying the converter's message (pass JSON Schema via `fromJsonSchema()`). ### Tools diff --git a/docs/migration.md b/docs/migration.md index 3365a1124c..946d46725b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -306,9 +306,10 @@ This applies to: > **Compatibility note:** a deprecated `registerTool`/`registerPrompt` overload still accepts raw shapes at runtime and auto-wraps them with the SDK-bundled `z.object()`. Shapes that cannot work fail at **registration time** (not later at `tools/list`): > -> - fields from **zod v3** → `TypeError` — wrap with your own `z.object()` from `zod/v4`, or pass JSON Schema via `fromJsonSchema()`; +> - fields from **zod v3** → `TypeError` — import your field schemas from `zod/v4` (or upgrade your zod import) and wrap with `z.object()`, or pass JSON Schema via `fromJsonSchema()`; v4's `z.object()` cannot consume v3 field schemas, so wrapping alone is not enough; > - fields mixing **zod v3 and v4** → `Error('Mixed Zod versions detected in object shape.')`, the same error v1 threw for this case; -> - fields from a **different zod v4 build** whose internals the bundled JSON Schema converter cannot walk → `TypeError` at registration — wrap with your own zod ≥4.2 `z.object()` so the schema carries its own converter. +> - fields from a **different zod v4 build** whose internals the bundled JSON Schema converter cannot walk → `TypeError` at registration — wrap with your own zod ≥4.2 `z.object()` so the schema carries its own converter; +> - fields **not representable in JSON Schema** (e.g. `z.custom()`), even from the SDK-bundled zod → `TypeError` at registration carrying the converter's message — pass JSON Schema via `fromJsonSchema()`, or use a representable field schema. **Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts index 8301ec147c..956d84eac2 100644 --- a/packages/core/src/util/zodCompat.ts +++ b/packages/core/src/util/zodCompat.ts @@ -62,20 +62,24 @@ export function normalizeRawShapeSchema( if (schema === undefined) return undefined; if (isZodRawShape(schema)) { // Fail at registration time, not serve time. Listing handlers run this - // exact JSON Schema conversion later; if the shape's field schemas come - // from a different zod build than the one the SDK bundles (e.g. the - // application's own zod 4.0/4.1 instance), the bundled converter can - // fail while walking their internals. Without this check, a bad shape - // registers fine and then crashes `tools/list` far from the cause. + // exact JSON Schema conversion later; it can fail when the shape's + // field schemas come from a different zod build than the one the SDK + // bundles (e.g. the application's own zod 4.0/4.1 instance), or when a + // field is not representable in JSON Schema (e.g. `z.custom()`). + // Without this check, a bad shape registers fine and then crashes + // `tools/list` far from the cause. let wrapped: StandardSchemaWithJSON; try { wrapped = z.object(schema) as StandardSchemaWithJSON; standardSchemaToJsonSchema(wrapped, io); } catch (error) { + const detail = error instanceof Error ? error.message : String(error); throw new TypeError( - 'Raw-shape inputSchema/outputSchema/argsSchema could not be converted to JSON Schema. ' + - 'The field schemas likely come from a different zod build than the one the SDK bundles. ' + - "Wrap the shape with your own zod's `z.object({...})` (zod >=4.2), or pass a JSON Schema via `fromJsonSchema()`.", + `Raw-shape inputSchema/outputSchema/argsSchema could not be converted to JSON Schema: ${detail}. ` + + 'This happens when the field schemas come from a different zod build than the one the SDK bundles, ' + + 'or when a field is not representable in JSON Schema (e.g. `z.custom()`). ' + + "For a foreign zod build, wrap the shape with your own zod's `z.object({...})` (zod >=4.2) so the schema carries its own converter; " + + 'for non-representable fields, pass a JSON Schema via `fromJsonSchema()`.', { cause: error } ); } diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts index b5d7a2c0eb..f4f971fa23 100644 --- a/packages/core/test/util/zodCompat.test.ts +++ b/packages/core/test/util/zodCompat.test.ts @@ -145,6 +145,10 @@ describe('normalizeRawShapeSchema real-instance coverage', () => { test('native z.custom raw-shape field fails at normalization time (version-independent construct)', () => { const shape = { c: z.custom(v => typeof v === 'string') }; expect(() => normalizeRawShapeSchema(shape as never)).toThrow(/could not be converted to JSON Schema/); + // The thrown message itself carries the converter's diagnosis, so the + // bundled-zod non-representable case is distinguishable from a foreign + // zod build without unwrapping `cause`. + expect(() => normalizeRawShapeSchema(shape as never)).toThrow(/Non-representable type|custom/i); }); test('raw shape from a real foreign zod build fails at normalization time, not serve time', () => {