refactor: migrate AppBridge to SDK v2 server
This commit is contained in:
parent
59e6ffc6d8
commit
d19a1fb83a
2 changed files with 546 additions and 317 deletions
|
|
@ -1,14 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Server, type ServerCapabilities } from "@modelcontextprotocol/server";
|
||||||
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import {
|
|
||||||
EmptyResultSchema,
|
|
||||||
ListPromptsResultSchema,
|
|
||||||
ListResourcesResultSchema,
|
|
||||||
ListResourceTemplatesResultSchema,
|
|
||||||
ReadResourceResultSchema,
|
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { App } from "./app";
|
import { App } from "./app";
|
||||||
|
|
@ -19,6 +11,7 @@ import {
|
||||||
getToolUiResourceUri,
|
getToolUiResourceUri,
|
||||||
isToolVisibilityModelOnly,
|
isToolVisibilityModelOnly,
|
||||||
isToolVisibilityAppOnly,
|
isToolVisibilityAppOnly,
|
||||||
|
McpUiOpenLinkResultSchema,
|
||||||
type McpUiHostCapabilities,
|
type McpUiHostCapabilities,
|
||||||
} from "./app-bridge";
|
} from "./app-bridge";
|
||||||
|
|
||||||
|
|
@ -31,11 +24,18 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
*/
|
*/
|
||||||
function createMockClient(
|
function createMockClient(
|
||||||
serverCapabilities: ServerCapabilities = {},
|
serverCapabilities: ServerCapabilities = {},
|
||||||
): Pick<Client, "getServerCapabilities" | "request" | "notification"> {
|
): Pick<
|
||||||
|
Client,
|
||||||
|
| "getServerCapabilities"
|
||||||
|
| "request"
|
||||||
|
| "notification"
|
||||||
|
| "setNotificationHandler"
|
||||||
|
> {
|
||||||
return {
|
return {
|
||||||
getServerCapabilities: () => serverCapabilities,
|
getServerCapabilities: () => serverCapabilities,
|
||||||
request: async () => ({}) as never,
|
request: async () => ({}) as never,
|
||||||
notification: async () => {},
|
notification: async () => {},
|
||||||
|
setNotificationHandler: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +82,58 @@ describe("App <-> AppBridge integration", () => {
|
||||||
expect(initializedFired).toBe(true);
|
expect(initializedFired).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps standard initialization separate from the Apps-ready gate", 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([
|
||||||
|
"initialize",
|
||||||
|
"notifications/initialized",
|
||||||
|
"ui/initialize",
|
||||||
|
"ui/notifications/initialized",
|
||||||
|
]);
|
||||||
|
expect(app.getNegotiatedProtocolVersion()).toBe("2025-11-25");
|
||||||
|
expect(bridge.getNegotiatedProtocolVersion()).toBe("2025-11-25");
|
||||||
|
expect(bridge.getClientVersion()).toEqual(testAppInfo);
|
||||||
|
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 () => {
|
it("App receives host info and capabilities after connect", async () => {
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
@ -91,6 +143,8 @@ describe("App <-> AppBridge integration", () => {
|
||||||
|
|
||||||
const hostCaps = app.getHostCapabilities();
|
const hostCaps = app.getHostCapabilities();
|
||||||
expect(hostCaps).toEqual(testHostCapabilities);
|
expect(hostCaps).toEqual(testHostCapabilities);
|
||||||
|
expect(bridge.getHostCapabilities()).toEqual(testHostCapabilities);
|
||||||
|
expect(bridge.getCapabilities()).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Bridge receives app info and capabilities after initialization", async () => {
|
it("Bridge receives app info and capabilities after initialization", async () => {
|
||||||
|
|
@ -611,6 +665,30 @@ describe("App <-> AppBridge integration", () => {
|
||||||
|
|
||||||
expect(result.isError).toBe(true);
|
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", () => {
|
describe("deprecated method aliases", () => {
|
||||||
|
|
@ -680,10 +758,7 @@ describe("App <-> AppBridge integration", () => {
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
// Bridge can send ping via the protocol's request method
|
// Bridge can send ping via the protocol's request method
|
||||||
const result = await bridge.request(
|
const result = await bridge.request({ method: "ping", params: {} });
|
||||||
{ method: "ping", params: {} },
|
|
||||||
EmptyResultSchema,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
@ -798,7 +873,7 @@ describe("App <-> AppBridge integration", () => {
|
||||||
});
|
});
|
||||||
await bridge.callTool({ name: "noargs", arguments: {} });
|
await bridge.callTool({ name: "noargs", arguments: {} });
|
||||||
expect(receivedExtra).toBeDefined();
|
expect(receivedExtra).toBeDefined();
|
||||||
expect(receivedExtra.signal).toBeInstanceOf(AbortSignal);
|
expect(receivedExtra.mcpReq.signal).toBeInstanceOf(AbortSignal);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("isError result skips output schema validation", async () => {
|
it("isError result skips output schema validation", async () => {
|
||||||
|
|
@ -2180,10 +2255,10 @@ describe("App <-> AppBridge integration", () => {
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
// App sends resources/list request via the protocol's request method
|
// App sends resources/list request via the protocol's request method
|
||||||
const result = await app.request(
|
const result = await app.request({
|
||||||
{ method: "resources/list", params: requestParams },
|
method: "resources/list",
|
||||||
ListResourcesResultSchema,
|
params: requestParams,
|
||||||
);
|
});
|
||||||
|
|
||||||
expect(receivedRequests).toHaveLength(1);
|
expect(receivedRequests).toHaveLength(1);
|
||||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||||
|
|
@ -2224,10 +2299,10 @@ describe("App <-> AppBridge integration", () => {
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
const result = await app.request(
|
const result = await app.request({
|
||||||
{ method: "resources/read", params: requestParams },
|
method: "resources/read",
|
||||||
ReadResourceResultSchema,
|
params: requestParams,
|
||||||
);
|
});
|
||||||
|
|
||||||
expect(receivedRequests).toHaveLength(1);
|
expect(receivedRequests).toHaveLength(1);
|
||||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||||
|
|
@ -2275,10 +2350,10 @@ describe("App <-> AppBridge integration", () => {
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
const result = await app.request(
|
const result = await app.request({
|
||||||
{ method: "resources/templates/list", params: requestParams },
|
method: "resources/templates/list",
|
||||||
ListResourceTemplatesResultSchema,
|
params: requestParams,
|
||||||
);
|
});
|
||||||
|
|
||||||
expect(receivedRequests).toHaveLength(1);
|
expect(receivedRequests).toHaveLength(1);
|
||||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||||
|
|
@ -2298,10 +2373,10 @@ describe("App <-> AppBridge integration", () => {
|
||||||
await bridge.connect(bridgeTransport);
|
await bridge.connect(bridgeTransport);
|
||||||
await app.connect(appTransport);
|
await app.connect(appTransport);
|
||||||
|
|
||||||
const result = await app.request(
|
const result = await app.request({
|
||||||
{ method: "prompts/list", params: requestParams },
|
method: "prompts/list",
|
||||||
ListPromptsResultSchema,
|
params: requestParams,
|
||||||
);
|
});
|
||||||
|
|
||||||
expect(receivedRequests).toHaveLength(1);
|
expect(receivedRequests).toHaveLength(1);
|
||||||
expect(receivedRequests[0]).toMatchObject(requestParams);
|
expect(receivedRequests[0]).toMatchObject(requestParams);
|
||||||
|
|
@ -2310,6 +2385,7 @@ describe("App <-> AppBridge integration", () => {
|
||||||
|
|
||||||
it("sendToolListChanged sends notification to app", async () => {
|
it("sendToolListChanged sends notification to app", async () => {
|
||||||
const receivedNotifications: unknown[] = [];
|
const receivedNotifications: unknown[] = [];
|
||||||
|
bridge.oncalltool = async () => ({ content: [] });
|
||||||
app.setNotificationHandler(
|
app.setNotificationHandler(
|
||||||
"notifications/tools/list_changed",
|
"notifications/tools/list_changed",
|
||||||
(notification) => {
|
(notification) => {
|
||||||
|
|
@ -2328,6 +2404,7 @@ describe("App <-> AppBridge integration", () => {
|
||||||
|
|
||||||
it("sendResourceListChanged sends notification to app", async () => {
|
it("sendResourceListChanged sends notification to app", async () => {
|
||||||
const receivedNotifications: unknown[] = [];
|
const receivedNotifications: unknown[] = [];
|
||||||
|
bridge.onlistresources = async () => ({ resources: [] });
|
||||||
app.setNotificationHandler(
|
app.setNotificationHandler(
|
||||||
"notifications/resources/list_changed",
|
"notifications/resources/list_changed",
|
||||||
(notification) => {
|
(notification) => {
|
||||||
|
|
@ -2346,6 +2423,7 @@ describe("App <-> AppBridge integration", () => {
|
||||||
|
|
||||||
it("sendPromptListChanged sends notification to app", async () => {
|
it("sendPromptListChanged sends notification to app", async () => {
|
||||||
const receivedNotifications: unknown[] = [];
|
const receivedNotifications: unknown[] = [];
|
||||||
|
bridge.onlistprompts = async () => ({ prompts: [] });
|
||||||
app.setNotificationHandler(
|
app.setNotificationHandler(
|
||||||
"notifications/prompts/list_changed",
|
"notifications/prompts/list_changed",
|
||||||
(notification) => {
|
(notification) => {
|
||||||
|
|
@ -2364,6 +2442,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("getToolUiResourceUri", () => {
|
||||||
describe("new nested format (_meta.ui.resourceUri)", () => {
|
describe("new nested format (_meta.ui.resourceUri)", () => {
|
||||||
it("extracts resourceUri from _meta.ui.resourceUri", () => {
|
it("extracts resourceUri from _meta.ui.resourceUri", () => {
|
||||||
|
|
@ -2758,24 +3014,16 @@ describe("isToolVisibilityAppOnly", () => {
|
||||||
expect(app.onteardown).toBe(handler);
|
expect(app.onteardown).toBe(handler);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("direct setRequestHandler throws when called twice", () => {
|
it("direct setRequestHandler uses base SDK replacement semantics", () => {
|
||||||
const bridge2 = new AppBridge(
|
const bridge2 = new AppBridge(
|
||||||
createMockClient() as Client,
|
createMockClient() as Client,
|
||||||
testHostInfo,
|
testHostInfo,
|
||||||
testHostCapabilities,
|
testHostCapabilities,
|
||||||
);
|
);
|
||||||
bridge2.setRequestHandler(
|
bridge2.setRequestHandler("ping", () => ({}));
|
||||||
// @ts-expect-error — exercising throw path with raw schema
|
|
||||||
{ shape: { method: { value: "test/method" } } },
|
|
||||||
() => ({}),
|
|
||||||
);
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
bridge2.setRequestHandler(
|
bridge2.setRequestHandler("ping", () => ({}));
|
||||||
// @ts-expect-error — exercising throw path with raw schema
|
}).not.toThrow();
|
||||||
{ shape: { method: { value: "test/method" } } },
|
|
||||||
() => ({}),
|
|
||||||
);
|
|
||||||
}).toThrow(/already registered/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,51 @@
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { type Client } from "@modelcontextprotocol/client";
|
||||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
||||||
import {
|
import {
|
||||||
CallToolRequest,
|
Server,
|
||||||
CallToolRequestSchema,
|
type CallToolRequest,
|
||||||
CallToolResult,
|
type CallToolResult,
|
||||||
CallToolResultSchema,
|
type CreateMessageRequest,
|
||||||
CreateMessageRequest,
|
type CreateMessageResult,
|
||||||
CreateMessageRequestSchema,
|
type CreateMessageResultWithTools,
|
||||||
CreateMessageResult,
|
type EmptyResult,
|
||||||
CreateMessageResultWithTools,
|
type Implementation,
|
||||||
EmptyResult,
|
type JSONRPCRequest,
|
||||||
Implementation,
|
type ListPromptsRequest,
|
||||||
ListPromptsRequest,
|
type ListPromptsResult,
|
||||||
ListPromptsRequestSchema,
|
type ListResourcesRequest,
|
||||||
ListPromptsResult,
|
type ListResourcesResult,
|
||||||
ListPromptsResultSchema,
|
type ListResourceTemplatesRequest,
|
||||||
ListResourcesRequest,
|
type ListResourceTemplatesResult,
|
||||||
ListResourcesRequestSchema,
|
type ListToolsRequest,
|
||||||
ListResourcesResult,
|
type LoggingMessageNotification,
|
||||||
ListResourcesResultSchema,
|
type PingRequest,
|
||||||
ListResourceTemplatesRequest,
|
type PromptListChangedNotification,
|
||||||
ListResourceTemplatesRequestSchema,
|
type ReadResourceRequest,
|
||||||
ListResourceTemplatesResult,
|
type ReadResourceResult,
|
||||||
ListResourceTemplatesResultSchema,
|
type RequestOptions,
|
||||||
ListToolsRequest,
|
type ResourceListChangedNotification,
|
||||||
ListToolsRequestSchema,
|
type Result,
|
||||||
ListToolsResultSchema,
|
type ServerCapabilities,
|
||||||
LoggingMessageNotification,
|
type ServerContext,
|
||||||
|
type ServerOptions,
|
||||||
|
type Tool,
|
||||||
|
type ToolListChangedNotification,
|
||||||
|
type Transport,
|
||||||
|
} from "@modelcontextprotocol/server";
|
||||||
|
import {
|
||||||
|
EmptyResultSchema,
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
PingRequest,
|
} from "@modelcontextprotocol/core";
|
||||||
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 { EventDispatcher } from "./events";
|
import { EventDispatcher } from "./events";
|
||||||
import type { ZodLiteral, ZodObject } from "zod/v4";
|
import type { ZodLiteral, ZodObject, ZodType } from "zod/v4";
|
||||||
|
|
||||||
type MethodSchema = ZodObject<{ method: ZodLiteral<string> }>;
|
type MethodSchema = ZodObject<{
|
||||||
|
method: ZodLiteral<string>;
|
||||||
|
params: ZodType;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const INNER_MCP_PROTOCOL_VERSION = "2025-11-25";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AppNotification,
|
|
||||||
type AppRequest,
|
|
||||||
type AppResult,
|
|
||||||
type McpUiSandboxResourceReadyNotification,
|
type McpUiSandboxResourceReadyNotification,
|
||||||
type McpUiSizeChangedNotification,
|
type McpUiSizeChangedNotification,
|
||||||
type McpUiToolCancelledNotification,
|
type McpUiToolCancelledNotification,
|
||||||
|
|
@ -74,15 +64,19 @@ import {
|
||||||
McpUiInitializeRequest,
|
McpUiInitializeRequest,
|
||||||
McpUiInitializeRequestSchema,
|
McpUiInitializeRequestSchema,
|
||||||
McpUiInitializeResult,
|
McpUiInitializeResult,
|
||||||
|
McpUiInitializeResultSchema,
|
||||||
McpUiMessageRequest,
|
McpUiMessageRequest,
|
||||||
McpUiMessageRequestSchema,
|
McpUiMessageRequestSchema,
|
||||||
McpUiMessageResult,
|
McpUiMessageResult,
|
||||||
|
McpUiMessageResultSchema,
|
||||||
McpUiOpenLinkRequest,
|
McpUiOpenLinkRequest,
|
||||||
McpUiOpenLinkRequestSchema,
|
McpUiOpenLinkRequestSchema,
|
||||||
McpUiOpenLinkResult,
|
McpUiOpenLinkResult,
|
||||||
|
McpUiOpenLinkResultSchema,
|
||||||
McpUiDownloadFileRequest,
|
McpUiDownloadFileRequest,
|
||||||
McpUiDownloadFileRequestSchema,
|
McpUiDownloadFileRequestSchema,
|
||||||
McpUiDownloadFileResult,
|
McpUiDownloadFileResult,
|
||||||
|
McpUiDownloadFileResultSchema,
|
||||||
McpUiResourceTeardownRequest,
|
McpUiResourceTeardownRequest,
|
||||||
McpUiResourceTeardownResultSchema,
|
McpUiResourceTeardownResultSchema,
|
||||||
McpUiRequestTeardownNotification,
|
McpUiRequestTeardownNotification,
|
||||||
|
|
@ -93,6 +87,7 @@ import {
|
||||||
McpUiRequestDisplayModeRequest,
|
McpUiRequestDisplayModeRequest,
|
||||||
McpUiRequestDisplayModeRequestSchema,
|
McpUiRequestDisplayModeRequestSchema,
|
||||||
McpUiRequestDisplayModeResult,
|
McpUiRequestDisplayModeResult,
|
||||||
|
McpUiRequestDisplayModeResultSchema,
|
||||||
McpUiResourcePermissions,
|
McpUiResourcePermissions,
|
||||||
McpUiToolMeta,
|
McpUiToolMeta,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
@ -101,6 +96,20 @@ export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app";
|
||||||
import { RESOURCE_URI_META_KEY } from "./app";
|
import { RESOURCE_URI_META_KEY } from "./app";
|
||||||
export { PostMessageTransport } from "./message-transport";
|
export { PostMessageTransport } from "./message-transport";
|
||||||
|
|
||||||
|
function proxiedInnerCapabilities(
|
||||||
|
capabilities: ServerCapabilities,
|
||||||
|
): ServerCapabilities {
|
||||||
|
return {
|
||||||
|
...(capabilities.tools && { tools: capabilities.tools }),
|
||||||
|
...(capabilities.resources && {
|
||||||
|
resources: {
|
||||||
|
listChanged: capabilities.resources.listChanged,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...(capabilities.prompts && { prompts: capabilities.prompts }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract UI resource URI from tool metadata.
|
* Extract UI resource URI from tool metadata.
|
||||||
*
|
*
|
||||||
|
|
@ -206,10 +215,13 @@ export function buildAllowAttribute(
|
||||||
*
|
*
|
||||||
* @property hostContext - Optional initial host context to provide to the view
|
* @property hostContext - Optional initial host context to provide to the view
|
||||||
*
|
*
|
||||||
* @see `ProtocolOptions` from @modelcontextprotocol/sdk for available options
|
* @see `ServerOptions` from @modelcontextprotocol/server for available options
|
||||||
* @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure
|
* @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure
|
||||||
*/
|
*/
|
||||||
export type HostOptions = ProtocolOptions & {
|
export type HostOptions = Omit<
|
||||||
|
ServerOptions,
|
||||||
|
"capabilities" | "supportedProtocolVersions"
|
||||||
|
> & {
|
||||||
hostContext?: McpUiHostContext;
|
hostContext?: McpUiHostContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -224,15 +236,12 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
|
||||||
/**
|
/**
|
||||||
* Extra metadata passed to request handlers.
|
* Extra metadata passed to request handlers.
|
||||||
*
|
*
|
||||||
* This type represents the additional context provided by the `Protocol` class
|
* This type represents the additional context provided by the base MCP SDK
|
||||||
* when handling requests, including abort signals and session information.
|
* `Server` when handling requests.
|
||||||
* It is extracted from the MCP SDK's request handler signature.
|
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
type RequestHandlerExtra = Parameters<
|
type RequestHandlerExtra = ServerContext;
|
||||||
Parameters<AppBridge["setRequestHandler"]>[1]
|
|
||||||
>[1];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps DOM-style event names to their notification `params` types.
|
* Maps DOM-style event names to their notification `params` types.
|
||||||
|
|
@ -306,66 +315,53 @@ export type AppBridgeEventMap = {
|
||||||
* await bridge.connect(transport);
|
* await bridge.connect(transport);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult> {
|
export class AppBridge extends Server {
|
||||||
private _appCapabilities?: McpUiAppCapabilities;
|
private _appCapabilities?: McpUiAppCapabilities;
|
||||||
private _hostContext: McpUiHostContext = {};
|
private _hostContext: McpUiHostContext = {};
|
||||||
private _appInfo?: Implementation;
|
private _appInfo?: Implementation;
|
||||||
private _initializedReceived = false;
|
private _initializedReceived = false;
|
||||||
private readonly _registeredMethods = new Set<string>();
|
|
||||||
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
|
private readonly _registeredEvents = new Set<keyof AppBridgeEventMap>();
|
||||||
private readonly _events = new EventDispatcher<AppBridgeEventMap>();
|
private readonly _events = new EventDispatcher<AppBridgeEventMap>();
|
||||||
|
|
||||||
// ProtocolWithEvents previously owned these registration guards. Keep them
|
|
||||||
// on AppBridge to preserve the existing double-registration behavior while
|
|
||||||
// event fan-out moves to protocol-independent composition. Arrow-function
|
|
||||||
// fields let Protocol's constructor run before the guards initialize.
|
|
||||||
override setRequestHandler: Protocol<
|
|
||||||
AppRequest,
|
|
||||||
AppNotification,
|
|
||||||
AppResult
|
|
||||||
>["setRequestHandler"] = (schema, handler) => {
|
|
||||||
this._assertMethodNotRegistered(schema, "setRequestHandler");
|
|
||||||
super.setRequestHandler(schema, handler);
|
|
||||||
};
|
|
||||||
|
|
||||||
override setNotificationHandler: Protocol<
|
|
||||||
AppRequest,
|
|
||||||
AppNotification,
|
|
||||||
AppResult
|
|
||||||
>["setNotificationHandler"] = (schema, handler) => {
|
|
||||||
this._assertMethodNotRegistered(schema, "setNotificationHandler");
|
|
||||||
super.setNotificationHandler(schema, handler);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap every handler registered via `replaceRequestHandler` with a check
|
* The base MCP SDK calls this hook for every standard and custom request
|
||||||
* that the View has sent `ui/notifications/initialized`. Warns (never
|
* handler registration. AppBridge uses it to preserve the existing warning
|
||||||
* throws) so lenient hosts keep working while still surfacing the
|
* when a View calls host methods before `ui/notifications/initialized`,
|
||||||
* misordering that leaves strict hosts with a permanently hidden iframe.
|
* without adding overload-forwarding adapters around `setRequestHandler`.
|
||||||
* `ui/initialize` and `ping` use `setRequestHandler` directly and are
|
* Chaining through `super._wrapHandler()` retains the base `Server`'s own
|
||||||
* intentionally exempt.
|
* role-specific behavior, including tool-result validation. The two
|
||||||
|
* initialization methods 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}
|
* @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149}
|
||||||
*/
|
*/
|
||||||
protected replaceRequestHandler: Protocol<
|
protected override _wrapHandler(
|
||||||
AppRequest,
|
method: string,
|
||||||
AppNotification,
|
handler: (
|
||||||
AppResult
|
request: JSONRPCRequest,
|
||||||
>["setRequestHandler"] = (schema, handler) => {
|
context: ServerContext,
|
||||||
const method = (schema as MethodSchema).shape.method.value;
|
) => Promise<Result>,
|
||||||
this._registeredMethods.add(method);
|
): (request: JSONRPCRequest, context: ServerContext) => Promise<Result> {
|
||||||
super.setRequestHandler(schema, (request, extra) => {
|
const wrapped = super._wrapHandler(method, handler);
|
||||||
|
if (
|
||||||
|
method === "initialize" ||
|
||||||
|
method === "ui/initialize" ||
|
||||||
|
method === "ping"
|
||||||
|
) {
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
return async (request, context) => {
|
||||||
if (!this._initializedReceived) {
|
if (!this._initializedReceived) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[ext-apps] AppBridge received '${request.method}' before ` +
|
`[ext-apps] AppBridge received '${method}' before ` +
|
||||||
`ui/notifications/initialized. The View is calling host ` +
|
`ui/notifications/initialized. The View is calling host ` +
|
||||||
`methods before completing the handshake; it should await ` +
|
`methods before completing the handshake; it should await ` +
|
||||||
`app.connect() first.`,
|
`app.connect() first.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return handler(request, extra);
|
return wrapped(request, context);
|
||||||
});
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
private readonly eventSchemas: {
|
private readonly eventSchemas: {
|
||||||
[K in keyof AppBridgeEventMap]: MethodSchema;
|
[K in keyof AppBridgeEventMap]: MethodSchema;
|
||||||
|
|
@ -406,17 +402,27 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
this._events.removeEventListener(event, handler);
|
this._events.removeEventListener(event, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _ensureEventSlot<K extends keyof AppBridgeEventMap>(
|
private _ensureEventSlot<K extends keyof AppBridgeEventMap>(event: K): void {
|
||||||
event: K,
|
|
||||||
): void {
|
|
||||||
if (this._registeredEvents.has(event)) return;
|
if (this._registeredEvents.has(event)) return;
|
||||||
const schema = this.eventSchemas[event];
|
const schema = this.eventSchemas[event];
|
||||||
if (!schema) throw new Error(`Unknown event: ${String(event)}`);
|
if (!schema) throw new Error(`Unknown event: ${String(event)}`);
|
||||||
this.setNotificationHandler(schema, (notification) => {
|
|
||||||
const params = (notification as { params: AppBridgeEventMap[K] }).params;
|
const dispatch = (params: AppBridgeEventMap[K]) => {
|
||||||
if (event === "initialized") this._initializedReceived = true;
|
if (event === "initialized") this._initializedReceived = true;
|
||||||
this._events.dispatch(event, params);
|
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);
|
this._registeredEvents.add(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -433,16 +439,12 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _assertMethodNotRegistered(schema: unknown, via: string): void {
|
private ensureInnerCapabilities(capabilities: ServerCapabilities): void {
|
||||||
const method = (schema as MethodSchema).shape.method.value;
|
const current = super.getCapabilities();
|
||||||
if (this._registeredMethods.has(method)) {
|
const missing = Object.keys(capabilities).some(
|
||||||
throw new Error(
|
(key) => current[key as keyof ServerCapabilities] === undefined,
|
||||||
`Handler for "${method}" already registered (via ${via}). ` +
|
);
|
||||||
`Use addEventListener() to attach multiple listeners, ` +
|
if (missing) this.registerCapabilities(capabilities);
|
||||||
`or the on* setter for replace semantics.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this._registeredMethods.add(method);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -453,8 +455,8 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
* between the View and the server. When `null`, you must register handlers
|
* between the View and the server. When `null`, you must register handlers
|
||||||
* manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters.
|
* manually using the {@link oncalltool `oncalltool`}, {@link onlistresources `onlistresources`}, etc. setters.
|
||||||
* @param _hostInfo - Host application identification (name and version)
|
* @param _hostInfo - Host application identification (name and version)
|
||||||
* @param _capabilities - Features and capabilities the host supports
|
* @param _hostCapabilities - Features and capabilities the host supports
|
||||||
* @param options - Configuration options (inherited from Protocol)
|
* @param options - Configuration options (inherited from Server)
|
||||||
*
|
*
|
||||||
* @example With MCP client (automatic forwarding)
|
* @example With MCP client (automatic forwarding)
|
||||||
* ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient"
|
* ```ts source="./app-bridge.examples.ts#AppBridge_constructor_withMcpClient"
|
||||||
|
|
@ -481,29 +483,61 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
constructor(
|
constructor(
|
||||||
private _client: Client | null,
|
private _client: Client | null,
|
||||||
private _hostInfo: Implementation,
|
private _hostInfo: Implementation,
|
||||||
private _capabilities: McpUiHostCapabilities,
|
private _hostCapabilities: McpUiHostCapabilities,
|
||||||
options?: HostOptions,
|
options?: HostOptions,
|
||||||
) {
|
) {
|
||||||
super(options);
|
// TEMPORARY INNER-CHANNEL NEGOTIATION:
|
||||||
|
// App and AppBridge use the public v2 Client/Server roles, so the iframe
|
||||||
|
// channel first completes the SDK's standard legacy MCP initialization.
|
||||||
|
// ui/initialize remains authoritative for Apps capabilities, identity, and
|
||||||
|
// host context, and ui/notifications/initialized remains the View-ready gate.
|
||||||
|
// Keep the inner MCP era pinned to 2025-11-25. Revisit this sequence only
|
||||||
|
// after the MCP Apps negotiation specification is resolved:
|
||||||
|
// https://github.com/modelcontextprotocol/ext-apps/issues/708
|
||||||
|
super(_hostInfo, {
|
||||||
|
...options,
|
||||||
|
capabilities: {},
|
||||||
|
supportedProtocolVersions: [INNER_MCP_PROTOCOL_VERSION],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The base Server's standard initialized callback is MCP-ready state. The
|
||||||
|
// public AppBridge callback remains exclusively the Apps View-ready gate.
|
||||||
|
super.setNotificationHandler("notifications/initialized", () => {});
|
||||||
|
Object.defineProperty(this, "oninitialized", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: () => this.getEventHandler("initialized"),
|
||||||
|
set: (callback: AppBridge["oninitialized"]) =>
|
||||||
|
this.setEventHandler("initialized", callback),
|
||||||
|
});
|
||||||
|
|
||||||
this._ensureEventSlot("initialized");
|
this._ensureEventSlot("initialized");
|
||||||
|
|
||||||
this._hostContext = options?.hostContext || {};
|
this._hostContext = options?.hostContext || {};
|
||||||
|
|
||||||
this.setRequestHandler(McpUiInitializeRequestSchema, (request) =>
|
this.setRequestHandler(
|
||||||
this._oninitialize(request),
|
"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);
|
this.onping?.(request.params, extra);
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default handler for requestDisplayMode - returns current mode from host context.
|
// Default handler for requestDisplayMode - returns current mode from host context.
|
||||||
// Hosts can override this by setting bridge.onrequestdisplaymode = ...
|
// Hosts can override this by setting bridge.onrequestdisplaymode = ...
|
||||||
this.replaceRequestHandler(
|
this.setRequestHandler(
|
||||||
McpUiRequestDisplayModeRequestSchema,
|
"ui/request-display-mode",
|
||||||
(request) => {
|
{
|
||||||
|
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||||
|
result: McpUiRequestDisplayModeResultSchema,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
const currentMode = this._hostContext.displayMode ?? "inline";
|
const currentMode = this._hostContext.displayMode ?? "inline";
|
||||||
return { mode: currentMode };
|
return { mode: currentMode };
|
||||||
},
|
},
|
||||||
|
|
@ -680,18 +714,9 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
* @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View
|
* @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View
|
||||||
* @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}.
|
* @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}.
|
||||||
*/
|
*/
|
||||||
get oninitialized():
|
override oninitialized?: (
|
||||||
| ((params: McpUiInitializedNotification["params"]) => void)
|
params?: McpUiInitializedNotification["params"],
|
||||||
| undefined {
|
) => void;
|
||||||
return this.getEventHandler("initialized");
|
|
||||||
}
|
|
||||||
set oninitialized(
|
|
||||||
callback:
|
|
||||||
| ((params: McpUiInitializedNotification["params"]) => void)
|
|
||||||
| undefined,
|
|
||||||
) {
|
|
||||||
this.setEventHandler("initialized", callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a handler for message requests from the view.
|
* Register a handler for message requests from the view.
|
||||||
|
|
@ -744,11 +769,15 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback);
|
this.warnIfRequestHandlerReplaced("onmessage", this._onmessage, callback);
|
||||||
this._onmessage = callback;
|
this._onmessage = callback;
|
||||||
this.replaceRequestHandler(
|
this.setRequestHandler(
|
||||||
McpUiMessageRequestSchema,
|
"ui/message",
|
||||||
async (request, extra) => {
|
{
|
||||||
|
params: McpUiMessageRequestSchema.shape.params,
|
||||||
|
result: McpUiMessageResultSchema,
|
||||||
|
},
|
||||||
|
async (params, extra) => {
|
||||||
if (!this._onmessage) throw new Error("No onmessage handler set");
|
if (!this._onmessage) throw new Error("No onmessage handler set");
|
||||||
return this._onmessage(request.params, extra);
|
return this._onmessage(params, extra);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -813,11 +842,15 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback);
|
this.warnIfRequestHandlerReplaced("onopenlink", this._onopenlink, callback);
|
||||||
this._onopenlink = callback;
|
this._onopenlink = callback;
|
||||||
this.replaceRequestHandler(
|
this.setRequestHandler(
|
||||||
McpUiOpenLinkRequestSchema,
|
"ui/open-link",
|
||||||
async (request, extra) => {
|
{
|
||||||
|
params: McpUiOpenLinkRequestSchema.shape.params,
|
||||||
|
result: McpUiOpenLinkResultSchema,
|
||||||
|
},
|
||||||
|
async (params, extra) => {
|
||||||
if (!this._onopenlink) throw new Error("No onopenlink handler set");
|
if (!this._onopenlink) throw new Error("No onopenlink handler set");
|
||||||
return this._onopenlink(request.params, extra);
|
return this._onopenlink(params, extra);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -885,12 +918,16 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._ondownloadfile = callback;
|
this._ondownloadfile = callback;
|
||||||
this.replaceRequestHandler(
|
this.setRequestHandler(
|
||||||
McpUiDownloadFileRequestSchema,
|
"ui/download-file",
|
||||||
async (request, extra) => {
|
{
|
||||||
|
params: McpUiDownloadFileRequestSchema.shape.params,
|
||||||
|
result: McpUiDownloadFileResultSchema,
|
||||||
|
},
|
||||||
|
async (params, extra) => {
|
||||||
if (!this._ondownloadfile)
|
if (!this._ondownloadfile)
|
||||||
throw new Error("No ondownloadfile handler set");
|
throw new Error("No ondownloadfile handler set");
|
||||||
return this._ondownloadfile(request.params, extra);
|
return this._ondownloadfile(params, extra);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -985,12 +1022,16 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onrequestdisplaymode = callback;
|
this._onrequestdisplaymode = callback;
|
||||||
this.replaceRequestHandler(
|
this.setRequestHandler(
|
||||||
McpUiRequestDisplayModeRequestSchema,
|
"ui/request-display-mode",
|
||||||
async (request, extra) => {
|
{
|
||||||
|
params: McpUiRequestDisplayModeRequestSchema.shape.params,
|
||||||
|
result: McpUiRequestDisplayModeResultSchema,
|
||||||
|
},
|
||||||
|
async (params, extra) => {
|
||||||
if (!this._onrequestdisplaymode)
|
if (!this._onrequestdisplaymode)
|
||||||
throw new Error("No onrequestdisplaymode handler set");
|
throw new Error("No onrequestdisplaymode handler set");
|
||||||
return this._onrequestdisplaymode(request.params, extra);
|
return this._onrequestdisplaymode(params, extra);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1082,12 +1123,16 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onupdatemodelcontext = callback;
|
this._onupdatemodelcontext = callback;
|
||||||
this.replaceRequestHandler(
|
this.setRequestHandler(
|
||||||
McpUiUpdateModelContextRequestSchema,
|
"ui/update-model-context",
|
||||||
async (request, extra) => {
|
{
|
||||||
|
params: McpUiUpdateModelContextRequestSchema.shape.params,
|
||||||
|
result: EmptyResultSchema,
|
||||||
|
},
|
||||||
|
async (params, extra) => {
|
||||||
if (!this._onupdatemodelcontext)
|
if (!this._onupdatemodelcontext)
|
||||||
throw new Error("No onupdatemodelcontext handler set");
|
throw new Error("No onupdatemodelcontext handler set");
|
||||||
return this._onupdatemodelcontext(request.params, extra);
|
return this._onupdatemodelcontext(params, extra);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1135,13 +1180,11 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
) {
|
) {
|
||||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||||
this._oncalltool = callback;
|
this._oncalltool = callback;
|
||||||
this.replaceRequestHandler(
|
this.ensureInnerCapabilities({ tools: {} });
|
||||||
CallToolRequestSchema,
|
this.setRequestHandler("tools/call", async (request, extra) => {
|
||||||
async (request, extra) => {
|
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
||||||
if (!this._oncalltool) throw new Error("No oncalltool handler set");
|
return this._oncalltool(request.params, extra);
|
||||||
return this._oncalltool(request.params, extra);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1179,12 +1222,9 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
extra: RequestHandlerExtra,
|
extra: RequestHandlerExtra,
|
||||||
) => Promise<CreateMessageResult | CreateMessageResultWithTools>,
|
) => Promise<CreateMessageResult | CreateMessageResultWithTools>,
|
||||||
) {
|
) {
|
||||||
this.setRequestHandler(
|
this.setRequestHandler("sampling/createMessage", async (request, extra) => {
|
||||||
CreateMessageRequestSchema,
|
return callback(request.params, extra);
|
||||||
async (request, extra) => {
|
});
|
||||||
return callback(request.params, extra);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1260,14 +1300,12 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onlistresources = callback;
|
this._onlistresources = callback;
|
||||||
this.replaceRequestHandler(
|
this.ensureInnerCapabilities({ resources: {} });
|
||||||
ListResourcesRequestSchema,
|
this.setRequestHandler("resources/list", async (request, extra) => {
|
||||||
async (request, extra) => {
|
if (!this._onlistresources)
|
||||||
if (!this._onlistresources)
|
throw new Error("No onlistresources handler set");
|
||||||
throw new Error("No onlistresources handler set");
|
return this._onlistresources(request.params, extra);
|
||||||
return this._onlistresources(request.params, extra);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1317,8 +1355,9 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onlistresourcetemplates = callback;
|
this._onlistresourcetemplates = callback;
|
||||||
this.replaceRequestHandler(
|
this.ensureInnerCapabilities({ resources: {} });
|
||||||
ListResourceTemplatesRequestSchema,
|
this.setRequestHandler(
|
||||||
|
"resources/templates/list",
|
||||||
async (request, extra) => {
|
async (request, extra) => {
|
||||||
if (!this._onlistresourcetemplates)
|
if (!this._onlistresourcetemplates)
|
||||||
throw new Error("No onlistresourcetemplates handler set");
|
throw new Error("No onlistresourcetemplates handler set");
|
||||||
|
|
@ -1374,14 +1413,12 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onreadresource = callback;
|
this._onreadresource = callback;
|
||||||
this.replaceRequestHandler(
|
this.ensureInnerCapabilities({ resources: {} });
|
||||||
ReadResourceRequestSchema,
|
this.setRequestHandler("resources/read", async (request, extra) => {
|
||||||
async (request, extra) => {
|
if (!this._onreadresource)
|
||||||
if (!this._onreadresource)
|
throw new Error("No onreadresource handler set");
|
||||||
throw new Error("No onreadresource handler set");
|
return this._onreadresource(request.params, extra);
|
||||||
return this._onreadresource(request.params, extra);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1459,14 +1496,11 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
callback,
|
callback,
|
||||||
);
|
);
|
||||||
this._onlistprompts = callback;
|
this._onlistprompts = callback;
|
||||||
this.replaceRequestHandler(
|
this.ensureInnerCapabilities({ prompts: {} });
|
||||||
ListPromptsRequestSchema,
|
this.setRequestHandler("prompts/list", async (request, extra) => {
|
||||||
async (request, extra) => {
|
if (!this._onlistprompts) throw new Error("No onlistprompts handler set");
|
||||||
if (!this._onlistprompts)
|
return this._onlistprompts(request.params, extra);
|
||||||
throw new Error("No onlistprompts handler set");
|
});
|
||||||
return this._onlistprompts(request.params, extra);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1495,46 +1529,6 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
* Get the host capabilities passed to the constructor.
|
||||||
*
|
*
|
||||||
|
|
@ -1542,18 +1536,18 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
*
|
*
|
||||||
* @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure
|
* @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure
|
||||||
*/
|
*/
|
||||||
getCapabilities(): McpUiHostCapabilities {
|
getHostCapabilities(): McpUiHostCapabilities {
|
||||||
return this._capabilities;
|
return this._hostCapabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the ui/initialize request from the guest.
|
* Handle the ui/initialize request from the guest.
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
private async _oninitialize(
|
private async _onAppsInitialize(
|
||||||
request: McpUiInitializeRequest,
|
params: McpUiInitializeRequest["params"],
|
||||||
): Promise<McpUiInitializeResult> {
|
): Promise<McpUiInitializeResult> {
|
||||||
const requestedVersion = request.params.protocolVersion;
|
const requestedVersion = params.protocolVersion;
|
||||||
|
|
||||||
if (this._appInfo !== undefined) {
|
if (this._appInfo !== undefined) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|
@ -1564,8 +1558,8 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._appCapabilities = request.params.appCapabilities;
|
this._appCapabilities = params.appCapabilities;
|
||||||
this._appInfo = request.params.appInfo;
|
this._appInfo = params.appInfo;
|
||||||
|
|
||||||
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(
|
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(
|
||||||
requestedVersion,
|
requestedVersion,
|
||||||
|
|
@ -1575,7 +1569,7 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
|
|
||||||
return {
|
return {
|
||||||
protocolVersion,
|
protocolVersion,
|
||||||
hostCapabilities: this.getCapabilities(),
|
hostCapabilities: this.getHostCapabilities(),
|
||||||
hostInfo: this._hostInfo,
|
hostInfo: this._hostInfo,
|
||||||
hostContext: this._hostContext,
|
hostContext: this._hostContext,
|
||||||
};
|
};
|
||||||
|
|
@ -1857,11 +1851,7 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
* @returns Promise resolving to the tool call result
|
* @returns Promise resolving to the tool call result
|
||||||
*/
|
*/
|
||||||
callTool(params: CallToolRequest["params"], options?: RequestOptions) {
|
callTool(params: CallToolRequest["params"], options?: RequestOptions) {
|
||||||
return this.request(
|
return this.request({ method: "tools/call", params }, options);
|
||||||
{ method: "tools/call", params },
|
|
||||||
CallToolResultSchema,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1874,11 +1864,7 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
* @returns Promise resolving to the list of tools
|
* @returns Promise resolving to the list of tools
|
||||||
*/
|
*/
|
||||||
listTools(params: ListToolsRequest["params"], options?: RequestOptions) {
|
listTools(params: ListToolsRequest["params"], options?: RequestOptions) {
|
||||||
return this.request(
|
return this.request({ method: "tools/list", params }, options);
|
||||||
{ method: "tools/list", params },
|
|
||||||
ListToolsResultSchema,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1949,18 +1935,18 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
if (!serverCapabilities) {
|
if (!serverCapabilities) {
|
||||||
throw new Error("Client server capabilities not available");
|
throw new Error("Client server capabilities not available");
|
||||||
}
|
}
|
||||||
|
this.registerCapabilities(proxiedInnerCapabilities(serverCapabilities));
|
||||||
|
|
||||||
if (serverCapabilities.tools) {
|
if (serverCapabilities.tools) {
|
||||||
this.oncalltool = async (params, extra) => {
|
this.oncalltool = async (params, extra) => {
|
||||||
return this._client!.request(
|
return this._client!.request(
|
||||||
{ method: "tools/call", params },
|
{ method: "tools/call", params },
|
||||||
CallToolResultSchema,
|
{ signal: extra.mcpReq.signal },
|
||||||
{ signal: extra.signal },
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
if (serverCapabilities.tools.listChanged) {
|
if (serverCapabilities.tools.listChanged) {
|
||||||
this._client.setNotificationHandler(
|
this._client.setNotificationHandler(
|
||||||
ToolListChangedNotificationSchema,
|
"notifications/tools/list_changed",
|
||||||
(n) => this.sendToolListChanged(n.params),
|
(n) => this.sendToolListChanged(n.params),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1969,27 +1955,24 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
this.onlistresources = async (params, extra) => {
|
this.onlistresources = async (params, extra) => {
|
||||||
return this._client!.request(
|
return this._client!.request(
|
||||||
{ method: "resources/list", params },
|
{ method: "resources/list", params },
|
||||||
ListResourcesResultSchema,
|
{ signal: extra.mcpReq.signal },
|
||||||
{ signal: extra.signal },
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
this.onlistresourcetemplates = async (params, extra) => {
|
this.onlistresourcetemplates = async (params, extra) => {
|
||||||
return this._client!.request(
|
return this._client!.request(
|
||||||
{ method: "resources/templates/list", params },
|
{ method: "resources/templates/list", params },
|
||||||
ListResourceTemplatesResultSchema,
|
{ signal: extra.mcpReq.signal },
|
||||||
{ signal: extra.signal },
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
this.onreadresource = async (params, extra) => {
|
this.onreadresource = async (params, extra) => {
|
||||||
return this._client!.request(
|
return this._client!.request(
|
||||||
{ method: "resources/read", params },
|
{ method: "resources/read", params },
|
||||||
ReadResourceResultSchema,
|
{ signal: extra.mcpReq.signal },
|
||||||
{ signal: extra.signal },
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
if (serverCapabilities.resources.listChanged) {
|
if (serverCapabilities.resources.listChanged) {
|
||||||
this._client.setNotificationHandler(
|
this._client.setNotificationHandler(
|
||||||
ResourceListChangedNotificationSchema,
|
"notifications/resources/list_changed",
|
||||||
(n) => this.sendResourceListChanged(n.params),
|
(n) => this.sendResourceListChanged(n.params),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1998,22 +1981,20 @@ export class AppBridge extends Protocol<AppRequest, AppNotification, AppResult>
|
||||||
this.onlistprompts = async (params, extra) => {
|
this.onlistprompts = async (params, extra) => {
|
||||||
return this._client!.request(
|
return this._client!.request(
|
||||||
{ method: "prompts/list", params },
|
{ method: "prompts/list", params },
|
||||||
ListPromptsResultSchema,
|
{ signal: extra.mcpReq.signal },
|
||||||
{ signal: extra.signal },
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
if (serverCapabilities.prompts.listChanged) {
|
if (serverCapabilities.prompts.listChanged) {
|
||||||
this._client.setNotificationHandler(
|
this._client.setNotificationHandler(
|
||||||
PromptListChangedNotificationSchema,
|
"notifications/prompts/list_changed",
|
||||||
(n) => this.sendPromptListChanged(n.params),
|
(n) => this.sendPromptListChanged(n.params),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MCP-UI specific handlers are registered by the host component
|
// The standard MCP handshake only establishes the temporary inner Server
|
||||||
// after the proxy is created. The standard MCP initialization
|
// role. ui/notifications/initialized remains the public View-ready signal.
|
||||||
// (via oninitialized callback set in constructor) handles the ready signal.
|
|
||||||
|
|
||||||
return super.connect(transport);
|
return super.connect(transport);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue