Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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()`.
41 changes: 41 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,45 @@ export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSch
return isStandardJSONSchema(schema) && isStandardSchema(schema);
}

function isZodSchema(v: unknown): v is z.ZodType {
Comment thread
felixweinberger marked this conversation as resolved.
Outdated
if (typeof v !== 'object' || v === null) return false;
if ('_def' in v) return true;
return isStandardSchema(v) && (v as StandardSchemaV1)['~standard'].vendor === 'zod';
}

/**
* Detects a "raw shape" — a plain object whose values are Zod field schemas,
* e.g. `{ name: z.string() }`. Powers the auto-wrap in
* {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only
* Zod values are supported.
*
* @internal
*/
export function isZodRawShape(obj: unknown): obj is Record<string, z.ZodType> {
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 => isZodSchema(v));
}

/**
* 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, z.ZodType> | undefined
): StandardSchemaWithJSON | undefined {
if (schema === undefined) return undefined;
if (isZodRawShape(schema)) {
return z.object(schema) as StandardSchemaWithJSON;
}
return schema;
Comment thread
felixweinberger marked this conversation as resolved.
Outdated
}

// JSON Schema conversion

/**
Expand Down
33 changes: 32 additions & 1 deletion packages/core/test/util/standardSchema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
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);
});
test('rejects a shape with non-Zod Standard Schema fields', () => {
const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } };
expect(isZodRawShape({ a: nonZod })).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');
});
test('passes through an already-wrapped Standard Schema unchanged', () => {
const schema = z.object({ a: z.string() });
expect(normalizeRawShapeSchema(schema)).toBe(schema);
});
test('returns undefined for undefined input', () => {
expect(normalizeRawShapeSchema(undefined)).toBeUndefined();
});
});

describe('standardSchemaToJsonSchema', () => {
test('emits type:object for plain z.object schemas', () => {
Expand Down
18 changes: 9 additions & 9 deletions packages/server/src/server/completable.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import type { StandardSchemaWithJSON } from '@modelcontextprotocol/core';
import type { StandardSchemaV1 } from '@modelcontextprotocol/core';

export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');

export type CompleteCallback<T extends StandardSchemaWithJSON = StandardSchemaWithJSON> = (
value: StandardSchemaWithJSON.InferInput<T>,
export type CompleteCallback<T extends StandardSchemaV1 = StandardSchemaV1> = (
value: StandardSchemaV1.InferInput<T>,
context?: {
arguments?: Record<string, string>;
}
) => StandardSchemaWithJSON.InferInput<T>[] | Promise<StandardSchemaWithJSON.InferInput<T>[]>;
) => StandardSchemaV1.InferInput<T>[] | Promise<StandardSchemaV1.InferInput<T>[]>;

export type CompletableMeta<T extends StandardSchemaWithJSON = StandardSchemaWithJSON> = {
export type CompletableMeta<T extends StandardSchemaV1 = StandardSchemaV1> = {
complete: CompleteCallback<T>;
};

export type CompletableSchema<T extends StandardSchemaWithJSON> = T & {
export type CompletableSchema<T extends StandardSchemaV1> = T & {

Check warning on line 16 in packages/server/src/server/completable.ts

View check run for this annotation

Claude / Claude Code Review

completable() widening: changeset omission + StandardSchemaV1 not publicly exported

nit: aba1d39 widens the public `completable()`/`CompleteCallback`/`CompletableSchema` constraint from `StandardSchemaWithJSON` to `StandardSchemaV1`, but the changeset only mentions the raw-shape auto-wrap, and `StandardSchemaV1` isn't re-exported from `@modelcontextprotocol/core/public` (only `StandardSchemaWithJSON` is, at line 139) — so the new constraint on a public generic isn't itself importable from `@modelcontextprotocol/server`. Consider adding a sentence to the changeset and adding `St
Comment thread
felixweinberger marked this conversation as resolved.
[COMPLETABLE_SYMBOL]: CompletableMeta<T>;
};

Expand Down Expand Up @@ -48,7 +48,7 @@
*
* @see {@linkcode server/mcp.McpServer.registerPrompt | McpServer.registerPrompt} for using completable schemas in prompt argument definitions
*/
export function completable<T extends StandardSchemaWithJSON>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
export function completable<T extends StandardSchemaV1>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, {
value: { complete } as CompletableMeta<T>,
enumerable: false,
Expand All @@ -61,14 +61,14 @@
/**
* Checks if a schema is completable (has completion metadata).
*/
export function isCompletable(schema: unknown): schema is CompletableSchema<StandardSchemaWithJSON> {
export function isCompletable(schema: unknown): schema is CompletableSchema<StandardSchemaV1> {
return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object);
}

/**
* Gets the completer callback from a completable schema, if it exists.
*/
export function getCompleter<T extends StandardSchemaWithJSON>(schema: T): CompleteCallback<T> | undefined {
export function getCompleter<T extends StandardSchemaV1>(schema: T): CompleteCallback<T> | undefined {
const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta<T> })[COMPLETABLE_SYMBOL];
return meta?.complete as CompleteCallback<T> | undefined;
}
74 changes: 71 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 All @@ -39,6 +40,7 @@ import {
validateStandardSchema
} from '@modelcontextprotocol/core';

import type * as z from 'zod/v4';
import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js';
import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js';
import { getCompleter, isCompletable } from './completable.js';
Expand Down Expand Up @@ -873,6 +875,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 +911,8 @@ export class McpServer {
name,
title,
description,
inputSchema,
outputSchema,
normalizeRawShapeSchema(inputSchema),
normalizeRawShapeSchema(outputSchema),
annotations,
{ taskSupport: 'forbidden' },
_meta,
Expand Down Expand Up @@ -928,6 +955,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 +987,7 @@ export class McpServer {
name,
title,
description,
argsSchema,
normalizeRawShapeSchema(argsSchema),
cb as PromptCallback<StandardSchemaWithJSON | undefined>,
_meta
);
Expand Down Expand Up @@ -1062,6 +1110,26 @@ export class ResourceTemplate {
}
}

/**
* A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by
* `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`.
* Zod schemas only — `z.object()` cannot wrap other Standard Schema libraries.
*/
export type ZodRawShape = Record<string, z.ZodType>;

/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */
export type InferRawShape<S extends ZodRawShape> = { [K in keyof S]: z.output<S[K]> };
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

/** {@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
121 changes: 121 additions & 0 deletions packages/server/test/server/mcp.compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/index.js';
import { completable } from '../../src/server/completable.js';

describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => {
it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => {
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);
});

it('registerTool accepts a raw shape for outputSchema and auto-wraps it', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerTool('out', { inputSchema: { n: z.number() }, outputSchema: { result: z.string() } }, async ({ n }) => ({
content: [{ type: 'text' as const, text: String(n) }],
structuredContent: { result: String(n) }
}));

const tools = (server as unknown as { _registeredTools: Record<string, { outputSchema?: unknown }> })._registeredTools;
expect(isStandardSchema(tools['out']?.outputSchema)).toBe(true);
});

it('registerTool with z.object() inputSchema also works (passthrough, no auto-wrap)', () => {
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) }]
}));

const tools = (server as unknown as { _registeredTools: Record<string, { inputSchema?: unknown }> })._registeredTools;
expect(isStandardSchema(tools['c']?.inputSchema)).toBe(true);
});

it('registerPrompt accepts a raw shape for argsSchema', () => {
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);
});

it('registerPrompt raw shape accepts completable() fields (v1 pattern)', () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

server.registerPrompt(
'p',
{
argsSchema: {
language: completable(z.string(), v => ['typescript', 'python'].filter(l => l.startsWith(v)))
}
},
async ({ language }) => ({
messages: [{ role: 'user' as const, content: { type: 'text' as const, text: language } }]
})
);

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

it('callback receives validated, typed args end-to-end via tools/call', async () => {
const server = new McpServer({ name: 't', version: '1.0.0' });

let received: { x: number } | undefined;
server.registerTool('echo', { inputSchema: { x: z.number() } }, async args => {
received = args;
return { content: [{ type: 'text' as const, text: String(args.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/call',
params: { name: 'echo', arguments: { x: 7 } }
} as JSONRPCMessage);

await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true));

expect(received).toEqual({ x: 7 });
const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text: string }> } };
expect(result.result?.content[0]?.text).toBe('7');

await server.close();
});
});
Loading