Add addEventListener/removeEventListener with DOM-model on* semantics (#573)
* 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) <noreply@anthropic.com> * Fix typedoc validation warnings - 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14969c5da2
commit
0266171b92
7 changed files with 1009 additions and 201 deletions
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AppBridge["setRequestHandler"]>[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<McpUiMessageResult>;
|
||||
get onmessage() {
|
||||
return this._onmessage;
|
||||
}
|
||||
set onmessage(
|
||||
callback: (
|
||||
params: McpUiMessageRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiMessageResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: McpUiMessageRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiMessageResult>)
|
||||
| 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<McpUiOpenLinkResult>;
|
||||
get onopenlink() {
|
||||
return this._onopenlink;
|
||||
}
|
||||
set onopenlink(
|
||||
callback: (
|
||||
params: McpUiOpenLinkRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiOpenLinkResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: McpUiOpenLinkRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiOpenLinkResult>)
|
||||
| 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<McpUiDownloadFileResult>;
|
||||
get ondownloadfile() {
|
||||
return this._ondownloadfile;
|
||||
}
|
||||
set ondownloadfile(
|
||||
callback: (
|
||||
params: McpUiDownloadFileRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiDownloadFileResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: McpUiDownloadFileRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiDownloadFileResult>)
|
||||
| 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<McpUiRequestDisplayModeResult>;
|
||||
get onrequestdisplaymode() {
|
||||
return this._onrequestdisplaymode;
|
||||
}
|
||||
set onrequestdisplaymode(
|
||||
callback: (
|
||||
params: McpUiRequestDisplayModeRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiRequestDisplayModeResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: McpUiRequestDisplayModeRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<McpUiRequestDisplayModeResult>)
|
||||
| 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<EmptyResult>;
|
||||
get onupdatemodelcontext() {
|
||||
return this._onupdatemodelcontext;
|
||||
}
|
||||
set onupdatemodelcontext(
|
||||
callback: (
|
||||
params: McpUiUpdateModelContextRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<EmptyResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: McpUiUpdateModelContextRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<EmptyResult>)
|
||||
| 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<CallToolResult>;
|
||||
get oncalltool() {
|
||||
return this._oncalltool;
|
||||
}
|
||||
set oncalltool(
|
||||
callback: (
|
||||
params: CallToolRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CallToolResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: CallToolRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CallToolResult>)
|
||||
| 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<ListResourcesResult>;
|
||||
get onlistresources() {
|
||||
return this._onlistresources;
|
||||
}
|
||||
set onlistresources(
|
||||
callback: (
|
||||
params: ListResourcesRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ListResourcesResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: ListResourcesRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ListResourcesResult>)
|
||||
| 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<ListResourceTemplatesResult>;
|
||||
get onlistresourcetemplates() {
|
||||
return this._onlistresourcetemplates;
|
||||
}
|
||||
set onlistresourcetemplates(
|
||||
callback: (
|
||||
params: ListResourceTemplatesRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ListResourceTemplatesResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: ListResourceTemplatesRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ListResourceTemplatesResult>)
|
||||
| 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<ReadResourceResult>;
|
||||
get onreadresource() {
|
||||
return this._onreadresource;
|
||||
}
|
||||
set onreadresource(
|
||||
callback: (
|
||||
params: ReadResourceRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ReadResourceResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: ReadResourceRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ReadResourceResult>)
|
||||
| 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<ListPromptsResult>;
|
||||
get onlistprompts() {
|
||||
return this._onlistprompts;
|
||||
}
|
||||
set onlistprompts(
|
||||
callback: (
|
||||
params: ListPromptsRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ListPromptsResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: ListPromptsRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ListPromptsResult>)
|
||||
| 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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
291
src/app.ts
291
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<App["setRequestHandler"]>[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<AppRequest, AppNotification, AppResult> {
|
||||
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<K extends keyof AppEventMap>(
|
||||
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<AppRequest, AppNotification, AppResult> {
|
|||
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<AppRequest, AppNotification, AppResult> {
|
|||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* (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<HTMLPreElement>("#code-preview")!;
|
||||
|
|
@ -404,16 +449,21 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @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<AppRequest, AppNotification, AppResult> {
|
|||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @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<AppRequest, AppNotification, AppResult> {
|
|||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @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<AppRequest, AppNotification, AppResult> {
|
|||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @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<AppRequest, AppNotification, AppResult> {
|
|||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @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<McpUiResourceTeardownResult>;
|
||||
get onteardown() {
|
||||
return this._onteardown;
|
||||
}
|
||||
set onteardown(
|
||||
callback: (
|
||||
params: McpUiResourceTeardownRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: McpUiResourceTeardownRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => McpUiResourceTeardownResult | Promise<McpUiResourceTeardownResult>)
|
||||
| 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<AppRequest, AppNotification, AppResult> {
|
|||
*
|
||||
* 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<AppRequest, AppNotification, AppResult> {
|
|||
* throw new Error(`Unknown tool: ${params.name}`);
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @see {@link setRequestHandler `setRequestHandler`} for the underlying method
|
||||
*/
|
||||
private _oncalltool?: (
|
||||
params: CallToolRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CallToolResult>;
|
||||
get oncalltool() {
|
||||
return this._oncalltool;
|
||||
}
|
||||
set oncalltool(
|
||||
callback: (
|
||||
params: CallToolRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CallToolResult>,
|
||||
callback:
|
||||
| ((
|
||||
params: CallToolRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CallToolResult>)
|
||||
| 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<AppRequest, AppNotification, AppResult> {
|
|||
*
|
||||
* 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<ListToolsResult>;
|
||||
get onlisttools() {
|
||||
return this._onlisttools;
|
||||
}
|
||||
set onlisttools(
|
||||
callback: (
|
||||
params: ListToolsRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<{ tools: string[] }>,
|
||||
callback:
|
||||
| ((
|
||||
params: ListToolsRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<ListToolsResult>)
|
||||
| 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
279
src/events.ts
Normal file
279
src/events.ts
Normal file
|
|
@ -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<string> }>;
|
||||
|
||||
/**
|
||||
* 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<T = unknown> {
|
||||
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<string, unknown>,
|
||||
> extends Protocol<SendRequestT, SendNotificationT, SendResultT> {
|
||||
private _registeredMethods = new Set<string>();
|
||||
private _eventSlots = new Map<keyof EventMap, EventSlot>();
|
||||
|
||||
/**
|
||||
* 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<K extends keyof EventMap>(
|
||||
_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<K extends keyof EventMap>(
|
||||
event: K,
|
||||
): EventSlot<EventMap[K]> {
|
||||
let slot = this._eventSlots.get(event) as
|
||||
| EventSlot<EventMap[K]>
|
||||
| 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<K extends keyof EventMap>(
|
||||
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<K extends keyof EventMap>(
|
||||
event: K,
|
||||
): ((params: EventMap[K]) => void) | undefined {
|
||||
return (this._eventSlots.get(event) as EventSlot<EventMap[K]> | 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<K extends keyof EventMap>(
|
||||
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<K extends keyof EventMap>(
|
||||
event: K,
|
||||
handler: (params: EventMap[K]) => void,
|
||||
): void {
|
||||
const slot = this._eventSlots.get(event) as
|
||||
| EventSlot<EventMap[K]>
|
||||
| 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const config = {
|
|||
],
|
||||
excludePrivate: true,
|
||||
excludeInternal: false,
|
||||
intentionallyNotExported: ["AppOptions"],
|
||||
intentionallyNotExported: ["AppOptions", "MethodSchema"],
|
||||
blockTags: [...OptionDefaults.blockTags, "@description"],
|
||||
jsDocCompatibility: {
|
||||
exampleTag: false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue