Skip to content

Commit 6fa5d4e

Browse files
authored
feat(app): warn when one-shot handlers are registered after connect() (#629)
Mirrors the outbound-side guard from #623: registering a toolinput / toolinputpartial / toolresult / toolcancelled handler after the ui/initialize handshake has completed now logs a console.warn (or throws under { strict: true }), since the host may have already fired the notification by then. hostcontextchanged is exempt — late listeners for repeating events are legitimate. Covers both the on* setters and addEventListener.
1 parent 9d68315 commit 6fa5d4e

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

src/app-bridge.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1965,6 +1965,99 @@ describe("App <-> AppBridge integration", () => {
19651965
errSpy.mockRestore();
19661966
});
19671967

1968+
describe("late handler registration", () => {
1969+
const lateMsg =
1970+
/handler registered after connect\(\) completed the ui\/initialize handshake/;
1971+
1972+
it("warns when ontoolresult is set after connect() resolves", async () => {
1973+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
1974+
await bridge.connect(bridgeTransport);
1975+
await app.connect(appTransport);
1976+
1977+
app.ontoolresult = () => {};
1978+
1979+
expect(warnSpy).toHaveBeenCalledTimes(1);
1980+
expect(warnSpy.mock.calls[0][0]).toMatch(lateMsg);
1981+
expect(warnSpy.mock.calls[0][0]).toContain('"toolresult"');
1982+
warnSpy.mockRestore();
1983+
});
1984+
1985+
it("warns when addEventListener('toolinput', …) is called after connect()", async () => {
1986+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
1987+
await bridge.connect(bridgeTransport);
1988+
await app.connect(appTransport);
1989+
1990+
app.addEventListener("toolinput", () => {});
1991+
1992+
expect(warnSpy).toHaveBeenCalledTimes(1);
1993+
expect(warnSpy.mock.calls[0][0]).toContain('"toolinput"');
1994+
warnSpy.mockRestore();
1995+
});
1996+
1997+
it("does not warn for handlers set before connect()", async () => {
1998+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
1999+
app.ontoolinput = () => {};
2000+
app.addEventListener("toolresult", () => {});
2001+
2002+
await bridge.connect(bridgeTransport);
2003+
await app.connect(appTransport);
2004+
2005+
expect(warnSpy).not.toHaveBeenCalled();
2006+
warnSpy.mockRestore();
2007+
});
2008+
2009+
it("does not warn for hostcontextchanged (repeating event)", async () => {
2010+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
2011+
await bridge.connect(bridgeTransport);
2012+
await app.connect(appTransport);
2013+
2014+
app.onhostcontextchanged = () => {};
2015+
app.addEventListener("hostcontextchanged", () => {});
2016+
2017+
expect(warnSpy).not.toHaveBeenCalled();
2018+
warnSpy.mockRestore();
2019+
});
2020+
2021+
it("does not warn when clearing a handler (set to undefined)", async () => {
2022+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
2023+
app.ontoolinput = () => {};
2024+
await bridge.connect(bridgeTransport);
2025+
await app.connect(appTransport);
2026+
2027+
app.ontoolinput = undefined;
2028+
2029+
expect(warnSpy).not.toHaveBeenCalled();
2030+
warnSpy.mockRestore();
2031+
});
2032+
2033+
it("throws instead of warning when strict: true", async () => {
2034+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
2035+
const [strictAppT, strictBridgeT] =
2036+
InMemoryTransport.createLinkedPair();
2037+
const strictBridge = new AppBridge(
2038+
createMockClient() as Client,
2039+
testHostInfo,
2040+
testHostCapabilities,
2041+
);
2042+
const strictApp = new App(
2043+
testAppInfo,
2044+
{},
2045+
{ autoResize: false, strict: true },
2046+
);
2047+
await strictBridge.connect(strictBridgeT);
2048+
await strictApp.connect(strictAppT);
2049+
2050+
expect(() => {
2051+
strictApp.ontoolresult = () => {};
2052+
}).toThrow(lateMsg);
2053+
expect(() => {
2054+
strictApp.addEventListener("toolinput", () => {});
2055+
}).toThrow(lateMsg);
2056+
expect(warnSpy).not.toHaveBeenCalled();
2057+
warnSpy.mockRestore();
2058+
});
2059+
});
2060+
19682061
it("AppBridge warns on tools/call from a View that skipped the handshake", async () => {
19692062
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
19702063
bridge.oncalltool = async () => ({ content: [] });

src/app.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,49 @@ export class App extends ProtocolWithEvents<
386386
hostcontextchanged: McpUiHostContextChangedNotificationSchema,
387387
};
388388

389+
/**
390+
* Events the host typically sends once, shortly after the handshake.
391+
* Registering a handler for one of these *after* {@link connect `connect`}
392+
* resolves risks missing the notification entirely.
393+
*/
394+
private static readonly ONE_SHOT_EVENTS: ReadonlySet<keyof AppEventMap> =
395+
new Set(["toolinput", "toolinputpartial", "toolresult", "toolcancelled"]);
396+
397+
/**
398+
* Warn (or throw under `strict`) when a one-shot event handler is registered
399+
* after the `ui/initialize` → `ui/notifications/initialized` handshake has
400+
* completed. The host may have already fired the notification by then.
401+
*
402+
* Mirrors {@link _assertInitialized `_assertInitialized`} (the outbound-side guard).
403+
*/
404+
private _assertHandlerTiming(event: keyof AppEventMap): void {
405+
if (!this._initializedSent || !App.ONE_SHOT_EVENTS.has(event)) return;
406+
const msg =
407+
`[ext-apps] "${String(event)}" handler registered after connect() ` +
408+
`completed the ui/initialize handshake. The host may have already sent ` +
409+
`this notification. Register handlers before calling app.connect().`;
410+
if (this.options?.strict) {
411+
throw new Error(msg);
412+
}
413+
console.warn(msg);
414+
}
415+
416+
protected override setEventHandler<K extends keyof AppEventMap>(
417+
event: K,
418+
handler: ((params: AppEventMap[K]) => void) | undefined,
419+
): void {
420+
if (handler) this._assertHandlerTiming(event);
421+
super.setEventHandler(event, handler);
422+
}
423+
424+
override addEventListener<K extends keyof AppEventMap>(
425+
event: K,
426+
handler: (params: AppEventMap[K]) => void,
427+
): void {
428+
this._assertHandlerTiming(event);
429+
super.addEventListener(event, handler);
430+
}
431+
389432
protected override onEventDispatch<K extends keyof AppEventMap>(
390433
event: K,
391434
params: AppEventMap[K],

0 commit comments

Comments
 (0)