fix: restore double-registration guard on App and AppBridge handlers

The base SDK Protocol silently replaces an existing request or
notification handler. v1's ProtocolWithEvents threw instead, so a stray
setRequestHandler / setNotificationHandler could not disconnect on*
handlers or addEventListener listeners. Restore that guard on both App
and AppBridge with a small MethodRegistry, keep replace semantics for the
on* setters via a protected replaceRequestHandler, and release methods
on removeRequestHandler / removeNotificationHandler.

Also:
- give oncreatesamplingmessage a getter, a replace warning and the same
  clearable shape as the other request setters
- let App register handlers for custom (non-spec) methods; the default
  branch of assertRequestHandlerCapability threw a misleading
  "No handler for method X registered" error
- move the RESOURCE_URI_META_KEY / RESOURCE_MIME_TYPE docs to
  src/constants.ts where the constants now live
- fix stale "inherited from Server" wording and point @see links at
  @modelcontextprotocol/client, which is what hosts install
This commit is contained in:
Olivier Chafik 2026-09-07 15:21:49 +01:00
commit cfdc0c725f
6 changed files with 510 additions and 127 deletions

View file

@ -3016,16 +3016,95 @@ describe("isToolVisibilityAppOnly", () => {
expect(app.onteardown).toBe(handler);
});
it("direct setRequestHandler uses base SDK replacement semantics", () => {
it("direct setRequestHandler throws when called twice", () => {
const bridge2 = new AppBridge(
createMockClient() as Client,
testHostInfo,
testHostCapabilities,
);
bridge2.setRequestHandler("ping", () => ({}));
const params = z.object({});
bridge2.setRequestHandler("test/method", { params }, () => ({}));
expect(() => {
bridge2.setRequestHandler("ping", () => ({}));
bridge2.setRequestHandler("test/method", { params }, () => ({}));
}).toThrow(/already registered/);
});
it("direct setRequestHandler cannot silently replace an on* host handler", () => {
const bridge2 = new AppBridge(
createMockClient() as Client,
testHostInfo,
testHostCapabilities,
);
bridge2.onopenlink = async () => ({});
expect(() => {
bridge2.setRequestHandler(
"ui/open-link",
{ params: z.object({}) },
() => ({}),
);
}).toThrow(/already registered/);
});
it("direct setNotificationHandler throws for event-mapped methods", () => {
const bridge2 = new AppBridge(
createMockClient() as Client,
testHostInfo,
testHostCapabilities,
);
bridge2.onsizechange = () => {};
expect(() => {
bridge2.setNotificationHandler(
"ui/notifications/size-changed",
{ params: z.object({}) },
() => {},
);
}).toThrow(/already registered/);
});
it("removeRequestHandler releases the method so an on* setter can re-register", () => {
const bridge2 = new AppBridge(
createMockClient() as Client,
testHostInfo,
testHostCapabilities,
);
bridge2.removeRequestHandler("tools/call");
expect(() => {
bridge2.setRequestHandler("tools/call", async () => ({ content: [] }));
}).not.toThrow();
expect(() => {
bridge2.oncalltool = async () => ({ content: [] });
}).not.toThrow();
});
it("oncreatesamplingmessage has a getter, replace semantics, and a replace warning", () => {
const bridge2 = new AppBridge(
createMockClient() as Client,
testHostInfo,
testHostCapabilities,
);
expect(bridge2.oncreatesamplingmessage).toBeUndefined();
const first = async () => ({
role: "assistant" as const,
content: { type: "text" as const, text: "" },
model: "m",
});
bridge2.oncreatesamplingmessage = first;
expect(bridge2.oncreatesamplingmessage).toBe(first);
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
expect(() => {
bridge2.oncreatesamplingmessage = first;
}).not.toThrow();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("oncreatesamplingmessage handler replaced"),
);
} finally {
warn.mockRestore();
}
bridge2.oncreatesamplingmessage = undefined;
expect(bridge2.oncreatesamplingmessage).toBeUndefined();
});
});
});

View file

@ -34,7 +34,7 @@ import {
EmptyResultSchema,
LoggingMessageNotificationSchema,
} from "@modelcontextprotocol/core";
import { EventDispatcher } from "./events.js";
import { EventDispatcher, MethodRegistry } from "./events.js";
import type { ZodLiteral, ZodObject, ZodType } from "zod/v4";
type MethodSchema = ZodObject<{
@ -42,6 +42,8 @@ type MethodSchema = ZodObject<{
params: ZodType;
}>;
type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void;
import {
type McpUiSandboxResourceReadyNotification,
type McpUiSizeChangedNotification,
@ -216,8 +218,8 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
/**
* Extra metadata passed to request handlers.
*
* This type represents the additional context provided by the base MCP SDK
* `Server` when handling requests.
* This type represents the additional context (`BaseContext`) provided by the
* base MCP SDK `Protocol` when handling requests.
*
* @internal
*/
@ -302,6 +304,81 @@ export class AppBridge extends Protocol<BaseContext> {
private _initializedReceived = false;
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
private readonly _events = new EventDispatcher<AppBridgeEventMap>();
private readonly _methods = new MethodRegistry();
// ── Handler registration with double-set protection ─────────────────
//
// The base SDK `Protocol` silently replaces an existing handler. The four
// overrides below restore the v1 behaviour: a direct `setRequestHandler` /
// `setNotificationHandler` for a method that already has a handler throws,
// so a stray registration cannot silently replace a host's `on*` handler
// (for example the URL allow-listing in `onopenlink`) or disconnect
// `addEventListener` listeners. They are arrow-function class fields rather
// than prototype methods so that `Protocol`'s constructor — which registers
// its own ping/cancelled/progress handlers before our fields initialize —
// hits the base implementation and skips tracking.
/**
* Registers a request handler. Throws if a handler for the same method has
* already been registered — use the `on*` setter for replace semantics.
*
* @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");
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
this,
method,
...rest,
);
};
/**
* Registers a notification handler. Throws if a handler for the same method
* has already been registered — use the `on*` setter (replace semantics) or
* `addEventListener` (multi-listener) for mapped events.
*
* @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 removeRequestHandler: Protocol<BaseContext>["removeRequestHandler"] =
(method: string) => {
this._methods.release(method);
super.removeRequestHandler(method);
};
override removeNotificationHandler: Protocol<BaseContext>["removeNotificationHandler"] =
(method: string) => {
this._methods.release(method);
super.removeNotificationHandler(method);
};
/**
* Register a request handler with replace semantics, bypassing the
* double-set protection of {@link setRequestHandler `setRequestHandler`}.
* Used by the `on*` request-handler setters.
*/
protected replaceRequestHandler: Protocol<BaseContext>["setRequestHandler"] =
(method: string, ...rest: unknown[]) => {
this._methods.replace(method);
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
this,
method,
...rest,
);
};
/**
* The base MCP SDK calls this hook for every standard and custom request
@ -419,7 +496,7 @@ export class AppBridge extends Protocol<BaseContext> {
* manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters.
* @param _hostInfo - Host application identification (name and version)
* @param _hostCapabilities - Features and capabilities the host supports
* @param options - Configuration options (inherited from Server)
* @param options - Configuration options (`ProtocolOptions` from the base MCP SDK plus `hostContext`)
*
* @example With MCP client (automatic forwarding)
* ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient"
@ -455,7 +532,7 @@ export class AppBridge extends Protocol<BaseContext> {
this._hostContext = options?.hostContext || {};
this.setRequestHandler(
this.replaceRequestHandler(
"ui/initialize",
{
params: McpUiInitializeRequestSchema.shape.params,
@ -464,14 +541,14 @@ export class AppBridge extends Protocol<BaseContext> {
(params) => this._onAppsInitialize(params),
);
this.setRequestHandler("ping", (request, extra) => {
this.replaceRequestHandler("ping", (request, extra) => {
this.onping?.(request.params, extra);
return {};
});
// Default handler for requestDisplayMode - returns current mode from host context.
// Hosts can override this by setting bridge.onrequestdisplaymode = ...
this.setRequestHandler(
this.replaceRequestHandler(
"ui/request-display-mode",
{
params: McpUiRequestDisplayModeRequestSchema.shape.params,
@ -746,7 +823,7 @@ export class AppBridge extends Protocol<BaseContext> {
) {
this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback);
this._onmessage = callback;
this.setRequestHandler(
this.replaceRequestHandler(
"ui/message",
{
params: McpUiMessageRequestSchema.shape.params,
@ -819,7 +896,7 @@ export class AppBridge extends Protocol<BaseContext> {
) {
this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback);
this._onopenlink = callback;
this.setRequestHandler(
this.replaceRequestHandler(
"ui/open-link",
{
params: McpUiOpenLinkRequestSchema.shape.params,
@ -895,7 +972,7 @@ export class AppBridge extends Protocol<BaseContext> {
callback,
);
this._ondownloadfile = callback;
this.setRequestHandler(
this.replaceRequestHandler(
"ui/download-file",
{
params: McpUiDownloadFileRequestSchema.shape.params,
@ -999,7 +1076,7 @@ export class AppBridge extends Protocol<BaseContext> {
callback,
);
this._onrequestdisplaymode = callback;
this.setRequestHandler(
this.replaceRequestHandler(
"ui/request-display-mode",
{
params: McpUiRequestDisplayModeRequestSchema.shape.params,
@ -1100,7 +1177,7 @@ export class AppBridge extends Protocol<BaseContext> {
callback,
);
this._onupdatemodelcontext = callback;
this.setRequestHandler(
this.replaceRequestHandler(
"ui/update-model-context",
{
params: McpUiUpdateModelContextRequestSchema.shape.params,
@ -1136,8 +1213,8 @@ export class AppBridge extends Protocol<BaseContext> {
* };
* ```
*
* @see `CallToolRequest` from @modelcontextprotocol/server for the request type
* @see `CallToolResult` from @modelcontextprotocol/server for the result type
* @see `CallToolRequest` from @modelcontextprotocol/client forthe request type
* @see `CallToolResult` from @modelcontextprotocol/client forthe result type
*/
private _oncalltool?: (
params: CallToolRequest["params"],
@ -1156,7 +1233,7 @@ export class AppBridge extends Protocol<BaseContext> {
) {
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
this._oncalltool = callback;
this.setRequestHandler("tools/call", async (request, extra) => {
this.replaceRequestHandler("tools/call", async (request, extra) => {
if (!this._oncalltool) throw new Error("No oncalltool handler set");
return this._oncalltool(request.params, extra);
});
@ -1190,18 +1267,39 @@ export class AppBridge extends Protocol<BaseContext> {
* };
* ```
*
* @see `CreateMessageRequest` from @modelcontextprotocol/server for the request type
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/server for result types
* @see `CreateMessageRequest` from @modelcontextprotocol/client forthe request type
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/client forresult types
*/
private _oncreatesamplingmessage?: (
params: CreateMessageRequest["params"],
extra: RequestHandlerExtra,
) => Promise<CreateMessageResult | CreateMessageResultWithTools>;
get oncreatesamplingmessage() {
return this._oncreatesamplingmessage;
}
set oncreatesamplingmessage(
callback: (
params: CreateMessageRequest["params"],
extra: RequestHandlerExtra,
) => Promise<CreateMessageResult | CreateMessageResultWithTools>,
callback:
| ((
params: CreateMessageRequest["params"],
extra: RequestHandlerExtra,
) => Promise<CreateMessageResult | CreateMessageResultWithTools>)
| undefined,
) {
this.setRequestHandler("sampling/createMessage", async (request, extra) => {
return callback(request.params, extra);
});
this.warnIfRequestHandlerReplaced(
"oncreatesamplingmessage",
this._oncreatesamplingmessage,
callback,
);
this._oncreatesamplingmessage = callback;
this.replaceRequestHandler(
"sampling/createMessage",
async (request, extra) => {
if (!this._oncreatesamplingmessage) {
throw new Error("No oncreatesamplingmessage handler set");
}
return this._oncreatesamplingmessage(request.params, extra);
},
);
}
/**
@ -1221,7 +1319,7 @@ export class AppBridge extends Protocol<BaseContext> {
* });
* ```
*
* @see `ToolListChangedNotification` from @modelcontextprotocol/server for the notification type
* @see `ToolListChangedNotification` from @modelcontextprotocol/client forthe notification type
*/
sendToolListChanged(params: ToolListChangedNotification["params"] = {}) {
return this.notification({
@ -1252,8 +1350,8 @@ export class AppBridge extends Protocol<BaseContext> {
* };
* ```
*
* @see `ListResourcesRequest` from @modelcontextprotocol/server for the request type
* @see `ListResourcesResult` from @modelcontextprotocol/server for the result type
* @see `ListResourcesRequest` from @modelcontextprotocol/client forthe request type
* @see `ListResourcesResult` from @modelcontextprotocol/client forthe result type
*/
private _onlistresources?: (
params: ListResourcesRequest["params"],
@ -1276,7 +1374,7 @@ export class AppBridge extends Protocol<BaseContext> {
callback,
);
this._onlistresources = callback;
this.setRequestHandler("resources/list", async (request, extra) => {
this.replaceRequestHandler("resources/list", async (request, extra) => {
if (!this._onlistresources)
throw new Error("No onlistresources handler set");
return this._onlistresources(request.params, extra);
@ -1305,8 +1403,8 @@ export class AppBridge extends Protocol<BaseContext> {
* };
* ```
*
* @see `ListResourceTemplatesRequest` from @modelcontextprotocol/server for the request type
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/server for the result type
* @see `ListResourceTemplatesRequest` from @modelcontextprotocol/client forthe request type
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/client forthe result type
*/
private _onlistresourcetemplates?: (
params: ListResourceTemplatesRequest["params"],
@ -1329,7 +1427,7 @@ export class AppBridge extends Protocol<BaseContext> {
callback,
);
this._onlistresourcetemplates = callback;
this.setRequestHandler(
this.replaceRequestHandler(
"resources/templates/list",
async (request, extra) => {
if (!this._onlistresourcetemplates)
@ -1361,8 +1459,8 @@ export class AppBridge extends Protocol<BaseContext> {
* };
* ```
*
* @see `ReadResourceRequest` from @modelcontextprotocol/server for the request type
* @see `ReadResourceResult` from @modelcontextprotocol/server for the result type
* @see `ReadResourceRequest` from @modelcontextprotocol/client forthe request type
* @see `ReadResourceResult` from @modelcontextprotocol/client forthe result type
*/
private _onreadresource?: (
params: ReadResourceRequest["params"],
@ -1385,7 +1483,7 @@ export class AppBridge extends Protocol<BaseContext> {
callback,
);
this._onreadresource = callback;
this.setRequestHandler("resources/read", async (request, extra) => {
this.replaceRequestHandler("resources/read", async (request, extra) => {
if (!this._onreadresource)
throw new Error("No onreadresource handler set");
return this._onreadresource(request.params, extra);
@ -1409,7 +1507,7 @@ export class AppBridge extends Protocol<BaseContext> {
* });
* ```
*
* @see `ResourceListChangedNotification` from @modelcontextprotocol/server for the notification type
* @see `ResourceListChangedNotification` from @modelcontextprotocol/client forthe notification type
*/
sendResourceListChanged(
params: ResourceListChangedNotification["params"] = {},
@ -1442,8 +1540,8 @@ export class AppBridge extends Protocol<BaseContext> {
* };
* ```
*
* @see `ListPromptsRequest` from @modelcontextprotocol/server for the request type
* @see `ListPromptsResult` from @modelcontextprotocol/server for the result type
* @see `ListPromptsRequest` from @modelcontextprotocol/client forthe request type
* @see `ListPromptsResult` from @modelcontextprotocol/client forthe result type
*/
private _onlistprompts?: (
params: ListPromptsRequest["params"],
@ -1466,7 +1564,7 @@ export class AppBridge extends Protocol<BaseContext> {
callback,
);
this._onlistprompts = callback;
this.setRequestHandler("prompts/list", async (request, extra) => {
this.replaceRequestHandler("prompts/list", async (request, extra) => {
if (!this._onlistprompts) throw new Error("No onlistprompts handler set");
return this._onlistprompts(request.params, extra);
});
@ -1489,7 +1587,7 @@ export class AppBridge extends Protocol<BaseContext> {
* });
* ```
*
* @see `PromptListChangedNotification` from @modelcontextprotocol/server for the notification type
* @see `PromptListChangedNotification` from @modelcontextprotocol/client forthe notification type
*/
sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) {
return this.notification({

View file

@ -248,25 +248,62 @@ describe("App base MCP SDK v2 Protocol migration", () => {
}).toThrow(/handler registered after connect/);
});
it("uses base MCP SDK replacement semantics for request handlers", async () => {
it("direct setRequestHandler throws when a handler is already registered", async () => {
const pair = await connectPair();
connected.push(pair);
const params = z.object({});
let handledBy = 0;
pair.app.setRequestHandler("ping", () => {
pair.app.setRequestHandler("test/method", { params }, () => {
handledBy = 1;
return {};
});
pair.app.setRequestHandler("ping", () => {
expect(() => {
pair.app.setRequestHandler("test/method", { params }, () => {
handledBy = 2;
return {};
});
}).toThrow(/already registered/);
await pair.server.request(
{ method: "test/method", params: {} },
z.object({}),
);
expect(handledBy).toBe(1);
});
it("direct setRequestHandler throws for methods owned by the App itself", async () => {
const pair = await connectPair();
connected.push(pair);
expect(() => {
pair.app.setRequestHandler("ping", () => ({}));
}).toThrow(/already registered/);
});
it("removeRequestHandler releases the method for re-registration", async () => {
const pair = await connectPair();
connected.push(pair);
const params = z.object({});
let handledBy = 0;
pair.app.setRequestHandler("test/method", { params }, () => {
handledBy = 1;
return {};
});
pair.app.removeRequestHandler("test/method");
pair.app.setRequestHandler("test/method", { params }, () => {
handledBy = 2;
return {};
});
await pair.server.request({ method: "ping", params: {} });
await pair.server.request(
{ method: "test/method", params: {} },
z.object({}),
);
expect(handledBy).toBe(2);
});
it("uses base MCP SDK replacement semantics for non-event notifications", async () => {
it("direct setNotificationHandler throws when a handler is already registered", async () => {
const pair = await connectPair();
connected.push(pair);
const calls: number[] = [];
@ -275,14 +312,70 @@ describe("App base MCP SDK v2 Protocol migration", () => {
pair.app.setNotificationHandler("test/notification", { params }, () => {
calls.push(1);
});
pair.app.setNotificationHandler("test/notification", { params }, () => {
calls.push(2);
});
expect(() => {
pair.app.setNotificationHandler("test/notification", { params }, () => {
calls.push(2);
});
}).toThrow(/already registered/);
await pair.server.notification({
method: "test/notification",
params: {},
});
expect(calls).toEqual([2]);
expect(calls).toEqual([1]);
});
it("direct setNotificationHandler throws for event-mapped methods (listener first)", () => {
const app = new App(
{ name: "view-app", version: "1.0.0" },
{},
{ autoResize: false },
);
app.addEventListener("toolinput", () => {});
expect(() => {
app.setNotificationHandler(
"ui/notifications/tool-input",
{ params: z.object({}) },
() => {},
);
}).toThrow(/already registered/);
});
it("event registration throws when a direct handler already owns the method", () => {
const app = new App(
{ name: "view-app", version: "1.0.0" },
{},
{ autoResize: false },
);
app.setNotificationHandler(
"ui/notifications/tool-input",
{ params: z.object({}) },
() => {},
);
expect(() => {
app.ontoolinput = () => {};
}).toThrow(/already registered/);
expect(() => {
app.addEventListener("toolinput", () => {});
}).toThrow(/already registered/);
});
it("on* request setters keep replace semantics", async () => {
const pair = await connectPair();
connected.push(pair);
let handledBy = 0;
pair.app.onteardown = async () => {
handledBy = 1;
return {};
};
pair.app.onteardown = async () => {
handledBy = 2;
return {};
};
await pair.server.request(
{ method: "ui/resource-teardown", params: {} },
z.object({}),
);
expect(handledBy).toBe(2);
});
});

View file

@ -23,8 +23,10 @@ import {
} from "@modelcontextprotocol/client";
import { EmptyResultSchema } from "@modelcontextprotocol/core";
export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js";
import { EventDispatcher } from "./events.js";
import { EventDispatcher, MethodRegistry } from "./events.js";
export { EventDispatcher } from "./events.js";
type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void;
import { PostMessageTransport } from "./message-transport.js";
import {
LATEST_PROTOCOL_VERSION,
@ -103,71 +105,6 @@ export {
applyDocumentTheme,
} from "./styles.js";
/**
* Metadata key for associating a UI resource URI with a tool.
*
* MCP servers include this key in tool definition metadata (via `tools/list`)
* to indicate which UI resource should be displayed when the tool is called.
* When hosts see a tool with this metadata, they fetch and render the
* corresponding {@link App `App`}.
*
* **Note**: This constant is provided for reference and backwards compatibility.
* Server developers should use {@link server-helpers!registerAppTool `registerAppTool`}
* with the `_meta.ui.resourceUri` format instead. Host developers must check both
* formats for compatibility.
*
* @example Modern format (server-side, not in Apps)
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_modernFormat"
* // Preferred: Use registerAppTool with nested ui.resourceUri
* registerAppTool(
* server,
* "weather",
* {
* description: "Get weather forecast",
* _meta: {
* ui: { resourceUri: "ui://weather/forecast" },
* },
* },
* handler,
* );
* ```
*
* @example Legacy format (deprecated, for backwards compatibility)
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_legacyFormat"
* // Deprecated: Direct use of RESOURCE_URI_META_KEY
* server.registerTool(
* "weather",
* {
* description: "Get weather forecast",
* _meta: {
* [RESOURCE_URI_META_KEY]: "ui://weather/forecast",
* },
* },
* handler,
* );
* ```
*
* @example How hosts check for this metadata (must support both formats)
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide"
* // Hosts should check both modern and legacy formats
* const meta = tool._meta;
* const uiMeta = meta?.ui as McpUiToolMeta | undefined;
* const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined;
* const uiUri = uiMeta?.resourceUri ?? legacyUri;
* if (typeof uiUri === "string" && uiUri.startsWith("ui://")) {
* // Fetch the resource and display the UI
* }
* ```
*/
/**
* MIME type for MCP UI resources.
*
* Identifies HTML content as an MCP App UI resource.
*
* Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources.
*/
/**
* Options for configuring {@link App `App`} behavior.
*
@ -359,6 +296,80 @@ export class App extends Protocol<BaseContext> {
private _initializedSent = false;
private readonly _registeredEvents = new Set<keyof AppEventMap>();
private readonly _events = new EventDispatcher<AppEventMap>();
private readonly _methods = new MethodRegistry();
// ── Handler registration with double-set protection ─────────────────
//
// The base SDK `Protocol` silently replaces an existing handler. The four
// overrides below restore the v1 behaviour: a direct `setRequestHandler` /
// `setNotificationHandler` for a method that already has a handler throws,
// so a stray registration cannot silently disconnect `on*` handlers or
// `addEventListener` listeners. They are arrow-function class fields rather
// than prototype methods so that `Protocol`'s constructor — which registers
// its own ping/cancelled/progress handlers before our fields initialize —
// hits the base implementation and skips tracking.
/**
* Registers a request handler. Throws if a handler for the same method has
* already been registered — use the `on*` setter for replace semantics.
*
* @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");
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
this,
method,
...rest,
);
};
/**
* Registers a notification handler. Throws if a handler for the same method
* has already been registered — use the `on*` setter (replace semantics) or
* `addEventListener` (multi-listener) for mapped events.
*
* @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 removeRequestHandler: Protocol<BaseContext>["removeRequestHandler"] =
(method: string) => {
this._methods.release(method);
super.removeRequestHandler(method);
};
override removeNotificationHandler: Protocol<BaseContext>["removeNotificationHandler"] =
(method: string) => {
this._methods.release(method);
super.removeNotificationHandler(method);
};
/**
* Register a request handler with replace semantics, bypassing the
* double-set protection of {@link setRequestHandler `setRequestHandler`}.
* Used by the `on*` request-handler setters.
*/
protected replaceRequestHandler: Protocol<BaseContext>["setRequestHandler"] =
(method: string, ...rest: unknown[]) => {
this._methods.replace(method);
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
this,
method,
...rest,
);
};
/**
* Warn if a host-bound method is called before {@link connect `connect`} has
@ -524,7 +535,7 @@ export class App extends Protocol<BaseContext> {
z.config({ jitless: true });
}
this.setRequestHandler("ping", (request) => {
this.replaceRequestHandler("ping", (request) => {
console.log("Received ping:", request.params);
return {};
});
@ -1056,7 +1067,7 @@ export class App extends Protocol<BaseContext> {
) {
this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback);
this._onteardown = callback;
this.setRequestHandler(
this.replaceRequestHandler(
"ui/resource-teardown",
{
params: McpUiResourceTeardownRequestSchema.shape.params,
@ -1114,7 +1125,7 @@ export class App extends Protocol<BaseContext> {
) {
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
this._oncalltool = callback;
this.setRequestHandler("tools/call", (request, extra) => {
this.replaceRequestHandler("tools/call", (request, extra) => {
if (!this._oncalltool) throw new Error("No oncalltool handler set");
return this._oncalltool(request.params, extra);
});
@ -1185,7 +1196,7 @@ export class App extends Protocol<BaseContext> {
callback,
);
this._onlisttools = callback;
this.setRequestHandler("tools/list", (request, extra) => {
this.replaceRequestHandler("tools/list", (request, extra) => {
if (!this._onlisttools) throw new Error("No onlisttools handler set");
return this._onlisttools(request.params, extra);
});
@ -1221,11 +1232,11 @@ export class App extends Protocol<BaseContext> {
);
}
return;
case "ping":
case "ui/resource-teardown":
return;
default:
throw new Error(`No handler for method ${method} registered`);
// `ping`, `ui/*`, and custom (vendor-prefixed) methods need no
// declared capability. The base SDK explicitly supports custom
// request handlers via the `{ params, result }` form.
return;
}
}

View file

@ -1,5 +1,66 @@
/** Legacy metadata key associating an MCP tool with its App resource. */
/**
* Metadata key for associating a UI resource URI with a tool.
*
* MCP servers include this key in tool definition metadata (via `tools/list`)
* to indicate which UI resource should be displayed when the tool is called.
* When hosts see a tool with this metadata, they fetch and render the
* corresponding {@link app!App `App`}.
*
* **Note**: This constant is provided for reference and backwards compatibility.
* Server developers should use {@link server-helpers!registerAppTool `registerAppTool`}
* with the `_meta.ui.resourceUri` format instead. Host developers must check both
* formats for compatibility.
*
* @example Modern format (server-side, not in Apps)
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_modernFormat"
* // Preferred: Use registerAppTool with nested ui.resourceUri
* registerAppTool(
* server,
* "weather",
* {
* description: "Get weather forecast",
* _meta: {
* ui: { resourceUri: "ui://weather/forecast" },
* },
* },
* handler,
* );
* ```
*
* @example Legacy format (deprecated, for backwards compatibility)
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_legacyFormat"
* // Deprecated: Direct use of RESOURCE_URI_META_KEY
* server.registerTool(
* "weather",
* {
* description: "Get weather forecast",
* _meta: {
* [RESOURCE_URI_META_KEY]: "ui://weather/forecast",
* },
* },
* handler,
* );
* ```
*
* @example How hosts check for this metadata (must support both formats)
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide"
* // Hosts should check both modern and legacy formats
* const meta = tool._meta;
* const uiMeta = meta?.ui as McpUiToolMeta | undefined;
* const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined;
* const uiUri = uiMeta?.resourceUri ?? legacyUri;
* if (typeof uiUri === "string" && uiUri.startsWith("ui://")) {
* // Fetch the resource and display the UI
* }
* ```
*/
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
/** MIME type for MCP App HTML resources. */
/**
* MIME type for MCP UI resources.
*
* Identifies HTML content as an MCP App UI resource.
*
* Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources.
*/
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";

View file

@ -89,3 +89,44 @@ export class EventDispatcher<EventMap extends Record<string, unknown>> {
return slot;
}
}
/**
* Tracks which JSON-RPC methods already have a handler registered through the
* owning `App` / `AppBridge`, so a second direct `setRequestHandler` /
* `setNotificationHandler` call for the same method throws instead of
* silently replacing the first handler (the base SDK `Protocol` replaces).
*
* The `on*` setters use {@link replace} for DOM-style replace semantics, and
* `removeRequestHandler` / `removeNotificationHandler` use {@link release}.
*
* @internal
*/
export class MethodRegistry {
private readonly _methods = new Set<string>();
/** Claim `method`. Throws if a handler is already registered for it. */
claim(method: string, via: string): void {
if (this._methods.has(method)) {
throw new Error(
`Handler for "${method}" already registered (via ${via}). ` +
`Use addEventListener() to attach multiple listeners, ` +
`or the on* setter for replace semantics.`,
);
}
this._methods.add(method);
}
/** Claim `method` with replace semantics (never throws). */
replace(method: string): void {
this._methods.add(method);
}
/** Release `method` so it can be claimed again. */
release(method: string): void {
this._methods.delete(method);
}
has(method: string): boolean {
return this._methods.has(method);
}
}