Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/migrate-to-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand Down
42 changes: 28 additions & 14 deletions src/app-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down
48 changes: 34 additions & 14 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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) => {
Expand Down
145 changes: 145 additions & 0 deletions src/legacy-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 94 additions & 0 deletions src/legacy-handlers.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading