refactor: compose protocol-neutral event dispatch
This commit is contained in:
parent
b383847bf2
commit
209cfc6241
6 changed files with 335 additions and 271 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 || {};
|
||||||
|
|
||||||
|
|
|
||||||
139
src/app.ts
139
src/app.ts
|
|
@ -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
82
src/events.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
261
src/events.ts
261
src/events.ts
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue