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:
parent
f50ec6ebfb
commit
ccae093d1a
8 changed files with 105 additions and 166 deletions
|
|
@ -60,8 +60,8 @@ rm -fR package-lock.json node_modules && \
|
|||
|
||||
### Key Source Files
|
||||
|
||||
- `src/app.ts` - `App` subclasses the base MCP SDK `Client`, 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.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 `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/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
|
||||
|
|
@ -70,12 +70,12 @@ rm -fR package-lock.json node_modules && \
|
|||
### 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
|
||||
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
|
||||
5. View can call server tools via `app.callServerTool()` or send messages via `app.sendMessage()`
|
||||
6. Host sends `sendToolResult()` when tool execution completes
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ sequenceDiagram
|
|||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -16,9 +16,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/client": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-client.tgz",
|
||||
"@modelcontextprotocol/core": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-core.tgz",
|
||||
"@modelcontextprotocol/server": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-server.tgz",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/node": "20.19.27",
|
||||
|
|
@ -2639,8 +2639,8 @@
|
|||
},
|
||||
"node_modules/@modelcontextprotocol/client": {
|
||||
"version": "2.0.0-beta.4",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.4.tgz",
|
||||
"integrity": "sha512-VNHA/UXDk7mCpVl+jOg5B4WMRRD2OEl+it360Lhi6HiCbrIB2f6pZ0cqSDKXQVUtZvqnhdQHzZAM9h8EKWhq/A==",
|
||||
"resolved": "file:../tarballs/mcp-client.tgz",
|
||||
"integrity": "sha512-aCTBtOjOSOj+SOLsoUfDjhD3Ngs4pMXzAczBmKRTesiz0N/3wUEXEmKgxhfbhF6Wtgk4tatWBUG9LDaj919K2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.4",
|
||||
|
|
@ -2657,7 +2657,7 @@
|
|||
},
|
||||
"node_modules/@modelcontextprotocol/core": {
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2784,8 +2784,8 @@
|
|||
},
|
||||
"node_modules/@modelcontextprotocol/server": {
|
||||
"version": "2.0.0-beta.4",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.4.tgz",
|
||||
"integrity": "sha512-pjMZcNEt1dOq0aJCcY3b7w1Ayh+qmQN2xlfTELcGV9yiAjEGIczBPBLWWW0k0zzo5T8kiv15jtKPsWeHGxLvLg==",
|
||||
"resolved": "file:../tarballs/mcp-server.tgz",
|
||||
"integrity": "sha512-/UJubByzGV+mlU8fN8yyuMI9zgoq4Ic5b1V/6yhzxrKjmXpTbNkY5rD2jRsWBk+At0AVcXLkcDzmLF91oOJtKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.4",
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -77,9 +77,9 @@
|
|||
"author": "Olivier Chafik",
|
||||
"devDependencies": {
|
||||
"@boneskull/typedoc-plugin-mermaid": "^0.2.0",
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/core": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.4",
|
||||
"@modelcontextprotocol/client": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-client.tgz",
|
||||
"@modelcontextprotocol/core": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-core.tgz",
|
||||
"@modelcontextprotocol/server": "file:/root/src/mcp/projects/v2-2026-ext-apps/tarballs/mcp-server.tgz",
|
||||
"@playwright/test": "1.57.0",
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/node": "20.19.27",
|
||||
|
|
@ -129,7 +129,10 @@
|
|||
"seroval": "1.4.1",
|
||||
"seroval-plugins": "1.4.2",
|
||||
"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": {
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ describe("App <-> AppBridge integration", () => {
|
|||
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[] = [];
|
||||
let releaseAppsInitialized!: () => void;
|
||||
let reachedAppsInitialized!: () => void;
|
||||
|
|
@ -115,14 +115,9 @@ describe("App <-> AppBridge integration", () => {
|
|||
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);
|
||||
|
|
@ -143,8 +138,7 @@ describe("App <-> AppBridge integration", () => {
|
|||
|
||||
const hostCaps = app.getHostCapabilities();
|
||||
expect(hostCaps).toEqual(testHostCapabilities);
|
||||
expect(bridge.getHostCapabilities()).toEqual(testHostCapabilities);
|
||||
expect(bridge.getCapabilities()).toEqual({});
|
||||
expect(bridge.getCapabilities()).toEqual(testHostCapabilities);
|
||||
});
|
||||
|
||||
it("Bridge receives app info and capabilities after initialization", async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { type Client } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
Server,
|
||||
Protocol,
|
||||
type BaseContext,
|
||||
type Client,
|
||||
type ProtocolOptions,
|
||||
type CallToolRequest,
|
||||
type CallToolResult,
|
||||
type CreateMessageRequest,
|
||||
|
|
@ -24,13 +26,10 @@ import {
|
|||
type RequestOptions,
|
||||
type ResourceListChangedNotification,
|
||||
type Result,
|
||||
type ServerCapabilities,
|
||||
type ServerContext,
|
||||
type ServerOptions,
|
||||
type Tool,
|
||||
type ToolListChangedNotification,
|
||||
type Transport,
|
||||
} from "@modelcontextprotocol/server";
|
||||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
EmptyResultSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
|
|
@ -43,8 +42,6 @@ type MethodSchema = ZodObject<{
|
|||
params: ZodType;
|
||||
}>;
|
||||
|
||||
const INNER_MCP_PROTOCOL_VERSION = "2025-11-25";
|
||||
|
||||
import {
|
||||
type McpUiSandboxResourceReadyNotification,
|
||||
type McpUiSizeChangedNotification,
|
||||
|
|
@ -96,20 +93,6 @@ 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.
|
||||
*
|
||||
|
|
@ -215,13 +198,10 @@ export function buildAllowAttribute(
|
|||
*
|
||||
* @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
|
||||
*/
|
||||
export type HostOptions = Omit<
|
||||
ServerOptions,
|
||||
"capabilities" | "supportedProtocolVersions"
|
||||
> & {
|
||||
export type HostOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & {
|
||||
hostContext?: McpUiHostContext;
|
||||
};
|
||||
|
||||
|
|
@ -241,7 +221,7 @@ export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION];
|
|||
*
|
||||
* @internal
|
||||
*/
|
||||
type RequestHandlerExtra = ServerContext;
|
||||
type RequestHandlerExtra = BaseContext;
|
||||
|
||||
/**
|
||||
* Maps DOM-style event names to their notification `params` types.
|
||||
|
|
@ -315,7 +295,7 @@ export type AppBridgeEventMap = {
|
|||
* await bridge.connect(transport);
|
||||
* ```
|
||||
*/
|
||||
export class AppBridge extends Server {
|
||||
export class AppBridge extends Protocol<BaseContext> {
|
||||
private _appCapabilities?: McpUiAppCapabilities;
|
||||
private _hostContext: McpUiHostContext = {};
|
||||
private _appInfo?: Implementation;
|
||||
|
|
@ -328,26 +308,17 @@ export class AppBridge extends Server {
|
|||
* 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.
|
||||
* `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}
|
||||
*/
|
||||
protected override _wrapHandler(
|
||||
method: string,
|
||||
handler: (
|
||||
request: JSONRPCRequest,
|
||||
context: ServerContext,
|
||||
) => Promise<Result>,
|
||||
): (request: JSONRPCRequest, context: ServerContext) => Promise<Result> {
|
||||
handler: (request: JSONRPCRequest, context: BaseContext) => Promise<Result>,
|
||||
): (request: JSONRPCRequest, context: BaseContext) => Promise<Result> {
|
||||
const wrapped = super._wrapHandler(method, handler);
|
||||
if (
|
||||
method === "initialize" ||
|
||||
method === "ui/initialize" ||
|
||||
method === "ping"
|
||||
) {
|
||||
if (method === "ui/initialize" || method === "ping") {
|
||||
return wrapped;
|
||||
}
|
||||
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.
|
||||
*
|
||||
|
|
@ -486,30 +449,7 @@ export class AppBridge extends Server {
|
|||
private _hostCapabilities: McpUiHostCapabilities,
|
||||
options?: HostOptions,
|
||||
) {
|
||||
// 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),
|
||||
});
|
||||
super(options);
|
||||
|
||||
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.
|
||||
*
|
||||
|
|
@ -713,9 +682,18 @@ export class AppBridge extends Server {
|
|||
* @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`}.
|
||||
*/
|
||||
override oninitialized?: (
|
||||
params?: McpUiInitializedNotification["params"],
|
||||
) => void;
|
||||
get oninitialized():
|
||||
| ((params?: McpUiInitializedNotification["params"]) => 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.
|
||||
|
|
@ -1178,7 +1156,6 @@ export class AppBridge extends Server {
|
|||
) {
|
||||
this.warnIfRequestHandlerReplaced("oncalltool", this._oncalltool, callback);
|
||||
this._oncalltool = callback;
|
||||
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);
|
||||
|
|
@ -1299,7 +1276,6 @@ export class AppBridge extends Server {
|
|||
callback,
|
||||
);
|
||||
this._onlistresources = callback;
|
||||
this.ensureInnerCapabilities({ resources: {} });
|
||||
this.setRequestHandler("resources/list", async (request, extra) => {
|
||||
if (!this._onlistresources)
|
||||
throw new Error("No onlistresources handler set");
|
||||
|
|
@ -1353,7 +1329,6 @@ export class AppBridge extends Server {
|
|||
callback,
|
||||
);
|
||||
this._onlistresourcetemplates = callback;
|
||||
this.ensureInnerCapabilities({ resources: {} });
|
||||
this.setRequestHandler(
|
||||
"resources/templates/list",
|
||||
async (request, extra) => {
|
||||
|
|
@ -1410,7 +1385,6 @@ export class AppBridge extends Server {
|
|||
callback,
|
||||
);
|
||||
this._onreadresource = callback;
|
||||
this.ensureInnerCapabilities({ resources: {} });
|
||||
this.setRequestHandler("resources/read", async (request, extra) => {
|
||||
if (!this._onreadresource)
|
||||
throw new Error("No onreadresource handler set");
|
||||
|
|
@ -1492,7 +1466,6 @@ export class AppBridge extends Server {
|
|||
callback,
|
||||
);
|
||||
this._onlistprompts = callback;
|
||||
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);
|
||||
|
|
@ -1532,7 +1505,7 @@ export class AppBridge extends Server {
|
|||
*
|
||||
* @see {@link McpUiHostCapabilities `McpUiHostCapabilities`} for the capabilities structure
|
||||
*/
|
||||
getHostCapabilities(): McpUiHostCapabilities {
|
||||
getCapabilities(): McpUiHostCapabilities {
|
||||
return this._hostCapabilities;
|
||||
}
|
||||
|
||||
|
|
@ -1565,7 +1538,7 @@ export class AppBridge extends Server {
|
|||
|
||||
return {
|
||||
protocolVersion,
|
||||
hostCapabilities: this.getHostCapabilities(),
|
||||
hostCapabilities: this.getCapabilities(),
|
||||
hostInfo: this._hostInfo,
|
||||
hostContext: this._hostContext,
|
||||
};
|
||||
|
|
@ -1931,7 +1904,6 @@ export class AppBridge extends Server {
|
|||
if (!serverCapabilities) {
|
||||
throw new Error("Client server capabilities not available");
|
||||
}
|
||||
this.registerCapabilities(proxiedInnerCapabilities(serverCapabilities));
|
||||
|
||||
if (serverCapabilities.tools) {
|
||||
this.oncalltool = async (params, extra) => {
|
||||
|
|
@ -1989,9 +1961,9 @@ export class AppBridge extends Server {
|
|||
}
|
||||
}
|
||||
|
||||
// The standard MCP handshake only establishes the temporary inner Server
|
||||
// role. ui/notifications/initialized remains the public View-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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ import {
|
|||
McpUiInitializedNotificationSchema,
|
||||
} from "./types";
|
||||
|
||||
const INNER_MCP_PROTOCOL_VERSION = "2025-11-25";
|
||||
|
||||
type ConnectedPair = {
|
||||
app: App;
|
||||
server: Server;
|
||||
|
|
@ -34,7 +32,6 @@ async function connectPair(
|
|||
{ name: "inner-mcp-server", version: "0.0.0" },
|
||||
{
|
||||
capabilities: { resources: {}, tools: {} },
|
||||
supportedProtocolVersions: [INNER_MCP_PROTOCOL_VERSION],
|
||||
},
|
||||
);
|
||||
const [appTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
|
@ -103,8 +100,8 @@ afterEach(async () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe("App base MCP SDK v2 Client migration", () => {
|
||||
it("completes the legacy MCP handshake before the authoritative Apps handshake", async () => {
|
||||
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);
|
||||
|
||||
|
|
@ -123,25 +120,19 @@ describe("App base MCP SDK v2 Client migration", () => {
|
|||
].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([
|
||||
"initialize",
|
||||
"notifications/initialized",
|
||||
"ui/initialize",
|
||||
"ui/notifications/initialized",
|
||||
]);
|
||||
expect(pair.app.getNegotiatedProtocolVersion()).toBe(
|
||||
INNER_MCP_PROTOCOL_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ui/initialize as the authority for Apps host state", async () => {
|
||||
const pair = await connectPair();
|
||||
connected.push(pair);
|
||||
|
||||
expect(pair.app.getServerVersion()).toEqual({
|
||||
name: "inner-mcp-server",
|
||||
version: "0.0.0",
|
||||
});
|
||||
expect(pair.app.getHostVersion()).toEqual({
|
||||
name: "apps-host",
|
||||
version: "2.0.0",
|
||||
|
|
@ -219,9 +210,6 @@ describe("App base MCP SDK v2 Client migration", () => {
|
|||
const second = await connectPair(first.app);
|
||||
connected.push(second);
|
||||
|
||||
expect(second.app.getNegotiatedProtocolVersion()).toBe(
|
||||
INNER_MCP_PROTOCOL_VERSION,
|
||||
);
|
||||
expect(second.app.getHostVersion()).toEqual({
|
||||
name: "apps-host",
|
||||
version: "2.0.0",
|
||||
|
|
|
|||
52
src/app.ts
52
src/app.ts
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
Client,
|
||||
type ClientContext,
|
||||
type ClientOptions,
|
||||
Protocol,
|
||||
type BaseContext,
|
||||
type ProtocolOptions,
|
||||
type RequestOptions,
|
||||
type CallToolRequest,
|
||||
type CallToolResult,
|
||||
|
|
@ -71,8 +71,6 @@ type MethodSchema = ZodObject<{
|
|||
params: ZodType;
|
||||
}>;
|
||||
|
||||
const INNER_MCP_PROTOCOL_VERSION = "2025-11-25";
|
||||
|
||||
function mergeAppCapabilities(
|
||||
base: McpUiAppCapabilities,
|
||||
additional: McpUiAppCapabilities,
|
||||
|
|
@ -174,14 +172,11 @@ export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|||
/**
|
||||
* 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<
|
||||
ClientOptions,
|
||||
"capabilities" | "supportedProtocolVersions" | "versionNegotiation"
|
||||
> & {
|
||||
export type AppOptions = Omit<ProtocolOptions, "supportedProtocolVersions"> & {
|
||||
/**
|
||||
* Automatically report size changes to the host using `ResizeObserver`.
|
||||
*
|
||||
|
|
@ -221,7 +216,7 @@ export type AppOptions = Omit<
|
|||
allowUnsafeEval?: boolean;
|
||||
};
|
||||
|
||||
type RequestHandlerExtra = ClientContext;
|
||||
type RequestHandlerExtra = BaseContext;
|
||||
|
||||
/**
|
||||
* Result of an app-registered tool callback. When `Out` is provided,
|
||||
|
|
@ -354,7 +349,7 @@ export type AppEventMap = {
|
|||
* await app.connect();
|
||||
* ```
|
||||
*/
|
||||
export class App extends Client {
|
||||
export class App extends Protocol<BaseContext> {
|
||||
private _hostCapabilities?: McpUiHostCapabilities;
|
||||
private _hostInfo?: Implementation;
|
||||
private _hostContext?: McpUiHostContext;
|
||||
|
|
@ -521,20 +516,7 @@ export class App extends Client {
|
|||
private _appCapabilities: McpUiAppCapabilities = {},
|
||||
private options: AppOptions = { autoResize: true },
|
||||
) {
|
||||
// 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(_appInfo, {
|
||||
...options,
|
||||
capabilities: {},
|
||||
versionNegotiation: { mode: "legacy" },
|
||||
supportedProtocolVersions: [INNER_MCP_PROTOCOL_VERSION],
|
||||
});
|
||||
super(options);
|
||||
|
||||
if (!options.allowUnsafeEval) {
|
||||
z.config({ jitless: true });
|
||||
|
|
@ -550,6 +532,11 @@ export class App extends Client {
|
|||
this.setEventHandler("hostcontextchanged", undefined);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
protected buildContext(ctx: BaseContext): BaseContext {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private registerAppCapabilities(capabilities: McpUiAppCapabilities): void {
|
||||
if (this.transport) {
|
||||
throw new Error(
|
||||
|
|
@ -1992,15 +1979,10 @@ export class App extends Client {
|
|||
}
|
||||
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 {
|
||||
// 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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue