Keep the 1.x handler registration forms as deprecated overloads (#769)

setRequestHandler(Schema, handler) and setNotificationHandler(Schema, handler) work on App and AppBridge as deprecated overloads forwarding to the 2.x method-keyed form; AppRequest, AppNotification and AppResult return as deprecated type aliases. Both removed in 3.0.
This commit is contained in:
Olivier Chafik 2026-09-08 16:17:02 +01:00 • committed by GitHub
commit 218aef167e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 426 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,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<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 +350,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;