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:
Olivier Chafik 2026-09-08 12:44:49 +01:00 • committed by GitHub
commit 1a713173cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2415 additions and 1867 deletions

View file

@ -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
}

View file

@ -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();
});
});
});

View file

@ -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);
}
}

View file

@ -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
View 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]);
});
});

View file

@ -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
View 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
View 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
View 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();
}
});
});

View file

@ -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;
}
}

View file

@ -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"
}

View file

@ -10,7 +10,7 @@ import {
RequestIdSchema,
ResourceLinkSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js";
} from "@modelcontextprotocol/core";
/**
* @description Color theme preference for the host environment.

View file

@ -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";

View file

@ -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";
/**

View file

@ -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,

View file

@ -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",

View file

@ -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", () => {

View file

@ -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;

View file

@ -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;

View file

@ -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;