feat(app,app-bridge): guard requests sent before handshake completes (#620) (#623)

* feat(app,app-bridge): guard requests sent before handshake completes

Calling host-bound methods before the ui/initialize →
ui/notifications/initialized handshake completes can race the handshake
on strict hosts and leave the iframe permanently hidden.

App (View side):
- All 8 host-bound methods (callServerTool, readServerResource,
  listServerResources, sendMessage, updateModelContext, openLink,
  downloadFile, requestDisplayMode) now check that connect() has sent
  the initialized notification.
- New AppOptions.strict (default false): console.error when false,
  throw when true. AppOptions is now exported.
- useApp() forwards strict to the App constructor.
- Flag resets on reconnect.

AppBridge (Host side):
- replaceRequestHandler is overridden to wrap every host-bound handler
  with a console.warn if the request arrives before
  ui/notifications/initialized. Never throws. ui/initialize and ping
  use setRequestHandler directly and are exempt; notifications are
  unaffected.
- Catches Views that hand-roll postMessage without the SDK.
- Flag resets on reconnect.

Refs anthropics/claude-ai-mcp#61, anthropics/claude-ai-mcp#149.

* fix(docs): drop AppOptions from intentionallyNotExported now that it is exported
This commit is contained in:
Olivier Chafik 2026-04-21 11:45:14 +01:00 • committed by GitHub
commit 94c94216d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 200 additions and 9 deletions

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js";
@ -780,6 +780,102 @@ describe("App <-> AppBridge integration", () => {
); );
}); });
describe("pre-handshake guard (claude-ai-mcp#149)", () => {
const guardMsg =
/called before connect\(\) completed the ui\/initialize handshake/;
it("callServerTool warns when called before connect() completes", async () => {
const errSpy = spyOn(console, "error").mockImplementation(() => {});
bridge.oncalltool = async () => ({ content: [] });
await bridge.connect(bridgeTransport);
const connecting = app.connect(appTransport);
// Handshake is in flight; _initializedSent is still false.
await app.callServerTool({ name: "t", arguments: {} }).catch(() => {});
expect(errSpy).toHaveBeenCalledTimes(1);
expect(errSpy.mock.calls[0][0]).toMatch(guardMsg);
expect(errSpy.mock.calls[0][0]).toContain("App.callServerTool()");
await connecting;
errSpy.mockRestore();
});
it("callServerTool does not warn after connect() resolves", async () => {
const errSpy = spyOn(console, "error").mockImplementation(() => {});
bridge.oncalltool = async () => ({ content: [] });
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
await app.callServerTool({ name: "t", arguments: {} });
expect(errSpy).not.toHaveBeenCalled();
errSpy.mockRestore();
});
it("sendMessage and readServerResource also warn before handshake", async () => {
const errSpy = spyOn(console, "error").mockImplementation(() => {});
await app.sendMessage({ role: "user", content: [] }).catch(() => {});
await app.readServerResource({ uri: "test://r" }).catch(() => {});
expect(errSpy).toHaveBeenCalledTimes(2);
expect(errSpy.mock.calls[0][0]).toContain("App.sendMessage()");
expect(errSpy.mock.calls[1][0]).toContain("App.readServerResource()");
errSpy.mockRestore();
});
it("throws instead of warning when strict: true", async () => {
const errSpy = spyOn(console, "error").mockImplementation(() => {});
const strictApp = new App(
testAppInfo,
{},
{ autoResize: false, strict: true },
);
await expect(
strictApp.callServerTool({ name: "t", arguments: {} }),
).rejects.toThrow(guardMsg);
expect(errSpy).not.toHaveBeenCalled();
errSpy.mockRestore();
});
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
bridge.oncalltool = async () => ({ content: [] });
await bridge.connect(bridgeTransport);
// Simulate a hand-rolled View (no SDK, no handshake) sending tools/call.
await appTransport.start();
appTransport.send({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "t", arguments: {} },
});
await flush();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain(
"received 'tools/call' before ui/notifications/initialized",
);
warnSpy.mockRestore();
});
it("AppBridge does not warn after initialized is received", async () => {
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
bridge.oncalltool = async () => ({ content: [] });
await bridge.connect(bridgeTransport);
await app.connect(appTransport);
await app.callServerTool({ name: "t", arguments: {} });
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
it("onlistresources setter registers handler for resources/list requests", async () => { it("onlistresources setter registers handler for resources/list requests", async () => {
const requestParams = {}; const requestParams = {};
const resources = [{ uri: "test://resource", name: "Test" }]; const resources = [{ uri: "test://resource", name: "Test" }];

View file

@ -39,6 +39,7 @@ import {
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { import {
Protocol,
ProtocolOptions, ProtocolOptions,
RequestOptions, RequestOptions,
} from "@modelcontextprotocol/sdk/shared/protocol.js"; } from "@modelcontextprotocol/sdk/shared/protocol.js";
@ -307,6 +308,37 @@ export class AppBridge extends ProtocolWithEvents<
private _appCapabilities?: McpUiAppCapabilities; private _appCapabilities?: McpUiAppCapabilities;
private _hostContext: McpUiHostContext = {}; private _hostContext: McpUiHostContext = {};
private _appInfo?: Implementation; private _appInfo?: Implementation;
private _initializedReceived = false;
/**
* 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.
*
* @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149}
*/
private _baseReplaceRequestHandler = this.replaceRequestHandler;
protected override replaceRequestHandler: Protocol<
AppRequest,
AppNotification,
AppResult
>["setRequestHandler"] = (schema, handler) => {
this._baseReplaceRequestHandler(schema, (request, extra) => {
if (!this._initializedReceived) {
console.warn(
`[ext-apps] AppBridge received '${request.method}' before ` +
`ui/notifications/initialized. The View is calling host ` +
`methods before completing the handshake; it should await ` +
`app.connect() first. See ` +
`https://github.com/anthropics/claude-ai-mcp/issues/149`,
);
}
return handler(request, extra);
});
};
protected readonly eventSchemas = { protected readonly eventSchemas = {
sizechange: McpUiSizeChangedNotificationSchema, sizechange: McpUiSizeChangedNotificationSchema,
@ -357,6 +389,10 @@ export class AppBridge extends ProtocolWithEvents<
) { ) {
super(options); super(options);
this.addEventListener("initialized", () => {
this._initializedReceived = true;
});
this._hostContext = options?.hostContext || {}; this._hostContext = options?.hostContext || {};
this.setRequestHandler(McpUiInitializeRequestSchema, (request) => this.setRequestHandler(McpUiInitializeRequestSchema, (request) =>
@ -1758,6 +1794,7 @@ export class AppBridge extends ProtocolWithEvents<
"AppBridge is already connected. Call close() before connecting again.", "AppBridge is already connected. Call close() before connecting again.",
); );
} }
this._initializedReceived = false;
if (this._client) { if (this._client) {
// When a client was passed to the constructor, automatically forward // When a client was passed to the constructor, automatically forward
// MCP requests/notifications between the view and the server // MCP requests/notifications between the view and the server

View file

@ -144,7 +144,7 @@ export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
* *
* @see `ProtocolOptions` from @modelcontextprotocol/sdk for inherited options * @see `ProtocolOptions` from @modelcontextprotocol/sdk for inherited options
*/ */
type AppOptions = ProtocolOptions & { export type AppOptions = ProtocolOptions & {
/** /**
* Automatically report size changes to the host using `ResizeObserver`. * Automatically report size changes to the host using `ResizeObserver`.
* *
@ -155,6 +155,19 @@ type AppOptions = ProtocolOptions & {
* @default true * @default true
*/ */
autoResize?: boolean; autoResize?: boolean;
/**
* Throw on detected misuse instead of logging a console error.
*
* Currently this affects calling host-bound methods (e.g.
* {@link App.callServerTool `callServerTool`}, {@link App.sendMessage `sendMessage`})
* before {@link App.connect `connect`} has completed the `ui/initialize`
* handshake. With `strict: false` (default) a `console.error` is emitted;
* with `strict: true` an `Error` is thrown.
*
* @remarks Throwing will become the default in a future release.
* @default false
*/
strict?: boolean;
}; };
type RequestHandlerExtra = Parameters< type RequestHandlerExtra = Parameters<
@ -244,6 +257,32 @@ export class App extends ProtocolWithEvents<
private _hostCapabilities?: McpUiHostCapabilities; private _hostCapabilities?: McpUiHostCapabilities;
private _hostInfo?: Implementation; private _hostInfo?: Implementation;
private _hostContext?: McpUiHostContext; private _hostContext?: McpUiHostContext;
private _initializedSent = false;
/**
* Warn if a host-bound method is called before {@link connect `connect`} has
* completed the `ui/initialize` → `ui/notifications/initialized` handshake.
*
* Calling these methods early can race the handshake on strict hosts and
* leave the iframe permanently hidden. See
* {@link https://github.com/anthropics/claude-ai-mcp/issues/61 claude-ai-mcp#61} /
* {@link https://github.com/anthropics/claude-ai-mcp/issues/149 #149}.
*
* @remarks This will become a thrown `Error` in a future minor release.
*/
private _assertInitialized(method: string): void {
if (this._initializedSent) return;
const msg =
`[ext-apps] App.${method}() called before connect() completed the ` +
`ui/initialize handshake. Await app.connect() before calling this ` +
`method, or move data loading to an ontoolresult handler. ` +
`See https://github.com/anthropics/claude-ai-mcp/issues/149`;
if (this.options?.strict) {
throw new Error(msg);
}
// TODO(next-minor): make `strict: true` the default.
console.error(`${msg}. This will throw in a future release.`);
}
protected readonly eventSchemas = { protected readonly eventSchemas = {
toolinput: McpUiToolInputNotificationSchema, toolinput: McpUiToolInputNotificationSchema,
@ -851,6 +890,7 @@ export class App extends ProtocolWithEvents<
params: CallToolRequest["params"], params: CallToolRequest["params"],
options?: RequestOptions, options?: RequestOptions,
): Promise<CallToolResult> { ): Promise<CallToolResult> {
this._assertInitialized("callServerTool");
if (typeof params === "string") { if (typeof params === "string") {
throw new Error( throw new Error(
`callServerTool() expects an object as its first argument, but received a string ("${params}"). ` + `callServerTool() expects an object as its first argument, but received a string ("${params}"). ` +
@ -915,6 +955,7 @@ export class App extends ProtocolWithEvents<
params: ReadResourceRequest["params"], params: ReadResourceRequest["params"],
options?: RequestOptions, options?: RequestOptions,
): Promise<ReadResourceResult> { ): Promise<ReadResourceResult> {
this._assertInitialized("readServerResource");
return await this.request( return await this.request(
{ method: "resources/read", params }, { method: "resources/read", params },
ReadResourceResultSchema, ReadResourceResultSchema,
@ -962,6 +1003,7 @@ export class App extends ProtocolWithEvents<
params?: ListResourcesRequest["params"], params?: ListResourcesRequest["params"],
options?: RequestOptions, options?: RequestOptions,
): Promise<ListResourcesResult> { ): Promise<ListResourcesResult> {
this._assertInitialized("listServerResources");
return await this.request( return await this.request(
{ method: "resources/list", params }, { method: "resources/list", params },
ListResourcesResultSchema, ListResourcesResultSchema,
@ -1020,6 +1062,7 @@ export class App extends ProtocolWithEvents<
* @see {@link McpUiMessageRequest `McpUiMessageRequest`} for request structure * @see {@link McpUiMessageRequest `McpUiMessageRequest`} for request structure
*/ */
sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) { sendMessage(params: McpUiMessageRequest["params"], options?: RequestOptions) {
this._assertInitialized("sendMessage");
return this.request( return this.request(
<McpUiMessageRequest>{ <McpUiMessageRequest>{
method: "ui/message", method: "ui/message",
@ -1113,6 +1156,7 @@ export class App extends ProtocolWithEvents<
params: McpUiUpdateModelContextRequest["params"], params: McpUiUpdateModelContextRequest["params"],
options?: RequestOptions, options?: RequestOptions,
) { ) {
this._assertInitialized("updateModelContext");
return this.request( return this.request(
<McpUiUpdateModelContextRequest>{ <McpUiUpdateModelContextRequest>{
method: "ui/update-model-context", method: "ui/update-model-context",
@ -1149,6 +1193,7 @@ export class App extends ProtocolWithEvents<
* @see {@link McpUiOpenLinkResult `McpUiOpenLinkResult`} for result structure * @see {@link McpUiOpenLinkResult `McpUiOpenLinkResult`} for result structure
*/ */
openLink(params: McpUiOpenLinkRequest["params"], options?: RequestOptions) { openLink(params: McpUiOpenLinkRequest["params"], options?: RequestOptions) {
this._assertInitialized("openLink");
return this.request( return this.request(
<McpUiOpenLinkRequest>{ <McpUiOpenLinkRequest>{
method: "ui/open-link", method: "ui/open-link",
@ -1229,6 +1274,7 @@ export class App extends ProtocolWithEvents<
params: McpUiDownloadFileRequest["params"], params: McpUiDownloadFileRequest["params"],
options?: RequestOptions, options?: RequestOptions,
) { ) {
this._assertInitialized("downloadFile");
return this.request( return this.request(
<McpUiDownloadFileRequest>{ <McpUiDownloadFileRequest>{
method: "ui/download-file", method: "ui/download-file",
@ -1313,6 +1359,7 @@ export class App extends ProtocolWithEvents<
params: McpUiRequestDisplayModeRequest["params"], params: McpUiRequestDisplayModeRequest["params"],
options?: RequestOptions, options?: RequestOptions,
) { ) {
this._assertInitialized("requestDisplayMode");
return this.request( return this.request(
<McpUiRequestDisplayModeRequest>{ <McpUiRequestDisplayModeRequest>{
method: "ui/request-display-mode", method: "ui/request-display-mode",
@ -1475,6 +1522,7 @@ export class App extends ProtocolWithEvents<
"App is already connected. Call close() before connecting again.", "App is already connected. Call close() before connecting again.",
); );
} }
this._initializedSent = false;
await super.connect(transport); await super.connect(transport);
try { try {
@ -1502,6 +1550,7 @@ export class App extends ProtocolWithEvents<
await this.notification(<McpUiInitializedNotification>{ await this.notification(<McpUiInitializedNotification>{
method: "ui/notifications/initialized", method: "ui/notifications/initialized",
}); });
this._initializedSent = true;
if (this.options?.autoResize) { if (this.options?.autoResize) {
this.setupSizeChangedNotifications(); this.setupSizeChangedNotifications();

View file

@ -1,20 +1,25 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Implementation } from "@modelcontextprotocol/sdk/types.js"; import { Implementation } from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client"; import { Client } from "@modelcontextprotocol/sdk/client";
import { App, McpUiAppCapabilities, PostMessageTransport } from "../app"; import {
App,
AppOptions,
McpUiAppCapabilities,
PostMessageTransport,
} from "../app";
export * from "../app"; export * from "../app";
/** /**
* Options for configuring the {@link useApp `useApp`} hook. * Options for configuring the {@link useApp `useApp`} hook.
* *
* Note: This interface does NOT expose {@link App `App`} options like `autoResize`. * The `strict` option is forwarded to the underlying {@link App `App`}
* The hook creates the `App` with default options (`autoResize: true`). If you * instance. For other {@link AppOptions `AppOptions`}, create the `App`
* need custom `App` options, create the `App` manually instead of using this hook. * manually instead of using this hook.
* *
* @see {@link useApp `useApp`} for the hook that uses these options * @see {@link useApp `useApp`} for the hook that uses these options
* @see {@link useAutoResize `useAutoResize`} for manual auto-resize control with custom `App` options * @see {@link useAutoResize `useAutoResize`} for manual auto-resize control with custom `App` options
*/ */
export interface UseAppOptions { export interface UseAppOptions extends Pick<AppOptions, "strict"> {
/** App identification (name and version) */ /** App identification (name and version) */
appInfo: Implementation; appInfo: Implementation;
/** /**
@ -121,6 +126,7 @@ export function useApp({
appInfo, appInfo,
capabilities, capabilities,
onAppCreated, onAppCreated,
strict,
}: UseAppOptions): AppState { }: UseAppOptions): AppState {
const [app, setApp] = useState<App | null>(null); const [app, setApp] = useState<App | null>(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@ -135,7 +141,10 @@ export function useApp({
window.parent, window.parent,
window.parent, window.parent,
); );
const app = new App(appInfo, capabilities); const app = new App(appInfo, capabilities, {
autoResize: true,
strict,
});
// Register handlers BEFORE connecting // Register handlers BEFORE connecting
onAppCreated?.(app); onAppCreated?.(app);

View file

@ -28,7 +28,7 @@ const config = {
], ],
excludePrivate: true, excludePrivate: true,
excludeInternal: false, excludeInternal: false,
intentionallyNotExported: ["AppOptions", "MethodSchema"], intentionallyNotExported: ["MethodSchema"],
blockTags: [...OptionDefaults.blockTags, "@description"], blockTags: [...OptionDefaults.blockTags, "@description"],
jsDocCompatibility: { jsDocCompatibility: {
exampleTag: false, exampleTag: false,