Migrate to MCP SDK 2.0 with official Protocol and isolated role peers (#720)
App and AppBridge extend the public Protocol from @modelcontextprotocol/client; the iframe wire protocol (ui/initialize only) is unchanged. Peers move to the split SDK 2.0 packages with client and server as optional role peers. Fixups (lockfile, peer ranges, restored handler guards, docs, wire-compat tests) follow in a separate PR. Co-authored-by: Andrew Khadder <andrew@manufact.com> Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Closes #702
This commit is contained in:
commit
1a713173cc
20 changed files with 2415 additions and 1867 deletions
|
|
@ -7,17 +7,11 @@
|
|||
* @module
|
||||
*/
|
||||
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import {
|
||||
CallToolResult,
|
||||
CallToolResultSchema,
|
||||
import { Client, type Transport } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
ListResourcesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from "@modelcontextprotocol/server";
|
||||
import { AppBridge, PostMessageTransport } from "./app-bridge.js";
|
||||
import type { McpUiDisplayMode } from "./types.js";
|
||||
|
||||
|
|
@ -223,8 +217,7 @@ function AppBridge_oncalltool_forwardToServer(
|
|||
bridge.oncalltool = async (params, extra) => {
|
||||
return mcpClient.request(
|
||||
{ method: "tools/call", params },
|
||||
CallToolResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
//#endregion AppBridge_oncalltool_forwardToServer
|
||||
|
|
@ -245,7 +238,9 @@ function AppBridge_oncreatesamplingmessage_forwardToLlm(
|
|||
//#region AppBridge_oncreatesamplingmessage_forwardToLlm
|
||||
bridge.oncreatesamplingmessage = async (params, extra) => {
|
||||
// Apply rate limiting, user approval, cost controls here
|
||||
return await myLlmProvider.complete(params, { signal: extra.signal });
|
||||
return await myLlmProvider.complete(params, {
|
||||
signal: extra.mcpReq.signal,
|
||||
});
|
||||
};
|
||||
//#endregion AppBridge_oncreatesamplingmessage_forwardToLlm
|
||||
}
|
||||
|
|
@ -261,8 +256,7 @@ function AppBridge_onlistresources_returnResources(
|
|||
bridge.onlistresources = async (params, extra) => {
|
||||
return mcpClient.request(
|
||||
{ method: "resources/list", params },
|
||||
ListResourcesResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
//#endregion AppBridge_onlistresources_returnResources
|
||||
|
|
@ -279,8 +273,7 @@ function AppBridge_onreadresource_returnResource(
|
|||
bridge.onreadresource = async (params, extra) => {
|
||||
return mcpClient.request(
|
||||
{ method: "resources/read", params },
|
||||
ReadResourceResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
//#endregion AppBridge_onreadresource_returnResource
|
||||
|
|
@ -297,8 +290,7 @@ function AppBridge_onlistprompts_returnPrompts(
|
|||
bridge.onlistprompts = async (params, extra) => {
|
||||
return mcpClient.request(
|
||||
{ method: "prompts/list", params },
|
||||
ListPromptsResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
//#endregion AppBridge_onlistprompts_returnPrompts
|
||||
|
|
@ -444,10 +436,10 @@ async function AppBridge_sendToolResult_afterExecution(
|
|||
args: Record<string, unknown>,
|
||||
) {
|
||||
//#region AppBridge_sendToolResult_afterExecution
|
||||
const result = await mcpClient.request(
|
||||
{ method: "tools/call", params: { name: "get_weather", arguments: args } },
|
||||
CallToolResultSchema,
|
||||
);
|
||||
const result = await mcpClient.request({
|
||||
method: "tools/call",
|
||||
params: { name: "get_weather", arguments: args },
|
||||
});
|
||||
bridge.sendToolResult(result);
|
||||
//#endregion AppBridge_sendToolResult_afterExecution
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
EmptyResultSchema,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
ReadResourceResultSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Server, type ServerCapabilities } from "@modelcontextprotocol/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { App } from "./app";
|
||||
|
|
@ -22,6 +11,7 @@ import {
|
|||
getToolUiResourceUri,
|
||||
isToolVisibilityModelOnly,
|
||||
isToolVisibilityAppOnly,
|
||||
McpUiOpenLinkResultSchema,
|
||||
type McpUiHostCapabilities,
|
||||
} from "./app-bridge";
|
||||
|
||||
|
|
@ -34,11 +24,18 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|||
*/
|
||||
function createMockClient(
|
||||
serverCapabilities: ServerCapabilities = {},
|
||||
): Pick<Client, "getServerCapabilities" | "request" | "notification"> {
|
||||
): Pick<
|
||||
Client,
|
||||
| "getServerCapabilities"
|
||||
| "request"
|
||||
| "notification"
|
||||
| "setNotificationHandler"
|
||||
> {
|
||||
return {
|
||||
getServerCapabilities: () => serverCapabilities,
|
||||
request: async () => ({}) as never,
|
||||
notification: async () => {},
|
||||
setNotificationHandler: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +85,53 @@ describe("App <-> AppBridge integration", () => {
|
|||
expect(initializedFired).toBe(true);
|
||||
});
|
||||
|
||||
it("sends only the Apps handshake on the wire and gates the Apps-ready callback", async () => {
|
||||
const methods: string[] = [];
|
||||
let releaseAppsInitialized!: () => void;
|
||||
let reachedAppsInitialized!: () => void;
|
||||
const appsInitializedGate = new Promise<void>((resolve) => {
|
||||
releaseAppsInitialized = resolve;
|
||||
});
|
||||
const reachedGate = new Promise<void>((resolve) => {
|
||||
reachedAppsInitialized = resolve;
|
||||
});
|
||||
const originalSend = appTransport.send.bind(appTransport);
|
||||
appTransport.send = async (message, options) => {
|
||||
if ("method" in message) methods.push(message.method);
|
||||
if (
|
||||
"method" in message &&
|
||||
message.method === "ui/notifications/initialized"
|
||||
) {
|
||||
reachedAppsInitialized();
|
||||
await appsInitializedGate;
|
||||
}
|
||||
return originalSend(message, options);
|
||||
};
|
||||
|
||||
let singularCalls = 0;
|
||||
let listenerCalls = 0;
|
||||
bridge.oninitialized = () => singularCalls++;
|
||||
bridge.addEventListener("initialized", () => listenerCalls++);
|
||||
|
||||
await bridge.connect(bridgeTransport);
|
||||
const connecting = app.connect(appTransport);
|
||||
await reachedGate;
|
||||
|
||||
expect(methods).toEqual([
|
||||
"ui/initialize",
|
||||
"ui/notifications/initialized",
|
||||
]);
|
||||
expect(bridge.getAppVersion()).toEqual(testAppInfo);
|
||||
expect(singularCalls).toBe(0);
|
||||
expect(listenerCalls).toBe(0);
|
||||
|
||||
releaseAppsInitialized();
|
||||
await connecting;
|
||||
|
||||
expect(singularCalls).toBe(1);
|
||||
expect(listenerCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("App receives host info and capabilities after connect", async () => {
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
|
@ -97,6 +141,7 @@ describe("App <-> AppBridge integration", () => {
|
|||
|
||||
const hostCaps = app.getHostCapabilities();
|
||||
expect(hostCaps).toEqual(testHostCapabilities);
|
||||
expect(bridge.getCapabilities()).toEqual(testHostCapabilities);
|
||||
});
|
||||
|
||||
it("Bridge receives app info and capabilities after initialization", async () => {
|
||||
|
|
@ -622,6 +667,30 @@ describe("App <-> AppBridge integration", () => {
|
|||
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid params for a custom ui request", async () => {
|
||||
bridge.onopenlink = async () => ({});
|
||||
await app.connect(appTransport);
|
||||
|
||||
await expect(
|
||||
app.request(
|
||||
{
|
||||
method: "ui/open-link",
|
||||
params: { url: 42 },
|
||||
},
|
||||
McpUiOpenLinkResultSchema,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid results from a custom ui handler", async () => {
|
||||
bridge.onopenlink = async () => ({ isError: "yes" }) as never;
|
||||
await app.connect(appTransport);
|
||||
|
||||
await expect(
|
||||
app.openLink({ url: "https://example.com" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deprecated method aliases", () => {
|
||||
|
|
@ -691,10 +760,7 @@ describe("App <-> AppBridge integration", () => {
|
|||
await app.connect(appTransport);
|
||||
|
||||
// Bridge can send ping via the protocol's request method
|
||||
const result = await bridge.request(
|
||||
{ method: "ping", params: {} },
|
||||
EmptyResultSchema,
|
||||
);
|
||||
const result = await bridge.request({ method: "ping", params: {} });
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
|
@ -809,7 +875,7 @@ describe("App <-> AppBridge integration", () => {
|
|||
});
|
||||
await bridge.callTool({ name: "noargs", arguments: {} });
|
||||
expect(receivedExtra).toBeDefined();
|
||||
expect(receivedExtra.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(receivedExtra.mcpReq.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("isError result skips output schema validation", async () => {
|
||||
|
|
@ -2191,10 +2257,10 @@ describe("App <-> AppBridge integration", () => {
|
|||
await app.connect(appTransport);
|
||||
|
||||
// App sends resources/list request via the protocol's request method
|
||||
const result = await app.request(
|
||||
{ method: "resources/list", params: requestParams },
|
||||
ListResourcesResultSchema,
|
||||
);
|
||||
const result = await app.request({
|
||||
method: "resources/list",
|
||||
params: requestParams,
|
||||
});
|
||||
|
||||
expect(receivedRequests).toHaveLength(1);
|
||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||
|
|
@ -2235,10 +2301,10 @@ describe("App <-> AppBridge integration", () => {
|
|||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
||||
const result = await app.request(
|
||||
{ method: "resources/read", params: requestParams },
|
||||
ReadResourceResultSchema,
|
||||
);
|
||||
const result = await app.request({
|
||||
method: "resources/read",
|
||||
params: requestParams,
|
||||
});
|
||||
|
||||
expect(receivedRequests).toHaveLength(1);
|
||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||
|
|
@ -2286,10 +2352,10 @@ describe("App <-> AppBridge integration", () => {
|
|||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
||||
const result = await app.request(
|
||||
{ method: "resources/templates/list", params: requestParams },
|
||||
ListResourceTemplatesResultSchema,
|
||||
);
|
||||
const result = await app.request({
|
||||
method: "resources/templates/list",
|
||||
params: requestParams,
|
||||
});
|
||||
|
||||
expect(receivedRequests).toHaveLength(1);
|
||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||
|
|
@ -2309,10 +2375,10 @@ describe("App <-> AppBridge integration", () => {
|
|||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
||||
const result = await app.request(
|
||||
{ method: "prompts/list", params: requestParams },
|
||||
ListPromptsResultSchema,
|
||||
);
|
||||
const result = await app.request({
|
||||
method: "prompts/list",
|
||||
params: requestParams,
|
||||
});
|
||||
|
||||
expect(receivedRequests).toHaveLength(1);
|
||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||
|
|
@ -2321,9 +2387,13 @@ describe("App <-> AppBridge integration", () => {
|
|||
|
||||
it("sendToolListChanged sends notification to app", async () => {
|
||||
const receivedNotifications: unknown[] = [];
|
||||
app.setNotificationHandler(ToolListChangedNotificationSchema, (n) => {
|
||||
receivedNotifications.push(n.params);
|
||||
});
|
||||
bridge.oncalltool = async () => ({ content: [] });
|
||||
app.setNotificationHandler(
|
||||
"notifications/tools/list_changed",
|
||||
(notification) => {
|
||||
receivedNotifications.push(notification.params);
|
||||
},
|
||||
);
|
||||
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
|
@ -2336,9 +2406,13 @@ describe("App <-> AppBridge integration", () => {
|
|||
|
||||
it("sendResourceListChanged sends notification to app", async () => {
|
||||
const receivedNotifications: unknown[] = [];
|
||||
app.setNotificationHandler(ResourceListChangedNotificationSchema, (n) => {
|
||||
receivedNotifications.push(n.params);
|
||||
});
|
||||
bridge.onlistresources = async () => ({ resources: [] });
|
||||
app.setNotificationHandler(
|
||||
"notifications/resources/list_changed",
|
||||
(notification) => {
|
||||
receivedNotifications.push(notification.params);
|
||||
},
|
||||
);
|
||||
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
|
@ -2351,9 +2425,13 @@ describe("App <-> AppBridge integration", () => {
|
|||
|
||||
it("sendPromptListChanged sends notification to app", async () => {
|
||||
const receivedNotifications: unknown[] = [];
|
||||
app.setNotificationHandler(PromptListChangedNotificationSchema, (n) => {
|
||||
receivedNotifications.push(n.params);
|
||||
});
|
||||
bridge.onlistprompts = async () => ({ prompts: [] });
|
||||
app.setNotificationHandler(
|
||||
"notifications/prompts/list_changed",
|
||||
(notification) => {
|
||||
receivedNotifications.push(notification.params);
|
||||
},
|
||||
);
|
||||
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
|
@ -2366,6 +2444,184 @@ describe("App <-> AppBridge integration", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("AppBridge outer v2 Client proxy", () => {
|
||||
let outerClient: Client;
|
||||
let outerServer: Server;
|
||||
let outerClientTransport: InMemoryTransport;
|
||||
let outerServerTransport: InMemoryTransport;
|
||||
let outerInitializeCount: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
[outerClientTransport, outerServerTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
outerInitializeCount = 0;
|
||||
const originalSend = outerClientTransport.send.bind(outerClientTransport);
|
||||
outerClientTransport.send = async (message, options) => {
|
||||
if ("method" in message && message.method === "initialize") {
|
||||
outerInitializeCount++;
|
||||
}
|
||||
return originalSend(message, options);
|
||||
};
|
||||
|
||||
outerServer = new Server(
|
||||
{ name: "ActualServer", version: "1.0.0" },
|
||||
{
|
||||
capabilities: {
|
||||
tools: { listChanged: true },
|
||||
resources: { listChanged: true },
|
||||
prompts: { listChanged: true },
|
||||
},
|
||||
supportedProtocolVersions: ["2025-11-25"],
|
||||
},
|
||||
);
|
||||
outerServer.setRequestHandler("tools/call", async (request) => ({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `outer:${request.params.name}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
outerClient = new Client(
|
||||
{ name: "HostOuterClient", version: "1.0.0" },
|
||||
{
|
||||
capabilities: {},
|
||||
versionNegotiation: { mode: "legacy" },
|
||||
supportedProtocolVersions: ["2025-11-25"],
|
||||
},
|
||||
);
|
||||
await outerServer.connect(outerServerTransport);
|
||||
await outerClient.connect(outerClientTransport);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await outerClient.close().catch(() => {});
|
||||
await outerServer.close().catch(() => {});
|
||||
});
|
||||
|
||||
it("proxies through the outer Client without reconnecting it", async () => {
|
||||
const bridge = new AppBridge(
|
||||
outerClient,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
const app = new App(testAppInfo, {}, { autoResize: false });
|
||||
let [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
expect(
|
||||
await app.callServerTool({ name: "first", arguments: {} }),
|
||||
).toMatchObject({
|
||||
content: [{ type: "text", text: "outer:first" }],
|
||||
});
|
||||
expect(outerInitializeCount).toBe(1);
|
||||
|
||||
await app.close();
|
||||
await bridge.close();
|
||||
[appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair();
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
||||
expect(
|
||||
await app.callServerTool({ name: "second", arguments: {} }),
|
||||
).toMatchObject({
|
||||
content: [{ type: "text", text: "outer:second" }],
|
||||
});
|
||||
expect(outerInitializeCount).toBe(1);
|
||||
expect(outerClient.getServerVersion()).toMatchObject({
|
||||
name: "ActualServer",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
await app.close();
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
it("propagates View cancellation to the actual outer Server", async () => {
|
||||
let started!: () => void;
|
||||
let aborted!: () => void;
|
||||
const startedPromise = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const abortedPromise = new Promise<void>((resolve) => {
|
||||
aborted = resolve;
|
||||
});
|
||||
outerServer.setRequestHandler("tools/call", async (_request, context) => {
|
||||
started();
|
||||
await new Promise<void>((resolve) => {
|
||||
context.mcpReq.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted();
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const bridge = new AppBridge(
|
||||
outerClient,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
const app = new App(testAppInfo, {}, { autoResize: false });
|
||||
const [appTransport, bridgeTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = app.callServerTool(
|
||||
{ name: "cancel-me", arguments: {} },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await startedPromise;
|
||||
controller.abort("test cancellation");
|
||||
await abortedPromise;
|
||||
await expect(result).rejects.toThrow();
|
||||
|
||||
await app.close();
|
||||
await bridge.close();
|
||||
});
|
||||
|
||||
it("forwards outer list-changed notifications to the App", async () => {
|
||||
const bridge = new AppBridge(
|
||||
outerClient,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
const app = new App(testAppInfo, {}, { autoResize: false });
|
||||
const [appTransport, bridgeTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const received: string[] = [];
|
||||
app.setNotificationHandler("notifications/tools/list_changed", () => {
|
||||
received.push("tools");
|
||||
});
|
||||
app.setNotificationHandler("notifications/resources/list_changed", () => {
|
||||
received.push("resources");
|
||||
});
|
||||
app.setNotificationHandler("notifications/prompts/list_changed", () => {
|
||||
received.push("prompts");
|
||||
});
|
||||
|
||||
await bridge.connect(bridgeTransport);
|
||||
await app.connect(appTransport);
|
||||
await outerServer.sendToolListChanged();
|
||||
await outerServer.sendResourceListChanged();
|
||||
await outerServer.sendPromptListChanged();
|
||||
await flush();
|
||||
|
||||
expect(received).toEqual(["tools", "resources", "prompts"]);
|
||||
|
||||
await app.close();
|
||||
await bridge.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getToolUiResourceUri", () => {
|
||||
describe("new nested format (_meta.ui.resourceUri)", () => {
|
||||
it("extracts resourceUri from _meta.ui.resourceUri", () => {
|
||||
|
|
@ -2721,7 +2977,7 @@ describe("isToolVisibilityAppOnly", () => {
|
|||
expect(a).toEqual([]);
|
||||
});
|
||||
|
||||
it("App.onEventDispatch merges hostcontext before listeners fire", async () => {
|
||||
it("App merges hostcontext before listeners fire", async () => {
|
||||
let seen: unknown;
|
||||
app.addEventListener("hostcontextchanged", () => {
|
||||
seen = app.getHostContext();
|
||||
|
|
@ -2760,38 +3016,16 @@ describe("isToolVisibilityAppOnly", () => {
|
|||
expect(app.onteardown).toBe(handler);
|
||||
});
|
||||
|
||||
it("direct setRequestHandler throws when called twice", () => {
|
||||
it("direct setRequestHandler uses base SDK replacement semantics", () => {
|
||||
const bridge2 = new AppBridge(
|
||||
createMockClient() as Client,
|
||||
testHostInfo,
|
||||
testHostCapabilities,
|
||||
);
|
||||
bridge2.setRequestHandler(
|
||||
// @ts-expect-error — exercising throw path with raw schema
|
||||
{ shape: { method: { value: "test/method" } } },
|
||||
() => ({}),
|
||||
);
|
||||
bridge2.setRequestHandler("ping", () => ({}));
|
||||
expect(() => {
|
||||
bridge2.setRequestHandler(
|
||||
// @ts-expect-error — exercising throw path with raw schema
|
||||
{ shape: { method: { value: "test/method" } } },
|
||||
() => ({}),
|
||||
);
|
||||
}).toThrow(/already registered/);
|
||||
});
|
||||
|
||||
it("direct setNotificationHandler throws for event-mapped methods", () => {
|
||||
const app2 = new App(testAppInfo, {}, { autoResize: false });
|
||||
app2.addEventListener("toolinput", () => {});
|
||||
expect(() => {
|
||||
app2.setNotificationHandler(
|
||||
// @ts-expect-error — exercising throw path with raw schema
|
||||
{
|
||||
shape: { method: { value: "ui/notifications/tool-input" } },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}).toThrow(/already registered/);
|
||||
bridge2.setRequestHandler("ping", () => ({}));
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,58 +1,48 @@
|
|||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import {
|
||||
CallToolRequest,
|
||||
CallToolRequestSchema,
|
||||
CallToolResult,
|
||||
CallToolResultSchema,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestSchema,
|
||||
CreateMessageResult,
|
||||
CreateMessageResultWithTools,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
ListPromptsRequest,
|
||||
ListPromptsRequestSchema,
|
||||
ListPromptsResult,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesRequest,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourcesResult,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesRequest,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListResourceTemplatesResult,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ListToolsRequest,
|
||||
ListToolsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
PingRequest,
|
||||
PingRequestSchema,
|
||||
PromptListChangedNotification,
|
||||
PromptListChangedNotificationSchema,
|
||||
ReadResourceRequest,
|
||||
ReadResourceRequestSchema,
|
||||
ReadResourceResult,
|
||||
ReadResourceResultSchema,
|
||||
ResourceListChangedNotification,
|
||||
ResourceListChangedNotificationSchema,
|
||||
Tool,
|
||||
ToolListChangedNotification,
|
||||
ToolListChangedNotificationSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
Protocol,
|
||||
ProtocolOptions,
|
||||
RequestOptions,
|
||||
} from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
import { ProtocolWithEvents } from "./events";
|
||||
type BaseContext,
|
||||
type Client,
|
||||
type ProtocolOptions,
|
||||
type CallToolRequest,
|
||||
type CallToolResult,
|
||||
type CreateMessageRequest,
|
||||
type CreateMessageResult,
|
||||
type CreateMessageResultWithTools,
|
||||
type EmptyResult,
|
||||
type Implementation,
|
||||
type JSONRPCRequest,
|
||||
type ListPromptsRequest,
|
||||
type ListPromptsResult,
|
||||
type ListResourcesRequest,
|
||||
type ListResourcesResult,
|
||||
type ListResourceTemplatesRequest,
|
||||
type ListResourceTemplatesResult,
|
||||
type ListToolsRequest,
|
||||
type LoggingMessageNotification,
|
||||
type PingRequest,
|
||||
type PromptListChangedNotification,
|
||||
type ReadResourceRequest,
|
||||
type ReadResourceResult,
|
||||
type RequestOptions,
|
||||
type ResourceListChangedNotification,
|
||||
type Result,
|
||||
type Tool,
|
||||
type ToolListChangedNotification,
|
||||
type Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
EmptyResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import { EventDispatcher } from "./events";
|
||||
import type { ZodLiteral, ZodObject, ZodType } from "zod/v4";
|
||||
|
||||
type MethodSchema = ZodObject<{
|
||||
method: ZodLiteral<string>;
|
||||
params: ZodType;
|
||||
}>;
|
||||
|
||||
import {
|
||||
type AppNotification,
|
||||
type AppRequest,
|
||||
type AppResult,
|
||||
type McpUiSandboxResourceReadyNotification,
|
||||
type McpUiSizeChangedNotification,
|
||||
type McpUiToolCancelledNotification,
|
||||
|
|
@ -71,15 +61,19 @@ import {
|
|||
McpUiInitializeRequest,
|
||||
McpUiInitializeRequestSchema,
|
||||
McpUiInitializeResult,
|
||||
McpUiInitializeResultSchema,
|
||||
McpUiMessageRequest,
|
||||
McpUiMessageRequestSchema,
|
||||
McpUiMessageResult,
|
||||
McpUiMessageResultSchema,
|
||||
McpUiOpenLinkRequest,
|
||||
McpUiOpenLinkRequestSchema,
|
||||
McpUiOpenLinkResult,
|
||||
McpUiOpenLinkResultSchema,
|
||||
McpUiDownloadFileRequest,
|
||||
McpUiDownloadFileRequestSchema,
|
||||
McpUiDownloadFileResult,
|
||||
McpUiDownloadFileResultSchema,
|
||||
McpUiResourceTeardownRequest,
|
||||
McpUiResourceTeardownResultSchema,
|
||||
McpUiRequestTeardownNotification,
|
||||
|
|
@ -90,12 +84,13 @@ import {
|
|||
McpUiRequestDisplayModeRequest,
|
||||
McpUiRequestDisplayModeRequestSchema,
|
||||
McpUiRequestDisplayModeResult,
|
||||
McpUiRequestDisplayModeResultSchema,
|
||||
McpUiResourcePermissions,
|
||||
McpUiToolMeta,
|
||||
} from "./types";
|
||||
export * from "./types";
|
||||
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app";
|
||||
import { RESOURCE_URI_META_KEY } from "./app";
|
||||
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./constants";
|
||||
import { RESOURCE_URI_META_KEY } from "./constants";
|
||||
export { PostMessageTransport } from "./message-transport";
|
||||
|
||||
/**
|
||||
|
|
@ -203,10 +198,10 @@ export function buildAllowAttribute(
|
|||
*
|
||||
* @property hostContext - Optional initial host context to provide to the view
|
||||
*
|
||||
* @see `ProtocolOptions` from @modelcontextprotocol/sdk for available options
|
||||
* @see `ProtocolOptions` from @modelcontextprotocol/client for available options
|
||||
* @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure
|
||||
*/
|
||||
export type HostOptions = ProtocolOptions & {
|
||||
export type HostOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & {
|
||||
hostContext?: McpUiHostContext;
|
||||
};
|
||||
|
||||
|
|
@ -221,15 +216,12 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
|
|||
/**
|
||||
* Extra metadata passed to request handlers.
|
||||
*
|
||||
* This type represents the additional context provided by the `Protocol` class
|
||||
* when handling requests, including abort signals and session information.
|
||||
* It is extracted from the MCP SDK's request handler signature.
|
||||
* This type represents the additional context provided by the base MCP SDK
|
||||
* `Server` when handling requests.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
type RequestHandlerExtra = Parameters<
|
||||
Parameters<AppBridge["setRequestHandler"]>[1]
|
||||
>[1];
|
||||
type RequestHandlerExtra = BaseContext;
|
||||
|
||||
/**
|
||||
* Maps DOM-style event names to their notification `params` types.
|
||||
|
|
@ -249,11 +241,11 @@ export type AppBridgeEventMap = {
|
|||
/**
|
||||
* Host-side bridge for communicating with a single View ({@link app!App `App`}).
|
||||
*
|
||||
* `AppBridge` extends the MCP SDK's `Protocol` class and acts as a proxy between
|
||||
* the host application and a view running in an iframe. When an MCP client
|
||||
* is provided to the constructor, it automatically forwards MCP server capabilities
|
||||
* (tools, resources, prompts) to the view. It also handles the initialization
|
||||
* handshake.
|
||||
* `AppBridge` extends the base MCP SDK's public `Protocol` class and acts as a
|
||||
* proxy between the host application and a view running in an iframe. When a
|
||||
* separate MCP client is provided to the constructor, it automatically forwards
|
||||
* MCP server capabilities (tools, resources, prompts) to the view. It also
|
||||
* handles the Apps initialization handshake.
|
||||
*
|
||||
* ## Architecture
|
||||
*
|
||||
|
|
@ -303,47 +295,48 @@ export type AppBridgeEventMap = {
|
|||
* await bridge.connect(transport);
|
||||
* ```
|
||||
*/
|
||||
export class AppBridge extends ProtocolWithEvents<
|
||||
AppRequest,
|
||||
AppNotification,
|
||||
AppResult,
|
||||
AppBridgeEventMap
|
||||
> {
|
||||
export class AppBridge extends Protocol<BaseContext> {
|
||||
private _appCapabilities?: McpUiAppCapabilities;
|
||||
private _hostContext: McpUiHostContext = {};
|
||||
private _appInfo?: Implementation;
|
||||
private _initializedReceived = false;
|
||||
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
|
||||
private readonly _events = new EventDispatcher<AppBridgeEventMap>();
|
||||
|
||||
/**
|
||||
* Wrap every handler registered via `replaceRequestHandler` with a check
|
||||
* that the View has sent `ui/notifications/initialized`. Warns (never
|
||||
* throws) so lenient hosts keep working while still surfacing the
|
||||
* misordering that leaves strict hosts with a permanently hidden iframe.
|
||||
* `ui/initialize` and `ping` use `setRequestHandler` directly and are
|
||||
* intentionally exempt.
|
||||
* The base MCP SDK calls this hook for every standard and custom request
|
||||
* handler registration. AppBridge uses it to preserve the existing warning
|
||||
* when a View calls host methods before `ui/notifications/initialized`,
|
||||
* without adding overload-forwarding adapters around `setRequestHandler`.
|
||||
* `ui/initialize` and `ping` are exempt because they are valid before the
|
||||
* Apps-ready gate. This wraps registered callbacks, not JSON-RPC dispatch.
|
||||
*
|
||||
* @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149}
|
||||
*/
|
||||
private _baseReplaceRequestHandler = this.replaceRequestHandler;
|
||||
protected override replaceRequestHandler: Protocol<
|
||||
AppRequest,
|
||||
AppNotification,
|
||||
AppResult
|
||||
>["setRequestHandler"] = (schema, handler) => {
|
||||
this._baseReplaceRequestHandler(schema, (request, extra) => {
|
||||
protected override _wrapHandler(
|
||||
method: string,
|
||||
handler: (request: JSONRPCRequest, context: BaseContext) => Promise<Result>,
|
||||
): (request: JSONRPCRequest, context: BaseContext) => Promise<Result> {
|
||||
const wrapped = super._wrapHandler(method, handler);
|
||||
if (method === "ui/initialize" || method === "ping") {
|
||||
return wrapped;
|
||||
}
|
||||
return async (request, context) => {
|
||||
if (!this._initializedReceived) {
|
||||
console.warn(
|
||||
`[ext-apps] AppBridge received '${request.method}' before ` +
|
||||
`[ext-apps] AppBridge received '${method}' before ` +
|
||||
`ui/notifications/initialized. The View is calling host ` +
|
||||
`methods before completing the handshake; it should await ` +
|
||||
`app.connect() first.`,
|
||||
);
|
||||
}
|
||||
return handler(request, extra);
|
||||
});
|
||||
};
|
||||
return wrapped(request, context);
|
||||
};
|
||||
}
|
||||
|
||||
protected readonly eventSchemas = {
|
||||
private readonly eventSchemas: {
|
||||
[K in keyof AppBridgeEventMap]: MethodSchema;
|
||||
} = {
|
||||
sizechange: McpUiSizeChangedNotificationSchema,
|
||||
sandboxready: McpUiSandboxProxyReadyNotificationSchema,
|
||||
initialized: McpUiInitializedNotificationSchema,
|
||||
|
|
@ -351,6 +344,72 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
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)}`);
|
||||
|
||||
const dispatch = (params: AppBridgeEventMap[K]) => {
|
||||
if (event === "initialized") this._initializedReceived = true;
|
||||
this._events.dispatch(event, params);
|
||||
};
|
||||
|
||||
if (event === "loggingmessage") {
|
||||
this.setNotificationHandler("notifications/message", (notification) => {
|
||||
dispatch(notification.params as AppBridgeEventMap[K]);
|
||||
});
|
||||
} else {
|
||||
this.setNotificationHandler(
|
||||
schema.shape.method.value,
|
||||
{ params: schema.shape.params },
|
||||
(params) => dispatch(params as AppBridgeEventMap[K]),
|
||||
);
|
||||
}
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new AppBridge instance.
|
||||
*
|
||||
|
|
@ -359,8 +418,8 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* between the View and the server. When `null`, you must register handlers
|
||||
* manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters.
|
||||
* @param _hostInfo - Host application identification (name and version)
|
||||
* @param _capabilities - Features and capabilities the host supports
|
||||
* @param options - Configuration options (inherited from Protocol)
|
||||
* @param _hostCapabilities - Features and capabilities the host supports
|
||||
* @param options - Configuration options (inherited from Server)
|
||||
*
|
||||
* @example With MCP client (automatic forwarding)
|
||||
* ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient"
|
||||
|
|
@ -387,37 +446,73 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
constructor(
|
||||
private _client: Client | null,
|
||||
private _hostInfo: Implementation,
|
||||
private _capabilities: McpUiHostCapabilities,
|
||||
private _hostCapabilities: McpUiHostCapabilities,
|
||||
options?: HostOptions,
|
||||
) {
|
||||
super(options);
|
||||
|
||||
this.addEventListener("initialized", () => {
|
||||
this._initializedReceived = true;
|
||||
});
|
||||
this._ensureEventSlot("initialized");
|
||||
|
||||
this._hostContext = options?.hostContext || {};
|
||||
|
||||
this.setRequestHandler(McpUiInitializeRequestSchema, (request) =>
|
||||
this._oninitialize(request),
|
||||
this.setRequestHandler(
|
||||
"ui/initialize",
|
||||
{
|
||||
params: McpUiInitializeRequestSchema.shape.params,
|
||||
result: McpUiInitializeResultSchema,
|
||||
},
|
||||
(params) => this._onAppsInitialize(params),
|
||||
);
|
||||
|
||||
this.setRequestHandler(PingRequestSchema, (request, extra) => {
|
||||
this.setRequestHandler("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.replaceRequestHandler(
|
||||
McpUiRequestDisplayModeRequestSchema,
|
||||
(request) => {
|
||||
this.setRequestHandler(
|
||||
"ui/request-display-mode",
|
||||
{
|
||||
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||
result: McpUiRequestDisplayModeResultSchema,
|
||||
},
|
||||
() => {
|
||||
const currentMode = this._hostContext.displayMode ?? "inline";
|
||||
return { mode: currentMode };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
protected buildContext(ctx: BaseContext): BaseContext {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the view supports the capability required for the given request method.
|
||||
* @internal
|
||||
*/
|
||||
protected assertCapabilityForMethod(_method: string): void {
|
||||
// Host-to-view requests are not capability-gated.
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a request handler is registered and supported for the given method.
|
||||
* @internal
|
||||
*/
|
||||
protected assertRequestHandlerCapability(_method: string): void {
|
||||
// Host handler registration is not capability-gated.
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the host supports the capability required for the given notification method.
|
||||
* @internal
|
||||
*/
|
||||
protected assertNotificationCapability(_method: string): void {
|
||||
// Host notifications are not capability-gated.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the view's capabilities discovered during initialization.
|
||||
*
|
||||
|
|
@ -541,8 +636,7 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* ```typescript
|
||||
* bridge.onsandboxready = async () => {
|
||||
* const resource = await mcpClient.request(
|
||||
* { method: "resources/read", params: { uri: "ui://my-app" } },
|
||||
* ReadResourceResultSchema
|
||||
* { method: "resources/read", params: { uri: "ui://my-app" } }
|
||||
* );
|
||||
*
|
||||
* bridge.sendSandboxResourceReady({
|
||||
|
|
@ -589,13 +683,13 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}.
|
||||
*/
|
||||
get oninitialized():
|
||||
| ((params: McpUiInitializedNotification["params"]) => void)
|
||||
| ((params?: McpUiInitializedNotification["params"]) => void)
|
||||
| undefined {
|
||||
return this.getEventHandler("initialized");
|
||||
}
|
||||
set oninitialized(
|
||||
callback:
|
||||
| ((params: McpUiInitializedNotification["params"]) => void)
|
||||
| ((params?: McpUiInitializedNotification["params"]) => void)
|
||||
| undefined,
|
||||
) {
|
||||
this.setEventHandler("initialized", callback);
|
||||
|
|
@ -652,11 +746,15 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback);
|
||||
this._onmessage = callback;
|
||||
this.replaceRequestHandler(
|
||||
McpUiMessageRequestSchema,
|
||||
async (request, extra) => {
|
||||
this.setRequestHandler(
|
||||
"ui/message",
|
||||
{
|
||||
params: McpUiMessageRequestSchema.shape.params,
|
||||
result: McpUiMessageResultSchema,
|
||||
},
|
||||
async (params, extra) => {
|
||||
if (!this._onmessage) throw new Error("No onmessage handler set");
|
||||
return this._onmessage(request.params, extra);
|
||||
return this._onmessage(params, extra);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -721,11 +819,15 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback);
|
||||
this._onopenlink = callback;
|
||||
this.replaceRequestHandler(
|
||||
McpUiOpenLinkRequestSchema,
|
||||
async (request, extra) => {
|
||||
this.setRequestHandler(
|
||||
"ui/open-link",
|
||||
{
|
||||
params: McpUiOpenLinkRequestSchema.shape.params,
|
||||
result: McpUiOpenLinkResultSchema,
|
||||
},
|
||||
async (params, extra) => {
|
||||
if (!this._onopenlink) throw new Error("No onopenlink handler set");
|
||||
return this._onopenlink(request.params, extra);
|
||||
return this._onopenlink(params, extra);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -793,12 +895,16 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._ondownloadfile = callback;
|
||||
this.replaceRequestHandler(
|
||||
McpUiDownloadFileRequestSchema,
|
||||
async (request, extra) => {
|
||||
this.setRequestHandler(
|
||||
"ui/download-file",
|
||||
{
|
||||
params: McpUiDownloadFileRequestSchema.shape.params,
|
||||
result: McpUiDownloadFileResultSchema,
|
||||
},
|
||||
async (params, extra) => {
|
||||
if (!this._ondownloadfile)
|
||||
throw new Error("No ondownloadfile handler set");
|
||||
return this._ondownloadfile(request.params, extra);
|
||||
return this._ondownloadfile(params, extra);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -893,12 +999,16 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._onrequestdisplaymode = callback;
|
||||
this.replaceRequestHandler(
|
||||
McpUiRequestDisplayModeRequestSchema,
|
||||
async (request, extra) => {
|
||||
this.setRequestHandler(
|
||||
"ui/request-display-mode",
|
||||
{
|
||||
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||
result: McpUiRequestDisplayModeResultSchema,
|
||||
},
|
||||
async (params, extra) => {
|
||||
if (!this._onrequestdisplaymode)
|
||||
throw new Error("No onrequestdisplaymode handler set");
|
||||
return this._onrequestdisplaymode(request.params, extra);
|
||||
return this._onrequestdisplaymode(params, extra);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -990,12 +1100,16 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._onupdatemodelcontext = callback;
|
||||
this.replaceRequestHandler(
|
||||
McpUiUpdateModelContextRequestSchema,
|
||||
async (request, extra) => {
|
||||
this.setRequestHandler(
|
||||
"ui/update-model-context",
|
||||
{
|
||||
params: McpUiUpdateModelContextRequestSchema.shape.params,
|
||||
result: EmptyResultSchema,
|
||||
},
|
||||
async (params, extra) => {
|
||||
if (!this._onupdatemodelcontext)
|
||||
throw new Error("No onupdatemodelcontext handler set");
|
||||
return this._onupdatemodelcontext(request.params, extra);
|
||||
return this._onupdatemodelcontext(params, extra);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1017,14 +1131,13 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* bridge.oncalltool = async (params, extra) => {
|
||||
* return mcpClient.request(
|
||||
* { method: "tools/call", params },
|
||||
* CallToolResultSchema,
|
||||
* { signal: extra.signal },
|
||||
* { signal: extra.mcpReq.signal },
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `CallToolRequest` from @modelcontextprotocol/sdk for the request type
|
||||
* @see `CallToolResult` from @modelcontextprotocol/sdk for the result type
|
||||
* @see `CallToolRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `CallToolResult` from @modelcontextprotocol/server for the result type
|
||||
*/
|
||||
private _oncalltool?: (
|
||||
params: CallToolRequest["params"],
|
||||
|
|
@ -1043,13 +1156,10 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||
this._oncalltool = callback;
|
||||
this.replaceRequestHandler(
|
||||
CallToolRequestSchema,
|
||||
async (request, extra) => {
|
||||
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||
return this._oncalltool(request.params, extra);
|
||||
},
|
||||
);
|
||||
this.setRequestHandler("tools/call", async (request, extra) => {
|
||||
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||
return this._oncalltool(request.params, extra);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1074,12 +1184,14 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* ```ts source="./app-bridge.examples.ts#AppBridge_oncreatesamplingmessage_forwardToLlm"
|
||||
* bridge.oncreatesamplingmessage = async (params, extra) => {
|
||||
* // Apply rate limiting, user approval, cost controls here
|
||||
* return await myLlmProvider.complete(params, { signal: extra.signal });
|
||||
* return await myLlmProvider.complete(params, {
|
||||
* signal: extra.mcpReq.signal,
|
||||
* });
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `CreateMessageRequest` from @modelcontextprotocol/sdk for the request type
|
||||
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/sdk for result types
|
||||
* @see `CreateMessageRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/server for result types
|
||||
*/
|
||||
set oncreatesamplingmessage(
|
||||
callback: (
|
||||
|
|
@ -1087,12 +1199,9 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
extra: RequestHandlerExtra,
|
||||
) => Promise<CreateMessageResult | CreateMessageResultWithTools>,
|
||||
) {
|
||||
this.setRequestHandler(
|
||||
CreateMessageRequestSchema,
|
||||
async (request, extra) => {
|
||||
return callback(request.params, extra);
|
||||
},
|
||||
);
|
||||
this.setRequestHandler("sampling/createMessage", async (request, extra) => {
|
||||
return callback(request.params, extra);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1112,7 +1221,7 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* });
|
||||
* ```
|
||||
*
|
||||
* @see `ToolListChangedNotification` from @modelcontextprotocol/sdk for the notification type
|
||||
* @see `ToolListChangedNotification` from @modelcontextprotocol/server for the notification type
|
||||
*/
|
||||
sendToolListChanged(params: ToolListChangedNotification["params"] = {}) {
|
||||
return this.notification({
|
||||
|
|
@ -1138,14 +1247,13 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* bridge.onlistresources = async (params, extra) => {
|
||||
* return mcpClient.request(
|
||||
* { method: "resources/list", params },
|
||||
* ListResourcesResultSchema,
|
||||
* { signal: extra.signal },
|
||||
* { signal: extra.mcpReq.signal },
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ListResourcesRequest` from @modelcontextprotocol/sdk for the request type
|
||||
* @see `ListResourcesResult` from @modelcontextprotocol/sdk for the result type
|
||||
* @see `ListResourcesRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ListResourcesResult` from @modelcontextprotocol/server for the result type
|
||||
*/
|
||||
private _onlistresources?: (
|
||||
params: ListResourcesRequest["params"],
|
||||
|
|
@ -1168,14 +1276,11 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._onlistresources = callback;
|
||||
this.replaceRequestHandler(
|
||||
ListResourcesRequestSchema,
|
||||
async (request, extra) => {
|
||||
if (!this._onlistresources)
|
||||
throw new Error("No onlistresources handler set");
|
||||
return this._onlistresources(request.params, extra);
|
||||
},
|
||||
);
|
||||
this.setRequestHandler("resources/list", async (request, extra) => {
|
||||
if (!this._onlistresources)
|
||||
throw new Error("No onlistresources handler set");
|
||||
return this._onlistresources(request.params, extra);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1195,14 +1300,13 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* bridge.onlistresourcetemplates = async (params, extra) => {
|
||||
* return mcpClient.request(
|
||||
* { method: "resources/templates/list", params },
|
||||
* ListResourceTemplatesResultSchema,
|
||||
* { signal: extra.signal }
|
||||
* { signal: extra.mcpReq.signal }
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ListResourceTemplatesRequest` from @modelcontextprotocol/sdk for the request type
|
||||
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/sdk for the result type
|
||||
* @see `ListResourceTemplatesRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ListResourceTemplatesResult` from @modelcontextprotocol/server for the result type
|
||||
*/
|
||||
private _onlistresourcetemplates?: (
|
||||
params: ListResourceTemplatesRequest["params"],
|
||||
|
|
@ -1225,8 +1329,8 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._onlistresourcetemplates = callback;
|
||||
this.replaceRequestHandler(
|
||||
ListResourceTemplatesRequestSchema,
|
||||
this.setRequestHandler(
|
||||
"resources/templates/list",
|
||||
async (request, extra) => {
|
||||
if (!this._onlistresourcetemplates)
|
||||
throw new Error("No onlistresourcetemplates handler set");
|
||||
|
|
@ -1252,14 +1356,13 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* bridge.onreadresource = async (params, extra) => {
|
||||
* return mcpClient.request(
|
||||
* { method: "resources/read", params },
|
||||
* ReadResourceResultSchema,
|
||||
* { signal: extra.signal },
|
||||
* { signal: extra.mcpReq.signal },
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ReadResourceRequest` from @modelcontextprotocol/sdk for the request type
|
||||
* @see `ReadResourceResult` from @modelcontextprotocol/sdk for the result type
|
||||
* @see `ReadResourceRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ReadResourceResult` from @modelcontextprotocol/server for the result type
|
||||
*/
|
||||
private _onreadresource?: (
|
||||
params: ReadResourceRequest["params"],
|
||||
|
|
@ -1282,14 +1385,11 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._onreadresource = callback;
|
||||
this.replaceRequestHandler(
|
||||
ReadResourceRequestSchema,
|
||||
async (request, extra) => {
|
||||
if (!this._onreadresource)
|
||||
throw new Error("No onreadresource handler set");
|
||||
return this._onreadresource(request.params, extra);
|
||||
},
|
||||
);
|
||||
this.setRequestHandler("resources/read", async (request, extra) => {
|
||||
if (!this._onreadresource)
|
||||
throw new Error("No onreadresource handler set");
|
||||
return this._onreadresource(request.params, extra);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1309,7 +1409,7 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* });
|
||||
* ```
|
||||
*
|
||||
* @see `ResourceListChangedNotification` from @modelcontextprotocol/sdk for the notification type
|
||||
* @see `ResourceListChangedNotification` from @modelcontextprotocol/server for the notification type
|
||||
*/
|
||||
sendResourceListChanged(
|
||||
params: ResourceListChangedNotification["params"] = {},
|
||||
|
|
@ -1337,14 +1437,13 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* bridge.onlistprompts = async (params, extra) => {
|
||||
* return mcpClient.request(
|
||||
* { method: "prompts/list", params },
|
||||
* ListPromptsResultSchema,
|
||||
* { signal: extra.signal },
|
||||
* { signal: extra.mcpReq.signal },
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* @see `ListPromptsRequest` from @modelcontextprotocol/sdk for the request type
|
||||
* @see `ListPromptsResult` from @modelcontextprotocol/sdk for the result type
|
||||
* @see `ListPromptsRequest` from @modelcontextprotocol/server for the request type
|
||||
* @see `ListPromptsResult` from @modelcontextprotocol/server for the result type
|
||||
*/
|
||||
private _onlistprompts?: (
|
||||
params: ListPromptsRequest["params"],
|
||||
|
|
@ -1367,14 +1466,10 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._onlistprompts = callback;
|
||||
this.replaceRequestHandler(
|
||||
ListPromptsRequestSchema,
|
||||
async (request, extra) => {
|
||||
if (!this._onlistprompts)
|
||||
throw new Error("No onlistprompts handler set");
|
||||
return this._onlistprompts(request.params, extra);
|
||||
},
|
||||
);
|
||||
this.setRequestHandler("prompts/list", async (request, extra) => {
|
||||
if (!this._onlistprompts) throw new Error("No onlistprompts handler set");
|
||||
return this._onlistprompts(request.params, extra);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1394,7 +1489,7 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* });
|
||||
* ```
|
||||
*
|
||||
* @see `PromptListChangedNotification` from @modelcontextprotocol/sdk for the notification type
|
||||
* @see `PromptListChangedNotification` from @modelcontextprotocol/server for the notification type
|
||||
*/
|
||||
sendPromptListChanged(params: PromptListChangedNotification["params"] = {}) {
|
||||
return this.notification({
|
||||
|
|
@ -1403,46 +1498,6 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the guest supports the capability required for the given request method.
|
||||
* @internal
|
||||
*/
|
||||
assertCapabilityForMethod(method: AppRequest["method"]): void {
|
||||
// TODO
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a request handler is registered and supported for the given method.
|
||||
* @internal
|
||||
*/
|
||||
assertRequestHandlerCapability(method: AppRequest["method"]): void {
|
||||
// TODO
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the host supports the capability required for the given notification method.
|
||||
* @internal
|
||||
*/
|
||||
assertNotificationCapability(method: AppNotification["method"]): void {
|
||||
// TODO
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that task creation is supported for the given request method.
|
||||
* @internal
|
||||
*/
|
||||
protected assertTaskCapability(_method: string): void {
|
||||
throw new Error("Tasks are not supported in MCP Apps");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that task handler is supported for the given method.
|
||||
* @internal
|
||||
*/
|
||||
protected assertTaskHandlerCapability(_method: string): void {
|
||||
throw new Error("Task handlers are not supported in MCP Apps");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the host capabilities passed to the constructor.
|
||||
*
|
||||
|
|
@ -1451,17 +1506,17 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure
|
||||
*/
|
||||
getCapabilities(): McpUiHostCapabilities {
|
||||
return this._capabilities;
|
||||
return this._hostCapabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the ui/initialize request from the guest.
|
||||
* @internal
|
||||
*/
|
||||
private async _oninitialize(
|
||||
request: McpUiInitializeRequest,
|
||||
private async _onAppsInitialize(
|
||||
params: McpUiInitializeRequest["params"],
|
||||
): Promise<McpUiInitializeResult> {
|
||||
const requestedVersion = request.params.protocolVersion;
|
||||
const requestedVersion = params.protocolVersion;
|
||||
|
||||
if (this._appInfo !== undefined) {
|
||||
console.warn(
|
||||
|
|
@ -1472,8 +1527,8 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
);
|
||||
}
|
||||
|
||||
this._appCapabilities = request.params.appCapabilities;
|
||||
this._appInfo = request.params.appInfo;
|
||||
this._appCapabilities = params.appCapabilities;
|
||||
this._appInfo = params.appInfo;
|
||||
|
||||
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(
|
||||
requestedVersion,
|
||||
|
|
@ -1636,10 +1691,10 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
*
|
||||
* @example
|
||||
* ```ts source="./app-bridge.examples.ts#AppBridge_sendToolResult_afterExecution"
|
||||
* const result = await mcpClient.request(
|
||||
* { method: "tools/call", params: { name: "get_weather", arguments: args } },
|
||||
* CallToolResultSchema,
|
||||
* );
|
||||
* const result = await mcpClient.request({
|
||||
* method: "tools/call",
|
||||
* params: { name: "get_weather", arguments: args },
|
||||
* });
|
||||
* bridge.sendToolResult(result);
|
||||
* ```
|
||||
*
|
||||
|
|
@ -1765,11 +1820,7 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* @returns Promise resolving to the tool call result
|
||||
*/
|
||||
callTool(params: CallToolRequest["params"], options?: RequestOptions) {
|
||||
return this.request(
|
||||
{ method: "tools/call", params },
|
||||
CallToolResultSchema,
|
||||
options,
|
||||
);
|
||||
return this.request({ method: "tools/call", params }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1782,11 +1833,7 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
* @returns Promise resolving to the list of tools
|
||||
*/
|
||||
listTools(params: ListToolsRequest["params"], options?: RequestOptions) {
|
||||
return this.request(
|
||||
{ method: "tools/list", params },
|
||||
ListToolsResultSchema,
|
||||
options,
|
||||
);
|
||||
return this.request({ method: "tools/list", params }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1862,13 +1909,12 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
this.oncalltool = async (params, extra) => {
|
||||
return this._client!.request(
|
||||
{ method: "tools/call", params },
|
||||
CallToolResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
if (serverCapabilities.tools.listChanged) {
|
||||
this._client.setNotificationHandler(
|
||||
ToolListChangedNotificationSchema,
|
||||
"notifications/tools/list_changed",
|
||||
(n) => this.sendToolListChanged(n.params),
|
||||
);
|
||||
}
|
||||
|
|
@ -1877,27 +1923,24 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
this.onlistresources = async (params, extra) => {
|
||||
return this._client!.request(
|
||||
{ method: "resources/list", params },
|
||||
ListResourcesResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
this.onlistresourcetemplates = async (params, extra) => {
|
||||
return this._client!.request(
|
||||
{ method: "resources/templates/list", params },
|
||||
ListResourceTemplatesResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
this.onreadresource = async (params, extra) => {
|
||||
return this._client!.request(
|
||||
{ method: "resources/read", params },
|
||||
ReadResourceResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
if (serverCapabilities.resources.listChanged) {
|
||||
this._client.setNotificationHandler(
|
||||
ResourceListChangedNotificationSchema,
|
||||
"notifications/resources/list_changed",
|
||||
(n) => this.sendResourceListChanged(n.params),
|
||||
);
|
||||
}
|
||||
|
|
@ -1906,23 +1949,21 @@ export class AppBridge extends ProtocolWithEvents<
|
|||
this.onlistprompts = async (params, extra) => {
|
||||
return this._client!.request(
|
||||
{ method: "prompts/list", params },
|
||||
ListPromptsResultSchema,
|
||||
{ signal: extra.signal },
|
||||
{ signal: extra.mcpReq.signal },
|
||||
);
|
||||
};
|
||||
if (serverCapabilities.prompts.listChanged) {
|
||||
this._client.setNotificationHandler(
|
||||
PromptListChangedNotificationSchema,
|
||||
"notifications/prompts/list_changed",
|
||||
(n) => this.sendPromptListChanged(n.params),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP-UI specific handlers are registered by the host component
|
||||
// after the proxy is created. The standard MCP initialization
|
||||
// (via oninitialized callback set in constructor) handles the ready signal.
|
||||
|
||||
// Protocol.connect only wires the transport — no MCP handshake happens on
|
||||
// the iframe channel. The View drives ui/initialize;
|
||||
// ui/notifications/initialized remains the public View-ready signal.
|
||||
return super.connect(transport);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,8 @@
|
|||
* @module
|
||||
*/
|
||||
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
McpServer,
|
||||
ToolCallback,
|
||||
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { SamplingMessage, Tool } from "@modelcontextprotocol/client";
|
||||
import type { McpServer, ToolCallback } from "@modelcontextprotocol/server";
|
||||
import {
|
||||
App,
|
||||
PostMessageTransport,
|
||||
|
|
@ -369,7 +366,7 @@ async function App_createSamplingMessage_simple(app: App) {
|
|||
*/
|
||||
async function App_createSamplingMessage_withTools(
|
||||
app: App,
|
||||
messages: import("@modelcontextprotocol/sdk/types.js").SamplingMessage[],
|
||||
messages: SamplingMessage[],
|
||||
) {
|
||||
//#region App_createSamplingMessage_withTools
|
||||
if (!app.getHostCapabilities()?.sampling?.tools) return;
|
||||
|
|
|
|||
288
src/app.test.ts
Normal file
288
src/app.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
InMemoryTransport,
|
||||
Server,
|
||||
type JSONRPCMessage,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { App } from "./app";
|
||||
import {
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
McpUiInitializeRequestSchema,
|
||||
McpUiInitializeResultSchema,
|
||||
McpUiInitializedNotificationSchema,
|
||||
} from "./types";
|
||||
|
||||
type ConnectedPair = {
|
||||
app: App;
|
||||
server: Server;
|
||||
sentByApp: JSONRPCMessage[];
|
||||
appsInitialized: Promise<void>;
|
||||
};
|
||||
|
||||
async function connectPair(
|
||||
app = new App(
|
||||
{ name: "view-app", version: "1.0.0" },
|
||||
{ tools: { listChanged: true } },
|
||||
{ autoResize: false },
|
||||
),
|
||||
): Promise<ConnectedPair> {
|
||||
const server = new Server(
|
||||
{ name: "inner-mcp-server", version: "0.0.0" },
|
||||
{
|
||||
capabilities: { resources: {}, tools: {} },
|
||||
},
|
||||
);
|
||||
const [appTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const sentByApp: JSONRPCMessage[] = [];
|
||||
const send = appTransport.send.bind(appTransport);
|
||||
appTransport.send = async (message, options) => {
|
||||
sentByApp.push(message);
|
||||
await send(message, options);
|
||||
};
|
||||
|
||||
let resolveAppsInitialized!: () => void;
|
||||
const appsInitialized = new Promise<void>((resolve) => {
|
||||
resolveAppsInitialized = resolve;
|
||||
});
|
||||
|
||||
server.setRequestHandler(
|
||||
"ui/initialize",
|
||||
{
|
||||
params: McpUiInitializeRequestSchema.shape.params,
|
||||
result: McpUiInitializeResultSchema,
|
||||
},
|
||||
(params) => {
|
||||
expect(params.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
return {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
hostCapabilities: { openLinks: {} },
|
||||
hostInfo: { name: "apps-host", version: "2.0.0" },
|
||||
hostContext: { theme: "dark" as const, locale: "en-US" },
|
||||
};
|
||||
},
|
||||
);
|
||||
server.setNotificationHandler(
|
||||
"ui/notifications/initialized",
|
||||
{ params: McpUiInitializedNotificationSchema.shape.params },
|
||||
() => resolveAppsInitialized(),
|
||||
);
|
||||
server.setRequestHandler("tools/call", (request) => ({
|
||||
content: [{ type: "text", text: `called ${request.params.name}` }],
|
||||
}));
|
||||
server.setRequestHandler("resources/read", (request) => ({
|
||||
contents: [{ uri: request.params.uri, text: "resource body" }],
|
||||
}));
|
||||
server.setRequestHandler("resources/list", () => ({
|
||||
resources: [{ uri: "test://resource", name: "Test resource" }],
|
||||
}));
|
||||
server.setRequestHandler("sampling/createMessage", () => ({
|
||||
model: "test-model",
|
||||
role: "assistant",
|
||||
content: { type: "text", text: "sampled" },
|
||||
}));
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await app.connect(appTransport);
|
||||
await appsInitialized;
|
||||
|
||||
return { app, server, sentByApp, appsInitialized };
|
||||
}
|
||||
|
||||
const connected: Array<Pick<ConnectedPair, "app" | "server">> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
connected
|
||||
.splice(0)
|
||||
.flatMap(({ app, server }) => [app.close(), server.close()]),
|
||||
);
|
||||
});
|
||||
|
||||
describe("App base MCP SDK v2 Protocol migration", () => {
|
||||
it("performs only the Apps handshake — no MCP initialize on the wire", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
|
||||
const lifecycleMethods = pair.sentByApp
|
||||
.filter(
|
||||
(message): message is JSONRPCMessage & { method: string } =>
|
||||
"method" in message,
|
||||
)
|
||||
.map((message) => message.method)
|
||||
.filter((method) =>
|
||||
[
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"ui/initialize",
|
||||
"ui/notifications/initialized",
|
||||
].includes(method),
|
||||
);
|
||||
|
||||
// The filter above includes the core MCP lifecycle methods, so this
|
||||
// equality also proves no `initialize`/`notifications/initialized` was sent
|
||||
// — the wire contract deployed v1 hosts depend on.
|
||||
expect(lifecycleMethods).toEqual([
|
||||
"ui/initialize",
|
||||
"ui/notifications/initialized",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses ui/initialize as the authority for Apps host state", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
|
||||
expect(pair.app.getHostVersion()).toEqual({
|
||||
name: "apps-host",
|
||||
version: "2.0.0",
|
||||
});
|
||||
expect(pair.app.getHostCapabilities()).toEqual({ openLinks: {} });
|
||||
expect(pair.app.getHostContext()).toEqual({
|
||||
theme: "dark",
|
||||
locale: "en-US",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves registered App tools with the base MCP SDK v2 Client handler context", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
|
||||
pair.app.registerTool(
|
||||
"greet",
|
||||
{ description: "Greets a person" },
|
||||
async (extra) => {
|
||||
receivedSignal = extra.mcpReq.signal;
|
||||
return { content: [{ type: "text", text: "hello" }] };
|
||||
},
|
||||
);
|
||||
|
||||
const listed = await pair.server.request({
|
||||
method: "tools/list",
|
||||
params: {},
|
||||
});
|
||||
const result = await pair.server.request({
|
||||
method: "tools/call",
|
||||
params: { name: "greet", arguments: {} },
|
||||
});
|
||||
|
||||
expect(listed.tools.map((tool) => tool.name)).toEqual(["greet"]);
|
||||
expect(result.content).toEqual([{ type: "text", text: "hello" }]);
|
||||
expect(receivedSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("uses base MCP SDK v2 method-keyed sends for standard MCP requests", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
|
||||
const toolResult = await pair.app.callServerTool({
|
||||
name: "refresh",
|
||||
arguments: {},
|
||||
});
|
||||
const resource = await pair.app.readServerResource({
|
||||
uri: "test://resource",
|
||||
});
|
||||
const resources = await pair.app.listServerResources();
|
||||
const sample = await pair.app.createSamplingMessage({
|
||||
messages: [{ role: "user", content: { type: "text", text: "hello" } }],
|
||||
maxTokens: 20,
|
||||
});
|
||||
|
||||
expect(toolResult.content).toEqual([
|
||||
{ type: "text", text: "called refresh" },
|
||||
]);
|
||||
expect(resource.contents[0]).toEqual({
|
||||
uri: "test://resource",
|
||||
text: "resource body",
|
||||
});
|
||||
expect(resources.resources.map(({ uri }) => uri)).toEqual([
|
||||
"test://resource",
|
||||
]);
|
||||
expect(sample.content).toEqual({ type: "text", text: "sampled" });
|
||||
});
|
||||
|
||||
it("preserves reconnect behavior after close", async () => {
|
||||
const first = await connectPair();
|
||||
await first.app.close();
|
||||
await first.server.close();
|
||||
|
||||
const second = await connectPair(first.app);
|
||||
connected.push(second);
|
||||
|
||||
expect(second.app.getHostVersion()).toEqual({
|
||||
name: "apps-host",
|
||||
version: "2.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves auto-resize setup after Apps initialization", async () => {
|
||||
const app = new App(
|
||||
{ name: "view-app", version: "1.0.0" },
|
||||
{},
|
||||
{ autoResize: true },
|
||||
);
|
||||
let setupCalls = 0;
|
||||
app.setupSizeChangedNotifications = () => {
|
||||
setupCalls += 1;
|
||||
return () => {};
|
||||
};
|
||||
|
||||
const pair = await connectPair(app);
|
||||
connected.push(pair);
|
||||
|
||||
expect(setupCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves strict late-handler timing errors", async () => {
|
||||
const app = new App(
|
||||
{ name: "view-app", version: "1.0.0" },
|
||||
{},
|
||||
{ autoResize: false, strict: true },
|
||||
);
|
||||
const pair = await connectPair(app);
|
||||
connected.push(pair);
|
||||
|
||||
expect(() => {
|
||||
app.ontoolinput = () => {};
|
||||
}).toThrow(/handler registered after connect/);
|
||||
});
|
||||
|
||||
it("uses base MCP SDK replacement semantics for request handlers", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
let handledBy = 0;
|
||||
|
||||
pair.app.setRequestHandler("ping", () => {
|
||||
handledBy = 1;
|
||||
return {};
|
||||
});
|
||||
pair.app.setRequestHandler("ping", () => {
|
||||
handledBy = 2;
|
||||
return {};
|
||||
});
|
||||
|
||||
await pair.server.request({ method: "ping", params: {} });
|
||||
expect(handledBy).toBe(2);
|
||||
});
|
||||
|
||||
it("uses base MCP SDK replacement semantics for non-event notifications", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
const calls: number[] = [];
|
||||
const params = z.object({});
|
||||
|
||||
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
||||
calls.push(1);
|
||||
});
|
||||
pair.app.setNotificationHandler("test/notification", { params }, () => {
|
||||
calls.push(2);
|
||||
});
|
||||
await pair.server.notification({
|
||||
method: "test/notification",
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(calls).toEqual([2]);
|
||||
});
|
||||
});
|
||||
291
src/app.ts
291
src/app.ts
|
|
@ -1,39 +1,30 @@
|
|||
import {
|
||||
Protocol,
|
||||
type BaseContext,
|
||||
type ProtocolOptions,
|
||||
type RequestOptions,
|
||||
mergeCapabilities,
|
||||
ProtocolOptions,
|
||||
} from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
|
||||
import {
|
||||
CallToolRequest,
|
||||
CallToolRequestSchema,
|
||||
CallToolResult,
|
||||
CallToolResultSchema,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
CreateMessageResultSchema,
|
||||
CreateMessageResultWithTools,
|
||||
CreateMessageResultWithToolsSchema,
|
||||
EmptyResultSchema,
|
||||
Implementation,
|
||||
ListResourcesRequest,
|
||||
ListResourcesResult,
|
||||
ListResourcesResultSchema,
|
||||
ListToolsRequest,
|
||||
ListToolsRequestSchema,
|
||||
ListToolsResult,
|
||||
LoggingMessageNotification,
|
||||
PingRequestSchema,
|
||||
ReadResourceRequest,
|
||||
ReadResourceResult,
|
||||
ReadResourceResultSchema,
|
||||
Tool,
|
||||
ToolAnnotations,
|
||||
ToolListChangedNotification,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AppNotification, AppRequest, AppResult } from "./types";
|
||||
import { ProtocolWithEvents } from "./events";
|
||||
export { ProtocolWithEvents };
|
||||
type CallToolRequest,
|
||||
type CallToolResult,
|
||||
type CreateMessageRequest,
|
||||
type CreateMessageResult,
|
||||
type CreateMessageResultWithTools,
|
||||
type Implementation,
|
||||
type ListResourcesRequest,
|
||||
type ListResourcesResult,
|
||||
type ListToolsRequest,
|
||||
type ListToolsResult,
|
||||
type LoggingMessageNotification,
|
||||
type ReadResourceRequest,
|
||||
type ReadResourceResult,
|
||||
type Tool,
|
||||
type ToolAnnotations,
|
||||
type ToolListChangedNotification,
|
||||
type Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { EmptyResultSchema } from "@modelcontextprotocol/core";
|
||||
export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants";
|
||||
import { EventDispatcher } from "./events";
|
||||
export { EventDispatcher } from "./events";
|
||||
import { PostMessageTransport } from "./message-transport";
|
||||
import {
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
|
|
@ -55,6 +46,7 @@ import {
|
|||
McpUiResourceTeardownRequest,
|
||||
McpUiResourceTeardownRequestSchema,
|
||||
McpUiResourceTeardownResult,
|
||||
McpUiResourceTeardownResultSchema,
|
||||
McpUiRequestTeardownNotification,
|
||||
McpUiSizeChangedNotification,
|
||||
McpUiToolCancelledNotification,
|
||||
|
|
@ -68,13 +60,34 @@ import {
|
|||
McpUiRequestDisplayModeRequest,
|
||||
McpUiRequestDisplayModeResultSchema,
|
||||
} from "./types";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import {
|
||||
StandardSchemaV1,
|
||||
standardSchemaToJsonSchema,
|
||||
validateStandardSchema,
|
||||
} from "./standard-schema";
|
||||
import { z } from "zod/v4";
|
||||
import { z, type ZodLiteral, type ZodObject, type ZodType } from "zod/v4";
|
||||
|
||||
type MethodSchema = ZodObject<{
|
||||
method: ZodLiteral<string>;
|
||||
params: ZodType;
|
||||
}>;
|
||||
|
||||
function mergeAppCapabilities(
|
||||
base: McpUiAppCapabilities,
|
||||
additional: McpUiAppCapabilities,
|
||||
): McpUiAppCapabilities {
|
||||
const result = { ...base, ...additional };
|
||||
if (additional.experimental !== undefined) {
|
||||
result.experimental = {
|
||||
...base.experimental,
|
||||
...additional.experimental,
|
||||
};
|
||||
}
|
||||
if (additional.tools !== undefined) {
|
||||
result.tools = { ...base.tools, ...additional.tools };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type {
|
||||
StandardSchemaV1,
|
||||
|
|
@ -146,7 +159,6 @@ export {
|
|||
* }
|
||||
* ```
|
||||
*/
|
||||
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
||||
|
||||
/**
|
||||
* MIME type for MCP UI resources.
|
||||
|
|
@ -155,16 +167,15 @@ export const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
|||
*
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* Options for configuring {@link App `App`} behavior.
|
||||
*
|
||||
* Extends `ProtocolOptions` from the MCP SDK with `App`-specific configuration.
|
||||
* Extends `ProtocolOptions` from the base MCP SDK with `App`-specific configuration.
|
||||
*
|
||||
* @see `ProtocolOptions` from @modelcontextprotocol/sdk for inherited options
|
||||
* @see `ProtocolOptions` from @modelcontextprotocol/client for inherited options
|
||||
*/
|
||||
export type AppOptions = ProtocolOptions & {
|
||||
export type AppOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & {
|
||||
/**
|
||||
* Automatically report size changes to the host using `ResizeObserver`.
|
||||
*
|
||||
|
|
@ -204,9 +215,7 @@ export type AppOptions = ProtocolOptions & {
|
|||
allowUnsafeEval?: boolean;
|
||||
};
|
||||
|
||||
type RequestHandlerExtra = Parameters<
|
||||
Parameters<App["setRequestHandler"]>[1]
|
||||
>[1];
|
||||
type RequestHandlerExtra = BaseContext;
|
||||
|
||||
/**
|
||||
* Result of an app-registered tool callback. When `Out` is provided,
|
||||
|
|
@ -228,7 +237,7 @@ export type AppToolResult<
|
|||
* validated/parsed input; when `In` is `undefined`, the callback receives only
|
||||
* `extra`. When `Out` is provided, the return's `structuredContent` is typed.
|
||||
*
|
||||
* Mirrors `ToolCallback` from `@modelcontextprotocol/sdk/server/mcp.js` but is
|
||||
* Mirrors `ToolCallback` from `@modelcontextprotocol/server` but is
|
||||
* parameterized over {@link StandardSchemaV1} instead of zod, so any
|
||||
* Standard-Schema-compatible library (Zod, ArkType, Valibot, …) can be used.
|
||||
*/
|
||||
|
|
@ -246,7 +255,7 @@ export type AppToolCallback<
|
|||
|
||||
/**
|
||||
* Handle returned by {@link App.registerTool}. Mirrors `RegisteredTool` from
|
||||
* `@modelcontextprotocol/sdk/server/mcp.js` but stores
|
||||
* `@modelcontextprotocol/server` but stores
|
||||
* {@link StandardSchemaV1} schemas.
|
||||
*/
|
||||
export type RegisteredAppTool = {
|
||||
|
|
@ -271,9 +280,9 @@ export type RegisteredAppTool = {
|
|||
/**
|
||||
* Maps DOM-style event names to their notification `params` types.
|
||||
*
|
||||
* Used by {@link App `App`} (which extends {@link ProtocolWithEvents `ProtocolWithEvents`})
|
||||
* to provide type-safe `addEventListener` / `removeEventListener` and
|
||||
* singular `on*` handler support.
|
||||
* Used by {@link App `App`} and its composed {@link EventDispatcher} to
|
||||
* provide type-safe `addEventListener` / `removeEventListener` and singular
|
||||
* `on*` handler support.
|
||||
*/
|
||||
export type AppEventMap = {
|
||||
toolinput: McpUiToolInputNotification["params"];
|
||||
|
|
@ -287,9 +296,9 @@ export type AppEventMap = {
|
|||
* Main class for MCP Apps to communicate with their host.
|
||||
*
|
||||
* The `App` class provides a framework-agnostic way to build interactive MCP Apps
|
||||
* that run inside host applications. It extends the MCP SDK's `Protocol` class and
|
||||
* handles the connection lifecycle, initialization handshake, and bidirectional
|
||||
* communication with the host.
|
||||
* that run inside host applications. It extends the base MCP SDK's public
|
||||
* `Protocol` class, composes an {@link EventDispatcher}, and adds the Apps
|
||||
* handshake, lifecycle, and high-level APIs.
|
||||
*
|
||||
* ## Architecture
|
||||
*
|
||||
|
|
@ -304,16 +313,16 @@ export type AppEventMap = {
|
|||
* 3. **Interactive**: Send requests, receive notifications, call tools
|
||||
* 4. **Teardown**: Host sends teardown request before unmounting
|
||||
*
|
||||
* ## Inherited Methods
|
||||
* ## Protocol and event methods
|
||||
*
|
||||
* As a subclass of {@link ProtocolWithEvents `ProtocolWithEvents`}, `App` inherits:
|
||||
* From the base MCP SDK's `Protocol`, `App` provides:
|
||||
* - `setRequestHandler()` - Register handlers for requests from host
|
||||
* - `setNotificationHandler()` - Register handlers for notifications from host
|
||||
*
|
||||
* From its composed {@link EventDispatcher}, `App` also provides:
|
||||
* - `addEventListener()` - Append a listener for a notification event (multi-listener)
|
||||
* - `removeEventListener()` - Remove a previously added listener
|
||||
*
|
||||
* @see {@link ProtocolWithEvents `ProtocolWithEvents`} for the DOM-model event system
|
||||
*
|
||||
* ## Notification Setters (DOM-model `on*` handlers)
|
||||
*
|
||||
* For common notifications, the `App` class provides getter/setter properties
|
||||
|
|
@ -342,17 +351,14 @@ export type AppEventMap = {
|
|||
* await app.connect();
|
||||
* ```
|
||||
*/
|
||||
export class App extends ProtocolWithEvents<
|
||||
AppRequest,
|
||||
AppNotification,
|
||||
AppResult,
|
||||
AppEventMap
|
||||
> {
|
||||
export class App extends Protocol<BaseContext> {
|
||||
private _hostCapabilities?: McpUiHostCapabilities;
|
||||
private _hostInfo?: Implementation;
|
||||
private _hostContext?: McpUiHostContext;
|
||||
private _registeredTools: { [name: string]: RegisteredAppTool } = {};
|
||||
private _initializedSent = false;
|
||||
private readonly _registeredEvents = new Set<keyof AppEventMap>();
|
||||
private readonly _events = new EventDispatcher<AppEventMap>();
|
||||
|
||||
/**
|
||||
* Warn if a host-bound method is called before {@link connect `connect`} has
|
||||
|
|
@ -378,7 +384,9 @@ export class App extends ProtocolWithEvents<
|
|||
console.warn(`${msg}. This will throw in a future release.`);
|
||||
}
|
||||
|
||||
protected readonly eventSchemas = {
|
||||
private readonly eventSchemas: {
|
||||
[K in keyof AppEventMap]: MethodSchema;
|
||||
} = {
|
||||
toolinput: McpUiToolInputNotificationSchema,
|
||||
toolinputpartial: McpUiToolInputPartialNotificationSchema,
|
||||
toolresult: McpUiToolResultNotificationSchema,
|
||||
|
|
@ -427,28 +435,65 @@ export class App extends ProtocolWithEvents<
|
|||
console.warn(msg);
|
||||
}
|
||||
|
||||
protected override setEventHandler<K extends keyof AppEventMap>(
|
||||
protected setEventHandler<K extends keyof AppEventMap>(
|
||||
event: K,
|
||||
handler: ((params: AppEventMap[K]) => void) | undefined,
|
||||
): void {
|
||||
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,
|
||||
handler: (params: AppEventMap[K]) => void,
|
||||
): void {
|
||||
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,
|
||||
params: AppEventMap[K],
|
||||
handler: (params: AppEventMap[K]) => void,
|
||||
): void {
|
||||
if (event === "hostcontextchanged") {
|
||||
this._hostContext = { ...this._hostContext, ...params };
|
||||
this._events.removeEventListener(event, handler);
|
||||
}
|
||||
|
||||
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.shape.method.value,
|
||||
{ params: schema.shape.params },
|
||||
(params) => {
|
||||
const eventParams = params as AppEventMap[K];
|
||||
if (event === "hostcontextchanged") {
|
||||
this._hostContext = { ...this._hostContext, ...eventParams };
|
||||
}
|
||||
this._events.dispatch(event, eventParams);
|
||||
},
|
||||
);
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -456,7 +501,7 @@ export class App extends ProtocolWithEvents<
|
|||
* Create a new MCP App instance.
|
||||
*
|
||||
* @param _appInfo - App identification (name and version)
|
||||
* @param _capabilities - Features and capabilities this app provides
|
||||
* @param _appCapabilities - Features and capabilities this app provides
|
||||
* @param options - Configuration options including `autoResize` behavior
|
||||
*
|
||||
* @example
|
||||
|
|
@ -470,7 +515,7 @@ export class App extends ProtocolWithEvents<
|
|||
*/
|
||||
constructor(
|
||||
private _appInfo: Implementation,
|
||||
private _capabilities: McpUiAppCapabilities = {},
|
||||
private _appCapabilities: McpUiAppCapabilities = {},
|
||||
private options: AppOptions = { autoResize: true },
|
||||
) {
|
||||
super(options);
|
||||
|
|
@ -479,24 +524,31 @@ export class App extends ProtocolWithEvents<
|
|||
z.config({ jitless: true });
|
||||
}
|
||||
|
||||
this.setRequestHandler(PingRequestSchema, (request) => {
|
||||
this.setRequestHandler("ping", (request) => {
|
||||
console.log("Received ping:", request.params);
|
||||
return {};
|
||||
});
|
||||
|
||||
// Eagerly register the hostcontextchanged event slot so that
|
||||
// onEventDispatch (which merges into _hostContext) fires even if the
|
||||
// user never assigns onhostcontextchanged or calls addEventListener.
|
||||
// Eagerly register hostcontextchanged so the owner-side context merge runs
|
||||
// even if the user never assigns a handler or adds a listener.
|
||||
this.setEventHandler("hostcontextchanged", undefined);
|
||||
}
|
||||
|
||||
private registerCapabilities(capabilities: McpUiAppCapabilities): void {
|
||||
/** @internal */
|
||||
protected buildContext(ctx: BaseContext): BaseContext {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private registerAppCapabilities(capabilities: McpUiAppCapabilities): void {
|
||||
if (this.transport) {
|
||||
throw new Error(
|
||||
"Cannot register capabilities after transport is established",
|
||||
);
|
||||
}
|
||||
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
|
||||
this._appCapabilities = mergeAppCapabilities(
|
||||
this._appCapabilities,
|
||||
capabilities,
|
||||
);
|
||||
}
|
||||
|
||||
registerTool<
|
||||
|
|
@ -519,7 +571,7 @@ export class App extends ProtocolWithEvents<
|
|||
}
|
||||
const app = this;
|
||||
const notify = () => {
|
||||
if (app._initializedSent && app._capabilities.tools?.listChanged) {
|
||||
if (app._initializedSent && app._appCapabilities.tools?.listChanged) {
|
||||
void app.sendToolListChanged();
|
||||
}
|
||||
};
|
||||
|
|
@ -591,8 +643,8 @@ export class App extends ProtocolWithEvents<
|
|||
// to declare { tools: {} } in the constructor just to use registerTool.
|
||||
// Only do this pre-connect; post-connect the capability was already
|
||||
// advertised (or wasn't) and can't change.
|
||||
if (!this._capabilities.tools && !this.transport) {
|
||||
this.registerCapabilities({ tools: { listChanged: true } });
|
||||
if (!this._appCapabilities.tools && !this.transport) {
|
||||
this.registerAppCapabilities({ tools: { listChanged: true } });
|
||||
}
|
||||
|
||||
this.ensureToolHandlersInitialized();
|
||||
|
|
@ -924,9 +976,9 @@ export class App extends ProtocolWithEvents<
|
|||
* without replacing.
|
||||
*
|
||||
* Notification params are automatically merged into the internal host context
|
||||
* via {@link onEventDispatch `onEventDispatch`} before any handler or listener
|
||||
* fires. This means {@link getHostContext `getHostContext`} will return the
|
||||
* updated values even before your callback runs.
|
||||
* before any handler or listener fires. This means
|
||||
* {@link getHostContext `getHostContext`} will return the updated values even
|
||||
* before your callback runs.
|
||||
*
|
||||
* Register handlers before calling {@link connect `connect`} to avoid missing notifications.
|
||||
*
|
||||
|
|
@ -1004,11 +1056,15 @@ export class App extends ProtocolWithEvents<
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("onteardown", this._onteardown, callback);
|
||||
this._onteardown = callback;
|
||||
this.replaceRequestHandler(
|
||||
McpUiResourceTeardownRequestSchema,
|
||||
(request, extra) => {
|
||||
this.setRequestHandler(
|
||||
"ui/resource-teardown",
|
||||
{
|
||||
params: McpUiResourceTeardownRequestSchema.shape.params,
|
||||
result: McpUiResourceTeardownResultSchema,
|
||||
},
|
||||
(params, extra) => {
|
||||
if (!this._onteardown) throw new Error("No onteardown handler set");
|
||||
return this._onteardown(request.params, extra);
|
||||
return this._onteardown(params, extra);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1058,7 +1114,7 @@ export class App extends ProtocolWithEvents<
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||
this._oncalltool = callback;
|
||||
this.replaceRequestHandler(CallToolRequestSchema, (request, extra) => {
|
||||
this.setRequestHandler("tools/call", (request, extra) => {
|
||||
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||
return this._oncalltool(request.params, extra);
|
||||
});
|
||||
|
|
@ -1129,7 +1185,7 @@ export class App extends ProtocolWithEvents<
|
|||
callback,
|
||||
);
|
||||
this._onlisttools = callback;
|
||||
this.replaceRequestHandler(ListToolsRequestSchema, (request, extra) => {
|
||||
this.setRequestHandler("tools/list", (request, extra) => {
|
||||
if (!this._onlisttools) throw new Error("No onlisttools handler set");
|
||||
return this._onlisttools(request.params, extra);
|
||||
});
|
||||
|
|
@ -1139,7 +1195,7 @@ export class App extends ProtocolWithEvents<
|
|||
* Verify that the host supports the capability required for the given request method.
|
||||
* @internal
|
||||
*/
|
||||
assertCapabilityForMethod(method: AppRequest["method"]): void {
|
||||
protected override assertCapabilityForMethod(method: string): void {
|
||||
switch (method) {
|
||||
case "sampling/createMessage":
|
||||
if (!this._hostCapabilities?.sampling) {
|
||||
|
|
@ -1155,11 +1211,11 @@ export class App extends ProtocolWithEvents<
|
|||
* Verify that the app declared the capability required for the given request method.
|
||||
* @internal
|
||||
*/
|
||||
assertRequestHandlerCapability(method: AppRequest["method"]): void {
|
||||
protected override assertRequestHandlerCapability(method: string): void {
|
||||
switch (method) {
|
||||
case "tools/call":
|
||||
case "tools/list":
|
||||
if (!this._capabilities.tools) {
|
||||
if (!this._appCapabilities.tools) {
|
||||
throw new Error(
|
||||
`Client does not support tool capability (required for ${method})`,
|
||||
);
|
||||
|
|
@ -1177,26 +1233,10 @@ export class App extends ProtocolWithEvents<
|
|||
* Verify that the app supports the capability required for the given notification method.
|
||||
* @internal
|
||||
*/
|
||||
assertNotificationCapability(_method: AppNotification["method"]): void {
|
||||
protected override assertNotificationCapability(_method: string): void {
|
||||
// TODO
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that task creation is supported for the given request method.
|
||||
* @internal
|
||||
*/
|
||||
protected assertTaskCapability(_method: string): void {
|
||||
throw new Error("Tasks are not supported in MCP Apps");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that task handler is supported for the given method.
|
||||
* @internal
|
||||
*/
|
||||
protected assertTaskHandlerCapability(_method: string): void {
|
||||
throw new Error("Task handlers are not supported in MCP Apps");
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool on the originating MCP server (proxied through the host).
|
||||
*
|
||||
|
|
@ -1245,7 +1285,6 @@ export class App extends ProtocolWithEvents<
|
|||
}
|
||||
return await this.request(
|
||||
{ method: "tools/call", params },
|
||||
CallToolResultSchema,
|
||||
{
|
||||
// Hosts may interpose long-running or user-interactive steps before the
|
||||
// tool result arrives. Opting in here lets a host heartbeat keep the
|
||||
|
|
@ -1302,11 +1341,7 @@ export class App extends ProtocolWithEvents<
|
|||
options?: RequestOptions,
|
||||
): Promise<ReadResourceResult> {
|
||||
this._assertInitialized("readServerResource");
|
||||
return await this.request(
|
||||
{ method: "resources/read", params },
|
||||
ReadResourceResultSchema,
|
||||
options,
|
||||
);
|
||||
return await this.request({ method: "resources/read", params }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1350,11 +1385,7 @@ export class App extends ProtocolWithEvents<
|
|||
options?: RequestOptions,
|
||||
): Promise<ListResourcesResult> {
|
||||
this._assertInitialized("listServerResources");
|
||||
return await this.request(
|
||||
{ method: "resources/list", params },
|
||||
ListResourcesResultSchema,
|
||||
options,
|
||||
);
|
||||
return await this.request({ method: "resources/list", params }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1415,8 +1446,8 @@ export class App extends ProtocolWithEvents<
|
|||
* }
|
||||
* ```
|
||||
*
|
||||
* @see `CreateMessageRequest` from @modelcontextprotocol/sdk for the request type
|
||||
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/sdk for result types
|
||||
* @see `CreateMessageRequest` from @modelcontextprotocol/client for the request type
|
||||
* @see `CreateMessageResult` / `CreateMessageResultWithTools` from @modelcontextprotocol/client for result types
|
||||
*/
|
||||
async createSamplingMessage(
|
||||
params: CreateMessageRequest["params"] & { tools?: undefined },
|
||||
|
|
@ -1431,12 +1462,8 @@ export class App extends ProtocolWithEvents<
|
|||
options?: RequestOptions,
|
||||
): Promise<CreateMessageResult | CreateMessageResultWithTools> {
|
||||
this._assertInitialized("createSamplingMessage");
|
||||
const resultSchema = params.tools
|
||||
? CreateMessageResultWithToolsSchema
|
||||
: CreateMessageResultSchema;
|
||||
return await this.request(
|
||||
{ method: "sampling/createMessage", params },
|
||||
resultSchema,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
|
@ -1953,14 +1980,18 @@ export class App extends ProtocolWithEvents<
|
|||
);
|
||||
}
|
||||
this._initializedSent = false;
|
||||
await super.connect(transport);
|
||||
|
||||
try {
|
||||
// Protocol.connect only wires the transport — no MCP handshake. The
|
||||
// ui/initialize request below is the sole negotiation on this channel,
|
||||
// matching the v1 wire behavior deployed hosts expect.
|
||||
await super.connect(transport);
|
||||
|
||||
const result = await this.request(
|
||||
<McpUiInitializeRequest>{
|
||||
method: "ui/initialize",
|
||||
params: {
|
||||
appCapabilities: this._capabilities,
|
||||
appCapabilities: this._appCapabilities,
|
||||
appInfo: this._appInfo,
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
},
|
||||
|
|
|
|||
5
src/constants.ts
Normal file
5
src/constants.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** Legacy metadata key associating an MCP tool with its App resource. */
|
||||
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
|
||||
|
||||
/** MIME type for MCP App HTML resources. */
|
||||
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
||||
20
src/core-types.ts
Normal file
20
src/core-types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
CallToolResultSchema,
|
||||
ContentBlockSchema,
|
||||
EmbeddedResourceSchema,
|
||||
ImplementationSchema,
|
||||
RequestIdSchema,
|
||||
ResourceLinkSchema,
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
// Infer shared wire types from the public role-neutral schemas so declarations
|
||||
// used by every entrypoint do not acquire a client or server package edge.
|
||||
export type CallToolResult = z.infer<typeof CallToolResultSchema>;
|
||||
export type ContentBlock = z.infer<typeof ContentBlockSchema>;
|
||||
export type EmbeddedResource = z.infer<typeof EmbeddedResourceSchema>;
|
||||
export type Implementation = z.infer<typeof ImplementationSchema>;
|
||||
export type RequestId = z.infer<typeof RequestIdSchema>;
|
||||
export type ResourceLink = z.infer<typeof ResourceLinkSchema>;
|
||||
export type Tool = z.infer<typeof ToolSchema>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
256
src/events.ts
256
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
|
||||
* listener array (`addEventListener` semantics), mirroring the DOM model
|
||||
* where `el.onclick` and `el.addEventListener("click", …)` coexist.
|
||||
* Per-event state: a singular `on*` handler (replace semantics) plus an
|
||||
* ordered listener array (`addEventListener` semantics).
|
||||
*/
|
||||
interface EventSlot<T = unknown> {
|
||||
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
|
||||
* MCP SDK's `Protocol`.
|
||||
* Protocol-independent DOM-style event fan-out.
|
||||
*
|
||||
* The base `Protocol` class stores one handler per method:
|
||||
* `setRequestHandler()` and `setNotificationHandler()` replace any existing
|
||||
* handler for the same method silently. This class introduces a two-channel
|
||||
* event model inspired by the DOM:
|
||||
* Owners perform event-specific state updates before calling
|
||||
* {@link dispatch}. Dispatch then invokes the singular handler followed by a
|
||||
* snapshot of listeners in insertion order.
|
||||
*
|
||||
* ### Singular `on*` handler (like `el.onclick`)
|
||||
*
|
||||
* Subclasses expose `get`/`set` pairs that delegate to
|
||||
* {@link setEventHandler `setEventHandler`} /
|
||||
* {@link getEventHandler `getEventHandler`}. Assigning replaces the previous
|
||||
* handler; assigning `undefined` clears it. `addEventListener` listeners are
|
||||
* unaffected.
|
||||
*
|
||||
* ### Multi-listener (`addEventListener` / `removeEventListener`)
|
||||
*
|
||||
* Append to a per-event listener array. Listeners fire in insertion order
|
||||
* after the singular `on*` handler.
|
||||
*
|
||||
* ### Dispatch order
|
||||
*
|
||||
* When a notification arrives for a mapped event:
|
||||
* 1. {@link onEventDispatch `onEventDispatch`} (subclass side-effects)
|
||||
* 2. The singular `on*` handler (if set)
|
||||
* 3. All `addEventListener` listeners in insertion order
|
||||
*
|
||||
* ### Double-set protection
|
||||
*
|
||||
* Direct calls to {@link setRequestHandler `setRequestHandler`} /
|
||||
* {@link setNotificationHandler `setNotificationHandler`} throw if a handler
|
||||
* for the same method has already been registered (through any path), so
|
||||
* accidental overwrites surface as errors instead of silent bugs.
|
||||
*
|
||||
* @typeParam EventMap - Maps event names to the listener's `params` type.
|
||||
* @typeParam EventMap - Maps event names to their listener parameter types.
|
||||
*/
|
||||
export abstract class ProtocolWithEvents<
|
||||
SendRequestT extends Request,
|
||||
SendNotificationT extends Notification,
|
||||
SendResultT extends Result,
|
||||
EventMap extends Record<string, unknown>,
|
||||
> extends Protocol<SendRequestT, SendNotificationT, SendResultT> {
|
||||
private _registeredMethods = new Set<string>();
|
||||
private _eventSlots = new Map<keyof EventMap, EventSlot>();
|
||||
export class EventDispatcher<EventMap extends Record<string, unknown>> {
|
||||
private readonly _eventSlots = new Map<keyof EventMap, EventSlot>();
|
||||
|
||||
/**
|
||||
* Event name → notification schema. Subclasses populate this so that
|
||||
* the event system can lazily register a dispatcher with the correct
|
||||
* schema on first use.
|
||||
*/
|
||||
protected abstract readonly eventSchemas: {
|
||||
[K in keyof EventMap]: MethodSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Called once per incoming notification, before any handlers or listeners
|
||||
* fire. Subclasses may override to perform side effects such as merging
|
||||
* notification params into cached state.
|
||||
*/
|
||||
protected onEventDispatch<K extends keyof EventMap>(
|
||||
_event: K,
|
||||
_params: EventMap[K],
|
||||
): void {}
|
||||
|
||||
// ── Event system (DOM model) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lazily create the event slot and register a single dispatcher with the
|
||||
* base `Protocol`. The dispatcher fans out to the `on*` handler and all
|
||||
* `addEventListener` listeners.
|
||||
*/
|
||||
private _ensureEventSlot<K extends keyof EventMap>(
|
||||
event: K,
|
||||
): EventSlot<EventMap[K]> {
|
||||
let slot = this._eventSlots.get(event) as
|
||||
| EventSlot<EventMap[K]>
|
||||
| undefined;
|
||||
if (!slot) {
|
||||
const schema = this.eventSchemas[event];
|
||||
if (!schema) {
|
||||
throw new Error(`Unknown event: ${String(event)}`);
|
||||
}
|
||||
slot = { listeners: [] };
|
||||
this._eventSlots.set(event, slot as EventSlot);
|
||||
|
||||
// Claim this method so direct setNotificationHandler calls throw.
|
||||
const method = schema.shape.method.value;
|
||||
this._registeredMethods.add(method);
|
||||
|
||||
const s = slot; // stable reference for the closure
|
||||
super.setNotificationHandler(schema, (n) => {
|
||||
const params = (n as { params: EventMap[K] }).params;
|
||||
this.onEventDispatch(event, params);
|
||||
// 1. Singular on* handler
|
||||
s.onHandler?.(params);
|
||||
// 2. addEventListener listeners — snapshot to tolerate removal during
|
||||
// dispatch (e.g., a listener that calls removeEventListener on itself)
|
||||
for (const l of [...s.listeners]) l(params);
|
||||
});
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the singular `on*` handler for an event.
|
||||
*
|
||||
* Replace semantics — like the DOM's `el.onclick = fn`. Assigning
|
||||
* `undefined` clears the handler without affecting `addEventListener`
|
||||
* listeners.
|
||||
*/
|
||||
protected setEventHandler<K extends keyof EventMap>(
|
||||
/** Set, replace, or clear the singular handler for an event. */
|
||||
setHandler<K extends keyof EventMap>(
|
||||
event: K,
|
||||
handler: ((params: EventMap[K]) => void) | undefined,
|
||||
): void {
|
||||
|
|
@ -144,31 +34,15 @@ export abstract class ProtocolWithEvents<
|
|||
slot.onHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singular `on*` handler for an event, or `undefined` if none is
|
||||
* set. `addEventListener` listeners are not reflected here.
|
||||
*/
|
||||
protected getEventHandler<K extends keyof EventMap>(
|
||||
/** Get the singular handler for an event, if one is set. */
|
||||
getHandler<K extends keyof EventMap>(
|
||||
event: K,
|
||||
): ((params: EventMap[K]) => void) | undefined {
|
||||
return (this._eventSlots.get(event) as EventSlot<EventMap[K]> | undefined)
|
||||
?.onHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener for a notification event.
|
||||
*
|
||||
* Unlike the singular `on*` handler, calling this multiple times appends
|
||||
* listeners rather than replacing them. All registered listeners fire in
|
||||
* insertion order after the `on*` handler when the notification arrives.
|
||||
*
|
||||
* Registration is lazy: the first call (for a given event, from either
|
||||
* this method or the `on*` setter) registers a dispatcher with the base
|
||||
* `Protocol`.
|
||||
*
|
||||
* @param event - Event name (a key of the `EventMap` type parameter).
|
||||
* @param handler - Listener invoked with the notification `params`.
|
||||
*/
|
||||
/** Add a listener. Listeners run in insertion order after the handler. */
|
||||
addEventListener<K extends keyof EventMap>(
|
||||
event: K,
|
||||
handler: (params: EventMap[K]) => void,
|
||||
|
|
@ -176,11 +50,7 @@ export abstract class ProtocolWithEvents<
|
|||
this._ensureEventSlot(event).listeners.push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a previously registered event listener. The dispatcher stays
|
||||
* registered even if the listener array becomes empty; future
|
||||
* notifications simply have no listeners to call.
|
||||
*/
|
||||
/** Remove the first matching listener, if present. */
|
||||
removeEventListener<K extends keyof EventMap>(
|
||||
event: K,
|
||||
handler: (params: EventMap[K]) => void,
|
||||
|
|
@ -193,87 +63,29 @@ export abstract class ProtocolWithEvents<
|
|||
if (idx !== -1) slot.listeners.splice(idx, 1);
|
||||
}
|
||||
|
||||
// ── Handler registration with double-set protection ─────────────────
|
||||
|
||||
// The two overrides below are arrow-function class fields rather than
|
||||
// prototype methods so that Protocol's constructor — which registers its
|
||||
// own ping/cancelled/progress handlers via `this.setRequestHandler`
|
||||
// before our fields initialize — hits the base implementation and skips
|
||||
// tracking. Converting these to proper methods would crash with
|
||||
// `_registeredMethods` undefined during super().
|
||||
|
||||
/**
|
||||
* Registers a request handler. Throws if a handler for the same method
|
||||
* has already been registered — use the `on*` setter (replace semantics)
|
||||
* or `addEventListener` (multi-listener) for notification events.
|
||||
*
|
||||
* @throws {Error} if a handler for this method is already registered.
|
||||
* Dispatch an event to its singular handler and a listener snapshot.
|
||||
* Listener mutations during dispatch take effect on the next dispatch.
|
||||
*/
|
||||
override setRequestHandler: Protocol<
|
||||
SendRequestT,
|
||||
SendNotificationT,
|
||||
SendResultT
|
||||
>["setRequestHandler"] = (schema, handler) => {
|
||||
this._assertMethodNotRegistered(schema, "setRequestHandler");
|
||||
super.setRequestHandler(schema, handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a notification handler. Throws if a handler for the same
|
||||
* method has already been registered — use the `on*` setter (replace
|
||||
* semantics) or `addEventListener` (multi-listener) for mapped events.
|
||||
*
|
||||
* @throws {Error} if a handler for this method is already registered.
|
||||
*/
|
||||
override setNotificationHandler: Protocol<
|
||||
SendRequestT,
|
||||
SendNotificationT,
|
||||
SendResultT
|
||||
>["setNotificationHandler"] = (schema, handler) => {
|
||||
this._assertMethodNotRegistered(schema, "setNotificationHandler");
|
||||
super.setNotificationHandler(schema, handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Warn if a request handler `on*` setter is replacing a previously-set
|
||||
* handler. Call from each request setter before updating the backing field.
|
||||
*/
|
||||
protected warnIfRequestHandlerReplaced(
|
||||
name: string,
|
||||
previous: unknown,
|
||||
next: unknown,
|
||||
): void {
|
||||
if (previous && next) {
|
||||
console.warn(
|
||||
`[MCP Apps] ${name} handler replaced. ` +
|
||||
`Previous handler will no longer be called.`,
|
||||
);
|
||||
}
|
||||
dispatch<K extends keyof EventMap>(event: K, params: EventMap[K]): void {
|
||||
const slot = this._eventSlots.get(event) as
|
||||
| EventSlot<EventMap[K]>
|
||||
| undefined;
|
||||
if (!slot) return;
|
||||
slot.onHandler?.(params);
|
||||
for (const l of [...slot.listeners]) l(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a request handler, bypassing double-set protection. Used by
|
||||
* `on*` request-handler setters that need replace semantics.
|
||||
*/
|
||||
protected replaceRequestHandler: Protocol<
|
||||
SendRequestT,
|
||||
SendNotificationT,
|
||||
SendResultT
|
||||
>["setRequestHandler"] = (schema, handler) => {
|
||||
const method = (schema as MethodSchema).shape.method.value;
|
||||
this._registeredMethods.add(method);
|
||||
super.setRequestHandler(schema, handler);
|
||||
};
|
||||
|
||||
private _assertMethodNotRegistered(schema: unknown, via: string): void {
|
||||
const method = (schema as MethodSchema).shape.method.value;
|
||||
if (this._registeredMethods.has(method)) {
|
||||
throw new Error(
|
||||
`Handler for "${method}" already registered (via ${via}). ` +
|
||||
`Use addEventListener() to attach multiple listeners, ` +
|
||||
`or the on* setter for replace semantics.`,
|
||||
);
|
||||
private _ensureEventSlot<K extends keyof EventMap>(
|
||||
event: K,
|
||||
): EventSlot<EventMap[K]> {
|
||||
let slot = this._eventSlots.get(event) as
|
||||
| EventSlot<EventMap[K]>
|
||||
| undefined;
|
||||
if (!slot) {
|
||||
slot = { listeners: [] };
|
||||
this._eventSlots.set(event, slot as EventSlot);
|
||||
}
|
||||
this._registeredMethods.add(method);
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
238
src/generated/schema.json
generated
238
src/generated/schema.json
generated
|
|
@ -614,7 +614,9 @@
|
|||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
|
|
@ -629,25 +631,10 @@
|
|||
"outputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"annotations": {
|
||||
|
|
@ -1304,7 +1291,40 @@
|
|||
}
|
||||
},
|
||||
"required": ["method", "params"],
|
||||
"additionalProperties": false
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"__schema0": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"McpUiHostContext": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
|
|
@ -1377,7 +1397,9 @@
|
|||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
|
|
@ -1392,25 +1414,10 @@
|
|||
"outputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"annotations": {
|
||||
|
|
@ -2062,7 +2069,40 @@
|
|||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": {}
|
||||
"additionalProperties": {},
|
||||
"$defs": {
|
||||
"__schema0": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"McpUiHostCss": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
|
|
@ -2978,7 +3018,9 @@
|
|||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
|
|
@ -2993,25 +3035,10 @@
|
|||
"outputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"annotations": {
|
||||
|
|
@ -3673,7 +3700,40 @@
|
|||
"hostCapabilities",
|
||||
"hostContext"
|
||||
],
|
||||
"additionalProperties": {}
|
||||
"additionalProperties": {},
|
||||
"$defs": {
|
||||
"__schema0": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/__schema0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"McpUiInitializedNotification": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
|
|
@ -5360,26 +5420,52 @@
|
|||
"_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"progressToken": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"minimum": -9007199254740991,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
]
|
||||
},
|
||||
"io.modelcontextprotocol/related-task": {
|
||||
"io.modelcontextprotocol/serverInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taskId": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"icons": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"src": {
|
||||
"type": "string"
|
||||
},
|
||||
"mimeType": {
|
||||
"type": "string"
|
||||
},
|
||||
"sizes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"]
|
||||
}
|
||||
},
|
||||
"required": ["src"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"websiteUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["taskId"],
|
||||
"required": ["name", "version"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
|
|
@ -5705,13 +5791,7 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"structuredContent": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"structuredContent": {},
|
||||
"isError": {
|
||||
"type": "boolean"
|
||||
}
|
||||
|
|
|
|||
2
src/generated/schema.ts
generated
2
src/generated/schema.ts
generated
|
|
@ -10,7 +10,7 @@ import {
|
|||
RequestIdSchema,
|
||||
ResourceLinkSchema,
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from "@modelcontextprotocol/core";
|
||||
|
||||
/**
|
||||
* @description Color theme preference for the host environment.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
||||
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||
|
||||
import { PostMessageTransport } from "./message-transport";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import {
|
||||
import { JSONRPCMessageSchema } from "@modelcontextprotocol/core";
|
||||
import type {
|
||||
JSONRPCMessage,
|
||||
JSONRPCMessageSchema,
|
||||
MessageExtraInfo,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import {
|
||||
Transport,
|
||||
TransportSendOptions,
|
||||
} from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { TOOL_INPUT_PARTIAL_METHOD } from "./spec.types";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Implementation } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client";
|
||||
import type { Implementation } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
App,
|
||||
AppOptions,
|
||||
|
|
|
|||
|
|
@ -9,17 +9,14 @@
|
|||
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import type {
|
||||
McpServer,
|
||||
ToolCallback,
|
||||
ReadResourceCallback,
|
||||
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { McpServer, ToolCallback } from "@modelcontextprotocol/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
registerAppTool,
|
||||
registerAppResource,
|
||||
getUiCapability,
|
||||
RESOURCE_MIME_TYPE,
|
||||
type ReadResourceCallback,
|
||||
} from "./index.js";
|
||||
|
||||
// Stubs for external functions used in examples
|
||||
|
|
@ -74,7 +71,7 @@ function registerAppTool_basicUsage(server: McpServer) {
|
|||
{
|
||||
title: "Get Weather",
|
||||
description: "Get current weather for a location",
|
||||
inputSchema: { location: z.string() },
|
||||
inputSchema: z.object({ location: z.string() }),
|
||||
_meta: {
|
||||
ui: { resourceUri: "ui://weather/view.html" },
|
||||
},
|
||||
|
|
@ -122,7 +119,7 @@ function registerAppTool_appOnlyVisibility(server: McpServer) {
|
|||
"update-quantity",
|
||||
{
|
||||
description: "Update item quantity in cart",
|
||||
inputSchema: { itemId: z.string(), quantity: z.number() },
|
||||
inputSchema: z.object({ itemId: z.string(), quantity: z.number() }),
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: "ui://shop/cart.html",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
getUiCapability,
|
||||
EXTENSION_ID,
|
||||
} from "./index";
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
describe("registerAppTool", () => {
|
||||
it("should pass through config to server.registerTool", () => {
|
||||
|
|
|
|||
|
|
@ -32,32 +32,24 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
RESOURCE_URI_META_KEY,
|
||||
RESOURCE_MIME_TYPE,
|
||||
McpUiResourceCsp,
|
||||
McpUiResourceMeta,
|
||||
McpUiToolMeta,
|
||||
McpUiClientCapabilities,
|
||||
} from "../app.js";
|
||||
} from "../spec.types.js";
|
||||
import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js";
|
||||
import type {
|
||||
BaseToolCallback,
|
||||
ClientCapabilities,
|
||||
McpServer,
|
||||
ReadResourceResult,
|
||||
RegisteredTool,
|
||||
ResourceMetadata,
|
||||
StandardSchemaWithJSON,
|
||||
ToolAnnotations,
|
||||
ToolCallback,
|
||||
ReadResourceCallback as _ReadResourceCallback,
|
||||
RegisteredResource,
|
||||
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type {
|
||||
AnySchema,
|
||||
ZodRawShapeCompat,
|
||||
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
||||
import type { StandardSchemaWithJSON } from "../standard-schema";
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
ReadResourceResult,
|
||||
ToolAnnotations,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from "@modelcontextprotocol/server";
|
||||
|
||||
// Re-exports for convenience
|
||||
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE };
|
||||
|
|
@ -70,8 +62,8 @@ export type { ResourceMetadata, ToolCallback };
|
|||
export interface ToolConfig {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: ZodRawShapeCompat | StandardSchemaWithJSON;
|
||||
outputSchema?: ZodRawShapeCompat | StandardSchemaWithJSON;
|
||||
inputSchema?: StandardSchemaWithJSON;
|
||||
outputSchema?: StandardSchemaWithJSON;
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -157,7 +149,7 @@ export interface McpUiAppResourceConfig extends ResourceMetadata {
|
|||
* {
|
||||
* title: "Get Weather",
|
||||
* description: "Get current weather for a location",
|
||||
* inputSchema: { location: z.string() },
|
||||
* inputSchema: z.object({ location: z.string() }),
|
||||
* _meta: {
|
||||
* ui: { resourceUri: "ui://weather/view.html" },
|
||||
* },
|
||||
|
|
@ -197,7 +189,7 @@ export interface McpUiAppResourceConfig extends ResourceMetadata {
|
|||
* "update-quantity",
|
||||
* {
|
||||
* description: "Update item quantity in cart",
|
||||
* inputSchema: { itemId: z.string(), quantity: z.number() },
|
||||
* inputSchema: z.object({ itemId: z.string(), quantity: z.number() }),
|
||||
* _meta: {
|
||||
* ui: {
|
||||
* resourceUri: "ui://shop/cart.html",
|
||||
|
|
@ -215,9 +207,8 @@ export interface McpUiAppResourceConfig extends ResourceMetadata {
|
|||
* @see {@link registerAppResource `registerAppResource`} to register the HTML resource referenced by the tool
|
||||
*/
|
||||
export function registerAppTool<
|
||||
OutputArgs extends ZodRawShapeCompat | StandardSchemaWithJSON,
|
||||
InputArgs extends undefined | ZodRawShapeCompat | StandardSchemaWithJSON =
|
||||
undefined,
|
||||
OutputArgs extends StandardSchemaWithJSON,
|
||||
InputArgs extends StandardSchemaWithJSON | undefined = undefined,
|
||||
>(
|
||||
server: Pick<McpServer, "registerTool">,
|
||||
name: string,
|
||||
|
|
@ -225,15 +216,7 @@ export function registerAppTool<
|
|||
inputSchema?: InputArgs;
|
||||
outputSchema?: OutputArgs;
|
||||
},
|
||||
// The widened constraint signals the v2 API shape, but NOTE: McpServer in
|
||||
// sdk@1.x still calls zod internals at runtime, so non-zod schemas will fail
|
||||
// here until we depend on sdk v2. Zod (which all current callers use) is
|
||||
// unaffected. The cast below bridges the 1.x type signature.
|
||||
cb: ToolCallback<
|
||||
InputArgs extends undefined | ZodRawShapeCompat | AnySchema
|
||||
? InputArgs
|
||||
: AnySchema
|
||||
>,
|
||||
cb: ToolCallback<InputArgs>,
|
||||
): RegisteredTool {
|
||||
// Normalize metadata for backward compatibility:
|
||||
// - If _meta.ui.resourceUri is set, also set the legacy flat key
|
||||
|
|
@ -251,14 +234,7 @@ export function registerAppTool<
|
|||
normalizedMeta = { ...meta, ui: { ...uiMeta, resourceUri: legacyUri } };
|
||||
}
|
||||
|
||||
// Cast bridges the widened StandardSchemaWithJSON constraint to the
|
||||
// sdk@1.x zod-typed signature. Drops once we depend on sdk v2.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return server.registerTool(
|
||||
name,
|
||||
{ ...config, _meta: normalizedMeta } as any,
|
||||
cb as any,
|
||||
);
|
||||
return server.registerTool(name, { ...config, _meta: normalizedMeta }, cb);
|
||||
}
|
||||
|
||||
export type McpUiReadResourceResult = ReadResourceResult & {
|
||||
|
|
@ -418,10 +394,6 @@ export const EXTENSION_ID = "io.modelcontextprotocol/ui";
|
|||
* This helper retrieves the capability object from the `extensions` field
|
||||
* where MCP Apps advertises its support.
|
||||
*
|
||||
* Note: The `clientCapabilities` parameter extends the SDK's `ClientCapabilities`
|
||||
* type with an `extensions` field (pending SEP-1724). Once `extensions` is added
|
||||
* to the SDK, this can use `ClientCapabilities` directly.
|
||||
*
|
||||
* @param clientCapabilities - The client capabilities from the initialize response
|
||||
* @returns The MCP Apps capability settings, or `undefined` if not supported
|
||||
*
|
||||
|
|
@ -456,10 +428,7 @@ export const EXTENSION_ID = "io.modelcontextprotocol/ui";
|
|||
* ```
|
||||
*/
|
||||
export function getUiCapability(
|
||||
clientCapabilities:
|
||||
| (ClientCapabilities & { extensions?: Record<string, unknown> })
|
||||
| null
|
||||
| undefined,
|
||||
clientCapabilities: ClientCapabilities | null | undefined,
|
||||
): McpUiClientCapabilities | undefined {
|
||||
if (!clientCapabilities) {
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type {
|
|||
RequestId,
|
||||
ResourceLink,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
} from "./core-types";
|
||||
|
||||
/**
|
||||
* Current protocol version supported by this SDK.
|
||||
|
|
@ -163,8 +163,8 @@ export interface McpUiOpenLinkResult {
|
|||
/** @description True if the host failed to open the URL (e.g., due to security policy). */
|
||||
isError?: boolean;
|
||||
/**
|
||||
* Index signature required for MCP SDK `Protocol` class compatibility.
|
||||
* Note: The generated schema uses passthrough() to allow additional properties.
|
||||
* Additional result properties are preserved for protocol extensibility.
|
||||
* The generated schema uses passthrough() to allow them.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -195,8 +195,8 @@ export interface McpUiDownloadFileResult {
|
|||
/** @description True if the download failed (e.g., user cancelled or host denied). */
|
||||
isError?: boolean;
|
||||
/**
|
||||
* Index signature required for MCP SDK `Protocol` class compatibility.
|
||||
* Note: The generated schema uses passthrough() to allow additional properties.
|
||||
* Additional result properties are preserved for protocol extensibility.
|
||||
* The generated schema uses passthrough() to allow them.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -223,8 +223,8 @@ export interface McpUiMessageResult {
|
|||
/** @description True if the host rejected or failed to deliver the message. */
|
||||
isError?: boolean;
|
||||
/**
|
||||
* Index signature required for MCP SDK `Protocol` class compatibility.
|
||||
* Note: The generated schema uses passthrough() to allow additional properties.
|
||||
* Additional result properties are preserved for protocol extensibility.
|
||||
* The generated schema uses passthrough() to allow them.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -454,7 +454,7 @@ export interface McpUiResourceTeardownRequest {
|
|||
*/
|
||||
export interface McpUiResourceTeardownResult {
|
||||
/**
|
||||
* Index signature required for MCP SDK `Protocol` class compatibility.
|
||||
* Additional result properties are preserved for protocol extensibility.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -577,8 +577,8 @@ export interface McpUiInitializeResult {
|
|||
/** @description Rich context about the host environment. */
|
||||
hostContext: McpUiHostContext;
|
||||
/**
|
||||
* Index signature required for MCP SDK `Protocol` class compatibility.
|
||||
* Note: The generated schema uses passthrough() to allow additional properties.
|
||||
* Additional result properties are preserved for protocol extensibility.
|
||||
* The generated schema uses passthrough() to allow them.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
@ -752,7 +752,7 @@ export interface McpUiRequestDisplayModeResult {
|
|||
/** @description The display mode that was actually set. May differ from requested if not supported. */
|
||||
mode: McpUiDisplayMode;
|
||||
/**
|
||||
* Index signature required for MCP SDK `Protocol` class compatibility.
|
||||
* Additional result properties are preserved for protocol extensibility.
|
||||
* Note: The generated schema uses passthrough() to allow additional properties.
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
130
src/types.ts
130
src/types.ts
|
|
@ -69,33 +69,6 @@ export {
|
|||
type McpUiClientCapabilities,
|
||||
} from "./spec.types.js";
|
||||
|
||||
// Import types needed for protocol type unions (not re-exported, just used internally)
|
||||
import type {
|
||||
McpUiInitializeRequest,
|
||||
McpUiOpenLinkRequest,
|
||||
McpUiDownloadFileRequest,
|
||||
McpUiMessageRequest,
|
||||
McpUiUpdateModelContextRequest,
|
||||
McpUiResourceTeardownRequest,
|
||||
McpUiRequestDisplayModeRequest,
|
||||
McpUiHostContextChangedNotification,
|
||||
McpUiToolInputNotification,
|
||||
McpUiToolInputPartialNotification,
|
||||
McpUiToolResultNotification,
|
||||
McpUiToolCancelledNotification,
|
||||
McpUiSandboxResourceReadyNotification,
|
||||
McpUiInitializedNotification,
|
||||
McpUiSizeChangedNotification,
|
||||
McpUiSandboxProxyReadyNotification,
|
||||
McpUiRequestTeardownNotification,
|
||||
McpUiInitializeResult,
|
||||
McpUiOpenLinkResult,
|
||||
McpUiDownloadFileResult,
|
||||
McpUiMessageResult,
|
||||
McpUiResourceTeardownResult,
|
||||
McpUiRequestDisplayModeResult,
|
||||
} from "./spec.types.js";
|
||||
|
||||
// Re-export all schemas from generated/schema.ts (already PascalCase)
|
||||
export {
|
||||
McpUiThemeSchema,
|
||||
|
|
@ -135,106 +108,3 @@ export {
|
|||
McpUiToolVisibilitySchema,
|
||||
McpUiToolMetaSchema,
|
||||
} from "./generated/schema.js";
|
||||
|
||||
// Re-export SDK types used in protocol type unions
|
||||
import {
|
||||
CallToolRequest,
|
||||
CallToolResult,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
CreateMessageResultWithTools,
|
||||
EmptyResult,
|
||||
ListPromptsRequest,
|
||||
ListPromptsResult,
|
||||
ListResourcesRequest,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesRequest,
|
||||
ListResourceTemplatesResult,
|
||||
ListToolsRequest,
|
||||
ListToolsResult,
|
||||
LoggingMessageNotification,
|
||||
PingRequest,
|
||||
PromptListChangedNotification,
|
||||
ReadResourceRequest,
|
||||
ReadResourceResult,
|
||||
ResourceListChangedNotification,
|
||||
ToolListChangedNotification,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
/**
|
||||
* All request types in the MCP Apps protocol.
|
||||
*
|
||||
* Includes:
|
||||
* - MCP UI requests (initialize, open-link, message, resource-teardown, request-display-mode)
|
||||
* - MCP server requests forwarded from the app (tools/call, tools/list, resources/list,
|
||||
* resources/templates/list, resources/read, prompts/list)
|
||||
* - MCP client requests forwarded to the host (sampling/createMessage)
|
||||
* - Protocol requests (ping)
|
||||
*/
|
||||
export type AppRequest =
|
||||
| McpUiInitializeRequest
|
||||
| McpUiOpenLinkRequest
|
||||
| McpUiDownloadFileRequest
|
||||
| McpUiMessageRequest
|
||||
| McpUiUpdateModelContextRequest
|
||||
| McpUiResourceTeardownRequest
|
||||
| McpUiRequestDisplayModeRequest
|
||||
| CallToolRequest
|
||||
| ListToolsRequest
|
||||
| ListResourcesRequest
|
||||
| ListResourceTemplatesRequest
|
||||
| ReadResourceRequest
|
||||
| ListPromptsRequest
|
||||
| CreateMessageRequest
|
||||
| PingRequest;
|
||||
|
||||
/**
|
||||
* All notification types in the MCP Apps protocol.
|
||||
*
|
||||
* Host to app:
|
||||
* - Tool lifecycle (input, input-partial, result, cancelled)
|
||||
* - Host context changes
|
||||
* - MCP list changes (tools, resources, prompts)
|
||||
* - Sandbox resource ready
|
||||
*
|
||||
* App to host:
|
||||
* - Initialized, size-changed, sandbox-proxy-ready, request-teardown
|
||||
* - Logging messages
|
||||
*/
|
||||
export type AppNotification =
|
||||
// Sent to app
|
||||
| McpUiHostContextChangedNotification
|
||||
| McpUiToolInputNotification
|
||||
| McpUiToolInputPartialNotification
|
||||
| McpUiToolResultNotification
|
||||
| McpUiToolCancelledNotification
|
||||
| McpUiSandboxResourceReadyNotification
|
||||
| ToolListChangedNotification
|
||||
| ResourceListChangedNotification
|
||||
| PromptListChangedNotification
|
||||
// Received from app
|
||||
| McpUiInitializedNotification
|
||||
| McpUiSizeChangedNotification
|
||||
| McpUiSandboxProxyReadyNotification
|
||||
| McpUiRequestTeardownNotification
|
||||
| LoggingMessageNotification;
|
||||
|
||||
/**
|
||||
* All result types in the MCP Apps protocol.
|
||||
*/
|
||||
export type AppResult =
|
||||
| McpUiInitializeResult
|
||||
| McpUiOpenLinkResult
|
||||
| McpUiDownloadFileResult
|
||||
| McpUiMessageResult
|
||||
| McpUiResourceTeardownResult
|
||||
| McpUiRequestDisplayModeResult
|
||||
| CallToolResult
|
||||
| ListToolsResult
|
||||
| ListResourcesResult
|
||||
| ListResourceTemplatesResult
|
||||
| ReadResourceResult
|
||||
| ListPromptsResult
|
||||
| CreateMessageResult
|
||||
| CreateMessageResultWithTools
|
||||
| EmptyResult;
|
||||
|
|
|
|||
Loading…
Reference in a new issue