From d19a1fb83ade586d6e2eb46535e7d84a7f7468a8 Mon Sep 17 00:00:00 2001 From: Andrew Khadder Date: Tue, 14 Jul 2026 15:34:33 -0700 Subject: [PATCH] refactor: migrate AppBridge to SDK v2 server --- src/app-bridge.test.ts | 336 ++++++++++++++++++++++---- src/app-bridge.ts | 527 ++++++++++++++++++++--------------------- 2 files changed, 546 insertions(+), 317 deletions(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 8f432b4..81e072f 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -1,14 +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, - ReadResourceResultSchema, -} 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"; @@ -19,6 +11,7 @@ import { getToolUiResourceUri, isToolVisibilityModelOnly, isToolVisibilityAppOnly, + McpUiOpenLinkResultSchema, type McpUiHostCapabilities, } from "./app-bridge"; @@ -31,11 +24,18 @@ const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); */ function createMockClient( serverCapabilities: ServerCapabilities = {}, -): Pick { +): Pick< + Client, + | "getServerCapabilities" + | "request" + | "notification" + | "setNotificationHandler" +> { return { getServerCapabilities: () => serverCapabilities, request: async () => ({}) as never, notification: async () => {}, + setNotificationHandler: () => {}, }; } @@ -82,6 +82,58 @@ describe("App <-> AppBridge integration", () => { expect(initializedFired).toBe(true); }); + it("keeps standard initialization separate from the Apps-ready gate", async () => { + const methods: string[] = []; + let releaseAppsInitialized!: () => void; + let reachedAppsInitialized!: () => void; + const appsInitializedGate = new Promise((resolve) => { + releaseAppsInitialized = resolve; + }); + const reachedGate = new Promise((resolve) => { + reachedAppsInitialized = resolve; + }); + const originalSend = appTransport.send.bind(appTransport); + appTransport.send = async (message, options) => { + if ("method" in message) methods.push(message.method); + if ( + "method" in message && + message.method === "ui/notifications/initialized" + ) { + reachedAppsInitialized(); + await appsInitializedGate; + } + return originalSend(message, options); + }; + + let singularCalls = 0; + let listenerCalls = 0; + bridge.oninitialized = () => singularCalls++; + bridge.addEventListener("initialized", () => listenerCalls++); + + await bridge.connect(bridgeTransport); + const connecting = app.connect(appTransport); + await reachedGate; + + expect(methods).toEqual([ + "initialize", + "notifications/initialized", + "ui/initialize", + "ui/notifications/initialized", + ]); + expect(app.getNegotiatedProtocolVersion()).toBe("2025-11-25"); + expect(bridge.getNegotiatedProtocolVersion()).toBe("2025-11-25"); + expect(bridge.getClientVersion()).toEqual(testAppInfo); + expect(bridge.getAppVersion()).toEqual(testAppInfo); + expect(singularCalls).toBe(0); + expect(listenerCalls).toBe(0); + + releaseAppsInitialized(); + await connecting; + + expect(singularCalls).toBe(1); + expect(listenerCalls).toBe(1); + }); + it("App receives host info and capabilities after connect", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); @@ -91,6 +143,8 @@ describe("App <-> AppBridge integration", () => { const hostCaps = app.getHostCapabilities(); expect(hostCaps).toEqual(testHostCapabilities); + expect(bridge.getHostCapabilities()).toEqual(testHostCapabilities); + expect(bridge.getCapabilities()).toEqual({}); }); it("Bridge receives app info and capabilities after initialization", async () => { @@ -611,6 +665,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", () => { @@ -680,10 +758,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({}); }); @@ -798,7 +873,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 () => { @@ -2180,10 +2255,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); @@ -2224,10 +2299,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); @@ -2275,10 +2350,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); @@ -2298,10 +2373,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); @@ -2310,6 +2385,7 @@ describe("App <-> AppBridge integration", () => { it("sendToolListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; + bridge.oncalltool = async () => ({ content: [] }); app.setNotificationHandler( "notifications/tools/list_changed", (notification) => { @@ -2328,6 +2404,7 @@ describe("App <-> AppBridge integration", () => { it("sendResourceListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; + bridge.onlistresources = async () => ({ resources: [] }); app.setNotificationHandler( "notifications/resources/list_changed", (notification) => { @@ -2346,6 +2423,7 @@ describe("App <-> AppBridge integration", () => { it("sendPromptListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; + bridge.onlistprompts = async () => ({ prompts: [] }); app.setNotificationHandler( "notifications/prompts/list_changed", (notification) => { @@ -2364,6 +2442,184 @@ describe("App <-> AppBridge integration", () => { }); }); +describe("AppBridge outer v2 Client proxy", () => { + let outerClient: Client; + let outerServer: Server; + let outerClientTransport: InMemoryTransport; + let outerServerTransport: InMemoryTransport; + let outerInitializeCount: number; + + beforeEach(async () => { + [outerClientTransport, outerServerTransport] = + InMemoryTransport.createLinkedPair(); + outerInitializeCount = 0; + const originalSend = outerClientTransport.send.bind(outerClientTransport); + outerClientTransport.send = async (message, options) => { + if ("method" in message && message.method === "initialize") { + outerInitializeCount++; + } + return originalSend(message, options); + }; + + outerServer = new Server( + { name: "ActualServer", version: "1.0.0" }, + { + capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true }, + prompts: { listChanged: true }, + }, + supportedProtocolVersions: ["2025-11-25"], + }, + ); + outerServer.setRequestHandler("tools/call", async (request) => ({ + content: [ + { + type: "text", + text: `outer:${request.params.name}`, + }, + ], + })); + + outerClient = new Client( + { name: "HostOuterClient", version: "1.0.0" }, + { + capabilities: {}, + versionNegotiation: { mode: "legacy" }, + supportedProtocolVersions: ["2025-11-25"], + }, + ); + await outerServer.connect(outerServerTransport); + await outerClient.connect(outerClientTransport); + }); + + afterEach(async () => { + await outerClient.close().catch(() => {}); + await outerServer.close().catch(() => {}); + }); + + it("proxies through the outer Client without reconnecting it", async () => { + const bridge = new AppBridge( + outerClient, + testHostInfo, + testHostCapabilities, + ); + const app = new App(testAppInfo, {}, { autoResize: false }); + let [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + expect( + await app.callServerTool({ name: "first", arguments: {} }), + ).toMatchObject({ + content: [{ type: "text", text: "outer:first" }], + }); + expect(outerInitializeCount).toBe(1); + + await app.close(); + await bridge.close(); + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + expect( + await app.callServerTool({ name: "second", arguments: {} }), + ).toMatchObject({ + content: [{ type: "text", text: "outer:second" }], + }); + expect(outerInitializeCount).toBe(1); + expect(outerClient.getServerVersion()).toMatchObject({ + name: "ActualServer", + version: "1.0.0", + }); + + await app.close(); + await bridge.close(); + }); + + it("propagates View cancellation to the actual outer Server", async () => { + let started!: () => void; + let aborted!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const abortedPromise = new Promise((resolve) => { + aborted = resolve; + }); + outerServer.setRequestHandler("tools/call", async (_request, context) => { + started(); + await new Promise((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", () => { @@ -2758,24 +3014,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/); + bridge2.setRequestHandler("ping", () => ({})); + }).not.toThrow(); }); }); }); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 18d7671..c65510d 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -1,61 +1,51 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { type Client } from "@modelcontextprotocol/client"; 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, + Server, + 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 ServerCapabilities, + type ServerContext, + type ServerOptions, + type Tool, + type ToolListChangedNotification, + type Transport, +} from "@modelcontextprotocol/server"; +import { + EmptyResultSchema, 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"; +} from "@modelcontextprotocol/core"; import { EventDispatcher } from "./events"; -import type { ZodLiteral, ZodObject } from "zod/v4"; +import type { ZodLiteral, ZodObject, ZodType } from "zod/v4"; -type MethodSchema = ZodObject<{ method: ZodLiteral }>; +type MethodSchema = ZodObject<{ + method: ZodLiteral; + params: ZodType; +}>; + +const INNER_MCP_PROTOCOL_VERSION = "2025-11-25"; import { - type AppNotification, - type AppRequest, - type AppResult, type McpUiSandboxResourceReadyNotification, type McpUiSizeChangedNotification, type McpUiToolCancelledNotification, @@ -74,15 +64,19 @@ import { McpUiInitializeRequest, McpUiInitializeRequestSchema, McpUiInitializeResult, + McpUiInitializeResultSchema, McpUiMessageRequest, McpUiMessageRequestSchema, McpUiMessageResult, + McpUiMessageResultSchema, McpUiOpenLinkRequest, McpUiOpenLinkRequestSchema, McpUiOpenLinkResult, + McpUiOpenLinkResultSchema, McpUiDownloadFileRequest, McpUiDownloadFileRequestSchema, McpUiDownloadFileResult, + McpUiDownloadFileResultSchema, McpUiResourceTeardownRequest, McpUiResourceTeardownResultSchema, McpUiRequestTeardownNotification, @@ -93,6 +87,7 @@ import { McpUiRequestDisplayModeRequest, McpUiRequestDisplayModeRequestSchema, McpUiRequestDisplayModeResult, + McpUiRequestDisplayModeResultSchema, McpUiResourcePermissions, McpUiToolMeta, } from "./types"; @@ -101,6 +96,20 @@ export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app"; import { RESOURCE_URI_META_KEY } from "./app"; export { PostMessageTransport } from "./message-transport"; +function proxiedInnerCapabilities( + capabilities: ServerCapabilities, +): ServerCapabilities { + return { + ...(capabilities.tools && { tools: capabilities.tools }), + ...(capabilities.resources && { + resources: { + listChanged: capabilities.resources.listChanged, + }, + }), + ...(capabilities.prompts && { prompts: capabilities.prompts }), + }; +} + /** * Extract UI resource URI from tool metadata. * @@ -206,10 +215,13 @@ export function buildAllowAttribute( * * @property hostContext - Optional initial host context to provide to the view * - * @see `ProtocolOptions` from @modelcontextprotocol/sdk for available options + * @see `ServerOptions` from @modelcontextprotocol/server for available options * @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure */ -export type HostOptions = ProtocolOptions & { +export type HostOptions = Omit< + ServerOptions, + "capabilities" | "supportedProtocolVersions" +> & { hostContext?: McpUiHostContext; }; @@ -224,15 +236,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[1] ->[1]; +type RequestHandlerExtra = ServerContext; /** * Maps DOM-style event names to their notification `params` types. @@ -306,66 +315,53 @@ export type AppBridgeEventMap = { * await bridge.connect(transport); * ``` */ -export class AppBridge extends Protocol { +export class AppBridge extends Server { private _appCapabilities?: McpUiAppCapabilities; private _hostContext: McpUiHostContext = {}; private _appInfo?: Implementation; private _initializedReceived = false; - private readonly _registeredMethods = new Set(); private readonly _registeredEvents = new Set(); private readonly _events = new EventDispatcher(); - // ProtocolWithEvents previously owned these registration guards. Keep them - // on AppBridge to preserve the existing double-registration behavior while - // event fan-out moves to protocol-independent composition. Arrow-function - // fields let Protocol's constructor run before the guards initialize. - override setRequestHandler: Protocol< - AppRequest, - AppNotification, - AppResult - >["setRequestHandler"] = (schema, handler) => { - this._assertMethodNotRegistered(schema, "setRequestHandler"); - super.setRequestHandler(schema, handler); - }; - - override setNotificationHandler: Protocol< - AppRequest, - AppNotification, - AppResult - >["setNotificationHandler"] = (schema, handler) => { - this._assertMethodNotRegistered(schema, "setNotificationHandler"); - super.setNotificationHandler(schema, handler); - }; - /** - * Wrap every handler registered via `replaceRequestHandler` with a check - * 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`. + * Chaining through `super._wrapHandler()` retains the base `Server`'s own + * role-specific behavior, including tool-result validation. The two + * initialization methods and `ping` are exempt because they are valid before + * the Apps-ready gate. This wraps registered callbacks, not JSON-RPC dispatch. * * @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149} */ - protected replaceRequestHandler: Protocol< - AppRequest, - AppNotification, - AppResult - >["setRequestHandler"] = (schema, handler) => { - const method = (schema as MethodSchema).shape.method.value; - this._registeredMethods.add(method); - super.setRequestHandler(schema, (request, extra) => { + protected override _wrapHandler( + method: string, + handler: ( + request: JSONRPCRequest, + context: ServerContext, + ) => Promise, + ): (request: JSONRPCRequest, context: ServerContext) => Promise { + const wrapped = super._wrapHandler(method, handler); + if ( + method === "initialize" || + 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); + }; + } private readonly eventSchemas: { [K in keyof AppBridgeEventMap]: MethodSchema; @@ -406,17 +402,27 @@ export class AppBridge extends Protocol this._events.removeEventListener(event, handler); } - private _ensureEventSlot( - event: K, - ): void { + private _ensureEventSlot(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, (notification) => { - const params = (notification as { params: AppBridgeEventMap[K] }).params; + + 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); } @@ -433,16 +439,12 @@ export class AppBridge extends Protocol } } - 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.`, - ); - } - this._registeredMethods.add(method); + private ensureInnerCapabilities(capabilities: ServerCapabilities): void { + const current = super.getCapabilities(); + const missing = Object.keys(capabilities).some( + (key) => current[key as keyof ServerCapabilities] === undefined, + ); + if (missing) this.registerCapabilities(capabilities); } /** @@ -453,8 +455,8 @@ export class AppBridge extends Protocol * 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" @@ -481,29 +483,61 @@ export class AppBridge extends Protocol constructor( private _client: Client | null, private _hostInfo: Implementation, - private _capabilities: McpUiHostCapabilities, + private _hostCapabilities: McpUiHostCapabilities, options?: HostOptions, ) { - super(options); + // TEMPORARY INNER-CHANNEL NEGOTIATION: + // App and AppBridge use the public v2 Client/Server roles, so the iframe + // channel first completes the SDK's standard legacy MCP initialization. + // ui/initialize remains authoritative for Apps capabilities, identity, and + // host context, and ui/notifications/initialized remains the View-ready gate. + // Keep the inner MCP era pinned to 2025-11-25. Revisit this sequence only + // after the MCP Apps negotiation specification is resolved: + // https://github.com/modelcontextprotocol/ext-apps/issues/708 + super(_hostInfo, { + ...options, + capabilities: {}, + supportedProtocolVersions: [INNER_MCP_PROTOCOL_VERSION], + }); + + // The base Server's standard initialized callback is MCP-ready state. The + // public AppBridge callback remains exclusively the Apps View-ready gate. + super.setNotificationHandler("notifications/initialized", () => {}); + Object.defineProperty(this, "oninitialized", { + configurable: true, + enumerable: true, + get: () => this.getEventHandler("initialized"), + set: (callback: AppBridge["oninitialized"]) => + this.setEventHandler("initialized", callback), + }); this._ensureEventSlot("initialized"); this._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 }; }, @@ -680,18 +714,9 @@ export class AppBridge extends Protocol * @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View * @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. */ - get oninitialized(): - | ((params: McpUiInitializedNotification["params"]) => void) - | undefined { - return this.getEventHandler("initialized"); - } - set oninitialized( - callback: - | ((params: McpUiInitializedNotification["params"]) => void) - | undefined, - ) { - this.setEventHandler("initialized", callback); - } + override oninitialized?: ( + params?: McpUiInitializedNotification["params"], + ) => void; /** * Register a handler for message requests from the view. @@ -744,11 +769,15 @@ export class AppBridge extends Protocol ) { 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); }, ); } @@ -813,11 +842,15 @@ export class AppBridge extends Protocol ) { 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); }, ); } @@ -885,12 +918,16 @@ export class AppBridge extends Protocol 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); }, ); } @@ -985,12 +1022,16 @@ export class AppBridge extends Protocol 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); }, ); } @@ -1082,12 +1123,16 @@ export class AppBridge extends Protocol 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); }, ); } @@ -1135,13 +1180,11 @@ export class AppBridge extends Protocol ) { 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.ensureInnerCapabilities({ tools: {} }); + this.setRequestHandler("tools/call", async (request, extra) => { + if (!this._oncalltool) throw new Error("No oncalltool handler set"); + return this._oncalltool(request.params, extra); + }); } /** @@ -1179,12 +1222,9 @@ export class AppBridge extends Protocol extra: RequestHandlerExtra, ) => Promise, ) { - this.setRequestHandler( - CreateMessageRequestSchema, - async (request, extra) => { - return callback(request.params, extra); - }, - ); + this.setRequestHandler("sampling/createMessage", async (request, extra) => { + return callback(request.params, extra); + }); } /** @@ -1260,14 +1300,12 @@ export class AppBridge extends Protocol 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.ensureInnerCapabilities({ resources: {} }); + this.setRequestHandler("resources/list", async (request, extra) => { + if (!this._onlistresources) + throw new Error("No onlistresources handler set"); + return this._onlistresources(request.params, extra); + }); } /** @@ -1317,8 +1355,9 @@ export class AppBridge extends Protocol callback, ); this._onlistresourcetemplates = callback; - this.replaceRequestHandler( - ListResourceTemplatesRequestSchema, + this.ensureInnerCapabilities({ resources: {} }); + this.setRequestHandler( + "resources/templates/list", async (request, extra) => { if (!this._onlistresourcetemplates) throw new Error("No onlistresourcetemplates handler set"); @@ -1374,14 +1413,12 @@ export class AppBridge extends Protocol 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.ensureInnerCapabilities({ resources: {} }); + this.setRequestHandler("resources/read", async (request, extra) => { + if (!this._onreadresource) + throw new Error("No onreadresource handler set"); + return this._onreadresource(request.params, extra); + }); } /** @@ -1459,14 +1496,11 @@ export class AppBridge extends Protocol 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.ensureInnerCapabilities({ prompts: {} }); + this.setRequestHandler("prompts/list", async (request, extra) => { + if (!this._onlistprompts) throw new Error("No onlistprompts handler set"); + return this._onlistprompts(request.params, extra); + }); } /** @@ -1495,46 +1529,6 @@ export class AppBridge extends Protocol }); } - /** - * 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. * @@ -1542,18 +1536,18 @@ export class AppBridge extends Protocol * * @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure */ - getCapabilities(): McpUiHostCapabilities { - return this._capabilities; + getHostCapabilities(): McpUiHostCapabilities { + return this._hostCapabilities; } /** * Handle the ui/initialize request from the guest. * @internal */ - private async _oninitialize( - request: McpUiInitializeRequest, + private async _onAppsInitialize( + params: McpUiInitializeRequest["params"], ): Promise { - const requestedVersion = request.params.protocolVersion; + const requestedVersion = params.protocolVersion; if (this._appInfo !== undefined) { console.warn( @@ -1564,8 +1558,8 @@ export class AppBridge extends Protocol ); } - 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, @@ -1575,7 +1569,7 @@ export class AppBridge extends Protocol return { protocolVersion, - hostCapabilities: this.getCapabilities(), + hostCapabilities: this.getHostCapabilities(), hostInfo: this._hostInfo, hostContext: this._hostContext, }; @@ -1857,11 +1851,7 @@ export class AppBridge extends Protocol * @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); } /** @@ -1874,11 +1864,7 @@ export class AppBridge extends Protocol * @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); } /** @@ -1949,18 +1935,18 @@ export class AppBridge extends Protocol if (!serverCapabilities) { throw new Error("Client server capabilities not available"); } + this.registerCapabilities(proxiedInnerCapabilities(serverCapabilities)); if (serverCapabilities.tools) { 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), ); } @@ -1969,27 +1955,24 @@ export class AppBridge extends Protocol 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), ); } @@ -1998,22 +1981,20 @@ export class AppBridge extends Protocol 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. + // The standard MCP handshake only establishes the temporary inner Server + // role. ui/notifications/initialized remains the public View-ready signal. return super.connect(transport); }