From 618d252de060a6d9e23ce81b06ee4c64b18823a3 Mon Sep 17 00:00:00 2001 From: ochafik Date: Sun, 29 Mar 2026 03:44:21 +0100 Subject: [PATCH 1/2] feat(events): DOM-model on* semantics with addEventListener/removeEventListener Fixes #551 (useHostStyles broken: fonts clobber theme/CSS variables) Fixes #225 (user onhostcontextchanged and SDK hooks mutually exclusive) The on* setters now follow the DOM event model: - Replace semantics (like el.onclick) with getters - Coexist with addEventListener/removeEventListener listeners - Both channels fire on dispatch: on* handler then listeners Introduces ProtocolWithEvents base class between Protocol and App/AppBridge. Each notification event gets a slot with two independent channels (singular on* handler + addEventListener listener array). useHostStyles hooks now use addEventListener with proper cleanup, so they compose correctly with user on* handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app-bridge.test.ts | 176 +++++++++++++++ src/app-bridge.ts | 430 +++++++++++++++++++++++++++++-------- src/app.examples.ts | 6 +- src/app.ts | 280 ++++++++++++++++-------- src/events.ts | 279 ++++++++++++++++++++++++ src/react/useHostStyles.ts | 8 +- 6 files changed, 996 insertions(+), 183 deletions(-) create mode 100644 src/events.ts diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index c1f4612ce..4761d766a 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -1221,4 +1221,180 @@ describe("isToolVisibilityAppOnly", () => { expect(isToolVisibilityAppOnly(tool)).toBe(false); }); }); + + describe("addEventListener / removeEventListener", () => { + let app: App; + let bridge: AppBridge; + let appTransport: InMemoryTransport; + let bridgeTransport: InMemoryTransport; + + beforeEach(async () => { + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + app = new App(testAppInfo, {}, { autoResize: false }); + bridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + await bridge.connect(bridgeTransport); + }); + + afterEach(async () => { + await appTransport.close(); + await bridgeTransport.close(); + }); + + it("App.addEventListener fires multiple listeners for the same event", async () => { + const a: unknown[] = []; + const b: unknown[] = []; + app.addEventListener("hostcontextchanged", (p) => a.push(p)); + app.addEventListener("hostcontextchanged", (p) => b.push(p)); + + await app.connect(appTransport); + bridge.setHostContext({ theme: "dark" }); + await flush(); + + expect(a).toEqual([{ theme: "dark" }]); + expect(b).toEqual([{ theme: "dark" }]); + }); + + it("App notification setters replace (DOM onclick model)", async () => { + const a: unknown[] = []; + const b: unknown[] = []; + const first = (p: unknown) => a.push(p); + app.ontoolinput = first; + expect(app.ontoolinput).toBe(first); + app.ontoolinput = (p) => b.push(p); + + await app.connect(appTransport); + await bridge.sendToolInput({ arguments: { x: 1 } }); + await flush(); + + // Second assignment replaced the first (like el.onclick) + expect(a).toEqual([]); + expect(b).toEqual([{ arguments: { x: 1 } }]); + }); + + it("App notification setter coexists with addEventListener", async () => { + const a: unknown[] = []; + const b: unknown[] = []; + app.ontoolinput = (p) => a.push(p); + app.addEventListener("toolinput", (p) => b.push(p)); + + await app.connect(appTransport); + await bridge.sendToolInput({ arguments: { x: 1 } }); + await flush(); + + // Both the on* handler and addEventListener listener fire + expect(a).toEqual([{ arguments: { x: 1 } }]); + expect(b).toEqual([{ arguments: { x: 1 } }]); + }); + + it("App notification getter returns the on* handler", () => { + expect(app.ontoolinput).toBeUndefined(); + const handler = () => {}; + app.ontoolinput = handler; + expect(app.ontoolinput).toBe(handler); + }); + + it("App notification setter can be cleared with undefined", async () => { + const a: unknown[] = []; + app.ontoolinput = (p) => a.push(p); + expect(app.ontoolinput).toBeDefined(); + app.ontoolinput = undefined; + + await app.connect(appTransport); + await bridge.sendToolInput({ arguments: { x: 1 } }); + await flush(); + + expect(a).toEqual([]); + expect(app.ontoolinput).toBeUndefined(); + }); + + it("App.removeEventListener stops a listener from firing", async () => { + const a: unknown[] = []; + const listener = (p: unknown) => a.push(p); + app.addEventListener("toolinput", listener); + app.removeEventListener("toolinput", listener); + + await app.connect(appTransport); + await bridge.sendToolInput({ arguments: {} }); + await flush(); + + expect(a).toEqual([]); + }); + + it("App.onEventDispatch merges hostcontext before listeners fire", async () => { + let seen: unknown; + app.addEventListener("hostcontextchanged", () => { + seen = app.getHostContext(); + }); + + await app.connect(appTransport); + bridge.setHostContext({ theme: "dark" }); + await flush(); + + expect(seen).toEqual({ theme: "dark" }); + }); + + it("AppBridge.addEventListener fires multiple listeners", async () => { + let a = 0; + let b = 0; + bridge.addEventListener("initialized", () => a++); + bridge.addEventListener("initialized", () => b++); + + await app.connect(appTransport); + + expect(a).toBe(1); + expect(b).toBe(1); + }); + + it("on* request setters have replace semantics (no throw)", () => { + app.onteardown = async () => ({}); + expect(() => { + app.onteardown = async () => ({}); + }).not.toThrow(); + }); + + it("on* request setters have getters", () => { + expect(app.onteardown).toBeUndefined(); + const handler = async () => ({}); + app.onteardown = handler; + expect(app.onteardown).toBe(handler); + }); + + it("direct setRequestHandler throws when called twice", () => { + const bridge2 = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + bridge2.setRequestHandler( + // @ts-expect-error — exercising throw path with raw schema + { shape: { method: { value: "test/method" } } }, + () => ({}), + ); + expect(() => { + bridge2.setRequestHandler( + // @ts-expect-error — exercising throw path with raw schema + { shape: { method: { value: "test/method" } } }, + () => ({}), + ); + }).toThrow(/already registered/); + }); + + it("direct setNotificationHandler throws for event-mapped methods", () => { + const app2 = new App(testAppInfo, {}, { autoResize: false }); + app2.addEventListener("toolinput", () => {}); + expect(() => { + app2.setNotificationHandler( + // @ts-expect-error — exercising throw path with raw schema + { + shape: { method: { value: "ui/notifications/tool-input" } }, + }, + () => {}, + ); + }).toThrow(/already registered/); + }); + }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index d409fc068..16c609b96 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -19,6 +19,9 @@ import { ListResourceTemplatesRequestSchema, ListResourceTemplatesResult, ListResourceTemplatesResultSchema, + ListToolsRequest, + ListToolsRequestSchema, + ListToolsResultSchema, LoggingMessageNotification, LoggingMessageNotificationSchema, PingRequest, @@ -36,10 +39,10 @@ import { ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js"; import { - Protocol, ProtocolOptions, RequestOptions, } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { ProtocolWithEvents } from "./events"; import { type AppNotification, @@ -280,15 +283,32 @@ type RequestHandlerExtra = Parameters< * await bridge.connect(transport); * ``` */ -export class AppBridge extends Protocol< +type AppBridgeEventMap = { + sizechange: McpUiSizeChangedNotification["params"]; + sandboxready: McpUiSandboxProxyReadyNotification["params"]; + initialized: McpUiInitializedNotification["params"]; + requestteardown: McpUiRequestTeardownNotification["params"]; + loggingmessage: LoggingMessageNotification["params"]; +}; + +export class AppBridge extends ProtocolWithEvents< AppRequest, AppNotification, - AppResult + AppResult, + AppBridgeEventMap > { private _appCapabilities?: McpUiAppCapabilities; private _hostContext: McpUiHostContext = {}; private _appInfo?: Implementation; + protected readonly eventSchemas = { + sizechange: McpUiSizeChangedNotificationSchema, + sandboxready: McpUiSandboxProxyReadyNotificationSchema, + initialized: McpUiInitializedNotificationSchema, + requestteardown: McpUiRequestTeardownNotificationSchema, + loggingmessage: LoggingMessageNotificationSchema, + }; + /** * Create a new AppBridge instance. * @@ -343,10 +363,13 @@ export class AppBridge extends Protocol< // Default handler for requestDisplayMode - returns current mode from host context. // Hosts can override this by setting bridge.onrequestdisplaymode = ... - this.setRequestHandler(McpUiRequestDisplayModeRequestSchema, (request) => { - const currentMode = this._hostContext.displayMode ?? "inline"; - return { mode: currentMode }; - }); + this.replaceRequestHandler( + McpUiRequestDisplayModeRequestSchema, + (request) => { + const currentMode = this._hostContext.displayMode ?? "inline"; + return { mode: currentMode }; + }, + ); } /** @@ -442,13 +465,19 @@ export class AppBridge extends Protocol< * * @see {@link McpUiSizeChangedNotification `McpUiSizeChangedNotification`} for the notification type * @see {@link app!App.sendSizeChanged `App.sendSizeChanged`} - the View method that sends these notifications + * @deprecated Use {@link addEventListener `addEventListener("sizechange", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ + get onsizechange(): + | ((params: McpUiSizeChangedNotification["params"]) => void) + | undefined { + return this.getEventHandler("sizechange"); + } set onsizechange( - callback: (params: McpUiSizeChangedNotification["params"]) => void, + callback: + | ((params: McpUiSizeChangedNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler(McpUiSizeChangedNotificationSchema, (n) => - callback(n.params), - ); + this.setEventHandler("sizechange", callback); } /** @@ -480,13 +509,19 @@ export class AppBridge extends Protocol< * @internal * @see {@link McpUiSandboxProxyReadyNotification `McpUiSandboxProxyReadyNotification`} for the notification type * @see {@link sendSandboxResourceReady `sendSandboxResourceReady`} for sending content to the sandbox + * @deprecated Use {@link addEventListener `addEventListener("sandboxready", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ + get onsandboxready(): + | ((params: McpUiSandboxProxyReadyNotification["params"]) => void) + | undefined { + return this.getEventHandler("sandboxready"); + } set onsandboxready( - callback: (params: McpUiSandboxProxyReadyNotification["params"]) => void, + callback: + | ((params: McpUiSandboxProxyReadyNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler(McpUiSandboxProxyReadyNotificationSchema, (n) => - callback(n.params), - ); + this.setEventHandler("sandboxready", callback); } /** @@ -505,13 +540,19 @@ export class AppBridge extends Protocol< * * @see {@link McpUiInitializedNotification `McpUiInitializedNotification`} for the notification type * @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View + * @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ + get oninitialized(): + | ((params: McpUiInitializedNotification["params"]) => void) + | undefined { + return this.getEventHandler("initialized"); + } set oninitialized( - callback: (params: McpUiInitializedNotification["params"]) => void, + callback: + | ((params: McpUiInitializedNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler(McpUiInitializedNotificationSchema, (n) => - callback(n.params), - ); + this.setEventHandler("initialized", callback); } /** @@ -548,16 +589,28 @@ export class AppBridge extends Protocol< * @see {@link McpUiMessageRequest `McpUiMessageRequest`} for the request type * @see {@link McpUiMessageResult `McpUiMessageResult`} for the result type */ + private _onmessage?: ( + params: McpUiMessageRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onmessage() { + return this._onmessage; + } set onmessage( - callback: ( - params: McpUiMessageRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: McpUiMessageRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback); + this._onmessage = callback; + this.replaceRequestHandler( McpUiMessageRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._onmessage) throw new Error("No onmessage handler set"); + return this._onmessage(request.params, extra); }, ); } @@ -605,16 +658,28 @@ export class AppBridge extends Protocol< * @see {@link McpUiOpenLinkRequest `McpUiOpenLinkRequest`} for the request type * @see {@link McpUiOpenLinkResult `McpUiOpenLinkResult`} for the result type */ + private _onopenlink?: ( + params: McpUiOpenLinkRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onopenlink() { + return this._onopenlink; + } set onopenlink( - callback: ( - params: McpUiOpenLinkRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: McpUiOpenLinkRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback); + this._onopenlink = callback; + this.replaceRequestHandler( McpUiOpenLinkRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._onopenlink) throw new Error("No onopenlink handler set"); + return this._onopenlink(request.params, extra); }, ); } @@ -661,16 +726,33 @@ export class AppBridge extends Protocol< * @see {@link McpUiDownloadFileRequest `McpUiDownloadFileRequest`} for the request type * @see {@link McpUiDownloadFileResult `McpUiDownloadFileResult`} for the result type */ + private _ondownloadfile?: ( + params: McpUiDownloadFileRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get ondownloadfile() { + return this._ondownloadfile; + } set ondownloadfile( - callback: ( - params: McpUiDownloadFileRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: McpUiDownloadFileRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced( + "ondownloadfile", + this._ondownloadfile, + callback, + ); + this._ondownloadfile = callback; + this.replaceRequestHandler( McpUiDownloadFileRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._ondownloadfile) + throw new Error("No ondownloadfile handler set"); + return this._ondownloadfile(request.params, extra); }, ); } @@ -700,14 +782,19 @@ export class AppBridge extends Protocol< * * @see {@link McpUiRequestTeardownNotification `McpUiRequestTeardownNotification`} for the notification type * @see {@link teardownResource `teardownResource`} for initiating teardown + * @deprecated Use {@link addEventListener `addEventListener("requestteardown", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ + get onrequestteardown(): + | ((params: McpUiRequestTeardownNotification["params"]) => void) + | undefined { + return this.getEventHandler("requestteardown"); + } set onrequestteardown( - callback: (params: McpUiRequestTeardownNotification["params"]) => void, + callback: + | ((params: McpUiRequestTeardownNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler( - McpUiRequestTeardownNotificationSchema, - (request) => callback(request.params), - ); + this.setEventHandler("requestteardown", callback); } /** @@ -742,16 +829,33 @@ export class AppBridge extends Protocol< * @see {@link McpUiRequestDisplayModeRequest `McpUiRequestDisplayModeRequest`} for the request type * @see {@link McpUiRequestDisplayModeResult `McpUiRequestDisplayModeResult`} for the result type */ + private _onrequestdisplaymode?: ( + params: McpUiRequestDisplayModeRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onrequestdisplaymode() { + return this._onrequestdisplaymode; + } set onrequestdisplaymode( - callback: ( - params: McpUiRequestDisplayModeRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: McpUiRequestDisplayModeRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced( + "onrequestdisplaymode", + this._onrequestdisplaymode, + callback, + ); + this._onrequestdisplaymode = callback; + this.replaceRequestHandler( McpUiRequestDisplayModeRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._onrequestdisplaymode) + throw new Error("No onrequestdisplaymode handler set"); + return this._onrequestdisplaymode(request.params, extra); }, ); } @@ -781,16 +885,19 @@ export class AppBridge extends Protocol< * ); * }; * ``` + * @deprecated Use {@link addEventListener `addEventListener("loggingmessage", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ + get onloggingmessage(): + | ((params: LoggingMessageNotification["params"]) => void) + | undefined { + return this.getEventHandler("loggingmessage"); + } set onloggingmessage( - callback: (params: LoggingMessageNotification["params"]) => void, + callback: + | ((params: LoggingMessageNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler( - LoggingMessageNotificationSchema, - async (notification) => { - callback(notification.params); - }, - ); + this.setEventHandler("loggingmessage", callback); } /** @@ -819,16 +926,33 @@ export class AppBridge extends Protocol< * * @see {@link McpUiUpdateModelContextRequest `McpUiUpdateModelContextRequest`} for the request type */ + private _onupdatemodelcontext?: ( + params: McpUiUpdateModelContextRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onupdatemodelcontext() { + return this._onupdatemodelcontext; + } set onupdatemodelcontext( - callback: ( - params: McpUiUpdateModelContextRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: McpUiUpdateModelContextRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced( + "onupdatemodelcontext", + this._onupdatemodelcontext, + callback, + ); + this._onupdatemodelcontext = callback; + this.replaceRequestHandler( McpUiUpdateModelContextRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._onupdatemodelcontext) + throw new Error("No onupdatemodelcontext handler set"); + return this._onupdatemodelcontext(request.params, extra); }, ); } @@ -859,15 +983,30 @@ export class AppBridge extends Protocol< * @see `CallToolRequest` from @modelcontextprotocol/sdk for the request type * @see `CallToolResult` from @modelcontextprotocol/sdk for the result type */ + private _oncalltool?: ( + params: CallToolRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get oncalltool() { + return this._oncalltool; + } set oncalltool( - callback: ( - params: CallToolRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: CallToolRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler(CallToolRequestSchema, async (request, extra) => { - return callback(request.params, extra); - }); + this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback); + this._oncalltool = callback; + this.replaceRequestHandler( + CallToolRequestSchema, + async (request, extra) => { + if (!this._oncalltool) throw new Error("No oncalltool handler set"); + return this._oncalltool(request.params, extra); + }, + ); } /** @@ -922,16 +1061,33 @@ export class AppBridge extends Protocol< * @see `ListResourcesRequest` from @modelcontextprotocol/sdk for the request type * @see `ListResourcesResult` from @modelcontextprotocol/sdk for the result type */ + private _onlistresources?: ( + params: ListResourcesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onlistresources() { + return this._onlistresources; + } set onlistresources( - callback: ( - params: ListResourcesRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: ListResourcesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced( + "onlistresources", + this._onlistresources, + callback, + ); + this._onlistresources = callback; + this.replaceRequestHandler( ListResourcesRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._onlistresources) + throw new Error("No onlistresources handler set"); + return this._onlistresources(request.params, extra); }, ); } @@ -962,16 +1118,33 @@ export class AppBridge extends Protocol< * @see `ListResourceTemplatesRequest` from @modelcontextprotocol/sdk for the request type * @see `ListResourceTemplatesResult` from @modelcontextprotocol/sdk for the result type */ + private _onlistresourcetemplates?: ( + params: ListResourceTemplatesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onlistresourcetemplates() { + return this._onlistresourcetemplates; + } set onlistresourcetemplates( - callback: ( - params: ListResourceTemplatesRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: ListResourceTemplatesRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced( + "onlistresourcetemplates", + this._onlistresourcetemplates, + callback, + ); + this._onlistresourcetemplates = callback; + this.replaceRequestHandler( ListResourceTemplatesRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._onlistresourcetemplates) + throw new Error("No onlistresourcetemplates handler set"); + return this._onlistresourcetemplates(request.params, extra); }, ); } @@ -1002,16 +1175,33 @@ export class AppBridge extends Protocol< * @see `ReadResourceRequest` from @modelcontextprotocol/sdk for the request type * @see `ReadResourceResult` from @modelcontextprotocol/sdk for the result type */ + private _onreadresource?: ( + params: ReadResourceRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onreadresource() { + return this._onreadresource; + } set onreadresource( - callback: ( - params: ReadResourceRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: ReadResourceRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced( + "onreadresource", + this._onreadresource, + callback, + ); + this._onreadresource = callback; + this.replaceRequestHandler( ReadResourceRequestSchema, async (request, extra) => { - return callback(request.params, extra); + if (!this._onreadresource) + throw new Error("No onreadresource handler set"); + return this._onreadresource(request.params, extra); }, ); } @@ -1070,15 +1260,35 @@ export class AppBridge extends Protocol< * @see `ListPromptsRequest` from @modelcontextprotocol/sdk for the request type * @see `ListPromptsResult` from @modelcontextprotocol/sdk for the result type */ + private _onlistprompts?: ( + params: ListPromptsRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onlistprompts() { + return this._onlistprompts; + } set onlistprompts( - callback: ( - params: ListPromptsRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: ListPromptsRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler(ListPromptsRequestSchema, async (request, extra) => { - return callback(request.params, extra); - }); + this.warnIfRequestHandlerReplaced( + "onlistprompts", + this._onlistprompts, + callback, + ); + this._onlistprompts = callback; + this.replaceRequestHandler( + ListPromptsRequestSchema, + async (request, extra) => { + if (!this._onlistprompts) + throw new Error("No onlistprompts handler set"); + return this._onlistprompts(request.params, extra); + }, + ); } /** @@ -1450,6 +1660,40 @@ export class AppBridge extends Protocol< /** @deprecated Use {@link teardownResource `teardownResource`} instead */ sendResourceTeardown: AppBridge["teardownResource"] = this.teardownResource; + /** + * Call a tool on the view. + * + * Sends a `tools/call` request to the view and returns the result. + * + * @param params - Tool call parameters (name and arguments) + * @param options - Request options (timeout, abort signal, etc.) + * @returns Promise resolving to the tool call result + */ + callTool(params: CallToolRequest["params"], options?: RequestOptions) { + return this.request( + { method: "tools/call", params }, + CallToolResultSchema, + options, + ); + } + + /** + * List tools available on the view. + * + * Sends a `tools/list` request to the view and returns the result. + * + * @param params - List tools parameters (may include cursor for pagination) + * @param options - Request options (timeout, abort signal, etc.) + * @returns Promise resolving to the list of tools + */ + listTools(params: ListToolsRequest["params"], options?: RequestOptions) { + return this.request( + { method: "tools/list", params }, + ListToolsResultSchema, + options, + ); + } + /** * Connect to the view via transport and optionally set up message forwarding. * diff --git a/src/app.examples.ts b/src/app.examples.ts index 1d12c5b22..a0a582076 100644 --- a/src/app.examples.ts +++ b/src/app.examples.ts @@ -303,7 +303,11 @@ function App_onlisttools_returnTools(app: App) { //#region App_onlisttools_returnTools app.onlisttools = async (params, extra) => { return { - tools: ["greet", "calculate", "format"], + tools: [ + { name: "greet", inputSchema: { type: "object" as const } }, + { name: "calculate", inputSchema: { type: "object" as const } }, + { name: "format", inputSchema: { type: "object" as const } }, + ], }; }; //#endregion App_onlisttools_returnTools diff --git a/src/app.ts b/src/app.ts index fa3bf23fc..2305852af 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,5 @@ import { type RequestOptions, - Protocol, ProtocolOptions, } from "@modelcontextprotocol/sdk/shared/protocol.js"; @@ -16,6 +15,7 @@ import { ListResourcesResultSchema, ListToolsRequest, ListToolsRequestSchema, + ListToolsResult, LoggingMessageNotification, PingRequestSchema, ReadResourceRequest, @@ -23,6 +23,7 @@ import { ReadResourceResultSchema, } from "@modelcontextprotocol/sdk/types.js"; import { AppNotification, AppRequest, AppResult } from "./types"; +import { ProtocolWithEvents } from "./events"; import { PostMessageTransport } from "./message-transport"; import { LATEST_PROTOCOL_VERSION, @@ -159,6 +160,21 @@ type RequestHandlerExtra = Parameters< Parameters[1] >[1]; +/** + * Maps DOM-style event names to their notification `params` types. + * + * Used by {@link App `App`} (which extends {@link ProtocolWithEvents `ProtocolWithEvents`}) + * to provide type-safe `addEventListener` / `removeEventListener` and + * singular `on*` handler support. + */ +type AppEventMap = { + toolinput: McpUiToolInputNotification["params"]; + toolinputpartial: McpUiToolInputPartialNotification["params"]; + toolresult: McpUiToolResultNotification["params"]; + toolcancelled: McpUiToolCancelledNotification["params"]; + hostcontextchanged: McpUiHostContextChangedNotification["params"]; +}; + /** * Main class for MCP Apps to communicate with their host. * @@ -182,24 +198,26 @@ type RequestHandlerExtra = Parameters< * * ## Inherited Methods * - * As a subclass of `Protocol`, `App` inherits key methods for handling communication: + * As a subclass of {@link ProtocolWithEvents `ProtocolWithEvents`}, `App` inherits: * - `setRequestHandler()` - Register handlers for requests from host * - `setNotificationHandler()` - Register handlers for notifications from host + * - `addEventListener()` - Append a listener for a notification event (multi-listener) + * - `removeEventListener()` - Remove a previously added listener * - * @see `Protocol` from @modelcontextprotocol/sdk for all inherited methods + * @see {@link ProtocolWithEvents `ProtocolWithEvents`} for the DOM-model event system * - * ## Notification Setters + * ## Notification Setters (DOM-model `on*` handlers) * - * For common notifications, the `App` class provides convenient setter properties - * that simplify handler registration: + * For common notifications, the `App` class provides getter/setter properties + * that follow DOM-model replace semantics (like `el.onclick`): * - `ontoolinput` - Complete tool arguments from host * - `ontoolinputpartial` - Streaming partial tool arguments * - `ontoolresult` - Tool execution results * - `ontoolcancelled` - Tool execution was cancelled by user or host * - `onhostcontextchanged` - Host context changes (theme, locale, etc.) * - * These setters are convenience wrappers around `setNotificationHandler()`. - * Both patterns work; use whichever fits your coding style better. + * Assigning replaces the previous handler; assigning `undefined` clears it. + * Use `addEventListener` to attach multiple listeners without replacing. * * @example Basic usage with PostMessageTransport * ```ts source="./app.examples.ts#App_basicUsage" @@ -216,11 +234,33 @@ type RequestHandlerExtra = Parameters< * await app.connect(); * ``` */ -export class App extends Protocol { +export class App extends ProtocolWithEvents< + AppRequest, + AppNotification, + AppResult, + AppEventMap +> { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; private _hostContext?: McpUiHostContext; + protected readonly eventSchemas = { + toolinput: McpUiToolInputNotificationSchema, + toolinputpartial: McpUiToolInputPartialNotificationSchema, + toolresult: McpUiToolResultNotificationSchema, + toolcancelled: McpUiToolCancelledNotificationSchema, + hostcontextchanged: McpUiHostContextChangedNotificationSchema, + }; + + protected override onEventDispatch( + event: K, + params: AppEventMap[K], + ): void { + if (event === "hostcontextchanged") { + this._hostContext = { ...this._hostContext, ...params }; + } + } + /** * Create a new MCP App instance. * @@ -249,9 +289,10 @@ export class App extends Protocol { return {}; }); - // Set up default handler to update _hostContext when notifications arrive. - // Users can override this by setting onhostcontextchanged. - this.onhostcontextchanged = () => {}; + // Eagerly register the hostcontextchanged event slot so that + // onEventDispatch (which merges into _hostContext) fires even if the + // user never assigns onhostcontextchanged or calls addEventListener. + this.setEventHandler("hostcontextchanged", undefined); } /** @@ -339,8 +380,9 @@ export class App extends Protocol { * sends a tool's complete arguments. This is sent after a tool call begins * and before the tool result is available. * - * This setter is a convenience wrapper around `setNotificationHandler()` that - * automatically handles the notification schema and extracts the params for you. + * Assigning replaces the previous handler; assigning `undefined` clears it. + * Use {@link addEventListener `addEventListener`} to attach multiple listeners + * without replacing. * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * @@ -356,15 +398,20 @@ export class App extends Protocol { * await app.connect(); * ``` * - * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method + * @deprecated Use {@link addEventListener `addEventListener("toolinput", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. * @see {@link McpUiToolInputNotification `McpUiToolInputNotification`} for the notification structure */ + get ontoolinput(): + | ((params: McpUiToolInputNotification["params"]) => void) + | undefined { + return this.getEventHandler("toolinput"); + } set ontoolinput( - callback: (params: McpUiToolInputNotification["params"]) => void, + callback: + | ((params: McpUiToolInputNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler(McpUiToolInputNotificationSchema, (n) => - callback(n.params), - ); + this.setEventHandler("toolinput", callback); } /** @@ -379,8 +426,9 @@ export class App extends Protocol { * (e.g., the last item in an array may be truncated). Use partial data only * for preview UI, not for critical operations. * - * This setter is a convenience wrapper around `setNotificationHandler()` that - * automatically handles the notification schema and extracts the params for you. + * Assigning replaces the previous handler; assigning `undefined` clears it. + * Use {@link addEventListener `addEventListener`} to attach multiple listeners + * without replacing. * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * @@ -404,16 +452,21 @@ export class App extends Protocol { * }; * ``` * - * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method + * @deprecated Use {@link addEventListener `addEventListener("toolinputpartial", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. * @see {@link McpUiToolInputPartialNotification `McpUiToolInputPartialNotification`} for the notification structure * @see {@link ontoolinput `ontoolinput`} for the complete tool input handler */ + get ontoolinputpartial(): + | ((params: McpUiToolInputPartialNotification["params"]) => void) + | undefined { + return this.getEventHandler("toolinputpartial"); + } set ontoolinputpartial( - callback: (params: McpUiToolInputPartialNotification["params"]) => void, + callback: + | ((params: McpUiToolInputPartialNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler(McpUiToolInputPartialNotificationSchema, (n) => - callback(n.params), - ); + this.setEventHandler("toolinputpartial", callback); } /** @@ -423,8 +476,9 @@ export class App extends Protocol { * sends the result of a tool execution. This is sent after the tool completes * on the MCP server, allowing your app to display the results or update its state. * - * This setter is a convenience wrapper around `setNotificationHandler()` that - * automatically handles the notification schema and extracts the params for you. + * Assigning replaces the previous handler; assigning `undefined` clears it. + * Use {@link addEventListener `addEventListener`} to attach multiple listeners + * without replacing. * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * @@ -441,16 +495,21 @@ export class App extends Protocol { * }; * ``` * - * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method + * @deprecated Use {@link addEventListener `addEventListener("toolresult", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. * @see {@link McpUiToolResultNotification `McpUiToolResultNotification`} for the notification structure * @see {@link ontoolinput `ontoolinput`} for the initial tool input handler */ + get ontoolresult(): + | ((params: McpUiToolResultNotification["params"]) => void) + | undefined { + return this.getEventHandler("toolresult"); + } set ontoolresult( - callback: (params: McpUiToolResultNotification["params"]) => void, + callback: + | ((params: McpUiToolResultNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler(McpUiToolResultNotificationSchema, (n) => - callback(n.params), - ); + this.setEventHandler("toolresult", callback); } /** @@ -461,8 +520,9 @@ export class App extends Protocol { * including user action, sampling error, classifier intervention, or other * interruptions. Apps should update their state and display appropriate feedback. * - * This setter is a convenience wrapper around `setNotificationHandler()` that - * automatically handles the notification schema and extracts the params for you. + * Assigning replaces the previous handler; assigning `undefined` clears it. + * Use {@link addEventListener `addEventListener`} to attach multiple listeners + * without replacing. * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * @@ -476,16 +536,21 @@ export class App extends Protocol { * }; * ``` * - * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method + * @deprecated Use {@link addEventListener `addEventListener("toolcancelled", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. * @see {@link McpUiToolCancelledNotification `McpUiToolCancelledNotification`} for the notification structure * @see {@link ontoolresult `ontoolresult`} for successful tool completion */ + get ontoolcancelled(): + | ((params: McpUiToolCancelledNotification["params"]) => void) + | undefined { + return this.getEventHandler("toolcancelled"); + } set ontoolcancelled( - callback: (params: McpUiToolCancelledNotification["params"]) => void, + callback: + | ((params: McpUiToolCancelledNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler(McpUiToolCancelledNotificationSchema, (n) => - callback(n.params), - ); + this.setEventHandler("toolcancelled", callback); } /** @@ -496,12 +561,14 @@ export class App extends Protocol { * other environmental updates. Apps should respond by updating their UI * accordingly. * - * This setter is a convenience wrapper around `setNotificationHandler()` that - * automatically handles the notification schema and extracts the params for you. + * Assigning replaces the previous handler; assigning `undefined` clears it. + * Use {@link addEventListener `addEventListener`} to attach multiple listeners + * without replacing. * * Notification params are automatically merged into the internal host context - * before the callback is invoked. This means {@link getHostContext `getHostContext`} will - * return the updated values even before your callback runs. + * via {@link onEventDispatch `onEventDispatch`} before any handler or listener + * fires. This means {@link getHostContext `getHostContext`} will return the + * updated values even before your callback runs. * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * @@ -518,21 +585,21 @@ export class App extends Protocol { * }; * ``` * - * @see {@link setNotificationHandler `setNotificationHandler`} for the underlying method + * @deprecated Use {@link addEventListener `addEventListener("hostcontextchanged", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. * @see {@link McpUiHostContextChangedNotification `McpUiHostContextChangedNotification`} for the notification structure * @see {@link McpUiHostContext `McpUiHostContext`} for the full context structure */ + get onhostcontextchanged(): + | ((params: McpUiHostContextChangedNotification["params"]) => void) + | undefined { + return this.getEventHandler("hostcontextchanged"); + } set onhostcontextchanged( - callback: (params: McpUiHostContextChangedNotification["params"]) => void, + callback: + | ((params: McpUiHostContextChangedNotification["params"]) => void) + | undefined, ) { - this.setNotificationHandler( - McpUiHostContextChangedNotificationSchema, - (n) => { - // Merge the partial update into the stored context - this._hostContext = { ...this._hostContext, ...n.params }; - callback(n.params); - }, - ); + this.setEventHandler("hostcontextchanged", callback); } /** @@ -545,8 +612,7 @@ export class App extends Protocol { * The handler can be sync or async. The host will wait for the returned promise * to resolve before proceeding with teardown. * - * This setter is a convenience wrapper around `setRequestHandler()` that - * automatically handles the request schema. + * Assigning replaces the previous handler; assigning `undefined` clears it. * * Register handlers before calling {@link connect `connect`} to avoid missing requests. * @@ -563,18 +629,31 @@ export class App extends Protocol { * }; * ``` * - * @see {@link setRequestHandler `setRequestHandler`} for the underlying method * @see {@link McpUiResourceTeardownRequest `McpUiResourceTeardownRequest`} for the request structure */ + private _onteardown?: ( + params: McpUiResourceTeardownRequest["params"], + extra: RequestHandlerExtra, + ) => McpUiResourceTeardownResult | Promise; + get onteardown() { + return this._onteardown; + } set onteardown( - callback: ( - params: McpUiResourceTeardownRequest["params"], - extra: RequestHandlerExtra, - ) => McpUiResourceTeardownResult | Promise, + callback: + | (( + params: McpUiResourceTeardownRequest["params"], + extra: RequestHandlerExtra, + ) => McpUiResourceTeardownResult | Promise) + | undefined, ) { - this.setRequestHandler( + this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback); + this._onteardown = callback; + this.replaceRequestHandler( McpUiResourceTeardownRequestSchema, - (request, extra) => callback(request.params, extra), + (request, extra) => { + if (!this._onteardown) throw new Error("No onteardown handler set"); + return this._onteardown(request.params, extra); + }, ); } @@ -587,8 +666,7 @@ export class App extends Protocol { * * The app must declare tool capabilities in the constructor to use this handler. * - * This setter is a convenience wrapper around `setRequestHandler()` that - * automatically handles the request schema and extracts the params for you. + * Assigning replaces the previous handler; assigning `undefined` clears it. * * Register handlers before calling {@link connect `connect`} to avoid missing requests. * @@ -606,18 +684,28 @@ export class App extends Protocol { * throw new Error(`Unknown tool: ${params.name}`); * }; * ``` - * - * @see {@link setRequestHandler `setRequestHandler`} for the underlying method */ + private _oncalltool?: ( + params: CallToolRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get oncalltool() { + return this._oncalltool; + } set oncalltool( - callback: ( - params: CallToolRequest["params"], - extra: RequestHandlerExtra, - ) => Promise, + callback: + | (( + params: CallToolRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler(CallToolRequestSchema, (request, extra) => - callback(request.params, extra), - ); + this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback); + this._oncalltool = callback; + this.replaceRequestHandler(CallToolRequestSchema, (request, extra) => { + if (!this._oncalltool) throw new Error("No oncalltool handler set"); + return this._oncalltool(request.params, extra); + }); } /** @@ -629,36 +717,54 @@ export class App extends Protocol { * * The app must declare tool capabilities in the constructor to use this handler. * - * This setter is a convenience wrapper around `setRequestHandler()` that - * automatically handles the request schema and extracts the params for you. + * Assigning replaces the previous handler; assigning `undefined` clears it. * * Register handlers before calling {@link connect `connect`} to avoid missing requests. * - * @param callback - Async function that returns tool names as strings (simplified - * from full `ListToolsResult` with `Tool` objects). Registration is always - * allowed; capability validation occurs when handlers are invoked. + * @param callback - Async function that returns a {@link ListToolsResult `ListToolsResult`}. + * Registration is always allowed; capability validation occurs when handlers + * are invoked. * * @example Return available tools * ```ts source="./app.examples.ts#App_onlisttools_returnTools" * app.onlisttools = async (params, extra) => { * return { - * tools: ["greet", "calculate", "format"], + * tools: [ + * { name: "greet", inputSchema: { type: "object" as const } }, + * { name: "calculate", inputSchema: { type: "object" as const } }, + * { name: "format", inputSchema: { type: "object" as const } }, + * ], * }; * }; * ``` * - * @see {@link setRequestHandler `setRequestHandler`} for the underlying method * @see {@link oncalltool `oncalltool`} for handling tool execution */ + private _onlisttools?: ( + params: ListToolsRequest["params"], + extra: RequestHandlerExtra, + ) => Promise; + get onlisttools() { + return this._onlisttools; + } set onlisttools( - callback: ( - params: ListToolsRequest["params"], - extra: RequestHandlerExtra, - ) => Promise<{ tools: string[] }>, + callback: + | (( + params: ListToolsRequest["params"], + extra: RequestHandlerExtra, + ) => Promise) + | undefined, ) { - this.setRequestHandler(ListToolsRequestSchema, (request, extra) => - callback(request.params, extra), + this.warnIfRequestHandlerReplaced( + "onlisttools", + this._onlisttools, + callback, ); + this._onlisttools = callback; + this.replaceRequestHandler(ListToolsRequestSchema, (request, extra) => { + if (!this._onlisttools) throw new Error("No onlisttools handler set"); + return this._onlisttools(request.params, extra); + }); } /** diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 000000000..4d17ca4f2 --- /dev/null +++ b/src/events.ts @@ -0,0 +1,279 @@ +import { Protocol } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import { + Request, + Notification, + Result, +} from "@modelcontextprotocol/sdk/types.js"; +import { ZodLiteral, ZodObject } from "zod/v4"; + +type MethodSchema = ZodObject<{ method: ZodLiteral }>; + +/** + * Per-event state: a singular `on*` handler (replace semantics) plus a + * listener array (`addEventListener` semantics), mirroring the DOM model + * where `el.onclick` and `el.addEventListener("click", …)` coexist. + */ +interface EventSlot { + onHandler?: ((params: T) => void) | undefined; + listeners: ((params: T) => void)[]; +} + +/** + * Intermediate base class that adds DOM-style event support on top of the + * MCP SDK's `Protocol`. + * + * The base `Protocol` class stores one handler per method: + * `setRequestHandler()` and `setNotificationHandler()` replace any existing + * handler for the same method silently. This class introduces a two-channel + * event model inspired by the DOM: + * + * ### Singular `on*` handler (like `el.onclick`) + * + * Subclasses expose `get`/`set` pairs that delegate to + * {@link setEventHandler `setEventHandler`} / + * {@link getEventHandler `getEventHandler`}. Assigning replaces the previous + * handler; assigning `undefined` clears it. `addEventListener` listeners are + * unaffected. + * + * ### Multi-listener (`addEventListener` / `removeEventListener`) + * + * Append to a per-event listener array. Listeners fire in insertion order + * after the singular `on*` handler. + * + * ### Dispatch order + * + * When a notification arrives for a mapped event: + * 1. {@link onEventDispatch `onEventDispatch`} (subclass side-effects) + * 2. The singular `on*` handler (if set) + * 3. All `addEventListener` listeners in insertion order + * + * ### Double-set protection + * + * Direct calls to {@link setRequestHandler `setRequestHandler`} / + * {@link setNotificationHandler `setNotificationHandler`} throw if a handler + * for the same method has already been registered (through any path), so + * accidental overwrites surface as errors instead of silent bugs. + * + * @typeParam EventMap - Maps event names to the listener's `params` type. + */ +export abstract class ProtocolWithEvents< + SendRequestT extends Request, + SendNotificationT extends Notification, + SendResultT extends Result, + EventMap extends Record, +> extends Protocol { + private _registeredMethods = new Set(); + private _eventSlots = new Map(); + + /** + * Event name → notification schema. Subclasses populate this so that + * the event system can lazily register a dispatcher with the correct + * schema on first use. + */ + protected abstract readonly eventSchemas: { + [K in keyof EventMap]: MethodSchema; + }; + + /** + * Called once per incoming notification, before any handlers or listeners + * fire. Subclasses may override to perform side effects such as merging + * notification params into cached state. + */ + protected onEventDispatch( + _event: K, + _params: EventMap[K], + ): void {} + + // ── Event system (DOM model) ──────────────────────────────────────── + + /** + * Lazily create the event slot and register a single dispatcher with the + * base `Protocol`. The dispatcher fans out to the `on*` handler and all + * `addEventListener` listeners. + */ + private _ensureEventSlot( + event: K, + ): EventSlot { + let slot = this._eventSlots.get(event) as + | EventSlot + | undefined; + if (!slot) { + const schema = this.eventSchemas[event]; + if (!schema) { + throw new Error(`Unknown event: ${String(event)}`); + } + slot = { listeners: [] }; + this._eventSlots.set(event, slot as EventSlot); + + // Claim this method so direct setNotificationHandler calls throw. + const method = schema.shape.method.value; + this._registeredMethods.add(method); + + const s = slot; // stable reference for the closure + super.setNotificationHandler(schema, (n) => { + const params = (n as { params: EventMap[K] }).params; + this.onEventDispatch(event, params); + // 1. Singular on* handler + s.onHandler?.(params); + // 2. addEventListener listeners — snapshot to tolerate removal during + // dispatch (e.g., a listener that calls removeEventListener on itself) + for (const l of [...s.listeners]) l(params); + }); + } + return slot; + } + + /** + * Set or clear the singular `on*` handler for an event. + * + * Replace semantics — like the DOM's `el.onclick = fn`. Assigning + * `undefined` clears the handler without affecting `addEventListener` + * listeners. + */ + protected setEventHandler( + event: K, + handler: ((params: EventMap[K]) => void) | undefined, + ): void { + const slot = this._ensureEventSlot(event); + if (slot.onHandler && handler) { + console.warn( + `[MCP Apps] on${String(event)} handler replaced. ` + + `Use addEventListener("${String(event)}", …) to add multiple listeners without replacing.`, + ); + } + slot.onHandler = handler; + } + + /** + * Get the singular `on*` handler for an event, or `undefined` if none is + * set. `addEventListener` listeners are not reflected here. + */ + protected getEventHandler( + event: K, + ): ((params: EventMap[K]) => void) | undefined { + return (this._eventSlots.get(event) as EventSlot | undefined) + ?.onHandler; + } + + /** + * Add a listener for a notification event. + * + * Unlike the singular `on*` handler, calling this multiple times appends + * listeners rather than replacing them. All registered listeners fire in + * insertion order after the `on*` handler when the notification arrives. + * + * Registration is lazy: the first call (for a given event, from either + * this method or the `on*` setter) registers a dispatcher with the base + * `Protocol`. + * + * @param event - Event name (a key of the `EventMap` type parameter). + * @param handler - Listener invoked with the notification `params`. + */ + addEventListener( + event: K, + handler: (params: EventMap[K]) => void, + ): void { + this._ensureEventSlot(event).listeners.push(handler); + } + + /** + * Remove a previously registered event listener. The dispatcher stays + * registered even if the listener array becomes empty; future + * notifications simply have no listeners to call. + */ + removeEventListener( + event: K, + handler: (params: EventMap[K]) => void, + ): void { + const slot = this._eventSlots.get(event) as + | EventSlot + | undefined; + if (!slot) return; + const idx = slot.listeners.indexOf(handler); + if (idx !== -1) slot.listeners.splice(idx, 1); + } + + // ── Handler registration with double-set protection ───────────────── + + // The two overrides below are arrow-function class fields rather than + // prototype methods so that Protocol's constructor — which registers its + // own ping/cancelled/progress handlers via `this.setRequestHandler` + // before our fields initialize — hits the base implementation and skips + // tracking. Converting these to proper methods would crash with + // `_registeredMethods` undefined during super(). + + /** + * Registers a request handler. Throws if a handler for the same method + * has already been registered — use the `on*` setter (replace semantics) + * or `addEventListener` (multi-listener) for notification events. + * + * @throws {Error} if a handler for this method is already registered. + */ + override setRequestHandler: Protocol< + SendRequestT, + SendNotificationT, + SendResultT + >["setRequestHandler"] = (schema, handler) => { + this._assertMethodNotRegistered(schema, "setRequestHandler"); + super.setRequestHandler(schema, handler); + }; + + /** + * 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< + SendRequestT, + SendNotificationT, + SendResultT + >["setNotificationHandler"] = (schema, handler) => { + this._assertMethodNotRegistered(schema, "setNotificationHandler"); + super.setNotificationHandler(schema, handler); + }; + + /** + * Warn if a request handler `on*` setter is replacing a previously-set + * handler. Call from each request setter before updating the backing field. + */ + protected warnIfRequestHandlerReplaced( + name: string, + previous: unknown, + next: unknown, + ): void { + if (previous && next) { + console.warn( + `[MCP Apps] ${name} handler replaced. ` + + `Previous handler will no longer be called.`, + ); + } + } + + /** + * Replace a request handler, bypassing double-set protection. Used by + * `on*` request-handler setters that need replace semantics. + */ + protected replaceRequestHandler: Protocol< + SendRequestT, + SendNotificationT, + SendResultT + >["setRequestHandler"] = (schema, handler) => { + const method = (schema as MethodSchema).shape.method.value; + this._registeredMethods.add(method); + super.setRequestHandler(schema, handler); + }; + + private _assertMethodNotRegistered(schema: unknown, via: string): void { + const method = (schema as MethodSchema).shape.method.value; + if (this._registeredMethods.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._registeredMethods.add(method); + } +} diff --git a/src/react/useHostStyles.ts b/src/react/useHostStyles.ts index 72247f752..e380efae9 100644 --- a/src/react/useHostStyles.ts +++ b/src/react/useHostStyles.ts @@ -77,7 +77,7 @@ export function useHostStyleVariables( return; } - app.onhostcontextchanged = (params) => { + const listener = (params: McpUiHostContext) => { if (params.theme) { applyDocumentTheme(params.theme); } @@ -85,6 +85,8 @@ export function useHostStyleVariables( applyHostStyleVariables(params.styles.variables); } }; + app.addEventListener("hostcontextchanged", listener); + return () => app.removeEventListener("hostcontextchanged", listener); }, [app]); } @@ -145,11 +147,13 @@ export function useHostFonts( return; } - app.onhostcontextchanged = (params) => { + const listener = (params: McpUiHostContext) => { if (params.styles?.css?.fonts) { applyHostFonts(params.styles.css.fonts); } }; + app.addEventListener("hostcontextchanged", listener); + return () => app.removeEventListener("hostcontextchanged", listener); }, [app]); } From d64adb8ffa45addf732a649528710b150f29ab08 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 30 Mar 2026 17:19:22 +0000 Subject: [PATCH 2/2] Fix typedoc validation warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export AppEventMap, AppBridgeEventMap, ProtocolWithEvents so they appear in generated docs (they're part of public addEventListener signatures) - Move AppBridgeEventMap above the AppBridge class JSDoc — it had been inserted between the doc block and the class, orphaning the AppBridge docs onto the wrong type - Drop @param callback tags from on* getter/setter pairs — typedoc attaches the doc block to the getter signature which has no params - Add MethodSchema to intentionallyNotExported (internal zod helper referenced by the now-documented eventSchemas field) --- src/app-bridge.ts | 34 +++++++++++++++++++--------------- src/app.ts | 13 ++----------- typedoc.config.mjs | 2 +- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 16c609b96..77125872c 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -226,6 +226,21 @@ type RequestHandlerExtra = Parameters< Parameters[1] >[1]; +/** + * Maps DOM-style event names to their notification `params` types. + * + * Used by {@link AppBridge `AppBridge`} to provide type-safe + * `addEventListener` / `removeEventListener` and singular `on*` handler + * support. + */ +export type AppBridgeEventMap = { + sizechange: McpUiSizeChangedNotification["params"]; + sandboxready: McpUiSandboxProxyReadyNotification["params"]; + initialized: McpUiInitializedNotification["params"]; + requestteardown: McpUiRequestTeardownNotification["params"]; + loggingmessage: LoggingMessageNotification["params"]; +}; + /** * Host-side bridge for communicating with a single View ({@link app!App `App`}). * @@ -283,14 +298,6 @@ type RequestHandlerExtra = Parameters< * await bridge.connect(transport); * ``` */ -type AppBridgeEventMap = { - sizechange: McpUiSizeChangedNotification["params"]; - sandboxready: McpUiSandboxProxyReadyNotification["params"]; - initialized: McpUiInitializedNotification["params"]; - requestteardown: McpUiRequestTeardownNotification["params"]; - loggingmessage: LoggingMessageNotification["params"]; -}; - export class AppBridge extends ProtocolWithEvents< AppRequest, AppNotification, @@ -765,9 +772,6 @@ export class AppBridge extends ProtocolWithEvents< * `ui/resource-teardown` (via {@link teardownResource `teardownResource`}) to allow * the view to perform gracefull termination, then unmount the iframe after the view responds. * - * @param callback - Handler that receives teardown request params - * - params - Empty object (reserved for future use) - * * @example * ```typescript * bridge.onrequestteardown = async (params) => { @@ -871,10 +875,10 @@ export class AppBridge extends ProtocolWithEvents< * This uses the standard MCP logging notification format, not a UI-specific * message type. * - * @param callback - Handler that receives logging params - * - `params.level` - Log level: "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency" - * - `params.logger` - Optional logger name/identifier - * - `params.data` - Log message and optional structured data + * The handler receives `LoggingMessageNotification["params"]`: + * - `level` — "debug" | "info" | "notice" | "warning" | "error" | "critical" | "alert" | "emergency" + * - `logger` — optional logger name/identifier + * - `data` — log message and optional structured data * * @example * ```ts source="./app-bridge.examples.ts#AppBridge_onloggingmessage_handleLog" diff --git a/src/app.ts b/src/app.ts index 2305852af..f5f97b37c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { AppNotification, AppRequest, AppResult } from "./types"; import { ProtocolWithEvents } from "./events"; +export { ProtocolWithEvents }; import { PostMessageTransport } from "./message-transport"; import { LATEST_PROTOCOL_VERSION, @@ -167,7 +168,7 @@ type RequestHandlerExtra = Parameters< * to provide type-safe `addEventListener` / `removeEventListener` and * singular `on*` handler support. */ -type AppEventMap = { +export type AppEventMap = { toolinput: McpUiToolInputNotification["params"]; toolinputpartial: McpUiToolInputPartialNotification["params"]; toolresult: McpUiToolResultNotification["params"]; @@ -386,8 +387,6 @@ export class App extends ProtocolWithEvents< * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * - * @param callback - Function called with the tool input params ({@link McpUiToolInputNotification.params `McpUiToolInputNotification.params`}) - * * @example * ```ts source="./app.examples.ts#App_ontoolinput_setter" * // Register before connecting to ensure no notifications are missed @@ -432,8 +431,6 @@ export class App extends ProtocolWithEvents< * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * - * @param callback - Function called with each partial tool input update ({@link McpUiToolInputPartialNotification.params `McpUiToolInputPartialNotification.params`}) - * * @example Progressive rendering of tool arguments * ```ts source="./app.examples.ts#App_ontoolinputpartial_progressiveRendering" * const codePreview = document.querySelector("#code-preview")!; @@ -482,8 +479,6 @@ export class App extends ProtocolWithEvents< * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * - * @param callback - Function called with the tool result ({@link McpUiToolResultNotification.params `McpUiToolResultNotification.params`}) - * * @example Display tool execution results * ```ts source="./app.examples.ts#App_ontoolresult_displayResults" * app.ontoolresult = (params) => { @@ -526,8 +521,6 @@ export class App extends ProtocolWithEvents< * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * - * @param callback - Function called when tool execution is cancelled. Receives optional cancellation reason — see {@link McpUiToolCancelledNotification.params `McpUiToolCancelledNotification.params`}. - * * @example Handle tool cancellation * ```ts source="./app.examples.ts#App_ontoolcancelled_handleCancellation" * app.ontoolcancelled = (params) => { @@ -572,8 +565,6 @@ export class App extends ProtocolWithEvents< * * Register handlers before calling {@link connect `connect`} to avoid missing notifications. * - * @param callback - Function called with the updated host context - * * @example Respond to theme changes * ```ts source="./app.examples.ts#App_onhostcontextchanged_respondToTheme" * app.onhostcontextchanged = (ctx) => { diff --git a/typedoc.config.mjs b/typedoc.config.mjs index a97bb29b0..5775caec7 100644 --- a/typedoc.config.mjs +++ b/typedoc.config.mjs @@ -28,7 +28,7 @@ const config = { ], excludePrivate: true, excludeInternal: false, - intentionallyNotExported: ["AppOptions"], + intentionallyNotExported: ["AppOptions", "MethodSchema"], blockTags: [...OptionDefaults.blockTags, "@description"], jsDocCompatibility: { exampleTag: false,