feat: store and expose hostContext in App class (#139)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik 2025-12-12 00:39:58 +01:00 • committed by GitHub
commit eaea3f9e07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 202 additions and 1 deletions

View file

@ -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 () => {

View file

@ -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<Request, Notification, Result> {
private _hostCapabilities?: McpUiHostCapabilities;
private _hostInfo?: Implementation;
private _hostContext?: McpUiHostContext;
/**
* Create a new MCP App instance.
@ -219,6 +221,10 @@ export class App extends Protocol<Request, Notification, Result> {
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<Request, Notification, Result> {
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<Request, Notification, Result> {
) {
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<Request, Notification, Result> {
this._hostCapabilities = result.hostCapabilities;
this._hostInfo = result.hostInfo;
this._hostContext = result.hostContext;
await this.notification(<McpUiInitializedNotification>{
method: "ui/notifications/initialized",