Accept raw zod shapes in registerAppTool as a deprecated overload

SDK v2 registerTool takes Standard JSON Schema objects (z.object(...))
and keeps a deprecated overload for the v1 raw-shape form. Mirror that
here so existing servers passing { field: z.string() } keep compiling;
the call is forwarded to the SDK, which auto-wraps the shape. Tests
exercise both forms end to end through a real McpServer and Client.
This commit is contained in:
Olivier Chafik 2026-09-07 15:27:11 +01:00
commit d6ebcca9a0
2 changed files with 195 additions and 4 deletions

View file

@ -7,7 +7,13 @@ import {
getUiCapability,
EXTENSION_ID,
} from "./index.js";
import type { McpServer } from "@modelcontextprotocol/server";
import {
InMemoryTransport,
McpServer as RealMcpServer,
type McpServer,
} from "@modelcontextprotocol/server";
import { Client } from "@modelcontextprotocol/client";
import { z } from "zod/v4";
describe("registerAppTool", () => {
it("should pass through config to server.registerTool", () => {
@ -197,6 +203,119 @@ describe("registerAppTool", () => {
});
});
describe("registerAppTool schema forms", () => {
/** Registers tools on a fresh server, then connects a client to it. */
async function connect(register: (server: RealMcpServer) => void) {
const server = new RealMcpServer({ name: "test", version: "0.0.0" });
register(server);
const client = new Client({ name: "client", version: "0.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return client;
}
it("accepts a z.object() input schema and validates arguments", async () => {
const client = await connect((server) =>
registerAppTool(
server,
"greet",
{
inputSchema: z.object({ name: z.string() }),
_meta: { ui: { resourceUri: "ui://greet/view.html" } },
},
async ({ name }) => ({
content: [{ type: "text", text: `hello ${name}` }],
}),
),
);
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0].inputSchema).toMatchObject({
type: "object",
properties: { name: { type: "string" } },
});
expect(tools[0]._meta).toMatchObject({
ui: { resourceUri: "ui://greet/view.html" },
[RESOURCE_URI_META_KEY]: "ui://greet/view.html",
});
const result = await client.callTool({
name: "greet",
arguments: { name: "world" },
});
expect(result.content).toEqual([{ type: "text", text: "hello world" }]);
const invalid = await client.callTool({
name: "greet",
arguments: { name: 42 },
});
expect(invalid.isError).toBe(true);
});
it("accepts a raw zod shape (deprecated form) and wraps it with z.object()", async () => {
const client = await connect((server) =>
registerAppTool(
server,
"greet",
{
inputSchema: { name: z.string() },
outputSchema: { greeting: z.string() },
_meta: { ui: { resourceUri: "ui://greet/view.html" } },
},
async ({ name }) => ({
content: [{ type: "text", text: `hello ${name}` }],
structuredContent: { greeting: `hello ${name}` },
}),
),
);
const { tools } = await client.listTools();
expect(tools[0].inputSchema).toMatchObject({
type: "object",
properties: { name: { type: "string" } },
});
expect(tools[0].outputSchema).toMatchObject({
type: "object",
properties: { greeting: { type: "string" } },
});
const result = await client.callTool({
name: "greet",
arguments: { name: "world" },
});
expect(result.structuredContent).toEqual({ greeting: "hello world" });
const invalid = await client.callTool({
name: "greet",
arguments: { name: 42 },
});
expect(invalid.isError).toBe(true);
});
it("accepts the raw-shape form without an input schema", async () => {
const client = await connect((server) =>
registerAppTool(
server,
"ping",
{
outputSchema: { ok: z.boolean() },
_meta: { ui: { resourceUri: "ui://ping/view.html" } },
},
async () => ({
content: [{ type: "text", text: "pong" }],
structuredContent: { ok: true },
}),
),
);
const result = await client.callTool({ name: "ping", arguments: {} });
expect(result.structuredContent).toEqual({ ok: true });
});
});
describe("registerAppResource", () => {
it("should register a resource with default MIME type", () => {
let capturedName: string | undefined;

View file

@ -39,31 +39,69 @@ import {
} from "../spec.types.js";
import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js";
import type {
CallToolResult,
ClientCapabilities,
InputRequiredResult,
McpServer,
ReadResourceResult,
RegisteredTool,
ResourceMetadata,
ServerContext,
StandardSchemaWithJSON,
ToolAnnotations,
ToolCallback,
ReadResourceCallback as _ReadResourceCallback,
RegisteredResource,
} from "@modelcontextprotocol/server";
import type { z } from "zod/v4";
// Re-exports for convenience
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE };
export type { ResourceMetadata, ToolCallback };
/**
* A plain `{ field: z.string() }` record accepted by the deprecated raw-shape
* form of {@link registerAppTool `registerAppTool`}; the SDK auto-wraps it with `z.object()`.
*
* @deprecated Wrap with `z.object({...})` instead.
*/
export type ZodRawShape = Record<string, z.ZodType>;
/**
* {@link ToolCallback `ToolCallback`} variant used when `inputSchema` is a {@link ZodRawShape `ZodRawShape`}.
* Mirrors the callback type of the SDK's deprecated `registerTool` overload.
*
* @deprecated Wrap with `z.object({...})` instead and use {@link ToolCallback `ToolCallback`}.
*/
export type LegacyToolCallback<Args extends ZodRawShape | undefined> =
Args extends ZodRawShape
? (
args: z.infer<z.ZodObject<Args>>,
ctx: ServerContext,
) =>
| CallToolResult
| InputRequiredResult
| Promise<CallToolResult | InputRequiredResult>
: (
ctx: ServerContext,
) =>
| CallToolResult
| InputRequiredResult
| Promise<CallToolResult | InputRequiredResult>;
/**
* Base tool configuration matching the standard MCP server tool options.
* Extended by {@link McpUiAppToolConfig `McpUiAppToolConfig`} to add UI metadata requirements.
*
* `inputSchema`/`outputSchema` accept any Standard JSON Schema (zod v4, ArkType,
* Valibot, ...). A raw zod shape (`{ field: z.string() }`) is still accepted for
* backward compatibility but deprecated.
*/
export interface ToolConfig {
title?: string;
description?: string;
inputSchema?: StandardSchemaWithJSON;
outputSchema?: StandardSchemaWithJSON;
inputSchema?: ZodRawShape | StandardSchemaWithJSON;
outputSchema?: ZodRawShape | StandardSchemaWithJSON;
annotations?: ToolAnnotations;
_meta?: Record<string, unknown>;
}
@ -217,6 +255,31 @@ export function registerAppTool<
outputSchema?: OutputArgs;
},
cb: ToolCallback<InputArgs>,
): RegisteredTool;
/**
* @deprecated Wrap with `z.object({...})` instead. Raw-shape form:
* `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record;
* the SDK auto-wraps it with `z.object()`.
*/
export function registerAppTool<
InputArgs extends ZodRawShape,
OutputArgs extends ZodRawShape | StandardSchemaWithJSON | undefined =
undefined,
>(
server: Pick<McpServer, "registerTool">,
name: string,
config: McpUiAppToolConfig & {
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
},
cb: LegacyToolCallback<InputArgs>,
): RegisteredTool;
export function registerAppTool(
server: Pick<McpServer, "registerTool">,
name: string,
config: McpUiAppToolConfig,
// Widest callback shape accepted by either overload above.
cb: (...args: never[]) => unknown,
): RegisteredTool {
// Normalize metadata for backward compatibility:
// - If _meta.ui.resourceUri is set, also set the legacy flat key
@ -234,7 +297,16 @@ export function registerAppTool<
normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } };
}
return server.registerTool(name, { ...config, _meta: normalizedMeta }, cb);
// The public overloads above guarantee `config`/`cb` match one of the SDK's
// own registerTool overloads; the union-typed implementation cannot select
// between them, so widen for the forwarding call.
return server.registerTool(
name,
{ ...config, _meta: normalizedMeta } as Parameters<
McpServer["registerTool"]
>[1],
cb as ToolCallback<StandardSchemaWithJSON>,
);
}
export type McpUiReadResourceResult = ReadResourceResult & {