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..77125872c 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, @@ -223,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`}). * @@ -280,15 +298,24 @@ type RequestHandlerExtra = Parameters< * await bridge.connect(transport); * ``` */ -export class AppBridge extends Protocol< +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 +370,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 +472,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 +516,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 +547,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 +596,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 +665,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 +733,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); }, ); } @@ -683,9 +772,6 @@ export class AppBridge extends Protocol< * `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) => { @@ -700,14 +786,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 +833,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); }, ); } @@ -767,10 +875,10 @@ export class AppBridge extends Protocol< * 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" @@ -781,16 +889,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 +930,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 +987,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 +1065,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 +1122,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 +1179,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 +1264,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 +1664,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..f5f97b37c 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,8 @@ import { ReadResourceResultSchema, } 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, @@ -159,6 +161,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. + */ +export 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 +199,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 +235,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 +290,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,13 +381,12 @@ 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. * - * @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 @@ -356,15 +397,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,13 +425,12 @@ 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. * - * @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")!; @@ -404,16 +449,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,13 +473,12 @@ 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. * - * @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) => { @@ -441,16 +490,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,13 +515,12 @@ 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. * - * @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) => { @@ -476,16 +529,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,17 +554,17 @@ 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. * - * @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) => { @@ -518,21 +576,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 +603,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 +620,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 +657,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 +675,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 +708,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]); } 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,