Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/raw-shape-registration-time-errors.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (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

Expand Down
7 changes: 7 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,13 @@ 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` — 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 **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.

Comment thread
claude[bot] marked this conversation as resolved.
**Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents):

| Removed | Replacement |
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
47 changes: 40 additions & 7 deletions packages/core/src/util/zodCompat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -56,16 +56,49 @@ export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
* @internal
*/
export function normalizeRawShapeSchema(
schema: StandardSchemaWithJSON | Record<string, z.ZodType> | undefined
schema: StandardSchemaWithJSON | Record<string, z.ZodType> | 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; 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: ${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 }
);
}
return wrapped;
}
Comment thread
claude[bot] marked this conversation as resolved.
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(
Expand Down
77 changes: 77 additions & 0 deletions packages/core/test/util/zodCompat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,80 @@
expect(() => normalizeRawShapeSchema(null as never)).toThrow(/must be a Standard Schema/);
});
});

// 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 } },
'~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');
});
});

// 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<string>(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);
});

Check warning on line 152 in packages/core/test/util/zodCompat.test.ts

View check run for this annotation

Claude / Claude Code Review

Vacuous test assertion: /Non-representable type|custom/i always matches the static error boilerplate

The second `.toThrow(/Non-representable type|custom/i)` assertion is vacuous: the `custom` alternative matches the static boilerplate "e.g. `z.custom()`" that is always present in the TypeError thrown by `normalizeRawShapeSchema`, so this test passes even if the `: ${detail}` interpolation (the converter's own diagnosis) is removed from the message — contrary to what the accompanying comment claims is being verified. Tighten it to match only the converter's own text, e.g. `.toThrow(/Non-represen
Comment on lines +145 to +152
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The second .toThrow(/Non-representable type|custom/i) assertion is vacuous: the custom alternative matches the static boilerplate "e.g. z.custom()" that is always present in the TypeError thrown by normalizeRawShapeSchema, so this test passes even if the : ${detail} interpolation (the converter's own diagnosis) is removed from the message — contrary to what the accompanying comment claims is being verified. Tighten it to match only the converter's own text, e.g. .toThrow(/Non-representable type encountered/), or assert the dynamic detail explicitly.

Extended reasoning...

What the assertion claims to verify vs. what it actually verifies. The test 'native z.custom raw-shape field fails at normalization time (version-independent construct)' (packages/core/test/util/zodCompat.test.ts:145-152) carries the comment: "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." The assertion meant to back that claim is expect(() => normalizeRawShapeSchema(shape as never)).toThrow(/Non-representable type|custom/i). But the TypeError thrown by normalizeRawShapeSchema (packages/core/src/util/zodCompat.ts:78-82) always contains the static boilerplate text "or when a field is not representable in JSON Schema (e.g. z.custom())" — and the case-insensitive custom branch of the alternation matches the literal z.custom() in that boilerplate on every invocation of this catch path, regardless of whether the dynamic ${detail} (the converter's own message) was interpolated.\n\nStep-by-step proof. (1) Suppose a future refactor drops the : ${detail} interpolation from the message at zodCompat.ts:78, leaving only the static prose. (2) The thrown message is now "Raw-shape inputSchema/outputSchema/argsSchema could not be converted to JSON Schema. This happens when ... or when a field is not representable in JSON Schema (e.g. z.custom()). ...". (3) The first assertion /could not be converted to JSON Schema/ still matches the static prefix. (4) The second assertion /Non-representable type|custom/i evaluates its alternation: Non-representable type no longer matches (the converter detail is gone, and the static text only says "not representable" / "non-representable fields", neither of which matches "non-representable type"), but custom matches z.custom() case-insensitively — so the assertion passes anyway. (5) The mutation goes undetected by this test, even though its comment says the message-carries-the-diagnosis property is exactly what it checks.\n\nWhy no other test catches it either. The only other test that inspects the converter's own error is 'raw shape from a real foreign zod build fails at normalization time, not serve time' (lines 154-165), and it does so via the cause chain — expect(String((e as TypeError).cause)).toMatch(/Non-representable type/) — not via the thrown message. The remaining conversion-failure tests in both packages match only the static prefix /could not be converted to JSON Schema/. So if the ${detail} interpolation were removed, no test in core or server would fail, despite the PR description's claim that mutation testing covers the eager check and that the registration-time error "carr[ies] the converter's own message".\n\nWhy this is a nit, not a blocker. The implementation is correct as shipped — detail is interpolated into the message — so there is no runtime bug here. The issue is purely that the second assertion adds zero verification power beyond the first one while its comment overstates what is verified, leaving the advertised behavior unprotected against regression. All four verifiers independently confirmed this and agreed on nit severity.\n\nFix. Drop the |custom alternation so the assertion matches only the converter's own diagnosis: .toThrow(/Non-representable type encountered/) (the bundled zod's toJSONSchema error for z.custom() contains that phrase, as the foreign-build test's cause assertion demonstrates for the analogous .optional() case). Alternatively, capture the thrown error and assert that its message contains String((e as TypeError).cause && (e.cause as Error).message) — i.e. assert the dynamic detail explicitly. Either change makes the test actually enforce what its comment promises.\n\nNote this is distinct from the existing inline comment on zodCompat.ts (about the runtime error's wording for the non-representable case) — that comment concerns the production message; this one concerns the test assertion that is supposed to verify the message.


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/);
}
});
});
3 changes: 2 additions & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
6 changes: 3 additions & 3 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,8 +817,8 @@ export class McpServer {
name,
title,
description,
normalizeRawShapeSchema(inputSchema),
normalizeRawShapeSchema(outputSchema),
normalizeRawShapeSchema(inputSchema, 'input'),
normalizeRawShapeSchema(outputSchema, 'output'),
annotations,
undefined,
_meta,
Expand Down Expand Up @@ -893,7 +893,7 @@ export class McpServer {
name,
title,
description,
normalizeRawShapeSchema(argsSchema),
normalizeRawShapeSchema(argsSchema, 'input'),
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
_meta
);
Expand Down
96 changes: 96 additions & 0 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -121,6 +122,101 @@ 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/
);
});

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<string, { description?: string }>; 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', () => {
it('preserves optionality from .optional() as ?: keys', () => {
type S = InferRawShape<{ a: z.ZodString; b: z.ZodOptional<z.ZodString> }>;
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading