From eaea3f9e074b3d989f89cd222f6be380b21cff0c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Fri, 12 Dec 2025 00:39:58 +0100 Subject: [PATCH] feat: store and expose hostContext in App class (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: store and expose hostContext in App class Fixes #129 - The App class now stores the initial hostContext received during initialization and exposes it via getHostContext(). Changes: - Add _hostContext private property to App class - Store result.hostContext in connect() method - Add getHostContext() getter method (parallel to getHostCapabilities()) - Update onhostcontextchanged setter to merge partial updates into stored context - Add default notification handler to update context even when user hasn't set onhostcontextchanged This enables apps to: - Access toolInfo immediately after connection (only in initial context) - Render with correct theme/locale without waiting for first notification - Synchronously access viewport, timezone, and other initial state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: use sendHostContextChange for partial update accumulation test Address PR review feedback: The test was using setHostContext with full context spreads which didn't clearly demonstrate partial update accumulation. Now uses sendHostContextChange directly to send truly partial updates (only theme, then only viewport), making it clear the App correctly merges multiple partial notifications. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/app-bridge.test.ts | 154 +++++++++++++++++++++++++++++++++++++++++ src/app.ts | 49 ++++++++++++- 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts index 43db7b2..f37142e 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 1526113..51189b9 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",