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);
|
expect(app.onteardown).toBe(handler);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("direct setRequestHandler uses base SDK replacement semantics", () => {
|
it("direct setRequestHandler throws when called twice", () => {
|
||||||
const bridge2 = new AppBridge(
|
const bridge2 = new AppBridge(
|
||||||
createMockClient() as Client,
|
createMockClient() as Client,
|
||||||
testHostInfo,
|
testHostInfo,
|
||||||
testHostCapabilities,
|
testHostCapabilities,
|
||||||
);
|
);
|
||||||
bridge2.setRequestHandler("ping", () => ({}));
|
const params = z.object({});
|
||||||
|
bridge2.setRequestHandler("test/method", { params }, () => ({}));
|
||||||
expect(() => {
|
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();
|
}).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,
|
EmptyResultSchema,
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
} from "@modelcontextprotocol/core";
|
} from "@modelcontextprotocol/core";
|
||||||
import { EventDispatcher } from "./events.js";
|
import { EventDispatcher, MethodRegistry } from "./events.js";
|
||||||
import type { ZodLiteral, ZodObject, ZodType } from "zod/v4";
|
import type { ZodLiteral, ZodObject, ZodType } from "zod/v4";
|
||||||
|
|
||||||
type MethodSchema = ZodObject<{
|
type MethodSchema = ZodObject<{
|
||||||
|
|
@ -42,6 +42,8 @@ type MethodSchema = ZodObject<{
|
||||||
params: ZodType;
|
params: ZodType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void;
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type McpUiSandboxResourceReadyNotification,
|
type McpUiSandboxResourceReadyNotification,
|
||||||
type McpUiSizeChangedNotification,
|
type McpUiSizeChangedNotification,
|
||||||
|
|
@ -216,8 +218,8 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
|
||||||
/**
|
/**
|
||||||
* Extra metadata passed to request handlers.
|
* Extra metadata passed to request handlers.
|
||||||
*
|
*
|
||||||
* This type represents the additional context provided by the base MCP SDK
|
* This type represents the additional context (`BaseContext`) provided by the
|
||||||
* `Server` when handling requests.
|
* base MCP SDK `Protocol` when handling requests.
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
|
|
@ -302,6 +304,81 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
private _initializedReceived = false;
|
private _initializedReceived = false;
|
||||||
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
|
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
|
||||||
private readonly _events = new EventDispatcher<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
|
* 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.
|
* manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters.
|
||||||
* @param _hostInfo - Host application identification (name and version)
|
* @param _hostInfo - Host application identification (name and version)
|
||||||
* @param _hostCapabilities - Features and capabilities the host supports
|
* @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)
|
* @example With MCP client (automatic forwarding)
|
||||||
* ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient"
|
* ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient"
|
||||||
|
|
@ -455,7 +532,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
|
|
||||||
this._hostContext = options?.hostContext || {};
|
this._hostContext = options?.hostContext || {};
|
||||||
|
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/initialize",
|
"ui/initialize",
|
||||||
{
|
{
|
||||||
params: McpUiInitializeRequestSchema.shape.params,
|
params: McpUiInitializeRequestSchema.shape.params,
|
||||||
|
|
@ -464,14 +541,14 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
(params) => this._onAppsInitialize(params),
|
(params) => this._onAppsInitialize(params),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.setRequestHandler("ping", (request, extra) => {
|
this.replaceRequestHandler("ping", (request, extra) => {
|
||||||
this.onping?.(request.params, extra);
|
this.onping?.(request.params, extra);
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default handler for requestDisplayMode - returns current mode from host context.
|
// Default handler for requestDisplayMode - returns current mode from host context.
|
||||||
// Hosts can override this by setting bridge.onrequestdisplaymode = ...
|
// Hosts can override this by setting bridge.onrequestdisplaymode = ...
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/request-display-mode",
|
"ui/request-display-mode",
|
||||||
{
|
{
|
||||||
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||||
|
|
@ -746,7 +823,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback);
|
this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback);
|
||||||
this._onmessage = callback;
|
this._onmessage = callback;
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/message",
|
"ui/message",
|
||||||
{
|
{
|
||||||
params: McpUiMessageRequestSchema.shape.params,
|
params: McpUiMessageRequestSchema.shape.params,
|
||||||
|
|
@ -819,7 +896,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback);
|
this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback);
|
||||||
this._onopenlink = callback;
|
this._onopenlink = callback;
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/open-link",
|
"ui/open-link",
|
||||||
{
|
{
|
||||||
params: McpUiOpenLinkRequestSchema.shape.params,
|
params: McpUiOpenLinkRequestSchema.shape.params,
|
||||||
|
|
@ -895,7 +972,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._ondownloadfile = callback;
|
this._ondownloadfile = callback;
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/download-file",
|
"ui/download-file",
|
||||||
{
|
{
|
||||||
params: McpUiDownloadFileRequestSchema.shape.params,
|
params: McpUiDownloadFileRequestSchema.shape.params,
|
||||||
|
|
@ -999,7 +1076,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onrequestdisplaymode = callback;
|
this._onrequestdisplaymode = callback;
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/request-display-mode",
|
"ui/request-display-mode",
|
||||||
{
|
{
|
||||||
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||||
|
|
@ -1100,7 +1177,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onupdatemodelcontext = callback;
|
this._onupdatemodelcontext = callback;
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/update-model-context",
|
"ui/update-model-context",
|
||||||
{
|
{
|
||||||
params: McpUiUpdateModelContextRequestSchema.shape.params,
|
params: McpUiUpdateModelContextRequestSchema.shape.params,
|
||||||
|
|
@ -1136,8 +1213,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
* };
|
* };
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @see `CallToolRequest` from @modelcontextprotocol/server for the request type
|
* @see `CallToolRequest` from @modelcontextprotocol/client forthe request type
|
||||||
* @see `CallToolResult` from @modelcontextprotocol/server for the result type
|
* @see `CallToolResult` from @modelcontextprotocol/client forthe result type
|
||||||
*/
|
*/
|
||||||
private _oncalltool?: (
|
private _oncalltool?: (
|
||||||
params: CallToolRequest["params"],
|
params: CallToolRequest["params"],
|
||||||
|
|
@ -1156,7 +1233,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||||
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");
|
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||||
return this._oncalltool(request.params, extra);
|
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 `CreateMessageRequest` from @modelcontextprotocol/client forthe request type
|
||||||
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/server for result types
|
* @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(
|
set oncreatesamplingmessage(
|
||||||
callback: (
|
callback:
|
||||||
params: CreateMessageRequest["params"],
|
| ((
|
||||||
extra: RequestHandlerExtra,
|
params: CreateMessageRequest["params"],
|
||||||
) => Promise<CreateMessageResult | CreateMessageResultWithTools>,
|
extra: RequestHandlerExtra,
|
||||||
|
) => Promise<CreateMessageResult | CreateMessageResultWithTools>)
|
||||||
|
| undefined,
|
||||||
) {
|
) {
|
||||||
this.setRequestHandler("sampling/createMessage", async (request, extra) => {
|
this.warnIfRequestHandlerReplaced(
|
||||||
return callback(request.params, extra);
|
"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"] = {}) {
|
sendToolListChanged(params: ToolListChangedNotification["params"] = {}) {
|
||||||
return this.notification({
|
return this.notification({
|
||||||
|
|
@ -1252,8 +1350,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
* };
|
* };
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @see `ListResourcesRequest` from @modelcontextprotocol/server for the request type
|
* @see `ListResourcesRequest` from @modelcontextprotocol/client forthe request type
|
||||||
* @see `ListResourcesResult` from @modelcontextprotocol/server for the result type
|
* @see `ListResourcesResult` from @modelcontextprotocol/client forthe result type
|
||||||
*/
|
*/
|
||||||
private _onlistresources?: (
|
private _onlistresources?: (
|
||||||
params: ListResourcesRequest["params"],
|
params: ListResourcesRequest["params"],
|
||||||
|
|
@ -1276,7 +1374,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onlistresources = callback;
|
this._onlistresources = callback;
|
||||||
this.setRequestHandler("resources/list", async (request, extra) => {
|
this.replaceRequestHandler("resources/list", async (request, extra) => {
|
||||||
if (!this._onlistresources)
|
if (!this._onlistresources)
|
||||||
throw new Error("No onlistresources handler set");
|
throw new Error("No onlistresources handler set");
|
||||||
return this._onlistresources(request.params, extra);
|
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 `ListResourceTemplatesRequest` from @modelcontextprotocol/client forthe request type
|
||||||
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/server for the result type
|
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/client forthe result type
|
||||||
*/
|
*/
|
||||||
private _onlistresourcetemplates?: (
|
private _onlistresourcetemplates?: (
|
||||||
params: ListResourceTemplatesRequest["params"],
|
params: ListResourceTemplatesRequest["params"],
|
||||||
|
|
@ -1329,7 +1427,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onlistresourcetemplates = callback;
|
this._onlistresourcetemplates = callback;
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"resources/templates/list",
|
"resources/templates/list",
|
||||||
async (request, extra) => {
|
async (request, extra) => {
|
||||||
if (!this._onlistresourcetemplates)
|
if (!this._onlistresourcetemplates)
|
||||||
|
|
@ -1361,8 +1459,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
* };
|
* };
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @see `ReadResourceRequest` from @modelcontextprotocol/server for the request type
|
* @see `ReadResourceRequest` from @modelcontextprotocol/client forthe request type
|
||||||
* @see `ReadResourceResult` from @modelcontextprotocol/server for the result type
|
* @see `ReadResourceResult` from @modelcontextprotocol/client forthe result type
|
||||||
*/
|
*/
|
||||||
private _onreadresource?: (
|
private _onreadresource?: (
|
||||||
params: ReadResourceRequest["params"],
|
params: ReadResourceRequest["params"],
|
||||||
|
|
@ -1385,7 +1483,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onreadresource = callback;
|
this._onreadresource = callback;
|
||||||
this.setRequestHandler("resources/read", async (request, extra) => {
|
this.replaceRequestHandler("resources/read", async (request, extra) => {
|
||||||
if (!this._onreadresource)
|
if (!this._onreadresource)
|
||||||
throw new Error("No onreadresource handler set");
|
throw new Error("No onreadresource handler set");
|
||||||
return this._onreadresource(request.params, extra);
|
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(
|
sendResourceListChanged(
|
||||||
params: ResourceListChangedNotification["params"] = {},
|
params: ResourceListChangedNotification["params"] = {},
|
||||||
|
|
@ -1442,8 +1540,8 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
* };
|
* };
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @see `ListPromptsRequest` from @modelcontextprotocol/server for the request type
|
* @see `ListPromptsRequest` from @modelcontextprotocol/client forthe request type
|
||||||
* @see `ListPromptsResult` from @modelcontextprotocol/server for the result type
|
* @see `ListPromptsResult` from @modelcontextprotocol/client forthe result type
|
||||||
*/
|
*/
|
||||||
private _onlistprompts?: (
|
private _onlistprompts?: (
|
||||||
params: ListPromptsRequest["params"],
|
params: ListPromptsRequest["params"],
|
||||||
|
|
@ -1466,7 +1564,7 @@ export class AppBridge extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onlistprompts = 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");
|
if (!this._onlistprompts) throw new Error("No onlistprompts handler set");
|
||||||
return this._onlistprompts(request.params, extra);
|
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"] = {}) {
|
sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) {
|
||||||
return this.notification({
|
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/);
|
}).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();
|
const pair = await connectPair();
|
||||||
connected.push(pair);
|
connected.push(pair);
|
||||||
|
const params = z.object({});
|
||||||
let handledBy = 0;
|
let handledBy = 0;
|
||||||
|
|
||||||
pair.app.setRequestHandler("ping", () => {
|
pair.app.setRequestHandler("test/method", { params }, () => {
|
||||||
handledBy = 1;
|
handledBy = 1;
|
||||||
return {};
|
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;
|
handledBy = 2;
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
await pair.server.request({ method: "ping", params: {} });
|
await pair.server.request(
|
||||||
|
{ method: "test/method", params: {} },
|
||||||
|
z.object({}),
|
||||||
|
);
|
||||||
expect(handledBy).toBe(2);
|
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();
|
const pair = await connectPair();
|
||||||
connected.push(pair);
|
connected.push(pair);
|
||||||
const calls: number[] = [];
|
const calls: number[] = [];
|
||||||
|
|
@ -275,14 +312,70 @@ describe("App base MCP SDK v2 Protocol migration", () => {
|
||||||
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
||||||
calls.push(1);
|
calls.push(1);
|
||||||
});
|
});
|
||||||
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
expect(() => {
|
||||||
calls.push(2);
|
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
||||||
});
|
calls.push(2);
|
||||||
|
});
|
||||||
|
}).toThrow(/already registered/);
|
||||||
await pair.server.notification({
|
await pair.server.notification({
|
||||||
method: "test/notification",
|
method: "test/notification",
|
||||||
params: {},
|
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";
|
} from "@modelcontextprotocol/client";
|
||||||
import { EmptyResultSchema } from "@modelcontextprotocol/core";
|
import { EmptyResultSchema } from "@modelcontextprotocol/core";
|
||||||
export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants.js";
|
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";
|
export { EventDispatcher } from "./events.js";
|
||||||
|
|
||||||
|
type UntypedHandlerSetter = (this: unknown, ...args: unknown[]) => void;
|
||||||
import { PostMessageTransport } from "./message-transport.js";
|
import { PostMessageTransport } from "./message-transport.js";
|
||||||
import {
|
import {
|
||||||
LATEST_PROTOCOL_VERSION,
|
LATEST_PROTOCOL_VERSION,
|
||||||
|
|
@ -103,71 +105,6 @@ export {
|
||||||
applyDocumentTheme,
|
applyDocumentTheme,
|
||||||
} from "./styles.js";
|
} 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.
|
* Options for configuring {@link App `App`} behavior.
|
||||||
*
|
*
|
||||||
|
|
@ -359,6 +296,80 @@ export class App extends Protocol<BaseContext> {
|
||||||
private _initializedSent = false;
|
private _initializedSent = false;
|
||||||
private readonly _registeredEvents = new Set<keyof AppEventMap>();
|
private readonly _registeredEvents = new Set<keyof AppEventMap>();
|
||||||
private readonly _events = new EventDispatcher<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
|
* 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 });
|
z.config({ jitless: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setRequestHandler("ping", (request) => {
|
this.replaceRequestHandler("ping", (request) => {
|
||||||
console.log("Received ping:", request.params);
|
console.log("Received ping:", request.params);
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
@ -1056,7 +1067,7 @@ export class App extends Protocol<BaseContext> {
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback);
|
this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback);
|
||||||
this._onteardown = callback;
|
this._onteardown = callback;
|
||||||
this.setRequestHandler(
|
this.replaceRequestHandler(
|
||||||
"ui/resource-teardown",
|
"ui/resource-teardown",
|
||||||
{
|
{
|
||||||
params: McpUiResourceTeardownRequestSchema.shape.params,
|
params: McpUiResourceTeardownRequestSchema.shape.params,
|
||||||
|
|
@ -1114,7 +1125,7 @@ export class App extends Protocol<BaseContext> {
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||||
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");
|
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||||
return this._oncalltool(request.params, extra);
|
return this._oncalltool(request.params, extra);
|
||||||
});
|
});
|
||||||
|
|
@ -1185,7 +1196,7 @@ export class App extends Protocol<BaseContext> {
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onlisttools = 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");
|
if (!this._onlisttools) throw new Error("No onlisttools handler set");
|
||||||
return this._onlisttools(request.params, extra);
|
return this._onlisttools(request.params, extra);
|
||||||
});
|
});
|
||||||
|
|
@ -1221,11 +1232,11 @@ export class App extends Protocol<BaseContext> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case "ping":
|
|
||||||
case "ui/resource-teardown":
|
|
||||||
return;
|
|
||||||
default:
|
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";
|
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";
|
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,44 @@ export class EventDispatcher<EventMap extends Record<string, unknown>> {
|
||||||
return slot;
|
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