diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 397e12f..ea85320 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -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(); }); }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 8e48677..04ccb4f 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -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 { private _initializedReceived = false; private readonly _registeredEvents = new Set(); private readonly _events = new EventDispatcher(); + 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["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["setNotificationHandler"] = + (method: string, ...rest: unknown[]) => { + this._methods.claim(method, "setNotificationHandler"); + (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; + + override removeRequestHandler: Protocol["removeRequestHandler"] = + (method: string) => { + this._methods.release(method); + super.removeRequestHandler(method); + }; + + override removeNotificationHandler: Protocol["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["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 { * 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 { this._hostContext = options?.hostContext || {}; - this.setRequestHandler( + this.replaceRequestHandler( "ui/initialize", { params: McpUiInitializeRequestSchema.shape.params, @@ -464,14 +541,14 @@ export class AppBridge extends Protocol { (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 { ) { 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 { ) { 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 { callback, ); this._ondownloadfile = callback; - this.setRequestHandler( + this.replaceRequestHandler( "ui/download-file", { params: McpUiDownloadFileRequestSchema.shape.params, @@ -999,7 +1076,7 @@ export class AppBridge extends Protocol { 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 { 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 { * }; * ``` * - * @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 { ) { 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 { * }; * ``` * - * @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; + get oncreatesamplingmessage() { + return this._oncreatesamplingmessage; + } set oncreatesamplingmessage( - callback: ( - params: CreateMessageRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: CreateMessageRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | 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 { * }); * ``` * - * @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 { * }; * ``` * - * @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 { 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 { * }; * ``` * - * @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 { 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 { * }; * ``` * - * @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 { 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 { * }); * ``` * - * @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 { * }; * ``` * - * @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 { 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 { * }); * ``` * - * @see `PromptListChangedNotification` from @modelcontextprotocol/server for the notification type + * @see `PromptListChangedNotification` from @modelcontextprotocol/client forthe notification type */ sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) { return this.notification({ diff --git a/src/app.test.ts b/src/app.test.ts index 9057710..35a924f 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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); }); }); diff --git a/src/app.ts b/src/app.ts index 3fe84d6..fd6bbc7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 { private _initializedSent = false; private readonly _registeredEvents = new Set(); private readonly _events = new EventDispatcher(); + 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["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["setNotificationHandler"] = + (method: string, ...rest: unknown[]) => { + this._methods.claim(method, "setNotificationHandler"); + (super.setNotificationHandler as unknown as UntypedHandlerSetter).call( + this, + method, + ...rest, + ); + }; + + override removeRequestHandler: Protocol["removeRequestHandler"] = + (method: string) => { + this._methods.release(method); + super.removeRequestHandler(method); + }; + + override removeNotificationHandler: Protocol["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["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 { 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 { ) { 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 { ) { 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 { 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 { ); } 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; } } diff --git a/src/constants.ts b/src/constants.ts index f69cfa5..c879a03 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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"; diff --git a/src/events.ts b/src/events.ts index aff1446..aa2e862 100644 --- a/src/events.ts +++ b/src/events.ts @@ -89,3 +89,44 @@ export class EventDispatcher> { 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(); + + /** 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); + } +}