From d6ebcca9a0f4536779666b2223916d64f3700a64 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 7 Sep 2026 15:27:11 +0100 Subject: [PATCH] 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. --- src/server/index.test.ts | 121 ++++++++++++++++++++++++++++++++++++++- src/server/index.ts | 78 ++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 4 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 389cde1..489d0b0 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -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; diff --git a/src/server/index.ts b/src/server/index.ts index dc9a1f3..5fad055 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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; + +/** + * {@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 + ? ( + args: z.infer>, + ctx: ServerContext, + ) => + | CallToolResult + | InputRequiredResult + | Promise + : ( + ctx: ServerContext, + ) => + | CallToolResult + | InputRequiredResult + | Promise; + /** * 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; } @@ -217,6 +255,31 @@ export function registerAppTool< outputSchema?: OutputArgs; }, cb: ToolCallback, +): 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, + name: string, + config: McpUiAppToolConfig & { + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + }, + cb: LegacyToolCallback, +): RegisteredTool; +export function registerAppTool( + server: Pick, + 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, + ); } export type McpUiReadResourceResult = ReadResourceResult & {