Skip to content

Commit a402e6b

Browse files
committed
feat(app-bridge): warn when View sends requests before initialized
Host-side complement to the App-side guard: AppBridge now console.warns when it receives any request (tools/call, ui/message, resources/read, etc.) before the View has sent ui/notifications/initialized. Unlike the App side this never throws — throwing here would cause the very teardown→hidden-iframe symptom we're diagnosing. The warn surfaces in the host's DevTools (where developers debugging "iframe stays hidden" look first) and catches hand-rolled Views that don't use the SDK. Implemented by overriding replaceRequestHandler to wrap every host-bound handler in one place. ui/initialize and ping use setRequestHandler directly and remain exempt; notifications are unaffected. Refs anthropics/claude-ai-mcp#61, anthropics/claude-ai-mcp#149.
1 parent 18164de commit a402e6b

2 files changed

Lines changed: 71 additions & 0 deletions

File tree

src/app-bridge.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,40 @@ describe("App <-> AppBridge integration", () => {
840840

841841
errSpy.mockRestore();
842842
});
843+
844+
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {
845+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
846+
bridge.oncalltool = async () => ({ content: [] });
847+
await bridge.connect(bridgeTransport);
848+
849+
// Simulate a hand-rolled View (no SDK, no handshake) sending tools/call.
850+
await appTransport.start();
851+
appTransport.send({
852+
jsonrpc: "2.0",
853+
id: 1,
854+
method: "tools/call",
855+
params: { name: "t", arguments: {} },
856+
});
857+
await flush();
858+
859+
expect(warnSpy).toHaveBeenCalledTimes(1);
860+
expect(warnSpy.mock.calls[0][0]).toContain(
861+
"received 'tools/call' before ui/notifications/initialized",
862+
);
863+
warnSpy.mockRestore();
864+
});
865+
866+
it("AppBridge does not warn after initialized is received", async () => {
867+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
868+
bridge.oncalltool = async () => ({ content: [] });
869+
await bridge.connect(bridgeTransport);
870+
await app.connect(appTransport);
871+
872+
await app.callServerTool({ name: "t", arguments: {} });
873+
874+
expect(warnSpy).not.toHaveBeenCalled();
875+
warnSpy.mockRestore();
876+
});
843877
});
844878

845879
it("onlistresources setter registers handler for resources/list requests", async () => {

src/app-bridge.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
ToolListChangedNotificationSchema,
4040
} from "@modelcontextprotocol/sdk/types.js";
4141
import {
42+
Protocol,
4243
ProtocolOptions,
4344
RequestOptions,
4445
} from "@modelcontextprotocol/sdk/shared/protocol.js";
@@ -307,6 +308,37 @@ export class AppBridge extends ProtocolWithEvents<
307308
private _appCapabilities?: McpUiAppCapabilities;
308309
private _hostContext: McpUiHostContext = {};
309310
private _appInfo?: Implementation;
311+
private _initializedReceived = false;
312+
313+
/**
314+
* Wrap every handler registered via `replaceRequestHandler` with a check
315+
* that the View has sent `ui/notifications/initialized`. Warns (never
316+
* throws) so lenient hosts keep working while still surfacing the
317+
* misordering that leaves strict hosts with a permanently hidden iframe.
318+
* `ui/initialize` and `ping` use `setRequestHandler` directly and are
319+
* intentionally exempt.
320+
*
321+
* @see {@link https://github.com/anthropics/claude-ai-mcp/issues/149 claude-ai-mcp#149}
322+
*/
323+
private _baseReplaceRequestHandler = this.replaceRequestHandler;
324+
protected override replaceRequestHandler: Protocol<
325+
AppRequest,
326+
AppNotification,
327+
AppResult
328+
>["setRequestHandler"] = (schema, handler) => {
329+
this._baseReplaceRequestHandler(schema, (request, extra) => {
330+
if (!this._initializedReceived) {
331+
console.warn(
332+
`[ext-apps] AppBridge received '${request.method}' before ` +
333+
`ui/notifications/initialized. The View is calling host ` +
334+
`methods before completing the handshake; it should await ` +
335+
`app.connect() first. See ` +
336+
`https://github.com/anthropics/claude-ai-mcp/issues/149`,
337+
);
338+
}
339+
return handler(request, extra);
340+
});
341+
};
310342

311343
protected readonly eventSchemas = {
312344
sizechange: McpUiSizeChangedNotificationSchema,
@@ -357,6 +389,10 @@ export class AppBridge extends ProtocolWithEvents<
357389
) {
358390
super(options);
359391

392+
this.addEventListener("initialized", () => {
393+
this._initializedReceived = true;
394+
});
395+
360396
this._hostContext = options?.hostContext || {};
361397

362398
this.setRequestHandler(McpUiInitializeRequestSchema, (request) =>
@@ -1758,6 +1794,7 @@ export class AppBridge extends ProtocolWithEvents<
17581794
"AppBridge is already connected. Call close() before connecting again.",
17591795
);
17601796
}
1797+
this._initializedReceived = false;
17611798
if (this._client) {
17621799
// When a client was passed to the constructor, automatically forward
17631800
// MCP requests/notifications between the view and the server

0 commit comments

Comments
 (0)