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..0ab51accd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,18 @@ 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 type { + LegacyMethodSchema, + LegacyNotificationHandler, + LegacyRequestHandler, + LegacyRequestHandlerExtra, +} from "./legacy-handlers.js"; export { EventDispatcher } from "./events.js"; import { PostMessageTransport } from "./message-transport.js"; @@ -316,11 +328,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 +350,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;