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.
This commit is contained in:
Olivier Chafik 2026-09-08 14:24:25 +01:00
commit 728b0bf38e
6 changed files with 420 additions and 31 deletions

View file

@ -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

View file

@ -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<BaseContext> {
*
* @throws {Error} if a handler for this method is already registered.
*/
override setRequestHandler: Protocol<BaseContext>["setRequestHandler"] = (
method: string,
...rest: unknown[]
) => {
this._methods.claim(method, "setRequestHandler");
override setRequestHandler: Protocol<BaseContext>["setRequestHandler"] &
(<S extends LegacyMethodSchema>(
/** @deprecated Pass the method name and `{ params }` instead. */
schema: S,
handler: LegacyRequestHandler<S>,
) => 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<BaseContext> {
*
* @throws {Error} if a handler for this method is already registered.
*/
override setNotificationHandler: Protocol<BaseContext>["setNotificationHandler"] =
(method: string, ...rest: unknown[]) => {
this._methods.claim(method, "setNotificationHandler");
(super.setNotificationHandler as unknown as UntypedHandlerSetter).call(
this,
method,
...rest,
);
};
override setNotificationHandler: Protocol<BaseContext>["setNotificationHandler"] &
(<S extends LegacyMethodSchema>(
/** @deprecated Pass the method name and `{ params }` instead. */
schema: S,
handler: LegacyNotificationHandler<S>,
) => 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<BaseContext>["removeRequestHandler"] =
(method: string) => {

View file

@ -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<BaseContext> {
*
* @throws {Error} if a handler for this method is already registered.
*/
override setRequestHandler: Protocol<BaseContext>["setRequestHandler"] = (
method: string,
...rest: unknown[]
) => {
this._methods.claim(method, "setRequestHandler");
override setRequestHandler: Protocol<BaseContext>["setRequestHandler"] &
(<S extends LegacyMethodSchema>(
/** @deprecated Pass the method name and `{ params }` instead. */
schema: S,
handler: LegacyRequestHandler<S>,
) => 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<BaseContext> {
*
* @throws {Error} if a handler for this method is already registered.
*/
override setNotificationHandler: Protocol<BaseContext>["setNotificationHandler"] =
(method: string, ...rest: unknown[]) => {
this._methods.claim(method, "setNotificationHandler");
(super.setNotificationHandler as unknown as UntypedHandlerSetter).call(
this,
method,
...rest,
);
};
override setNotificationHandler: Protocol<BaseContext>["setNotificationHandler"] &
(<S extends LegacyMethodSchema>(
/** @deprecated Pass the method name and `{ params }` instead. */
schema: S,
handler: LegacyNotificationHandler<S>,
) => 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<BaseContext>["removeRequestHandler"] =
(method: string) => {

145
src/legacy-handlers.test.ts Normal file
View file

@ -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<typeof spyOn>;
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/);
});
});

94
src/legacy-handlers.ts Normal file
View file

@ -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<string>;
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<BaseContext["http"]>["authInfo"];
};
/** @deprecated Use `setRequestHandler("method", { params, result }, (params, ctx) => …)`. */
export type LegacyRequestHandler<S extends LegacyMethodSchema> = (
request: S["_output"],
extra: LegacyRequestHandlerExtra,
) => unknown;
/** @deprecated Use `setNotificationHandler("method", { params }, (params) => …)`. */
export type LegacyNotificationHandler<S extends LegacyMethodSchema> = (
notification: S["_output"],
) => void | Promise<void>;
/**
* 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<string>();
/**
* 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<LegacyMethodSchema> | 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,
};
}

View file

@ -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;