fix: restore double-registration guard on App and AppBridge handlers
The base SDK Protocol silently replaces an existing request or notification handler. v1's ProtocolWithEvents threw instead, so a stray setRequestHandler / setNotificationHandler could not disconnect on* handlers or addEventListener listeners. Restore that guard on both App and AppBridge with a small MethodRegistry, keep replace semantics for the on* setters via a protected replaceRequestHandler, and release methods on removeRequestHandler / removeNotificationHandler. Also: - give oncreatesamplingmessage a getter, a replace warning and the same clearable shape as the other request setters - let App register handlers for custom (non-spec) methods; the default branch of assertRequestHandlerCapability threw a misleading "No handler for method X registered" error - move the RESOURCE_URI_META_KEY / RESOURCE_MIME_TYPE docs to src/constants.ts where the constants now live - fix stale "inherited from Server" wording and point @see links at @modelcontextprotocol/client, which is what hosts install
This commit is contained in:
parent
ea6b89c9e2
commit
cfdc0c725f
6 changed files with 510 additions and 127 deletions
|
|
@ -3016,16 +3016,95 @@ describe("isToolVisibilityAppOnly", () => {
|
|||
expect(app.onteardown).toBe(handler);
|
||||
});
|
||||
|
||||
it("direct setRequestHandler uses base SDK replacement semantics", () => {
|
||||
it("direct setRequestHandler throws when called twice", () => {
|
||||
const bridge2 = new AppBridge(
|
||||
createMockClient() as Client,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
bridge2.setRequestHandler("ping", () => ({}));
|
||||
const params = z.object({});
|
||||
bridge2.setRequestHandler("test/method", { params }, () => ({}));
|
||||
expect(() => {
|
||||
bridge2.setRequestHandler("ping", () => ({}));
|
||||
bridge2.setRequestHandler("test/method", { params }, () => ({}));
|
||||
}).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it("direct setRequestHandler cannot silently replace an on* host handler", () => {
|
||||
const bridge2 = new AppBridge(
|
||||
createMockClient() as Client,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
bridge2.onopenlink = async () => ({});
|
||||
expect(() => {
|
||||
bridge2.setRequestHandler(
|
||||
"ui/open-link",
|
||||
{ params: z.object({}) },
|
||||
() => ({}),
|
||||
);
|
||||
}).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it("direct setNotificationHandler throws for event-mapped methods", () => {
|
||||
const bridge2 = new AppBridge(
|
||||
createMockClient() as Client,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
bridge2.onsizechange = () => {};
|
||||
expect(() => {
|
||||
bridge2.setNotificationHandler(
|
||||
"ui/notifications/size-changed",
|
||||
{ params: z.object({}) },
|
||||
() => {},
|
||||
);
|
||||
}).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it("removeRequestHandler releases the method so an on* setter can re-register", () => {
|
||||
const bridge2 = new AppBridge(
|
||||
createMockClient() as Client,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
bridge2.removeRequestHandler("tools/call");
|
||||
expect(() => {
|
||||
bridge2.setRequestHandler("tools/call", async () => ({ content: [] }));
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
bridge2.oncalltool = async () => ({ content: [] });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("oncreatesamplingmessage has a getter, replace semantics, and a replace warning", () => {
|
||||
const bridge2 = new AppBridge(
|
||||
createMockClient() as Client,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
expect(bridge2.oncreatesamplingmessage).toBeUndefined();
|
||||
const first = async () => ({
|
||||
role: "assistant" as const,
|
||||
content: { type: "text" as const, text: "" },
|
||||
model: "m",
|
||||
});
|
||||
bridge2.oncreatesamplingmessage = first;
|
||||
expect(bridge2.oncreatesamplingmessage).toBe(first);
|
||||
|
||||
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
expect(() => {
|
||||
bridge2.oncreatesamplingmessage = first;
|
||||
}).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("oncreatesamplingmessage handler replaced"),
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
bridge2.oncreatesamplingmessage = undefined;
|
||||
expect(bridge2.oncreatesamplingmessage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
EmptyResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import { EventDispatcher } from "./events.js";
|
||||
import { EventDispatcher, MethodRegistry } from "./events.js";
|
||||
import type { ZodLiteral, ZodObject, ZodType } from "zod/v4";
|
||||
|
||||
type MethodSchema = ZodObject<{
|
||||
|
|
@ -42,6 +42,8 @@ type MethodSchema = ZodObject<{
|
|||
params: ZodType;
|
||||
}>;
|
||||
|
||||
type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void;
|
||||
|
||||
import {
|
||||
type McpUiSandboxResourceReadyNotification,
|
||||
type McpUiSizeChangedNotification,
|
||||
|
|
@ -216,8 +218,8 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
|
|||
/**
|
||||
* Extra metadata passed to request handlers.
|
||||
*
|
||||
* This type represents the additional context provided by the base MCP SDK
|
||||
* `Server` when handling requests.
|
||||
* This type represents the additional context (`BaseContext`) provided by the
|
||||
* base MCP SDK `Protocol` when handling requests.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
|
|
@ -302,6 +304,81 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
private _initializedReceived = false;
|
||||
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
|
||||
private readonly _events = new EventDispatcher<AppBridgeEventMap>();
|
||||
private readonly _methods = new MethodRegistry();
|
||||
|
||||
// ── Handler registration with double-set protection ─────────────────
|
||||
//
|
||||
// The base SDK `Protocol` silently replaces an existing handler. The four
|
||||
// overrides below restore the v1 behaviour: a direct `setRequestHandler` /
|
||||
// `setNotificationHandler` for a method that already has a handler throws,
|
||||
// so a stray registration cannot silently replace a host's `on*` handler
|
||||
// (for example the URL allow-listing in `onopenlink`) or disconnect
|
||||
// `addEventListener` listeners. They are arrow-function class fields rather
|
||||
// than prototype methods so that `Protocol`'s constructor — which registers
|
||||
// its own ping/cancelled/progress handlers before our fields initialize —
|
||||
// hits the base implementation and skips tracking.
|
||||
|
||||
/**
|
||||
* Registers a request handler. Throws if a handler for the same method has
|
||||
* already been registered — use the `on*` setter for replace semantics.
|
||||
*
|
||||
* @throws {Error} if a handler for this method is already registered.
|
||||
*/
|
||||
override setRequestHandler: Protocol<BaseContext>["setRequestHandler"] = (
|
||||
method: string,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
this._methods.claim(method, "setRequestHandler");
|
||||
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
|
||||
this,
|
||||
method,
|
||||
...rest,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<BaseContext>["setNotificationHandler"] =
|
||||
(method: string, ...rest: unknown[]) => {
|
||||
this._methods.claim(method, "setNotificationHandler");
|
||||
(super.setNotificationHandler as unknown as UntypedHandlerSetter).call(
|
||||
this,
|
||||
method,
|
||||
...rest,
|
||||
);
|
||||
};
|
||||
|
||||
override removeRequestHandler: Protocol<BaseContext>["removeRequestHandler"] =
|
||||
(method: string) => {
|
||||
this._methods.release(method);
|
||||
super.removeRequestHandler(method);
|
||||
};
|
||||
|
||||
override removeNotificationHandler: Protocol<BaseContext>["removeNotificationHandler"] =
|
||||
(method: string) => {
|
||||
this._methods.release(method);
|
||||
super.removeNotificationHandler(method);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a request handler with replace semantics, bypassing the
|
||||
* double-set protection of {@link setRequestHandler `setRequestHandler`}.
|
||||
* Used by the `on*` request-handler setters.
|
||||
*/
|
||||
protected replaceRequestHandler: Protocol<BaseContext>["setRequestHandler"] =
|
||||
(method: string, ...rest: unknown[]) => {
|
||||
this._methods.replace(method);
|
||||
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
|
||||
this,
|
||||
method,
|
||||
...rest,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The base MCP SDK calls this hook for every standard and custom request
|
||||
|
|
@ -419,7 +496,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters.
|
||||
* @param _hostInfo - Host application identification (name and version)
|
||||
* @param _hostCapabilities - Features and capabilities the host supports
|
||||
* @param options - Configuration options (inherited from Server)
|
||||
* @param options - Configuration options (`ProtocolOptions` from the base MCP SDK plus `hostContext`)
|
||||
*
|
||||
* @example With MCP client (automatic forwarding)
|
||||
* ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient"
|
||||
|
|
@ -455,7 +532,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
|
||||
this._hostContext = options?.hostContext || {};
|
||||
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/initialize",
|
||||
{
|
||||
params: McpUiInitializeRequestSchema.shape.params,
|
||||
|
|
@ -464,14 +541,14 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
(params) => this._onAppsInitialize(params),
|
||||
);
|
||||
|
||||
this.setRequestHandler("ping", (request, extra) => {
|
||||
this.replaceRequestHandler("ping", (request, extra) => {
|
||||
this.onping?.(request.params, extra);
|
||||
return {};
|
||||
});
|
||||
|
||||
// Default handler for requestDisplayMode - returns current mode from host context.
|
||||
// Hosts can override this by setting bridge.onrequestdisplaymode = ...
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/request-display-mode",
|
||||
{
|
||||
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||
|
|
@ -746,7 +823,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback);
|
||||
this._onmessage = callback;
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/message",
|
||||
{
|
||||
params: McpUiMessageRequestSchema.shape.params,
|
||||
|
|
@ -819,7 +896,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback);
|
||||
this._onopenlink = callback;
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/open-link",
|
||||
{
|
||||
params: McpUiOpenLinkRequestSchema.shape.params,
|
||||
|
|
@ -895,7 +972,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._ondownloadfile = callback;
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/download-file",
|
||||
{
|
||||
params: McpUiDownloadFileRequestSchema.shape.params,
|
||||
|
|
@ -999,7 +1076,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._onrequestdisplaymode = callback;
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/request-display-mode",
|
||||
{
|
||||
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||
|
|
@ -1100,7 +1177,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._onupdatemodelcontext = callback;
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/update-model-context",
|
||||
{
|
||||
params: McpUiUpdateModelContextRequestSchema.shape.params,
|
||||
|
|
@ -1136,8 +1213,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `CallToolRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `CallToolResult` from @modelcontextprotocol/server for the result type
|
||||
* @see `CallToolRequest` from @modelcontextprotocol/client forthe request type
|
||||
* @see `CallToolResult` from @modelcontextprotocol/client forthe result type
|
||||
*/
|
||||
private _oncalltool?: (
|
||||
params: CallToolRequest["params"],
|
||||
|
|
@ -1156,7 +1233,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||
this._oncalltool = callback;
|
||||
this.setRequestHandler("tools/call", async (request, extra) => {
|
||||
this.replaceRequestHandler("tools/call", async (request, extra) => {
|
||||
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||
return this._oncalltool(request.params, extra);
|
||||
});
|
||||
|
|
@ -1190,18 +1267,39 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `CreateMessageRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/server for result types
|
||||
* @see `CreateMessageRequest` from @modelcontextprotocol/client forthe request type
|
||||
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/client forresult types
|
||||
*/
|
||||
private _oncreatesamplingmessage?: (
|
||||
params: CreateMessageRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CreateMessageResult | CreateMessageResultWithTools>;
|
||||
get oncreatesamplingmessage() {
|
||||
return this._oncreatesamplingmessage;
|
||||
}
|
||||
set oncreatesamplingmessage(
|
||||
callback: (
|
||||
params: CreateMessageRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CreateMessageResult | CreateMessageResultWithTools>,
|
||||
callback:
|
||||
| ((
|
||||
params: CreateMessageRequest["params"],
|
||||
extra: RequestHandlerExtra,
|
||||
) => Promise<CreateMessageResult | CreateMessageResultWithTools>)
|
||||
| undefined,
|
||||
) {
|
||||
this.setRequestHandler("sampling/createMessage", async (request, extra) => {
|
||||
return callback(request.params, extra);
|
||||
});
|
||||
this.warnIfRequestHandlerReplaced(
|
||||
"oncreatesamplingmessage",
|
||||
this._oncreatesamplingmessage,
|
||||
callback,
|
||||
);
|
||||
this._oncreatesamplingmessage = callback;
|
||||
this.replaceRequestHandler(
|
||||
"sampling/createMessage",
|
||||
async (request, extra) => {
|
||||
if (!this._oncreatesamplingmessage) {
|
||||
throw new Error("No oncreatesamplingmessage handler set");
|
||||
}
|
||||
return this._oncreatesamplingmessage(request.params, extra);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1221,7 +1319,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* });
|
||||
* ```
|
||||
*
|
||||
* @see `ToolListChangedNotification` from @modelcontextprotocol/server for the notification type
|
||||
* @see `ToolListChangedNotification` from @modelcontextprotocol/client forthe notification type
|
||||
*/
|
||||
sendToolListChanged(params: ToolListChangedNotification["params"] = {}) {
|
||||
return this.notification({
|
||||
|
|
@ -1252,8 +1350,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ListResourcesRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ListResourcesResult` from @modelcontextprotocol/server for the result type
|
||||
* @see `ListResourcesRequest` from @modelcontextprotocol/client forthe request type
|
||||
* @see `ListResourcesResult` from @modelcontextprotocol/client forthe result type
|
||||
*/
|
||||
private _onlistresources?: (
|
||||
params: ListResourcesRequest["params"],
|
||||
|
|
@ -1276,7 +1374,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._onlistresources = callback;
|
||||
this.setRequestHandler("resources/list", async (request, extra) => {
|
||||
this.replaceRequestHandler("resources/list", async (request, extra) => {
|
||||
if (!this._onlistresources)
|
||||
throw new Error("No onlistresources handler set");
|
||||
return this._onlistresources(request.params, extra);
|
||||
|
|
@ -1305,8 +1403,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ListResourceTemplatesRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/server for the result type
|
||||
* @see `ListResourceTemplatesRequest` from @modelcontextprotocol/client forthe request type
|
||||
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/client forthe result type
|
||||
*/
|
||||
private _onlistresourcetemplates?: (
|
||||
params: ListResourceTemplatesRequest["params"],
|
||||
|
|
@ -1329,7 +1427,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._onlistresourcetemplates = callback;
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"resources/templates/list",
|
||||
async (request, extra) => {
|
||||
if (!this._onlistresourcetemplates)
|
||||
|
|
@ -1361,8 +1459,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ReadResourceRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ReadResourceResult` from @modelcontextprotocol/server for the result type
|
||||
* @see `ReadResourceRequest` from @modelcontextprotocol/client forthe request type
|
||||
* @see `ReadResourceResult` from @modelcontextprotocol/client forthe result type
|
||||
*/
|
||||
private _onreadresource?: (
|
||||
params: ReadResourceRequest["params"],
|
||||
|
|
@ -1385,7 +1483,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._onreadresource = callback;
|
||||
this.setRequestHandler("resources/read", async (request, extra) => {
|
||||
this.replaceRequestHandler("resources/read", async (request, extra) => {
|
||||
if (!this._onreadresource)
|
||||
throw new Error("No onreadresource handler set");
|
||||
return this._onreadresource(request.params, extra);
|
||||
|
|
@ -1409,7 +1507,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* });
|
||||
* ```
|
||||
*
|
||||
* @see `ResourceListChangedNotification` from @modelcontextprotocol/server for the notification type
|
||||
* @see `ResourceListChangedNotification` from @modelcontextprotocol/client forthe notification type
|
||||
*/
|
||||
sendResourceListChanged(
|
||||
params: ResourceListChangedNotification["params"] = {},
|
||||
|
|
@ -1442,8 +1540,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ListPromptsRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ListPromptsResult` from @modelcontextprotocol/server for the result type
|
||||
* @see `ListPromptsRequest` from @modelcontextprotocol/client forthe request type
|
||||
* @see `ListPromptsResult` from @modelcontextprotocol/client forthe result type
|
||||
*/
|
||||
private _onlistprompts?: (
|
||||
params: ListPromptsRequest["params"],
|
||||
|
|
@ -1466,7 +1564,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._onlistprompts = callback;
|
||||
this.setRequestHandler("prompts/list", async (request, extra) => {
|
||||
this.replaceRequestHandler("prompts/list", async (request, extra) => {
|
||||
if (!this._onlistprompts) throw new Error("No onlistprompts handler set");
|
||||
return this._onlistprompts(request.params, extra);
|
||||
});
|
||||
|
|
@ -1489,7 +1587,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
|||
* });
|
||||
* ```
|
||||
*
|
||||
* @see `PromptListChangedNotification` from @modelcontextprotocol/server for the notification type
|
||||
* @see `PromptListChangedNotification` from @modelcontextprotocol/client forthe notification type
|
||||
*/
|
||||
sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) {
|
||||
return this.notification({
|
||||
|
|
|
|||
111
src/app.test.ts
111
src/app.test.ts
|
|
@ -248,25 +248,62 @@ describe("App base MCP SDK v2 Protocol migration", () => {
|
|||
}).toThrow(/handler registered after connect/);
|
||||
});
|
||||
|
||||
it("uses base MCP SDK replacement semantics for request handlers", async () => {
|
||||
it("direct setRequestHandler throws when a handler is already registered", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
const params = z.object({});
|
||||
let handledBy = 0;
|
||||
|
||||
pair.app.setRequestHandler("ping", () => {
|
||||
pair.app.setRequestHandler("test/method", { params }, () => {
|
||||
handledBy = 1;
|
||||
return {};
|
||||
});
|
||||
pair.app.setRequestHandler("ping", () => {
|
||||
expect(() => {
|
||||
pair.app.setRequestHandler("test/method", { params }, () => {
|
||||
handledBy = 2;
|
||||
return {};
|
||||
});
|
||||
}).toThrow(/already registered/);
|
||||
|
||||
await pair.server.request(
|
||||
{ method: "test/method", params: {} },
|
||||
z.object({}),
|
||||
);
|
||||
expect(handledBy).toBe(1);
|
||||
});
|
||||
|
||||
it("direct setRequestHandler throws for methods owned by the App itself", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
expect(() => {
|
||||
pair.app.setRequestHandler("ping", () => ({}));
|
||||
}).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it("removeRequestHandler releases the method for re-registration", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
const params = z.object({});
|
||||
let handledBy = 0;
|
||||
|
||||
pair.app.setRequestHandler("test/method", { params }, () => {
|
||||
handledBy = 1;
|
||||
return {};
|
||||
});
|
||||
pair.app.removeRequestHandler("test/method");
|
||||
pair.app.setRequestHandler("test/method", { params }, () => {
|
||||
handledBy = 2;
|
||||
return {};
|
||||
});
|
||||
|
||||
await pair.server.request({ method: "ping", params: {} });
|
||||
await pair.server.request(
|
||||
{ method: "test/method", params: {} },
|
||||
z.object({}),
|
||||
);
|
||||
expect(handledBy).toBe(2);
|
||||
});
|
||||
|
||||
it("uses base MCP SDK replacement semantics for non-event notifications", async () => {
|
||||
it("direct setNotificationHandler throws when a handler is already registered", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
const calls: number[] = [];
|
||||
|
|
@ -275,14 +312,70 @@ describe("App base MCP SDK v2 Protocol migration", () => {
|
|||
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
||||
calls.push(1);
|
||||
});
|
||||
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
||||
calls.push(2);
|
||||
});
|
||||
expect(() => {
|
||||
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
||||
calls.push(2);
|
||||
});
|
||||
}).toThrow(/already registered/);
|
||||
await pair.server.notification({
|
||||
method: "test/notification",
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(calls).toEqual([2]);
|
||||
expect(calls).toEqual([1]);
|
||||
});
|
||||
|
||||
it("direct setNotificationHandler throws for event-mapped methods (listener first)", () => {
|
||||
const app = new App(
|
||||
{ name: "view-app", version: "1.0.0" },
|
||||
{},
|
||||
{ autoResize: false },
|
||||
);
|
||||
app.addEventListener("toolinput", () => {});
|
||||
expect(() => {
|
||||
app.setNotificationHandler(
|
||||
"ui/notifications/tool-input",
|
||||
{ params: z.object({}) },
|
||||
() => {},
|
||||
);
|
||||
}).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it("event registration throws when a direct handler already owns the method", () => {
|
||||
const app = new App(
|
||||
{ name: "view-app", version: "1.0.0" },
|
||||
{},
|
||||
{ autoResize: false },
|
||||
);
|
||||
app.setNotificationHandler(
|
||||
"ui/notifications/tool-input",
|
||||
{ params: z.object({}) },
|
||||
() => {},
|
||||
);
|
||||
expect(() => {
|
||||
app.ontoolinput = () => {};
|
||||
}).toThrow(/already registered/);
|
||||
expect(() => {
|
||||
app.addEventListener("toolinput", () => {});
|
||||
}).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it("on* request setters keep replace semantics", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
let handledBy = 0;
|
||||
pair.app.onteardown = async () => {
|
||||
handledBy = 1;
|
||||
return {};
|
||||
};
|
||||
pair.app.onteardown = async () => {
|
||||
handledBy = 2;
|
||||
return {};
|
||||
};
|
||||
await pair.server.request(
|
||||
{ method: "ui/resource-teardown", params: {} },
|
||||
z.object({}),
|
||||
);
|
||||
expect(handledBy).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
159
src/app.ts
159
src/app.ts
|
|
@ -23,8 +23,10 @@ import {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import { EmptyResultSchema } from "@modelcontextprotocol/core";
|
||||
export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js";
|
||||
import { EventDispatcher } from "./events.js";
|
||||
import { EventDispatcher, MethodRegistry } from "./events.js";
|
||||
export { EventDispatcher } from "./events.js";
|
||||
|
||||
type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void;
|
||||
import { PostMessageTransport } from "./message-transport.js";
|
||||
import {
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
|
|
@ -103,71 +105,6 @@ export {
|
|||
applyDocumentTheme,
|
||||
} from "./styles.js";
|
||||
|
||||
/**
|
||||
* Metadata key for associating a UI resource URI with a tool.
|
||||
*
|
||||
* MCP servers include this key in tool definition metadata (via `tools/list`)
|
||||
* to indicate which UI resource should be displayed when the tool is called.
|
||||
* When hosts see a tool with this metadata, they fetch and render the
|
||||
* corresponding {@link App `App`}.
|
||||
*
|
||||
* **Note**: This constant is provided for reference and backwards compatibility.
|
||||
* Server developers should use {@link server-helpers!registerAppTool `registerAppTool`}
|
||||
* with the `_meta.ui.resourceUri` format instead. Host developers must check both
|
||||
* formats for compatibility.
|
||||
*
|
||||
* @example Modern format (server-side, not in Apps)
|
||||
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_modernFormat"
|
||||
* // Preferred: Use registerAppTool with nested ui.resourceUri
|
||||
* registerAppTool(
|
||||
* server,
|
||||
* "weather",
|
||||
* {
|
||||
* description: "Get weather forecast",
|
||||
* _meta: {
|
||||
* ui: { resourceUri: "ui://weather/forecast" },
|
||||
* },
|
||||
* },
|
||||
* handler,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Legacy format (deprecated, for backwards compatibility)
|
||||
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_legacyFormat"
|
||||
* // Deprecated: Direct use of RESOURCE_URI_META_KEY
|
||||
* server.registerTool(
|
||||
* "weather",
|
||||
* {
|
||||
* description: "Get weather forecast",
|
||||
* _meta: {
|
||||
* [RESOURCE_URI_META_KEY]: "ui://weather/forecast",
|
||||
* },
|
||||
* },
|
||||
* handler,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example How hosts check for this metadata (must support both formats)
|
||||
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide"
|
||||
* // Hosts should check both modern and legacy formats
|
||||
* const meta = tool._meta;
|
||||
* const uiMeta = meta?.ui as McpUiToolMeta | undefined;
|
||||
* const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined;
|
||||
* const uiUri = uiMeta?.resourceUri ?? legacyUri;
|
||||
* if (typeof uiUri === "string" && uiUri.startsWith("ui://")) {
|
||||
* // Fetch the resource and display the UI
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* MIME type for MCP UI resources.
|
||||
*
|
||||
* Identifies HTML content as an MCP App UI resource.
|
||||
*
|
||||
* Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for configuring {@link App `App`} behavior.
|
||||
*
|
||||
|
|
@ -359,6 +296,80 @@ export class App extends Protocol<BaseContext> {
|
|||
private _initializedSent = false;
|
||||
private readonly _registeredEvents = new Set<keyof AppEventMap>();
|
||||
private readonly _events = new EventDispatcher<AppEventMap>();
|
||||
private readonly _methods = new MethodRegistry();
|
||||
|
||||
// ── Handler registration with double-set protection ─────────────────
|
||||
//
|
||||
// The base SDK `Protocol` silently replaces an existing handler. The four
|
||||
// overrides below restore the v1 behaviour: a direct `setRequestHandler` /
|
||||
// `setNotificationHandler` for a method that already has a handler throws,
|
||||
// so a stray registration cannot silently disconnect `on*` handlers or
|
||||
// `addEventListener` listeners. They are arrow-function class fields rather
|
||||
// than prototype methods so that `Protocol`'s constructor — which registers
|
||||
// its own ping/cancelled/progress handlers before our fields initialize —
|
||||
// hits the base implementation and skips tracking.
|
||||
|
||||
/**
|
||||
* Registers a request handler. Throws if a handler for the same method has
|
||||
* already been registered — use the `on*` setter for replace semantics.
|
||||
*
|
||||
* @throws {Error} if a handler for this method is already registered.
|
||||
*/
|
||||
override setRequestHandler: Protocol<BaseContext>["setRequestHandler"] = (
|
||||
method: string,
|
||||
...rest: unknown[]
|
||||
) => {
|
||||
this._methods.claim(method, "setRequestHandler");
|
||||
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
|
||||
this,
|
||||
method,
|
||||
...rest,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<BaseContext>["setNotificationHandler"] =
|
||||
(method: string, ...rest: unknown[]) => {
|
||||
this._methods.claim(method, "setNotificationHandler");
|
||||
(super.setNotificationHandler as unknown as UntypedHandlerSetter).call(
|
||||
this,
|
||||
method,
|
||||
...rest,
|
||||
);
|
||||
};
|
||||
|
||||
override removeRequestHandler: Protocol<BaseContext>["removeRequestHandler"] =
|
||||
(method: string) => {
|
||||
this._methods.release(method);
|
||||
super.removeRequestHandler(method);
|
||||
};
|
||||
|
||||
override removeNotificationHandler: Protocol<BaseContext>["removeNotificationHandler"] =
|
||||
(method: string) => {
|
||||
this._methods.release(method);
|
||||
super.removeNotificationHandler(method);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a request handler with replace semantics, bypassing the
|
||||
* double-set protection of {@link setRequestHandler `setRequestHandler`}.
|
||||
* Used by the `on*` request-handler setters.
|
||||
*/
|
||||
protected replaceRequestHandler: Protocol<BaseContext>["setRequestHandler"] =
|
||||
(method: string, ...rest: unknown[]) => {
|
||||
this._methods.replace(method);
|
||||
(super.setRequestHandler as unknown as UntypedHandlerSetter).call(
|
||||
this,
|
||||
method,
|
||||
...rest,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Warn if a host-bound method is called before {@link connect `connect`} has
|
||||
|
|
@ -524,7 +535,7 @@ export class App extends Protocol<BaseContext> {
|
|||
z.config({ jitless: true });
|
||||
}
|
||||
|
||||
this.setRequestHandler("ping", (request) => {
|
||||
this.replaceRequestHandler("ping", (request) => {
|
||||
console.log("Received ping:", request.params);
|
||||
return {};
|
||||
});
|
||||
|
|
@ -1056,7 +1067,7 @@ export class App extends Protocol<BaseContext> {
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback);
|
||||
this._onteardown = callback;
|
||||
this.setRequestHandler(
|
||||
this.replaceRequestHandler(
|
||||
"ui/resource-teardown",
|
||||
{
|
||||
params: McpUiResourceTeardownRequestSchema.shape.params,
|
||||
|
|
@ -1114,7 +1125,7 @@ export class App extends Protocol<BaseContext> {
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||
this._oncalltool = callback;
|
||||
this.setRequestHandler("tools/call", (request, extra) => {
|
||||
this.replaceRequestHandler("tools/call", (request, extra) => {
|
||||
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||
return this._oncalltool(request.params, extra);
|
||||
});
|
||||
|
|
@ -1185,7 +1196,7 @@ export class App extends Protocol<BaseContext> {
|
|||
callback,
|
||||
);
|
||||
this._onlisttools = callback;
|
||||
this.setRequestHandler("tools/list", (request, extra) => {
|
||||
this.replaceRequestHandler("tools/list", (request, extra) => {
|
||||
if (!this._onlisttools) throw new Error("No onlisttools handler set");
|
||||
return this._onlisttools(request.params, extra);
|
||||
});
|
||||
|
|
@ -1221,11 +1232,11 @@ export class App extends Protocol<BaseContext> {
|
|||
);
|
||||
}
|
||||
return;
|
||||
case "ping":
|
||||
case "ui/resource-teardown":
|
||||
return;
|
||||
default:
|
||||
throw new Error(`No handler for method ${method} registered`);
|
||||
// `ping`, `ui/*`, and custom (vendor-prefixed) methods need no
|
||||
// declared capability. The base SDK explicitly supports custom
|
||||
// request handlers via the `{ params, result }` form.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,66 @@
|
|||
/** Legacy metadata key associating an MCP tool with its App resource. */
|
||||
/**
|
||||
* Metadata key for associating a UI resource URI with a tool.
|
||||
*
|
||||
* MCP servers include this key in tool definition metadata (via `tools/list`)
|
||||
* to indicate which UI resource should be displayed when the tool is called.
|
||||
* When hosts see a tool with this metadata, they fetch and render the
|
||||
* corresponding {@link app!App `App`}.
|
||||
*
|
||||
* **Note**: This constant is provided for reference and backwards compatibility.
|
||||
* Server developers should use {@link server-helpers!registerAppTool `registerAppTool`}
|
||||
* with the `_meta.ui.resourceUri` format instead. Host developers must check both
|
||||
* formats for compatibility.
|
||||
*
|
||||
* @example Modern format (server-side, not in Apps)
|
||||
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_modernFormat"
|
||||
* // Preferred: Use registerAppTool with nested ui.resourceUri
|
||||
* registerAppTool(
|
||||
* server,
|
||||
* "weather",
|
||||
* {
|
||||
* description: "Get weather forecast",
|
||||
* _meta: {
|
||||
* ui: { resourceUri: "ui://weather/forecast" },
|
||||
* },
|
||||
* },
|
||||
* handler,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Legacy format (deprecated, for backwards compatibility)
|
||||
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_legacyFormat"
|
||||
* // Deprecated: Direct use of RESOURCE_URI_META_KEY
|
||||
* server.registerTool(
|
||||
* "weather",
|
||||
* {
|
||||
* description: "Get weather forecast",
|
||||
* _meta: {
|
||||
* [RESOURCE_URI_META_KEY]: "ui://weather/forecast",
|
||||
* },
|
||||
* },
|
||||
* handler,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example How hosts check for this metadata (must support both formats)
|
||||
* ```ts source="./app.examples.ts#RESOURCE_URI_META_KEY_hostSide"
|
||||
* // Hosts should check both modern and legacy formats
|
||||
* const meta = tool._meta;
|
||||
* const uiMeta = meta?.ui as McpUiToolMeta | undefined;
|
||||
* const legacyUri = meta?.[RESOURCE_URI_META_KEY] as string | undefined;
|
||||
* const uiUri = uiMeta?.resourceUri ?? legacyUri;
|
||||
* if (typeof uiUri === "string" && uiUri.startsWith("ui://")) {
|
||||
* // Fetch the resource and display the UI
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
||||
|
||||
/** MIME type for MCP App HTML resources. */
|
||||
/**
|
||||
* MIME type for MCP UI resources.
|
||||
*
|
||||
* Identifies HTML content as an MCP App UI resource.
|
||||
*
|
||||
* Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources.
|
||||
*/
|
||||
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
||||
|
|
|
|||
|
|
@ -89,3 +89,44 @@ export class EventDispatcher<EventMap extends Record<string, unknown>> {
|
|||
return slot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which JSON-RPC methods already have a handler registered through the
|
||||
* owning `App` / `AppBridge`, so a second direct `setRequestHandler` /
|
||||
* `setNotificationHandler` call for the same method throws instead of
|
||||
* silently replacing the first handler (the base SDK `Protocol` replaces).
|
||||
*
|
||||
* The `on*` setters use {@link replace} for DOM-style replace semantics, and
|
||||
* `removeRequestHandler` / `removeNotificationHandler` use {@link release}.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export class MethodRegistry {
|
||||
private readonly _methods = new Set<string>();
|
||||
|
||||
/** Claim `method`. Throws if a handler is already registered for it. */
|
||||
claim(method: string, via: string): void {
|
||||
if (this._methods.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._methods.add(method);
|
||||
}
|
||||
|
||||
/** Claim `method` with replace semantics (never throws). */
|
||||
replace(method: string): void {
|
||||
this._methods.add(method);
|
||||
}
|
||||
|
||||
/** Release `method` so it can be claimed again. */
|
||||
release(method: string): void {
|
||||
this._methods.delete(method);
|
||||
}
|
||||
|
||||
has(method: string): boolean {
|
||||
return this._methods.has(method);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue