refactor: compose protocol-neutral event dispatch

This commit is contained in:
Andrew Khadder 2026-07-14 13:49:10 -07:00
commit 209cfc6241
No known key found for this signature in database
6 changed files with 335 additions and 271 deletions

View file

@ -2713,7 +2713,7 @@ describe("isToolVisibilityAppOnly", () => {
expect(a).toEqual([]); expect(a).toEqual([]);
}); });
it("App.onEventDispatch merges hostcontext before listeners fire", async () => { it("App merges hostcontext before listeners fire", async () => {
let seen: unknown; let seen: unknown;
app.addEventListener("hostcontextchanged", () => { app.addEventListener("hostcontextchanged", () => {
seen = app.getHostContext(); seen = app.getHostContext();

View file

@ -47,7 +47,10 @@ import {
ProtocolOptions, ProtocolOptions,
RequestOptions, RequestOptions,
} from "@modelcontextprotocol/sdk/shared/protocol.js"; } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { ProtocolWithEvents } from "./events"; import { EventDispatcher } from "./events";
import type { ZodLiteral, ZodObject } from "zod/v4";
type MethodSchema = ZodObject<{ method: ZodLiteral<string> }>;
import { import {
type AppNotification, type AppNotification,
@ -303,16 +306,36 @@ export type AppBridgeEventMap = {
* await bridge.connect(transport); * await bridge.connect(transport);
* ``` * ```
*/ */
export class AppBridge extends ProtocolWithEvents< export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult> {
AppRequest,
AppNotification,
AppResult,
AppBridgeEventMap
> {
private _appCapabilities?: McpUiAppCapabilities; private _appCapabilities?: McpUiAppCapabilities;
private _hostContext: McpUiHostContext = {}; private _hostContext: McpUiHostContext = {};
private _appInfo?: Implementation; private _appInfo?: Implementation;
private _initializedReceived = false; private _initializedReceived = false;
private readonly _registeredMethods = new Set<string>();
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
private readonly _events = new EventDispatcher<AppBridgeEventMap>();
// ProtocolWithEvents previously owned these registration guards. Keep them
// on AppBridge to preserve the existing double-registration behavior while
// event fan-out moves to protocol-independent composition. Arrow-function
// fields let Protocol's constructor run before the guards initialize.
override setRequestHandler: Protocol<
AppRequest,
AppNotification,
AppResult
>["setRequestHandler"] = (schema, handler) => {
this._assertMethodNotRegistered(schema, "setRequestHandler");
super.setRequestHandler(schema, handler);
};
override setNotificationHandler: Protocol<
AppRequest,
AppNotification,
AppResult
>["setNotificationHandler"] = (schema, handler) => {
this._assertMethodNotRegistered(schema, "setNotificationHandler");
super.setNotificationHandler(schema, handler);
};
/** /**
* Wrap every handler registered via `replaceRequestHandler` with a check * Wrap every handler registered via `replaceRequestHandler` with a check
@ -324,13 +347,14 @@ export class AppBridge extends ProtocolWithEvents<
* *
* @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149} * @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149}
*/ */
private _baseReplaceRequestHandler = this.replaceRequestHandler; protected replaceRequestHandler: Protocol<
protected override replaceRequestHandler: Protocol<
AppRequest, AppRequest,
AppNotification, AppNotification,
AppResult AppResult
>["setRequestHandler"] = (schema, handler) => { >["setRequestHandler"] = (schema, handler) => {
this._baseReplaceRequestHandler(schema, (request, extra) => { const method = (schema as MethodSchema).shape.method.value;
this._registeredMethods.add(method);
super.setRequestHandler(schema, (request, extra) => {
if (!this._initializedReceived) { if (!this._initializedReceived) {
console.warn( console.warn(
`[ext-apps] AppBridge received '${request.method}' before ` + `[ext-apps] AppBridge received '${request.method}' before ` +
@ -343,7 +367,9 @@ export class AppBridge extends ProtocolWithEvents<
}); });
}; };
protected readonly eventSchemas = { private readonly eventSchemas: {
[K in keyof AppBridgeEventMap]: MethodSchema;
} = {
sizechange: McpUiSizeChangedNotificationSchema, sizechange: McpUiSizeChangedNotificationSchema,
sandboxready: McpUiSandboxProxyReadyNotificationSchema, sandboxready: McpUiSandboxProxyReadyNotificationSchema,
initialized: McpUiInitializedNotificationSchema, initialized: McpUiInitializedNotificationSchema,
@ -351,6 +377,74 @@ export class AppBridge extends ProtocolWithEvents<
loggingmessage: LoggingMessageNotificationSchema, loggingmessage: LoggingMessageNotificationSchema,
}; };
protected setEventHandler<K extends keyof AppBridgeEventMap>(
event: K,
handler: ((params: AppBridgeEventMap[K]) => void) | undefined,
): void {
this._ensureEventSlot(event);
this._events.setHandler(event, handler);
}
protected getEventHandler<K extends keyof AppBridgeEventMap>(
event: K,
): ((params: AppBridgeEventMap[K]) => void) | undefined {
return this._events.getHandler(event);
}
addEventListener<K extends keyof AppBridgeEventMap>(
event: K,
handler: (params: AppBridgeEventMap[K]) => void,
): void {
this._ensureEventSlot(event);
this._events.addEventListener(event, handler);
}
removeEventListener<K extends keyof AppBridgeEventMap>(
event: K,
handler: (params: AppBridgeEventMap[K]) => void,
): void {
this._events.removeEventListener(event, handler);
}
private _ensureEventSlot<K extends keyof AppBridgeEventMap>(
event: K,
): void {
if (this._registeredEvents.has(event)) return;
const schema = this.eventSchemas[event];
if (!schema) throw new Error(`Unknown event: ${String(event)}`);
this.setNotificationHandler(schema, (notification) => {
const params = (notification as { params: AppBridgeEventMap[K] }).params;
if (event === "initialized") this._initializedReceived = true;
this._events.dispatch(event, params);
});
this._registeredEvents.add(event);
}
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.`,
);
}
}
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);
}
/** /**
* Create a new AppBridge instance. * Create a new AppBridge instance.
* *
@ -392,9 +486,7 @@ export class AppBridge extends ProtocolWithEvents<
) { ) {
super(options); super(options);
this.addEventListener("initialized", () => { this._ensureEventSlot("initialized");
this._initializedReceived = true;
});
this._hostContext = options?.hostContext || {}; this._hostContext = options?.hostContext || {};

View file

@ -1,6 +1,7 @@
import { import {
type RequestOptions, type RequestOptions,
mergeCapabilities, mergeCapabilities,
Protocol,
ProtocolOptions, ProtocolOptions,
} from "@modelcontextprotocol/sdk/shared/protocol.js"; } from "@modelcontextprotocol/sdk/shared/protocol.js";
@ -32,8 +33,8 @@ import {
ToolListChangedNotification, ToolListChangedNotification,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { AppNotification, AppRequest, AppResult } from "./types"; import { AppNotification, AppRequest, AppResult } from "./types";
import { ProtocolWithEvents } from "./events"; import { EventDispatcher } from "./events";
export { ProtocolWithEvents }; export { EventDispatcher } from "./events";
import { PostMessageTransport } from "./message-transport"; import { PostMessageTransport } from "./message-transport";
import { import {
LATEST_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION,
@ -74,7 +75,9 @@ import {
standardSchemaToJsonSchema, standardSchemaToJsonSchema,
validateStandardSchema, validateStandardSchema,
} from "./standard-schema"; } from "./standard-schema";
import { z } from "zod/v4"; import { z, type ZodLiteral, type ZodObject } from "zod/v4";
type MethodSchema = ZodObject<{ method: ZodLiteral<string> }>;
export type { export type {
StandardSchemaV1, StandardSchemaV1,
@ -271,9 +274,9 @@ export type RegisteredAppTool = {
/** /**
* Maps DOM-style event names to their notification `params` types. * Maps DOM-style event names to their notification `params` types.
* *
* Used by {@link App `App`} (which extends {@link ProtocolWithEvents `ProtocolWithEvents`}) * Used by {@link App `App`} and its composed {@link EventDispatcher} to
* to provide type-safe `addEventListener` / `removeEventListener` and * provide type-safe `addEventListener` / `removeEventListener` and singular
* singular `on*` handler support. * `on*` handler support.
*/ */
export type AppEventMap = { export type AppEventMap = {
toolinput: McpUiToolInputNotification["params"]; toolinput: McpUiToolInputNotification["params"];
@ -304,16 +307,14 @@ export type AppEventMap = {
* 3. **Interactive**: Send requests, receive notifications, call tools * 3. **Interactive**: Send requests, receive notifications, call tools
* 4. **Teardown**: Host sends teardown request before unmounting * 4. **Teardown**: Host sends teardown request before unmounting
* *
* ## Inherited Methods * ## Protocol and event methods
* *
* As a subclass of {@link ProtocolWithEvents `ProtocolWithEvents`}, `App` inherits: * As a subclass of the MCP SDK's `Protocol`, `App` provides:
* - `setRequestHandler()` - Register handlers for requests from host * - `setRequestHandler()` - Register handlers for requests from host
* - `setNotificationHandler()` - Register handlers for notifications from host * - `setNotificationHandler()` - Register handlers for notifications from host
* - `addEventListener()` - Append a listener for a notification event (multi-listener) * - `addEventListener()` - Append a listener for a notification event (multi-listener)
* - `removeEventListener()` - Remove a previously added listener * - `removeEventListener()` - Remove a previously added listener
* *
* @see {@link ProtocolWithEvents `ProtocolWithEvents`} for the DOM-model event system
*
* ## Notification Setters (DOM-model `on*` handlers) * ## Notification Setters (DOM-model `on*` handlers)
* *
* For common notifications, the `App` class provides getter/setter properties * For common notifications, the `App` class provides getter/setter properties
@ -342,17 +343,37 @@ export type AppEventMap = {
* await app.connect(); * await app.connect();
* ``` * ```
*/ */
export class App extends ProtocolWithEvents< export class App extends Protocol<AppRequest, AppNotification, AppResult> {
AppRequest,
AppNotification,
AppResult,
AppEventMap
> {
private _hostCapabilities?: McpUiHostCapabilities; private _hostCapabilities?: McpUiHostCapabilities;
private _hostInfo?: Implementation; private _hostInfo?: Implementation;
private _hostContext?: McpUiHostContext; private _hostContext?: McpUiHostContext;
private _registeredTools: { [name: string]: RegisteredAppTool } = {}; private _registeredTools: { [name: string]: RegisteredAppTool } = {};
private _initializedSent = false; private _initializedSent = false;
private readonly _registeredMethods = new Set<string>();
private readonly _registeredEvents = new Set<keyof AppEventMap>();
private readonly _events = new EventDispatcher<AppEventMap>();
// ProtocolWithEvents previously owned these registration guards. Keep them
// on App to preserve the existing double-registration behavior while event
// fan-out moves to protocol-independent composition. Arrow-function fields
// ensure Protocol's constructor uses its own methods before the guards init.
override setRequestHandler: Protocol<
AppRequest,
AppNotification,
AppResult
>["setRequestHandler"] = (schema, handler) => {
this._assertMethodNotRegistered(schema, "setRequestHandler");
super.setRequestHandler(schema, handler);
};
override setNotificationHandler: Protocol<
AppRequest,
AppNotification,
AppResult
>["setNotificationHandler"] = (schema, handler) => {
this._assertMethodNotRegistered(schema, "setNotificationHandler");
super.setNotificationHandler(schema, handler);
};
/** /**
* Warn if a host-bound method is called before {@link connect `connect`} has * Warn if a host-bound method is called before {@link connect `connect`} has
@ -378,7 +399,9 @@ export class App extends ProtocolWithEvents<
console.warn(`${msg}. This will throw in a future release.`); console.warn(`${msg}. This will throw in a future release.`);
} }
protected readonly eventSchemas = { private readonly eventSchemas: {
[K in keyof AppEventMap]: MethodSchema;
} = {
toolinput: McpUiToolInputNotificationSchema, toolinput: McpUiToolInputNotificationSchema,
toolinputpartial: McpUiToolInputPartialNotificationSchema, toolinputpartial: McpUiToolInputPartialNotificationSchema,
toolresult: McpUiToolResultNotificationSchema, toolresult: McpUiToolResultNotificationSchema,
@ -427,31 +450,86 @@ export class App extends ProtocolWithEvents<
console.warn(msg); console.warn(msg);
} }
protected override setEventHandler<K extends keyof AppEventMap>( protected setEventHandler<K extends keyof AppEventMap>(
event: K, event: K,
handler: ((params: AppEventMap[K]) => void) | undefined, handler: ((params: AppEventMap[K]) => void) | undefined,
): void { ): void {
if (handler) this._assertHandlerTiming(event); if (handler) this._assertHandlerTiming(event);
super.setEventHandler(event, handler); this._ensureEventSlot(event);
this._events.setHandler(event, handler);
} }
override addEventListener<K extends keyof AppEventMap>( addEventListener<K extends keyof AppEventMap>(
event: K, event: K,
handler: (params: AppEventMap[K]) => void, handler: (params: AppEventMap[K]) => void,
): void { ): void {
this._assertHandlerTiming(event); this._assertHandlerTiming(event);
super.addEventListener(event, handler); this._ensureEventSlot(event);
this._events.addEventListener(event, handler);
} }
protected override onEventDispatch<K extends keyof AppEventMap>( removeEventListener<K extends keyof AppEventMap>(
event: K, event: K,
params: AppEventMap[K], handler: (params: AppEventMap[K]) => void,
): void { ): void {
if (event === "hostcontextchanged") { this._events.removeEventListener(event, handler);
this._hostContext = { ...this._hostContext, ...params }; }
private _ensureEventSlot<K extends keyof AppEventMap>(event: K): void {
if (this._registeredEvents.has(event)) return;
const schema = this.eventSchemas[event];
if (!schema) throw new Error(`Unknown event: ${String(event)}`);
this.setNotificationHandler(schema, (notification) => {
const params = (notification as { params: AppEventMap[K] }).params;
if (event === "hostcontextchanged") {
this._hostContext = { ...this._hostContext, ...params };
}
this._events.dispatch(event, params);
});
this._registeredEvents.add(event);
}
protected getEventHandler<K extends keyof AppEventMap>(
event: K,
): ((params: AppEventMap[K]) => void) | undefined {
return this._events.getHandler(event);
}
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.`,
);
} }
} }
protected replaceRequestHandler: Protocol<
AppRequest,
AppNotification,
AppResult
>["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);
}
/** /**
* Create a new MCP App instance. * Create a new MCP App instance.
* *
@ -484,9 +562,8 @@ export class App extends ProtocolWithEvents<
return {}; return {};
}); });
// Eagerly register the hostcontextchanged event slot so that // Eagerly register hostcontextchanged so the owner-side context merge runs
// onEventDispatch (which merges into _hostContext) fires even if the // even if the user never assigns a handler or adds a listener.
// user never assigns onhostcontextchanged or calls addEventListener.
this.setEventHandler("hostcontextchanged", undefined); this.setEventHandler("hostcontextchanged", undefined);
} }
@ -924,9 +1001,9 @@ export class App extends ProtocolWithEvents<
* without replacing. * without replacing.
* *
* Notification params are automatically merged into the internal host context * Notification params are automatically merged into the internal host context
* via {@link onEventDispatch `onEventDispatch`} before any handler or listener * before any handler or listener fires. This means
* fires. This means {@link getHostContext `getHostContext`} will return the * {@link getHostContext `getHostContext`} will return the updated values even
* updated values even before your callback runs. * before your callback runs.
* *
* Register handlers before calling {@link connect `connect`} to avoid missing notifications. * Register handlers before calling {@link connect `connect`} to avoid missing notifications.
* *

82
src/events.test.ts Normal file
View file

@ -0,0 +1,82 @@
import { describe, expect, it, spyOn } from "bun:test";
import { EventDispatcher } from "./events";
type TestEventMap = {
change: { value: number };
};
describe("EventDispatcher", () => {
it("replaces the singular handler", () => {
const dispatcher = new EventDispatcher<TestEventMap>();
const calls: string[] = [];
const first = () => calls.push("first");
const second = () => calls.push("second");
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
dispatcher.setHandler("change", first);
dispatcher.setHandler("change", second);
expect(dispatcher.getHandler("change")).toBe(second);
dispatcher.dispatch("change", { value: 1 });
expect(calls).toEqual(["second"]);
} finally {
warn.mockRestore();
}
});
it("dispatches the singular handler before listeners in insertion order", () => {
const dispatcher = new EventDispatcher<TestEventMap>();
const calls: string[] = [];
dispatcher.setHandler("change", () => calls.push("handler"));
dispatcher.addEventListener("change", () => calls.push("listener-1"));
dispatcher.addEventListener("change", () => calls.push("listener-2"));
dispatcher.dispatch("change", { value: 1 });
expect(calls).toEqual(["handler", "listener-1", "listener-2"]);
});
it("uses a listener snapshot when a listener is removed during dispatch", () => {
const dispatcher = new EventDispatcher<TestEventMap>();
const calls: string[] = [];
const second = () => calls.push("second");
const first = () => {
calls.push("first");
dispatcher.removeEventListener("change", second);
};
dispatcher.addEventListener("change", first);
dispatcher.addEventListener("change", second);
dispatcher.dispatch("change", { value: 1 });
dispatcher.dispatch("change", { value: 2 });
expect(calls).toEqual(["first", "second", "first"]);
});
it("warns only when one singular handler replaces another", () => {
const dispatcher = new EventDispatcher<TestEventMap>();
const warn = spyOn(console, "warn").mockImplementation(() => {});
const first = () => {};
const second = () => {};
try {
dispatcher.setHandler("change", first);
dispatcher.setHandler("change", undefined);
dispatcher.setHandler("change", first);
expect(warn).not.toHaveBeenCalled();
dispatcher.setHandler("change", second);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain(
"onchange handler replaced",
);
} finally {
warn.mockRestore();
}
});
});

View file

@ -1,17 +1,6 @@
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 * Per-event state: a singular `on*` handler (replace semantics) plus an
* listener array (`addEventListener` semantics), mirroring the DOM model * ordered listener array (`addEventListener` semantics).
* where `el.onclick` and `el.addEventListener("click", …)` coexist.
*/ */
interface EventSlot<T = unknown> { interface EventSlot<T = unknown> {
onHandler?: ((params: T) => void) | undefined; onHandler?: ((params: T) => void) | undefined;
@ -19,118 +8,19 @@ interface EventSlot<T = unknown> {
} }
/** /**
* Intermediate base class that adds DOM-style event support on top of the * Protocol-independent DOM-style event fan-out.
* MCP SDK's `Protocol`.
* *
* The base `Protocol` class stores one handler per method: * Owners perform event-specific state updates before calling
* `setRequestHandler()` and `setNotificationHandler()` replace any existing * {@link dispatch}. Dispatch then invokes the singular handler followed by a
* handler for the same method silently. This class introduces a two-channel * snapshot of listeners in insertion order.
* event model inspired by the DOM:
* *
* ### Singular `on*` handler (like `el.onclick`) * @typeParam EventMap - Maps event names to their listener parameter types.
*
* 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< export class EventDispatcher<EventMap extends Record<string, unknown>> {
SendRequestT extends Request, private readonly _eventSlots = new Map<keyof EventMap, EventSlot>();
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>();
/** /** Set, replace, or clear the singular handler for an event. */
* Event name → notification schema. Subclasses populate this so that setHandler<K extends keyof EventMap>(
* 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, event: K,
handler: ((params: EventMap[K]) => void) | undefined, handler: ((params: EventMap[K]) => void) | undefined,
): void { ): void {
@ -144,31 +34,16 @@ export abstract class ProtocolWithEvents<
slot.onHandler = handler; slot.onHandler = handler;
} }
/** /** Get the singular handler for an event, if one is set. */
* Get the singular `on*` handler for an event, or `undefined` if none is getHandler<K extends keyof EventMap>(
* set. `addEventListener` listeners are not reflected here.
*/
protected getEventHandler<K extends keyof EventMap>(
event: K, event: K,
): ((params: EventMap[K]) => void) | undefined { ): ((params: EventMap[K]) => void) | undefined {
return (this._eventSlots.get(event) as EventSlot<EventMap[K]> | undefined) return (this._eventSlots.get(event) as
?.onHandler; | EventSlot<EventMap[K]>
| undefined)?.onHandler;
} }
/** /** Add a listener. Listeners run in insertion order after the handler. */
* 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>( addEventListener<K extends keyof EventMap>(
event: K, event: K,
handler: (params: EventMap[K]) => void, handler: (params: EventMap[K]) => void,
@ -176,11 +51,7 @@ export abstract class ProtocolWithEvents<
this._ensureEventSlot(event).listeners.push(handler); this._ensureEventSlot(event).listeners.push(handler);
} }
/** /** Remove the first matching listener, if present. */
* 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>( removeEventListener<K extends keyof EventMap>(
event: K, event: K,
handler: (params: EventMap[K]) => void, handler: (params: EventMap[K]) => void,
@ -193,87 +64,29 @@ export abstract class ProtocolWithEvents<
if (idx !== -1) slot.listeners.splice(idx, 1); 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 * Dispatch an event to its singular handler and a listener snapshot.
* has already been registered — use the `on*` setter (replace semantics) * Listener mutations during dispatch take effect on the next dispatch.
* or `addEventListener` (multi-listener) for notification events.
*
* @throws {Error} if a handler for this method is already registered.
*/ */
override setRequestHandler: Protocol< dispatch<K extends keyof EventMap>(event: K, params: EventMap[K]): void {
SendRequestT, const slot = this._eventSlots.get(event) as
SendNotificationT, | EventSlot<EventMap[K]>
SendResultT | undefined;
>["setRequestHandler"] = (schema, handler) => { if (!slot) return;
this._assertMethodNotRegistered(schema, "setRequestHandler"); slot.onHandler?.(params);
super.setRequestHandler(schema, handler); for (const l of [...slot.listeners]) l(params);
};
/**
* 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.`,
);
}
} }
/** private _ensureEventSlot<K extends keyof EventMap>(
* Replace a request handler, bypassing double-set protection. Used by event: K,
* `on*` request-handler setters that need replace semantics. ): EventSlot<EventMap[K]> {
*/ let slot = this._eventSlots.get(event) as
protected replaceRequestHandler: Protocol< | EventSlot<EventMap[K]>
SendRequestT, | undefined;
SendNotificationT, if (!slot) {
SendResultT slot = { listeners: [] };
>["setRequestHandler"] = (schema, handler) => { this._eventSlots.set(event, slot as EventSlot);
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); return slot;
} }
} }

View file

@ -28,7 +28,7 @@ const config = {
], ],
excludePrivate: true, excludePrivate: true,
excludeInternal: false, excludeInternal: false,
intentionallyNotExported: ["MethodSchema", "RequestHandlerExtra"], intentionallyNotExported: ["RequestHandlerExtra"],
blockTags: [...OptionDefaults.blockTags, "@description"], blockTags: [...OptionDefaults.blockTags, "@description"],
jsDocCompatibility: { jsDocCompatibility: {
exampleTag: false, exampleTag: false,