diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 43db7b23..f37142ea 100644 --- a/src/app-bridge.test.ts +++ b/src/app-bridge.test.ts @@ -91,6 +91,38 @@ describe("App <-> AppBridge integration", () => { const appCaps = bridge.getAppCapabilities(); expect(appCaps).toEqual(appCapabilities); }); + + it("App receives initial hostContext after connect", async () => { + // Need fresh transports for new bridge + const [newAppTransport, newBridgeTransport] = + InMemoryTransport.createLinkedPair(); + + const testHostContext = { + theme: "dark" as const, + locale: "en-US", + viewport: { width: 800, height: 600 }, + }; + const newBridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + { hostContext: testHostContext }, + ); + const newApp = new App(testAppInfo, {}, { autoResize: false }); + + await newBridge.connect(newBridgeTransport); + await newApp.connect(newAppTransport); + + const hostContext = newApp.getHostContext(); + expect(hostContext).toEqual(testHostContext); + + await newAppTransport.close(); + await newBridgeTransport.close(); + }); + + it("getHostContext returns undefined before connect", () => { + expect(app.getHostContext()).toBeUndefined(); + }); }); describe("Host -> App notifications", () => { @@ -204,6 +236,128 @@ describe("App <-> AppBridge integration", () => { ]); }); + it("getHostContext merges updates from onhostcontextchanged", async () => { + // Need fresh transports for new bridge + const [newAppTransport, newBridgeTransport] = + InMemoryTransport.createLinkedPair(); + + // Set up bridge with initial context + const initialContext = { + theme: "light" as const, + locale: "en-US", + }; + const newBridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + { hostContext: initialContext }, + ); + const newApp = new App(testAppInfo, {}, { autoResize: false }); + + await newBridge.connect(newBridgeTransport); + + // Set up handler before connecting app + newApp.onhostcontextchanged = () => { + // User handler (can be empty, we're testing getHostContext behavior) + }; + + await newApp.connect(newAppTransport); + + // Verify initial context + expect(newApp.getHostContext()).toEqual(initialContext); + + // Update context + newBridge.setHostContext({ theme: "dark", locale: "en-US" }); + await flush(); + + // getHostContext should reflect merged state + const updatedContext = newApp.getHostContext(); + expect(updatedContext?.theme).toBe("dark"); + expect(updatedContext?.locale).toBe("en-US"); + + await newAppTransport.close(); + await newBridgeTransport.close(); + }); + + it("getHostContext updates even without user setting onhostcontextchanged", async () => { + // Need fresh transports for new bridge + const [newAppTransport, newBridgeTransport] = + InMemoryTransport.createLinkedPair(); + + // Set up bridge with initial context + const initialContext = { + theme: "light" as const, + locale: "en-US", + }; + const newBridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + { hostContext: initialContext }, + ); + const newApp = new App(testAppInfo, {}, { autoResize: false }); + + await newBridge.connect(newBridgeTransport); + // Note: We do NOT set app.onhostcontextchanged here + await newApp.connect(newAppTransport); + + // Verify initial context + expect(newApp.getHostContext()).toEqual(initialContext); + + // Update context from bridge + newBridge.setHostContext({ theme: "dark", locale: "en-US" }); + await flush(); + + // getHostContext should still update (default handler should work) + const updatedContext = newApp.getHostContext(); + expect(updatedContext?.theme).toBe("dark"); + + await newAppTransport.close(); + await newBridgeTransport.close(); + }); + + it("getHostContext accumulates multiple partial updates", async () => { + // Need fresh transports for new bridge + const [newAppTransport, newBridgeTransport] = + InMemoryTransport.createLinkedPair(); + + const initialContext = { + theme: "light" as const, + locale: "en-US", + viewport: { width: 800, height: 600 }, + }; + const newBridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + { hostContext: initialContext }, + ); + const newApp = new App(testAppInfo, {}, { autoResize: false }); + + await newBridge.connect(newBridgeTransport); + await newApp.connect(newAppTransport); + + // Send partial update: only theme changes + newBridge.sendHostContextChange({ theme: "dark" }); + await flush(); + + // Send another partial update: only viewport changes + newBridge.sendHostContextChange({ viewport: { width: 1024, height: 768 } }); + await flush(); + + // getHostContext should have accumulated all updates: + // - locale from initial (unchanged) + // - theme from first partial update + // - viewport from second partial update + const context = newApp.getHostContext(); + expect(context?.theme).toBe("dark"); + expect(context?.locale).toBe("en-US"); + expect(context?.viewport).toEqual({ width: 1024, height: 768 }); + + await newAppTransport.close(); + await newBridgeTransport.close(); + }); + it("sendResourceTeardown triggers app.onteardown", async () => { let teardownCalled = false; app.onteardown = async () => { diff --git a/src/app.ts b/src/app.ts index 15261136..51189b97 100644 --- a/src/app.ts +++ b/src/app.ts @@ -22,6 +22,7 @@ import { LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, McpUiHostCapabilities, + McpUiHostContext, McpUiHostContextChangedNotification, McpUiHostContextChangedNotificationSchema, McpUiInitializedNotification, @@ -191,6 +192,7 @@ type RequestHandlerExtra = Parameters< export class App extends Protocol { private _hostCapabilities?: McpUiHostCapabilities; private _hostInfo?: Implementation; + private _hostContext?: McpUiHostContext; /** * Create a new MCP App instance. @@ -219,6 +221,10 @@ export class App extends Protocol { console.log("Received ping:", request.params); return {}; }); + + // Set up default handler to update _hostContext when notifications arrive. + // Users can override this by setting onhostcontextchanged. + this.onhostcontextchanged = () => {}; } /** @@ -276,6 +282,42 @@ export class App extends Protocol { return this._hostInfo; } + /** + * Get the host context discovered during initialization. + * + * Returns the host context that was provided in the initialization response, + * including tool info, theme, viewport, locale, and other environment details. + * This context is automatically updated when the host sends + * `ui/notifications/host-context-changed` notifications. + * + * Returns `undefined` if called before connection is established. + * + * @returns Host context, or `undefined` if not yet connected + * + * @example Access host context after connection + * ```typescript + * await app.connect(transport); + * const context = app.getHostContext(); + * if (context === undefined) { + * console.error("Not connected"); + * return; + * } + * if (context.theme === "dark") { + * document.body.classList.add("dark-theme"); + * } + * if (context.toolInfo) { + * console.log("Tool:", context.toolInfo.tool.name); + * } + * ``` + * + * @see {@link connect} for the initialization handshake + * @see {@link onhostcontextchanged} for context change notifications + * @see {@link McpUiHostContext} for the context structure + */ + getHostContext(): McpUiHostContext | undefined { + return this._hostContext; + } + /** * Convenience handler for receiving complete tool input from the host. * @@ -463,7 +505,11 @@ export class App extends Protocol { ) { this.setNotificationHandler( McpUiHostContextChangedNotificationSchema, - (n) => callback(n.params), + (n) => { + // Merge the partial update into the stored context + this._hostContext = { ...this._hostContext, ...n.params }; + callback(n.params); + }, ); } @@ -961,6 +1007,7 @@ export class App extends Protocol { this._hostCapabilities = result.hostCapabilities; this._hostInfo = result.hostInfo; + this._hostContext = result.hostContext; await this.notification({ method: "ui/notifications/initialized",