Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
27e4ddf
feat(compat): registerTool/registerPrompt accept raw Zod shape (auto-…
felixweinberger Apr 15, 2026
5266131
fix: isZodRawShape treats empty object as raw shape (matches v1)
felixweinberger Apr 16, 2026
f2fdbe7
docs: changeset wording aligns with @deprecated overloads (not first-…
felixweinberger Apr 16, 2026
9576f20
docs: clarify isZodRawShape only supports Zod values for auto-wrap
felixweinberger Apr 16, 2026
0152b26
fix(compat): narrow ZodRawShape to Zod-only (detector + type); add ou…
felixweinberger Apr 17, 2026
1af9ed2
test(compat): add e2e raw-shape tools/call test; drop vestigial warn-…
felixweinberger Apr 17, 2026
3155be7
Merge branch 'main' into fweinberger/v2-bc-register-rawshape
KKonstantinov Apr 24, 2026
aba1d39
feat(compat): widen completable() constraint to StandardSchemaV1
felixweinberger Apr 24, 2026
0febd83
refactor(compat): move zod helpers to zodCompat.ts; throw on invalid …
felixweinberger Apr 27, 2026
7e80880
Merge branch 'main' into fweinberger/v2-bc-register-rawshape
felixweinberger Apr 27, 2026
c75bc88
test(compat): move zod-compat tests to zodCompat.test.ts; tighten nor…
felixweinberger Apr 27, 2026
a6b25ee
fix(compat): reject Zod v3 fields in raw-shape auto-wrap with actiona…
felixweinberger Apr 27, 2026
b5854c1
fix(compat): require plain-object prototype in isZodRawShape; null-gu…
felixweinberger Apr 27, 2026
9b7ee90
Merge branch 'main' into fweinberger/v2-bc-register-rawshape
felixweinberger Apr 29, 2026
27e2c4b
fix(compat): pass StandardSchema without ~standard.jsonSchema through…
felixweinberger Apr 29, 2026
617830b
Merge branch 'main' into fweinberger/v2-bc-register-rawshape
felixweinberger Apr 29, 2026
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
6 changes: 6 additions & 0 deletions .changeset/register-rawshape-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/server': patch
---

`registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. The raw-shape overloads are `@deprecated`; prefer wrapping with `z.object()`.
34 changes: 34 additions & 0 deletions packages/core/src/util/standardSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

/* eslint-disable @typescript-eslint/no-namespace */

import * as z from 'zod/v4';

// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025)

export interface StandardTypedV1<Input = unknown, Output = Input> {
Expand Down Expand Up @@ -136,6 +138,38 @@
return isStandardJSONSchema(schema) && isStandardSchema(schema);
}

/**
* Detects a "raw shape" — a plain object whose values are Zod (or other
* Standard Schema) field schemas, e.g. `{ name: z.string() }`. Powers the
* auto-wrap in {@linkcode normalizeRawShapeSchema}.
*
* @internal
*/
export function isZodRawShape(obj: unknown): obj is Record<string, StandardSchemaV1> {
if (typeof obj !== 'object' || obj === null) return false;
if (isStandardSchema(obj)) return false;
// [].every() is true, so an empty object is a valid raw shape (matches v1).
return Object.values(obj).every(v => isStandardSchema(v) || (typeof v === 'object' && v !== null && '_def' in v));
}

Check warning on line 153 in packages/core/src/util/standardSchema.ts

View check run for this annotation

Claude / Claude Code Review

isZodRawShape accepts non-Zod StandardSchema fields but z.object() requires Zod types

The raw-shape detector and types advertise broader Standard Schema support than the implementation can deliver: `ZodRawShape = Record<string, StandardSchemaWithJSON>` and `isZodRawShape`'s `isStandardSchema(v)` branch both accept ArkType/Valibot fields, but `z.object()` only works with actual Zod types — so `{ a: type('string') }` type-checks, passes the guard, gets wrapped, and then throws on `tools/list`/`tools/call`. Since this is a Zod-only v1-compat shim, drop the `isStandardSchema(v)` disj
Comment thread
felixweinberger marked this conversation as resolved.
Outdated
Comment thread
felixweinberger marked this conversation as resolved.
Outdated

/**
* Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape
* `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}.
* Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a
* uniform schema type; already-wrapped schemas pass through unchanged.
*
* @internal
*/
export function normalizeRawShapeSchema(
schema: StandardSchemaWithJSON | Record<string, StandardSchemaV1> | undefined
): StandardSchemaWithJSON | undefined {
if (schema === undefined) return undefined;
if (isZodRawShape(schema)) {
return z.object(schema as z.ZodRawShape) as StandardSchemaWithJSON;
}
return schema;
Comment thread
felixweinberger marked this conversation as resolved.
Outdated
}

// JSON Schema conversion

/**
Expand Down
22 changes: 21 additions & 1 deletion packages/core/test/util/standardSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import * as z from 'zod/v4';

import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';
import { isZodRawShape, normalizeRawShapeSchema, standardSchemaToJsonSchema } from '../../src/util/standardSchema.js';

describe('isZodRawShape', () => {
test('treats empty object as a raw shape (matches v1)', () => {
expect(isZodRawShape({})).toBe(true);
});
test('detects raw shape with zod fields', () => {
expect(isZodRawShape({ a: z.string() })).toBe(true);
});
test('rejects a Standard Schema instance', () => {
expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false);
});
});

describe('normalizeRawShapeSchema', () => {
test('wraps empty raw shape into z.object({})', () => {
const wrapped = normalizeRawShapeSchema({});
expect(wrapped).toBeDefined();
expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object');
});
});

describe('standardSchemaToJsonSchema', () => {
test('emits type:object for plain z.object schemas', () => {
Expand Down
72 changes: 69 additions & 3 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
import {
assertCompleteRequestPrompt,
assertCompleteRequestResourceTemplate,
normalizeRawShapeSchema,
promptArgumentsFromStandardSchema,
ProtocolError,
ProtocolErrorCode,
Expand Down Expand Up @@ -873,6 +874,31 @@ export class McpServer {
_meta?: Record<string, unknown>;
},
cb: ToolCallback<InputArgs>
): RegisteredTool;
Comment thread
claude[bot] marked this conversation as resolved.
/** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */
registerTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined = undefined>(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
_meta?: Record<string, unknown>;
},
cb: LegacyToolCallback<InputArgs>
): RegisteredTool;
Comment thread
felixweinberger marked this conversation as resolved.
registerTool(
name: string,
config: {
title?: string;
description?: string;
inputSchema?: StandardSchemaWithJSON | ZodRawShape;
outputSchema?: StandardSchemaWithJSON | ZodRawShape;
annotations?: ToolAnnotations;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
): RegisteredTool {
if (this._registeredTools[name]) {
throw new Error(`Tool ${name} is already registered`);
Expand All @@ -884,8 +910,8 @@ export class McpServer {
name,
title,
description,
inputSchema,
outputSchema,
normalizeRawShapeSchema(inputSchema),
normalizeRawShapeSchema(outputSchema),
annotations,
{ taskSupport: 'forbidden' },
_meta,
Expand Down Expand Up @@ -928,6 +954,27 @@ export class McpServer {
_meta?: Record<string, unknown>;
},
cb: PromptCallback<Args>
): RegisteredPrompt;
/** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `argsSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */
registerPrompt<Args extends ZodRawShape>(
name: string,
config: {
title?: string;
description?: string;
argsSchema?: Args;
_meta?: Record<string, unknown>;
},
cb: LegacyPromptCallback<Args>
): RegisteredPrompt;
registerPrompt(
name: string,
config: {
title?: string;
description?: string;
argsSchema?: StandardSchemaWithJSON | ZodRawShape;
_meta?: Record<string, unknown>;
},
cb: PromptCallback<StandardSchemaWithJSON> | LegacyPromptCallback<ZodRawShape>
): RegisteredPrompt {
if (this._registeredPrompts[name]) {
throw new Error(`Prompt ${name} is already registered`);
Expand All @@ -939,7 +986,7 @@ export class McpServer {
name,
title,
description,
argsSchema,
normalizeRawShapeSchema(argsSchema),
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
_meta
);
Expand Down Expand Up @@ -1062,6 +1109,25 @@ export class ResourceTemplate {
}
}

/**
* A plain record of field schemas, e.g. `{ name: z.string() }`. Accepted by
* `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`.
*/
export type ZodRawShape = Record<string, StandardSchemaWithJSON>;

/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: StandardSchemaWithJSON.InferOutput<S[K]> };

/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */
export type LegacyToolCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
? (args: InferRawShape<Args>, ctx: ServerContext) => CallToolResult | Promise<CallToolResult>
: (ctx: ServerContext) => CallToolResult | Promise<CallToolResult>;

/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */
export type LegacyPromptCallback<Args extends ZodRawShape | undefined> = Args extends ZodRawShape
? (args: InferRawShape<Args>, ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>
: (ctx: ServerContext) => GetPromptResult | Promise<GetPromptResult>;
Comment thread
claude[bot] marked this conversation as resolved.

export type BaseToolCallback<
SendResultT extends Result,
Ctx extends ServerContext,
Expand Down
54 changes: 54 additions & 0 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { isStandardSchema } from '@modelcontextprotocol/core';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/index.js';

describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => {
it('registerTool accepts a raw shape for inputSchema, auto-wraps, and does not warn', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerTool('a', { inputSchema: { x: z.number() } }, async ({ x }) => ({
content: [{ type: 'text' as const, text: String(x) }]
}));
server.registerTool('b', { inputSchema: { y: z.number() } }, async ({ y }) => ({
content: [{ type: 'text' as const, text: String(y) }]
}));

const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
expect(Object.keys(tools)).toEqual(['a', 'b']);
// raw shape was wrapped into a Standard Schema (z.object)
expect(isStandardSchema(tools['a']?.inputSchema)).toBe(true);

expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
Comment thread
felixweinberger marked this conversation as resolved.
Outdated

it('registerTool with z.object() inputSchema also works without warning', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerTool('c', { inputSchema: z.object({ x: z.number() }) }, async ({ x }) => ({
content: [{ type: 'text' as const, text: String(x) }]
}));

expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});

it('registerPrompt accepts a raw shape for argsSchema and does not warn', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerPrompt('p', { argsSchema: { topic: z.string() } }, async ({ topic }) => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: topic } }]
}));

const prompts = (server as unknown as { _registeredPrompts: Record<string, { argsSchema?: unknown }> })._registeredPrompts;
expect(Object.keys(prompts)).toContain('p');
expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true);

expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});
Loading