From 728b0bf38ece48aa636a39be4fa3f44deaead010 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 8 Sep 2026 14:24:25 +0100 Subject: [PATCH 1/2] Keep the 1.x handler registration forms as deprecated overloads Consumers on 1.x wrote setRequestHandler(Schema, handler) and setNotificationHandler(Schema, handler) with a whole-message Zod schema. SDK 2.0 keys handlers by method name, so App and AppBridge now accept that form as a third, deprecated overload: a small helper detects the schema, extracts the method literal, forwards to the 2.x (method, { params }, handler) form, and hands the legacy handler the reassembled { method, params } message plus a 1.x-shaped extra (signal, requestId, sessionId, _meta). A one-time console warning points at the new form. The double-registration guard applies to both forms. The AppRequest, AppNotification and AppResult unions that 1.x fed to the SDK's Protocol generics come back as deprecated type aliases, since they leaked through the root entry and cost nothing to keep. Both go away in 3.0. --- docs/migrate-to-2.md | 11 ++- src/app-bridge.ts | 42 +++++++---- src/app.ts | 42 +++++++---- src/legacy-handlers.test.ts | 145 ++++++++++++++++++++++++++++++++++++ src/legacy-handlers.ts | 94 +++++++++++++++++++++++ src/types.ts | 117 +++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 31 deletions(-) create mode 100644 src/legacy-handlers.test.ts create mode 100644 src/legacy-handlers.ts diff --git a/docs/migrate-to-2.md b/docs/migrate-to-2.md index 2164b8334..515c36384 100644 --- a/docs/migrate-to-2.md +++ b/docs/migrate-to-2.md @@ -41,8 +41,9 @@ its `Protocol` class); `@modelcontextprotocol/core` is a required peer that `cli Valibot. Raw zod shapes (`{ q: z.string() }`) still work with `registerAppTool` but are deprecated; wrap them with `z.object({...})`. - **`App` / `AppBridge` extend `Protocol` from `@modelcontextprotocol/client`.** - `ProtocolWithEvents`, `AppRequest`, `AppNotification` and `AppResult` are - gone; use the SDK's `Protocol`, `Request`, `Notification` and `Result`. + `ProtocolWithEvents` is gone; use the SDK's `Protocol`. The `AppRequest`, + `AppNotification` and `AppResult` unions are kept as deprecated type + aliases (nothing in 2.x consumes them) and will be removed in 3.0. - **Handler context.** Custom handlers receive the SDK 2.x context: `extra.signal` → `extra.mcpReq.signal`, `extra.requestId` → `extra.mcpReq.id`. @@ -51,7 +52,11 @@ its `Protocol` class); `@modelcontextprotocol/core` is a required peer that `cli `app.setRequestHandler("some/method", { params: SomeParamsSchema }, (params, ctx) => …)` for custom methods (the handler receives the parsed params); the two-argument `setRequestHandler("tools/call", handler)` form exists only for spec-defined - method names. + method names. The 1.x `(Schema, handler)` form still works on `App` and + `AppBridge` as a deprecated overload: it logs a one-time warning, hands the + handler the whole `{ method, params }` message as before, and gives request + handlers a 1.x-shaped `extra` (`signal`, `requestId`, `sessionId`, `_meta`). + It will be removed in 3.0. - **Errors.** Remote JSON-RPC errors are `ProtocolError` (numeric `code`); local conditions are `SdkError` with a string `code`: request timeout → `"REQUEST_TIMEOUT"`, connection closed → `"CONNECTION_CLOSED"`. Cancelling a diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 3d145d413..c61dfa4df 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -35,6 +35,12 @@ import { LoggingMessageNotificationSchema, } from "@modelcontextprotocol/core"; import { EventDispatcher, MethodRegistry } from "./events.js"; +import { + toModernArgs, + type LegacyMethodSchema, + type LegacyNotificationHandler, + type LegacyRequestHandler, +} from "./legacy-handlers.js"; import type { ZodLiteral, ZodObject, ZodType } from "zod/v4"; type MethodSchema = ZodObject<{ @@ -324,11 +330,14 @@ export class AppBridge extends Protocol { * * @throws {Error} if a handler for this method is already registered. */ - override setRequestHandler: Protocol["setRequestHandler"] = ( - method: string, - ...rest: unknown[] - ) => { - this._methods.claim(method, "setRequestHandler"); + override setRequestHandler: Protocol["setRequestHandler"] & + (( + /** @deprecated Pass the method name and `{ params }` instead. */ + schema: S, + handler: LegacyRequestHandler, + ) => void) = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("request", args) ?? args; + this._methods.claim(method as string, "setRequestHandler"); (super.setRequestHandler as unknown as UntypedHandlerSetter).call( this, method, @@ -343,15 +352,20 @@ export class AppBridge extends Protocol { * * @throws {Error} if a handler for this method is already registered. */ - override setNotificationHandler: Protocol["setNotificationHandler"] = - (method: string, ...rest: unknown[]) => { - this._methods.claim(method, "setNotificationHandler"); - (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; + override setNotificationHandler: Protocol["setNotificationHandler"] & + (( + /** @deprecated Pass the method name and `{ params }` instead. */ + schema: S, + handler: LegacyNotificationHandler, + ) => void) = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("notification", args) ?? args; + this._methods.claim(method as string, "setNotificationHandler"); + (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; override removeRequestHandler: Protocol["removeRequestHandler"] = (method: string) => { diff --git a/src/app.ts b/src/app.ts index 58ea9da1c..da27b439d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,12 @@ import { import { EmptyResultSchema } from "@modelcontextprotocol/core"; export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js"; import { EventDispatcher, MethodRegistry } from "./events.js"; +import { + toModernArgs, + type LegacyMethodSchema, + type LegacyNotificationHandler, + type LegacyRequestHandler, +} from "./legacy-handlers.js"; export { EventDispatcher } from "./events.js"; import { PostMessageTransport } from "./message-transport.js"; @@ -316,11 +322,14 @@ export class App extends Protocol { * * @throws {Error} if a handler for this method is already registered. */ - override setRequestHandler: Protocol["setRequestHandler"] = ( - method: string, - ...rest: unknown[] - ) => { - this._methods.claim(method, "setRequestHandler"); + override setRequestHandler: Protocol["setRequestHandler"] & + (( + /** @deprecated Pass the method name and `{ params }` instead. */ + schema: S, + handler: LegacyRequestHandler, + ) => void) = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("request", args) ?? args; + this._methods.claim(method as string, "setRequestHandler"); (super.setRequestHandler as unknown as UntypedHandlerSetter).call( this, method, @@ -335,15 +344,20 @@ export class App extends Protocol { * * @throws {Error} if a handler for this method is already registered. */ - override setNotificationHandler: Protocol["setNotificationHandler"] = - (method: string, ...rest: unknown[]) => { - this._methods.claim(method, "setNotificationHandler"); - (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( - this, - method, - ...rest, - ); - }; + override setNotificationHandler: Protocol["setNotificationHandler"] & + (( + /** @deprecated Pass the method name and `{ params }` instead. */ + schema: S, + handler: LegacyNotificationHandler, + ) => void) = (...args: unknown[]) => { + const [method, ...rest] = toModernArgs("notification", args) ?? args; + this._methods.claim(method as string, "setNotificationHandler"); + (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; override removeRequestHandler: Protocol["removeRequestHandler"] = (method: string) => { diff --git a/src/legacy-handlers.test.ts b/src/legacy-handlers.test.ts new file mode 100644 index 000000000..0ef36d6f1 --- /dev/null +++ b/src/legacy-handlers.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { InMemoryTransport, Server } from "@modelcontextprotocol/server"; +import { z } from "zod/v4"; + +import { App } from "./app.js"; +import { AppBridge } from "./app-bridge.js"; +import { + McpUiInitializeRequestSchema, + McpUiInitializeResultSchema, + McpUiInitializedNotificationSchema, + McpUiResourceTeardownRequestSchema, + McpUiToolInputNotificationSchema, + McpUiSizeChangedNotificationSchema, + McpUiOpenLinkRequestSchema, +} from "./types.js"; + +const CustomRequestSchema = z.object({ + method: z.literal("test/echo"), + params: z.object({ text: z.string() }), +}); + +const CustomNotificationSchema = z.object({ + method: z.literal("test/ping"), + params: z.object({ n: z.number() }), +}); + +let warn: ReturnType; +beforeEach(() => { + warn = spyOn(console, "warn").mockImplementation(() => {}); +}); +afterEach(() => warn.mockRestore()); + +describe("1.x setRequestHandler(Schema, handler) on App", () => { + it("registers a custom request and hands the handler the whole message plus a 1.x extra", async () => { + const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); + const server = new Server( + { name: "s", version: "1" }, + { capabilities: {} }, + ); + server.setRequestHandler( + "ui/initialize", + { + params: McpUiInitializeRequestSchema.shape.params, + result: McpUiInitializeResultSchema, + }, + () => ({ + protocolVersion: "2026-01-26", + hostCapabilities: {}, + hostInfo: { name: "h", version: "1" }, + hostContext: {}, + }), + ); + server.setNotificationHandler( + "ui/notifications/initialized", + { params: McpUiInitializedNotificationSchema.shape.params }, + () => {}, + ); + + let seen: unknown; + let seenExtra: { signal: AbortSignal; requestId: unknown } | undefined; + app.setRequestHandler(CustomRequestSchema, (request, extra) => { + seen = request; + seenExtra = extra; + return { echoed: request.params.text }; + }); + + const [at, st] = InMemoryTransport.createLinkedPair(); + await server.connect(st); + await app.connect(at); + + const result = await server.request( + { method: "test/echo", params: { text: "hi" } }, + z.object({ echoed: z.string() }), + ); + + expect(result).toEqual({ echoed: "hi" }); + expect(seen).toEqual({ method: "test/echo", params: { text: "hi" } }); + expect(seenExtra?.signal).toBeInstanceOf(AbortSignal); + expect(seenExtra?.requestId).toBeDefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain( + 'setRequestHandler("test/echo", { params: Schema.shape.params }, handler)', + ); + + await app.close(); + await server.close(); + }); + + it("still throws for a method an on* setter already owns", () => { + const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); + app.onteardown = async () => ({}); + expect(() => + app.setRequestHandler( + McpUiResourceTeardownRequestSchema, + async () => ({}), + ), + ).toThrow(/already registered/); + }); + + it("still throws for an event-mapped notification", () => { + const app = new App({ name: "a", version: "1" }, {}, { autoResize: false }); + app.ontoolinput = () => {}; + expect(() => + app.setNotificationHandler(McpUiToolInputNotificationSchema, () => {}), + ).toThrow(/already registered/); + }); +}); + +describe("1.x setNotificationHandler(Schema, handler) on AppBridge", () => { + it("registers a custom notification and hands the handler the whole message", async () => { + const bridge = new AppBridge(null, { name: "h", version: "1" }, {}); + // A bare Server stands in for the View: unlike Client, connect() sends no + // MCP initialize, matching what a real App does on this channel. + const view = new Server({ name: "v", version: "1" }, { capabilities: {} }); + let seen: unknown; + bridge.setNotificationHandler(CustomNotificationSchema, (n) => { + seen = n; + }); + + const [bt, vt] = InMemoryTransport.createLinkedPair(); + await bridge.connect(bt); + await view.connect(vt); + await view.notification({ method: "test/ping", params: { n: 3 } }); + await new Promise((r) => setTimeout(r, 10)); + + expect(seen).toEqual({ method: "test/ping", params: { n: 3 } }); + await view.close(); + await bridge.close(); + }); + + it("still throws for a method an on* setter already owns", () => { + const bridge = new AppBridge(null, { name: "h", version: "1" }, {}); + bridge.onsizechange = () => {}; + expect(() => + bridge.setNotificationHandler( + McpUiSizeChangedNotificationSchema, + () => {}, + ), + ).toThrow(/already registered/); + bridge.onopenlink = async () => ({}); + expect(() => + bridge.setRequestHandler(McpUiOpenLinkRequestSchema, async () => ({})), + ).toThrow(/already registered/); + }); +}); diff --git a/src/legacy-handlers.ts b/src/legacy-handlers.ts new file mode 100644 index 000000000..b4d115754 --- /dev/null +++ b/src/legacy-handlers.ts @@ -0,0 +1,94 @@ +import type { BaseContext, RequestId } from "@modelcontextprotocol/client"; +import type { ZodLiteral, ZodObject, ZodType } from "zod/v4"; + +/** + * A whole-message Zod schema (`method` literal plus `params`), as accepted by + * the 1.x `setRequestHandler(Schema, handler)` forms. + */ +export type LegacyMethodSchema = ZodObject<{ + method: ZodLiteral; + params: ZodType; +}>; + +/** + * The `extra` argument 1.x request handlers received. + * + * @deprecated Use the 2.x `BaseContext` (`extra.mcpReq.signal`, `extra.mcpReq.id`). + */ +export type LegacyRequestHandlerExtra = { + signal: AbortSignal; + requestId: RequestId; + sessionId?: string; + _meta?: BaseContext["mcpReq"]["_meta"]; + sendRequest: BaseContext["mcpReq"]["send"]; + sendNotification: BaseContext["mcpReq"]["notify"]; + authInfo?: NonNullable["authInfo"]; +}; + +/** @deprecated Use `setRequestHandler("method", { params, result }, (params, ctx) => …)`. */ +export type LegacyRequestHandler = ( + request: S["_output"], + extra: LegacyRequestHandlerExtra, +) => unknown; + +/** @deprecated Use `setNotificationHandler("method", { params }, (params) => …)`. */ +export type LegacyNotificationHandler = ( + notification: S["_output"], +) => void | Promise; + +/** + * Arguments for the 2.x three-argument `setRequestHandler` / + * `setNotificationHandler` form. + */ +type ModernArgs = [ + method: string, + schemas: { params: ZodType }, + handler: (params: unknown, ctx: BaseContext) => unknown, +]; + +const warned = new Set(); + +/** + * Translate a 1.x `(Schema, handler)` registration into the 2.x + * `(method, { params }, handler)` form, or return `undefined` when the call + * is already in the 2.x form. The 1.x handler receives the reassembled + * `{ method, params }` message and, for requests, a 1.x-shaped `extra`. + */ +export function toModernArgs( + kind: "request" | "notification", + args: unknown[], +): ModernArgs | undefined { + const [first, handler] = args; + const shape = (first as Partial | undefined)?.shape; + if (typeof shape?.method?.value !== "string") return undefined; + const method = shape.method.value; + const setter = + kind === "request" ? "setRequestHandler" : "setNotificationHandler"; + if (!warned.has(setter)) { + warned.add(setter); + console.warn( + `[ext-apps] ${setter}(Schema, handler) is deprecated; use ` + + `${setter}("${method}", { params: Schema.shape.params }, handler).`, + ); + } + const call = handler as (...a: unknown[]) => unknown; + return [ + method, + { params: shape.params }, + kind === "request" + ? (params, ctx) => call({ method, params }, legacyExtra(ctx)) + : (params) => call({ method, params }), + ]; +} + +function legacyExtra(ctx: BaseContext): LegacyRequestHandlerExtra { + return { + signal: ctx.mcpReq.signal, + requestId: ctx.mcpReq.id, + sessionId: ctx.sessionId, + _meta: ctx.mcpReq._meta, + sendRequest: ctx.mcpReq.send, + sendNotification: ctx.mcpReq.notify, + authInfo: ctx.http?.authInfo, + }; +} diff --git a/src/types.ts b/src/types.ts index 655812bd3..e4ed629c4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,55 @@ * @see `generated/schema.test.ts` for compile-time verification */ +import type { + CallToolRequest, + CallToolResult, + CreateMessageRequest, + CreateMessageResult, + CreateMessageResultWithTools, + EmptyResult, + ListPromptsRequest, + ListPromptsResult, + ListResourceTemplatesRequest, + ListResourceTemplatesResult, + ListResourcesRequest, + ListResourcesResult, + ListToolsRequest, + ListToolsResult, + LoggingMessageNotification, + PingRequest, + PromptListChangedNotification, + ReadResourceRequest, + ReadResourceResult, + ResourceListChangedNotification, + ToolListChangedNotification, +} from "@modelcontextprotocol/client"; +import type { + McpUiDownloadFileRequest, + McpUiDownloadFileResult, + McpUiHostContextChangedNotification, + McpUiInitializeRequest, + McpUiInitializeResult, + McpUiInitializedNotification, + McpUiMessageRequest, + McpUiMessageResult, + McpUiOpenLinkRequest, + McpUiOpenLinkResult, + McpUiRequestDisplayModeRequest, + McpUiRequestDisplayModeResult, + McpUiRequestTeardownNotification, + McpUiResourceTeardownRequest, + McpUiResourceTeardownResult, + McpUiSandboxProxyReadyNotification, + McpUiSandboxResourceReadyNotification, + McpUiSizeChangedNotification, + McpUiToolCancelledNotification, + McpUiToolInputNotification, + McpUiToolInputPartialNotification, + McpUiToolResultNotification, + McpUiUpdateModelContextRequest, +} from "./spec.types.js"; + // Re-export all types from spec.types.ts export { LATEST_PROTOCOL_VERSION, @@ -108,3 +157,71 @@ export { McpUiToolVisibilitySchema, McpUiToolMetaSchema, } from "./generated/schema.js"; + +/** + * Union of every request an {@link app!App `App`} may send or receive. + * + * @deprecated 1.x fed this to the SDK's `Protocol` generics; SDK 2.x derives + * request types from the method name, so nothing consumes it anymore. + */ +export type AppRequest = + | McpUiInitializeRequest + | McpUiOpenLinkRequest + | McpUiDownloadFileRequest + | McpUiMessageRequest + | McpUiUpdateModelContextRequest + | McpUiResourceTeardownRequest + | McpUiRequestDisplayModeRequest + | CallToolRequest + | ListToolsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | ListPromptsRequest + | CreateMessageRequest + | PingRequest; + +/** + * Union of every notification an {@link app!App `App`} may send or receive. + * + * @deprecated See {@link AppRequest}. + */ +export type AppNotification = + // Sent to app + | McpUiHostContextChangedNotification + | McpUiToolInputNotification + | McpUiToolInputPartialNotification + | McpUiToolResultNotification + | McpUiToolCancelledNotification + | McpUiSandboxResourceReadyNotification + | ToolListChangedNotification + | ResourceListChangedNotification + | PromptListChangedNotification + // Received from app + | McpUiInitializedNotification + | McpUiSizeChangedNotification + | McpUiSandboxProxyReadyNotification + | McpUiRequestTeardownNotification + | LoggingMessageNotification; + +/** + * Union of every result an {@link app!App `App`} may send or receive. + * + * @deprecated See {@link AppRequest}. + */ +export type AppResult = + | McpUiInitializeResult + | McpUiOpenLinkResult + | McpUiDownloadFileResult + | McpUiMessageResult + | McpUiResourceTeardownResult + | McpUiRequestDisplayModeResult + | CallToolResult + | ListToolsResult + | ListResourcesResult + | ListResourceTemplatesResult + | ReadResourceResult + | ListPromptsResult + | CreateMessageResult + | CreateMessageResultWithTools + | EmptyResult; From 4eab52e372e064f4a58dfcb24040df9155564f7d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 8 Sep 2026 15:31:03 +0100 Subject: [PATCH 2/2] Export the legacy handler types from the root entry They appear in App's public setRequestHandler / setNotificationHandler signatures, so TypeDoc's link validation needs them reachable from the entry point. --- src/app.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app.ts b/src/app.ts index da27b439d..0ab51accd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -30,6 +30,12 @@ import { type LegacyNotificationHandler, type LegacyRequestHandler, } from "./legacy-handlers.js"; +export type { + LegacyMethodSchema, + LegacyNotificationHandler, + LegacyRequestHandler, + LegacyRequestHandlerExtra, +} from "./legacy-handlers.js"; export { EventDispatcher } from "./events.js"; import { PostMessageTransport } from "./message-transport.js";