Port App and AppBridge to the public v2 Protocol base class

Replaces the Client/Server subclassing from the v2 migration with direct
Protocol extension, now that the SDK exports Protocol from the client
package root. This restores the v1 wire contract on the iframe channel:
ui/initialize is the first and only handshake, so deployed hosts keep
working unchanged.

Deleted with the double handshake: the inner MCP initialize round-trip
and its pinned protocol version, the contentless inner capabilities and
their retro-registration plumbing, the oninitialized defineProperty
shadow, and the getCapabilities/getHostCapabilities split (v1
getCapabilities restored). Tests pin the wire order and assert no MCP
initialize is sent in either direction.

Note: package.json overrides point the SDK packages at locally built
tarballs carrying the Protocol export; replace with published versions
before any upstream PR.
This commit is contained in:
Felix Weinberger 2026-07-15 08:30:45 +00:00 • committed by Enrico
commit ccae093d1a
8 changed files with 105 additions and 166 deletions

View file

@ -60,8 +60,8 @@ rm -fR package-lock.json node_modules && \
### Key Source Files ### Key Source Files
- `src/app.ts` - `App` subclasses the base MCP SDK `Client`, handles View initialization, tool calls, and messaging - `src/app.ts` - `App` subclasses the base MCP SDK `Protocol`, handles View initialization, tool calls, and messaging
- `src/app-bridge.ts` - `AppBridge` subclasses the base MCP SDK `Server` for the inner iframe channel and proxies through a separate outer `Client` - `src/app-bridge.ts` - `AppBridge` subclasses the base MCP SDK `Protocol` for the iframe channel and proxies through a separate outer `Client`
- `src/server/index.ts` - Helpers for MCP servers to register tools/resources with UI metadata - `src/server/index.ts` - Helpers for MCP servers to register tools/resources with UI metadata
- `src/types.ts` - Protocol types re-exported from `spec.types.ts` and Zod schemas from `generated/schema.ts` (auto-generated during build) - `src/types.ts` - Protocol types re-exported from `spec.types.ts` and Zod schemas from `generated/schema.ts` (auto-generated during build)
- `src/message-transport.ts` - `PostMessageTransport` for iframe communication - `src/message-transport.ts` - `PostMessageTransport` for iframe communication
@ -70,12 +70,12 @@ rm -fR package-lock.json node_modules && \
### Protocol Flow ### Protocol Flow
``` ```
View (App/Client) <--PostMessageTransport--> Host (AppBridge/Server) <--separate MCP Client--> MCP Server View (App/Protocol) <--PostMessageTransport--> Host (AppBridge/Protocol) <--separate MCP Client--> MCP Server
``` ```
1. Host creates iframe with view HTML 1. Host creates iframe with view HTML
2. View creates `App` instance and calls `connect()` with `PostMessageTransport` 2. View creates `App` instance and calls `connect()` with `PostMessageTransport`
3. View and Host complete the temporary inner base MCP handshake, then the View sends `ui/initialize` and receives authoritative Apps capabilities and context 3. View sends `ui/initialize` (the only handshake on the iframe channel) and receives authoritative Apps capabilities and context
4. Host sends `sendToolInput()` with tool arguments after initialization 4. Host sends `sendToolInput()` with tool arguments after initialization
5. View can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()` 5. View can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()`
6. Host sends `sendToolResult()` when tool execution completes 6. Host sends `sendToolResult()` when tool execution completes

View file

@ -97,7 +97,7 @@ sequenceDiagram
``` ```
1. **Discovery** — The Host learns about tools and their UI resources when connecting to the server. 1. **Discovery** — The Host learns about tools and their UI resources when connecting to the server.
2. **Initialization** — When a UI tool is called, the Host renders the iframe. The SDK first completes its temporary inner base MCP handshake, then the View sends `ui/initialize` and receives host context (theme, capabilities, container dimensions). `ui/initialize` remains authoritative for Apps capabilities and identity, and `ui/notifications/initialized` remains the public View-ready signal. 2. **Initialization** — When a UI tool is called, the Host renders the iframe. The View sends `ui/initialize` (the only handshake on the iframe channel) and receives host context (theme, capabilities, container dimensions). `ui/initialize` is authoritative for Apps capabilities and identity, and `ui/notifications/initialized` is the public View-ready signal.
3. **Data delivery** — The Host sends tool arguments and, once available, tool results to the View. Results include both `content` (text for the model's context) and optionally `structuredContent` (data optimized for UI rendering). This separation lets servers provide rich data to the UI without bloating the model's context. 3. **Data delivery** — The Host sends tool arguments and, once available, tool results to the View. Results include both `content` (text for the model's context) and optionally `structuredContent` (data optimized for UI rendering). This separation lets servers provide rich data to the UI without bloating the model's context.
4. **Interactive phase** — The user interacts with the View. The View can call tools, send messages, or update context. 4. **Interactive phase** — The user interacts with the View. The View can call tools, send messages, or update context.
5. **Teardown** — Before unmounting, the Host notifies the View so it can save state or release resources. 5. **Teardown** — Before unmounting, the Host notifies the View so it can save state or release resources.

16
package-lock.json generated
View file

@ -16,9 +16,9 @@
}, },
"devDependencies": { "devDependencies": {
"@boneskull/typedoc-plugin-mermaid": "^0.2.0", "@boneskull/typedoc-plugin-mermaid": "^0.2.0",
"@modelcontextprotocol/client": "2.0.0-beta.4", "@modelcontextprotocol/client": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-client.tgz",
"@modelcontextprotocol/core": "2.0.0-beta.4", "@modelcontextprotocol/core": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-core.tgz",
"@modelcontextprotocol/server": "2.0.0-beta.4", "@modelcontextprotocol/server": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-server.tgz",
"@playwright/test": "1.57.0", "@playwright/test": "1.57.0",
"@types/bun": "^1.3.2", "@types/bun": "^1.3.2",
"@types/node": "20.19.27", "@types/node": "20.19.27",
@ -2639,8 +2639,8 @@
}, },
"node_modules/@modelcontextprotocol/client": { "node_modules/@modelcontextprotocol/client": {
"version": "2.0.0-beta.4", "version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz", "resolved": "file:../tarballs/mcp-client.tgz",
"integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==", "integrity": "sha512-aCTBtOjOSOj+SOLsoUfDjhD3Ngs4pMXzAczBmKRTesiz0N/3wUEXEmKgxhfbhF6Wtgk4tatWBUG9LDaj919K2Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4", "@modelcontextprotocol/core": "2.0.0-beta.4",
@ -2657,7 +2657,7 @@
}, },
"node_modules/@modelcontextprotocol/core": { "node_modules/@modelcontextprotocol/core": {
"version": "2.0.0-beta.4", "version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.4.tgz", "resolved": "file:../tarballs/mcp-core.tgz",
"integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==", "integrity": "sha512-nsMXd4wQBKzmph6r+WOhum+mXjDYljTAqwY/XUg3hLtvNOQ8+JVqBSJOVCMJvx9lXhTpTOPrGZ3BuNiaNPjSvg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2784,8 +2784,8 @@
}, },
"node_modules/@modelcontextprotocol/server": { "node_modules/@modelcontextprotocol/server": {
"version": "2.0.0-beta.4", "version": "2.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz", "resolved": "file:../tarballs/mcp-server.tgz",
"integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==", "integrity": "sha512-/UJubByzGV+mlU8fN8yyuMI9zgoq4Ic5b1V/6yhzxrKjmXpTbNkY5rD2jRsWBk+At0AVcXLkcDzmLF91oOJtKw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/core": "2.0.0-beta.4", "@modelcontextprotocol/core": "2.0.0-beta.4",

View file

@ -77,9 +77,9 @@
"author": "Olivier Chafik", "author": "Olivier Chafik",
"devDependencies": { "devDependencies": {
"@boneskull/typedoc-plugin-mermaid": "^0.2.0", "@boneskull/typedoc-plugin-mermaid": "^0.2.0",
"@modelcontextprotocol/client": "2.0.0-beta.4", "@modelcontextprotocol/client": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-client.tgz",
"@modelcontextprotocol/core": "2.0.0-beta.4", "@modelcontextprotocol/core": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-core.tgz",
"@modelcontextprotocol/server": "2.0.0-beta.4", "@modelcontextprotocol/server": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-server.tgz",
"@playwright/test": "1.57.0", "@playwright/test": "1.57.0",
"@types/bun": "^1.3.2", "@types/bun": "^1.3.2",
"@types/node": "20.19.27", "@types/node": "20.19.27",
@ -129,7 +129,10 @@
"seroval": "1.4.1", "seroval": "1.4.1",
"seroval-plugins": "1.4.2", "seroval-plugins": "1.4.2",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"@types/node": "20.19.27" "@types/node": "20.19.27",
"@modelcontextprotocol/core": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-core.tgz",
"@modelcontextprotocol/client": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-client.tgz",
"@modelcontextprotocol/server": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-server.tgz"
}, },
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0" "@standard-schema/spec": "^1.1.0"

View file

@ -82,7 +82,7 @@ describe("App <-> AppBridge integration", () => {
expect(initializedFired).toBe(true); expect(initializedFired).toBe(true);
}); });
it("keeps standard initialization separate from the Apps-ready gate", async () => { it("sends only the Apps handshake on the wire and gates the Apps-ready callback", async () => {
const methods: string[] = []; const methods: string[] = [];
let releaseAppsInitialized!: () => void; let releaseAppsInitialized!: () => void;
let reachedAppsInitialized!: () => void; let reachedAppsInitialized!: () => void;
@ -115,14 +115,9 @@ describe("App <-> AppBridge integration", () => {
await reachedGate; await reachedGate;
expect(methods).toEqual([ expect(methods).toEqual([
"initialize",
"notifications/initialized",
"ui/initialize", "ui/initialize",
"ui/notifications/initialized", "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(bridge.getAppVersion()).toEqual(testAppInfo);
expect(singularCalls).toBe(0); expect(singularCalls).toBe(0);
expect(listenerCalls).toBe(0); expect(listenerCalls).toBe(0);
@ -143,8 +138,7 @@ describe("App <-> AppBridge integration", () => {
const hostCaps = app.getHostCapabilities(); const hostCaps = app.getHostCapabilities();
expect(hostCaps).toEqual(testHostCapabilities); expect(hostCaps).toEqual(testHostCapabilities);
expect(bridge.getHostCapabilities()).toEqual(testHostCapabilities); expect(bridge.getCapabilities()).toEqual(testHostCapabilities);
expect(bridge.getCapabilities()).toEqual({});
}); });
it("Bridge receives app info and capabilities after initialization", async () => { it("Bridge receives app info and capabilities after initialization", async () => {

View file

@ -1,6 +1,8 @@
import { type Client } from "@modelcontextprotocol/client";
import { import {
Server, Protocol,
type BaseContext,
type Client,
type ProtocolOptions,
type CallToolRequest, type CallToolRequest,
type CallToolResult, type CallToolResult,
type CreateMessageRequest, type CreateMessageRequest,
@ -24,13 +26,10 @@ import {
type RequestOptions, type RequestOptions,
type ResourceListChangedNotification, type ResourceListChangedNotification,
type Result, type Result,
type ServerCapabilities,
type ServerContext,
type ServerOptions,
type Tool, type Tool,
type ToolListChangedNotification, type ToolListChangedNotification,
type Transport, type Transport,
} from "@modelcontextprotocol/server"; } from "@modelcontextprotocol/client";
import { import {
EmptyResultSchema, EmptyResultSchema,
LoggingMessageNotificationSchema, LoggingMessageNotificationSchema,
@ -43,8 +42,6 @@ type MethodSchema = ZodObject<{
params: ZodType; params: ZodType;
}>; }>;
const INNER_MCP_PROTOCOL_VERSION = "2025-11-25";
import { import {
type McpUiSandboxResourceReadyNotification, type McpUiSandboxResourceReadyNotification,
type McpUiSizeChangedNotification, type McpUiSizeChangedNotification,
@ -96,20 +93,6 @@ export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app";
import { RESOURCE_URI_META_KEY } from "./app"; import { RESOURCE_URI_META_KEY } from "./app";
export { PostMessageTransport } from "./message-transport"; export { PostMessageTransport } from "./message-transport";
function proxiedInnerCapabilities(
capabilities: ServerCapabilities,
): ServerCapabilities {
return {
...(capabilities.tools && { tools: capabilities.tools }),
...(capabilities.resources && {
resources: {
listChanged: capabilities.resources.listChanged,
},
}),
...(capabilities.prompts && { prompts: capabilities.prompts }),
};
}
/** /**
* Extract UI resource URI from tool metadata. * Extract UI resource URI from tool metadata.
* *
@ -215,13 +198,10 @@ export function buildAllowAttribute(
* *
* @property hostContext - Optional initial host context to provide to the view * @property hostContext - Optional initial host context to provide to the view
* *
* @see `ServerOptions` from @modelcontextprotocol/server for available options * @see `ProtocolOptions` from @modelcontextprotocol/client for available options
* @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure * @see {@link McpUiHostContext `McpUiHostContext`} for the hostContext structure
*/ */
export type HostOptions = Omit< export type HostOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & {
ServerOptions,
"capabilities" | "supportedProtocolVersions"
> & {
hostContext?: McpUiHostContext; hostContext?: McpUiHostContext;
}; };
@ -241,7 +221,7 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
* *
* @internal * @internal
*/ */
type RequestHandlerExtra = ServerContext; type RequestHandlerExtra = BaseContext;
/** /**
* Maps DOM-style event names to their notification `params` types. * Maps DOM-style event names to their notification `params` types.
@ -315,7 +295,7 @@ export type AppBridgeEventMap = {
* await bridge.connect(transport); * await bridge.connect(transport);
* ``` * ```
*/ */
export class AppBridge extends Server { export class AppBridge extends Protocol<BaseContext> {
private _appCapabilities?: McpUiAppCapabilities; private _appCapabilities?: McpUiAppCapabilities;
private _hostContext: McpUiHostContext = {}; private _hostContext: McpUiHostContext = {};
private _appInfo?: Implementation; private _appInfo?: Implementation;
@ -328,26 +308,17 @@ export class AppBridge extends Server {
* handler registration. AppBridge uses it to preserve the existing warning * handler registration. AppBridge uses it to preserve the existing warning
* when a View calls host methods before `ui/notifications/initialized`, * when a View calls host methods before `ui/notifications/initialized`,
* without adding overload-forwarding adapters around `setRequestHandler`. * without adding overload-forwarding adapters around `setRequestHandler`.
* Chaining through `super._wrapHandler()` retains the base `Server`'s own * `ui/initialize` and `ping` are exempt because they are valid before the
* role-specific behavior, including tool-result validation. The two * Apps-ready gate. This wraps registered callbacks, not JSON-RPC dispatch.
* initialization methods and `ping` are exempt because they are valid before
* the Apps-ready gate. This wraps registered callbacks, not JSON-RPC dispatch.
* *
* @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149} * @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149}
*/ */
protected override _wrapHandler( protected override _wrapHandler(
method: string, method: string,
handler: ( handler: (request: JSONRPCRequest, context: BaseContext) => Promise<Result>,
request: JSONRPCRequest, ): (request: JSONRPCRequest, context: BaseContext) => Promise<Result> {
context: ServerContext,
) => Promise<Result>,
): (request: JSONRPCRequest, context: ServerContext) => Promise<Result> {
const wrapped = super._wrapHandler(method, handler); const wrapped = super._wrapHandler(method, handler);
if ( if (method === "ui/initialize" || method === "ping") {
method === "initialize" ||
method === "ui/initialize" ||
method === "ping"
) {
return wrapped; return wrapped;
} }
return async (request, context) => { return async (request, context) => {
@ -439,14 +410,6 @@ export class AppBridge extends Server {
} }
} }
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);
}
/** /**
* Create a new AppBridge instance. * Create a new AppBridge instance.
* *
@ -486,30 +449,7 @@ export class AppBridge extends Server {
private _hostCapabilities: McpUiHostCapabilities, private _hostCapabilities: McpUiHostCapabilities,
options?: HostOptions, options?: HostOptions,
) { ) {
// TEMPORARY INNER-CHANNEL NEGOTIATION: super(options);
// App and AppBridge use the public v2 Client/Server roles, so the iframe
// channel first completes the SDK's standard legacy MCP initialization.
// ui/initialize remains authoritative for Apps capabilities, identity, and
// host context, and ui/notifications/initialized remains the View-ready gate.
// Keep the inner MCP era pinned to 2025-11-25. Revisit this sequence only
// after the MCP Apps negotiation specification is resolved:
// https://github.com/modelcontextprotocol/ext-apps/issues/708
super(_hostInfo, {
...options,
capabilities: {},
supportedProtocolVersions: [INNER_MCP_PROTOCOL_VERSION],
});
// The base Server's standard initialized callback is MCP-ready state. The
// public AppBridge callback remains exclusively the Apps View-ready gate.
super.setNotificationHandler("notifications/initialized", () => {});
Object.defineProperty(this, "oninitialized", {
configurable: true,
enumerable: true,
get: () => this.getEventHandler("initialized"),
set: (callback: AppBridge["oninitialized"]) =>
this.setEventHandler("initialized", callback),
});
this._ensureEventSlot("initialized"); this._ensureEventSlot("initialized");
@ -544,6 +484,35 @@ export class AppBridge extends Server {
); );
} }
/** @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. * Get the view's capabilities discovered during initialization.
* *
@ -713,9 +682,18 @@ export class AppBridge extends Server {
* @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View * @see {@link sendToolInput `sendToolInput`} for sending tool arguments to the View
* @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}. * @deprecated Use {@link addEventListener `addEventListener("initialized", handler)`} instead — it composes with other listeners and supports cleanup via {@link removeEventListener `removeEventListener`}.
*/ */
override oninitialized?: ( get oninitialized():
params?: McpUiInitializedNotification["params"], | ((params?: McpUiInitializedNotification["params"]) => void)
) => void; | undefined {
return this.getEventHandler("initialized");
}
set oninitialized(
callback:
| ((params?: McpUiInitializedNotification["params"]) => void)
| undefined,
) {
this.setEventHandler("initialized", callback);
}
/** /**
* Register a handler for message requests from the view. * Register a handler for message requests from the view.
@ -1178,7 +1156,6 @@ export class AppBridge extends Server {
) { ) {
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback); this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
this._oncalltool = callback; this._oncalltool = callback;
this.ensureInnerCapabilities({ tools: {} });
this.setRequestHandler("tools/call", async (request, extra) => { this.setRequestHandler("tools/call", async (request, extra) => {
if (!this._oncalltool) throw new Error("No oncalltool handler set"); if (!this._oncalltool) throw new Error("No oncalltool handler set");
return this._oncalltool(request.params, extra); return this._oncalltool(request.params, extra);
@ -1299,7 +1276,6 @@ export class AppBridge extends Server {
callback, callback,
); );
this._onlistresources = callback; this._onlistresources = callback;
this.ensureInnerCapabilities({ resources: {} });
this.setRequestHandler("resources/list", async (request, extra) => { this.setRequestHandler("resources/list", async (request, extra) => {
if (!this._onlistresources) if (!this._onlistresources)
throw new Error("No onlistresources handler set"); throw new Error("No onlistresources handler set");
@ -1353,7 +1329,6 @@ export class AppBridge extends Server {
callback, callback,
); );
this._onlistresourcetemplates = callback; this._onlistresourcetemplates = callback;
this.ensureInnerCapabilities({ resources: {} });
this.setRequestHandler( this.setRequestHandler(
"resources/templates/list", "resources/templates/list",
async (request, extra) => { async (request, extra) => {
@ -1410,7 +1385,6 @@ export class AppBridge extends Server {
callback, callback,
); );
this._onreadresource = callback; this._onreadresource = callback;
this.ensureInnerCapabilities({ resources: {} });
this.setRequestHandler("resources/read", async (request, extra) => { this.setRequestHandler("resources/read", async (request, extra) => {
if (!this._onreadresource) if (!this._onreadresource)
throw new Error("No onreadresource handler set"); throw new Error("No onreadresource handler set");
@ -1492,7 +1466,6 @@ export class AppBridge extends Server {
callback, callback,
); );
this._onlistprompts = callback; this._onlistprompts = callback;
this.ensureInnerCapabilities({ prompts: {} });
this.setRequestHandler("prompts/list", async (request, extra) => { this.setRequestHandler("prompts/list", async (request, extra) => {
if (!this._onlistprompts) throw new Error("No onlistprompts handler set"); if (!this._onlistprompts) throw new Error("No onlistprompts handler set");
return this._onlistprompts(request.params, extra); return this._onlistprompts(request.params, extra);
@ -1532,7 +1505,7 @@ export class AppBridge extends Server {
* *
* @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure * @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure
*/ */
getHostCapabilities(): McpUiHostCapabilities { getCapabilities(): McpUiHostCapabilities {
return this._hostCapabilities; return this._hostCapabilities;
} }
@ -1565,7 +1538,7 @@ export class AppBridge extends Server {
return { return {
protocolVersion, protocolVersion,
hostCapabilities: this.getHostCapabilities(), hostCapabilities: this.getCapabilities(),
hostInfo: this._hostInfo, hostInfo: this._hostInfo,
hostContext: this._hostContext, hostContext: this._hostContext,
}; };
@ -1931,7 +1904,6 @@ export class AppBridge extends Server {
if (!serverCapabilities) { if (!serverCapabilities) {
throw new Error("Client server capabilities not available"); throw new Error("Client server capabilities not available");
} }
this.registerCapabilities(proxiedInnerCapabilities(serverCapabilities));
if (serverCapabilities.tools) { if (serverCapabilities.tools) {
this.oncalltool = async (params, extra) => { this.oncalltool = async (params, extra) => {
@ -1989,9 +1961,9 @@ export class AppBridge extends Server {
} }
} }
// The standard MCP handshake only establishes the temporary inner Server // Protocol.connect only wires the transport — no MCP handshake happens on
// role. ui/notifications/initialized remains the public View-ready signal. // the iframe channel. The View drives ui/initialize;
// ui/notifications/initialized remains the public View-ready signal.
return super.connect(transport); return super.connect(transport);
} }
} }

View file

@ -14,8 +14,6 @@ import {
McpUiInitializedNotificationSchema, McpUiInitializedNotificationSchema,
} from "./types"; } from "./types";
const INNER_MCP_PROTOCOL_VERSION = "2025-11-25";
type ConnectedPair = { type ConnectedPair = {
app: App; app: App;
server: Server; server: Server;
@ -34,7 +32,6 @@ async function connectPair(
{ name: "inner-mcp-server", version: "0.0.0" }, { name: "inner-mcp-server", version: "0.0.0" },
{ {
capabilities: { resources: {}, tools: {} }, capabilities: { resources: {}, tools: {} },
supportedProtocolVersions: [INNER_MCP_PROTOCOL_VERSION],
}, },
); );
const [appTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const [appTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@ -103,8 +100,8 @@ afterEach(async () => {
); );
}); });
describe("App base MCP SDK v2 Client migration", () => { describe("App base MCP SDK v2 Protocol migration", () => {
it("completes the legacy MCP handshake before the authoritative Apps handshake", async () => { it("performs only the Apps handshake — no MCP initialize on the wire", async () => {
const pair = await connectPair(); const pair = await connectPair();
connected.push(pair); connected.push(pair);
@ -123,25 +120,19 @@ describe("App base MCP SDK v2 Client migration", () => {
].includes(method), ].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([ expect(lifecycleMethods).toEqual([
"initialize",
"notifications/initialized",
"ui/initialize", "ui/initialize",
"ui/notifications/initialized", "ui/notifications/initialized",
]); ]);
expect(pair.app.getNegotiatedProtocolVersion()).toBe(
INNER_MCP_PROTOCOL_VERSION,
);
}); });
it("uses ui/initialize as the authority for Apps host state", async () => { it("uses ui/initialize as the authority for Apps host state", async () => {
const pair = await connectPair(); const pair = await connectPair();
connected.push(pair); connected.push(pair);
expect(pair.app.getServerVersion()).toEqual({
name: "inner-mcp-server",
version: "0.0.0",
});
expect(pair.app.getHostVersion()).toEqual({ expect(pair.app.getHostVersion()).toEqual({
name: "apps-host", name: "apps-host",
version: "2.0.0", version: "2.0.0",
@ -219,9 +210,6 @@ describe("App base MCP SDK v2 Client migration", () => {
const second = await connectPair(first.app); const second = await connectPair(first.app);
connected.push(second); connected.push(second);
expect(second.app.getNegotiatedProtocolVersion()).toBe(
INNER_MCP_PROTOCOL_VERSION,
);
expect(second.app.getHostVersion()).toEqual({ expect(second.app.getHostVersion()).toEqual({
name: "apps-host", name: "apps-host",
version: "2.0.0", version: "2.0.0",

View file

@ -1,7 +1,7 @@
import { import {
Client, Protocol,
type ClientContext, type BaseContext,
type ClientOptions, type ProtocolOptions,
type RequestOptions, type RequestOptions,
type CallToolRequest, type CallToolRequest,
type CallToolResult, type CallToolResult,
@ -71,8 +71,6 @@ type MethodSchema = ZodObject<{
params: ZodType; params: ZodType;
}>; }>;
const INNER_MCP_PROTOCOL_VERSION = "2025-11-25";
function mergeAppCapabilities( function mergeAppCapabilities(
base: McpUiAppCapabilities, base: McpUiAppCapabilities,
additional: McpUiAppCapabilities, additional: McpUiAppCapabilities,
@ -174,14 +172,11 @@ export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
/** /**
* Options for configuring {@link App `App`} behavior. * Options for configuring {@link App `App`} behavior.
* *
* Extends `ClientOptions` from the base MCP SDK with `App`-specific configuration. * Extends `ProtocolOptions` from the base MCP SDK with `App`-specific configuration.
* *
* @see `ClientOptions` from @modelcontextprotocol/client for inherited options * @see `ProtocolOptions` from @modelcontextprotocol/client for inherited options
*/ */
export type AppOptions = Omit< export type AppOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & {
ClientOptions,
"capabilities" | "supportedProtocolVersions" | "versionNegotiation"
> & {
/** /**
* Automatically report size changes to the host using `ResizeObserver`. * Automatically report size changes to the host using `ResizeObserver`.
* *
@ -221,7 +216,7 @@ export type AppOptions = Omit<
allowUnsafeEval?: boolean; allowUnsafeEval?: boolean;
}; };
type RequestHandlerExtra = ClientContext; type RequestHandlerExtra = BaseContext;
/** /**
* Result of an app-registered tool callback. When `Out` is provided, * Result of an app-registered tool callback. When `Out` is provided,
@ -354,7 +349,7 @@ export type AppEventMap = {
* await app.connect(); * await app.connect();
* ``` * ```
*/ */
export class App extends Client { export class App extends Protocol<BaseContext> {
private _hostCapabilities?: McpUiHostCapabilities; private _hostCapabilities?: McpUiHostCapabilities;
private _hostInfo?: Implementation; private _hostInfo?: Implementation;
private _hostContext?: McpUiHostContext; private _hostContext?: McpUiHostContext;
@ -521,20 +516,7 @@ export class App extends Client {
private _appCapabilities: McpUiAppCapabilities = {}, private _appCapabilities: McpUiAppCapabilities = {},
private options: AppOptions = { autoResize: true }, private options: AppOptions = { autoResize: true },
) { ) {
// TEMPORARY INNER-CHANNEL NEGOTIATION: super(options);
// 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(_appInfo, {
...options,
capabilities: {},
versionNegotiation: { mode: "legacy" },
supportedProtocolVersions: [INNER_MCP_PROTOCOL_VERSION],
});
if (!options.allowUnsafeEval) { if (!options.allowUnsafeEval) {
z.config({ jitless: true }); z.config({ jitless: true });
@ -550,6 +532,11 @@ export class App extends Client {
this.setEventHandler("hostcontextchanged", undefined); this.setEventHandler("hostcontextchanged", undefined);
} }
/** @internal */
protected buildContext(ctx: BaseContext): BaseContext {
return ctx;
}
private registerAppCapabilities(capabilities: McpUiAppCapabilities): void { private registerAppCapabilities(capabilities: McpUiAppCapabilities): void {
if (this.transport) { if (this.transport) {
throw new Error( throw new Error(
@ -1992,15 +1979,10 @@ export class App extends Client {
} }
this._initializedSent = false; this._initializedSent = false;
// 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
try { 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); await super.connect(transport);
const result = await this.request( const result = await this.request(