Skip to content

Commit 36aa8a8

Browse files
ochafikclaude
andcommitted
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 <[email protected]>
1 parent 736da14 commit 36aa8a8

File tree

2 files changed

+208
-1
lines changed

2 files changed

+208
-1
lines changed

src/app-bridge.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,38 @@ describe("App <-> AppBridge integration", () => {
9191
const appCaps = bridge.getAppCapabilities();
9292
expect(appCaps).toEqual(appCapabilities);
9393
});
94+
95+
it("App receives initial hostContext after connect", async () => {
96+
// Need fresh transports for new bridge
97+
const [newAppTransport, newBridgeTransport] =
98+
InMemoryTransport.createLinkedPair();
99+
100+
const testHostContext = {
101+
theme: "dark" as const,
102+
locale: "en-US",
103+
viewport: { width: 800, height: 600 },
104+
};
105+
const newBridge = new AppBridge(
106+
createMockClient() as Client,
107+
testHostInfo,
108+
testHostCapabilities,
109+
{ hostContext: testHostContext },
110+
);
111+
const newApp = new App(testAppInfo, {}, { autoResize: false });
112+
113+
await newBridge.connect(newBridgeTransport);
114+
await newApp.connect(newAppTransport);
115+
116+
const hostContext = newApp.getHostContext();
117+
expect(hostContext).toEqual(testHostContext);
118+
119+
await newAppTransport.close();
120+
await newBridgeTransport.close();
121+
});
122+
123+
it("getHostContext returns undefined before connect", () => {
124+
expect(app.getHostContext()).toBeUndefined();
125+
});
94126
});
95127

96128
describe("Host -> App notifications", () => {
@@ -204,6 +236,129 @@ describe("App <-> AppBridge integration", () => {
204236
]);
205237
});
206238

239+
it("getHostContext merges updates from onhostcontextchanged", async () => {
240+
// Need fresh transports for new bridge
241+
const [newAppTransport, newBridgeTransport] =
242+
InMemoryTransport.createLinkedPair();
243+
244+
// Set up bridge with initial context
245+
const initialContext = {
246+
theme: "light" as const,
247+
locale: "en-US",
248+
};
249+
const newBridge = new AppBridge(
250+
createMockClient() as Client,
251+
testHostInfo,
252+
testHostCapabilities,
253+
{ hostContext: initialContext },
254+
);
255+
const newApp = new App(testAppInfo, {}, { autoResize: false });
256+
257+
await newBridge.connect(newBridgeTransport);
258+
259+
// Set up handler before connecting app
260+
newApp.onhostcontextchanged = () => {
261+
// User handler (can be empty, we're testing getHostContext behavior)
262+
};
263+
264+
await newApp.connect(newAppTransport);
265+
266+
// Verify initial context
267+
expect(newApp.getHostContext()).toEqual(initialContext);
268+
269+
// Update context
270+
newBridge.setHostContext({ theme: "dark", locale: "en-US" });
271+
await flush();
272+
273+
// getHostContext should reflect merged state
274+
const updatedContext = newApp.getHostContext();
275+
expect(updatedContext?.theme).toBe("dark");
276+
expect(updatedContext?.locale).toBe("en-US");
277+
278+
await newAppTransport.close();
279+
await newBridgeTransport.close();
280+
});
281+
282+
it("getHostContext updates even without user setting onhostcontextchanged", async () => {
283+
// Need fresh transports for new bridge
284+
const [newAppTransport, newBridgeTransport] =
285+
InMemoryTransport.createLinkedPair();
286+
287+
// Set up bridge with initial context
288+
const initialContext = {
289+
theme: "light" as const,
290+
locale: "en-US",
291+
};
292+
const newBridge = new AppBridge(
293+
createMockClient() as Client,
294+
testHostInfo,
295+
testHostCapabilities,
296+
{ hostContext: initialContext },
297+
);
298+
const newApp = new App(testAppInfo, {}, { autoResize: false });
299+
300+
await newBridge.connect(newBridgeTransport);
301+
// Note: We do NOT set app.onhostcontextchanged here
302+
await newApp.connect(newAppTransport);
303+
304+
// Verify initial context
305+
expect(newApp.getHostContext()).toEqual(initialContext);
306+
307+
// Update context from bridge
308+
newBridge.setHostContext({ theme: "dark", locale: "en-US" });
309+
await flush();
310+
311+
// getHostContext should still update (default handler should work)
312+
const updatedContext = newApp.getHostContext();
313+
expect(updatedContext?.theme).toBe("dark");
314+
315+
await newAppTransport.close();
316+
await newBridgeTransport.close();
317+
});
318+
319+
it("getHostContext accumulates multiple partial updates", async () => {
320+
// Need fresh transports for new bridge
321+
const [newAppTransport, newBridgeTransport] =
322+
InMemoryTransport.createLinkedPair();
323+
324+
const initialContext = {
325+
theme: "light" as const,
326+
locale: "en-US",
327+
viewport: { width: 800, height: 600 },
328+
};
329+
const newBridge = new AppBridge(
330+
createMockClient() as Client,
331+
testHostInfo,
332+
testHostCapabilities,
333+
{ hostContext: initialContext },
334+
);
335+
const newApp = new App(testAppInfo, {}, { autoResize: false });
336+
337+
await newBridge.connect(newBridgeTransport);
338+
await newApp.connect(newAppTransport);
339+
340+
// Update only theme
341+
newBridge.setHostContext({ ...initialContext, theme: "dark" });
342+
await flush();
343+
344+
// Update viewport
345+
newBridge.setHostContext({
346+
theme: "dark",
347+
locale: "en-US",
348+
viewport: { width: 1024, height: 768 },
349+
});
350+
await flush();
351+
352+
// getHostContext should have all updates merged
353+
const context = newApp.getHostContext();
354+
expect(context?.theme).toBe("dark");
355+
expect(context?.locale).toBe("en-US");
356+
expect(context?.viewport).toEqual({ width: 1024, height: 768 });
357+
358+
await newAppTransport.close();
359+
await newBridgeTransport.close();
360+
});
361+
207362
it("sendResourceTeardown triggers app.onteardown", async () => {
208363
let teardownCalled = false;
209364
app.onteardown = async () => {

src/app.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
LATEST_PROTOCOL_VERSION,
2323
McpUiAppCapabilities,
2424
McpUiHostCapabilities,
25+
McpUiHostContext,
2526
McpUiHostContextChangedNotification,
2627
McpUiHostContextChangedNotificationSchema,
2728
McpUiInitializedNotification,
@@ -191,6 +192,7 @@ type RequestHandlerExtra = Parameters<
191192
export class App extends Protocol<Request, Notification, Result> {
192193
private _hostCapabilities?: McpUiHostCapabilities;
193194
private _hostInfo?: Implementation;
195+
private _hostContext?: McpUiHostContext;
194196

195197
/**
196198
* Create a new MCP App instance.
@@ -219,6 +221,15 @@ export class App extends Protocol<Request, Notification, Result> {
219221
console.log("Received ping:", request.params);
220222
return {};
221223
});
224+
225+
// Default handler to update _hostContext when notifications arrive.
226+
// This will be overridden if the user sets onhostcontextchanged.
227+
this.setNotificationHandler(
228+
McpUiHostContextChangedNotificationSchema,
229+
(n) => {
230+
this._hostContext = { ...this._hostContext, ...n.params };
231+
},
232+
);
222233
}
223234

224235
/**
@@ -276,6 +287,42 @@ export class App extends Protocol<Request, Notification, Result> {
276287
return this._hostInfo;
277288
}
278289

290+
/**
291+
* Get the host context discovered during initialization.
292+
*
293+
* Returns the host context that was provided in the initialization response,
294+
* including tool info, theme, viewport, locale, and other environment details.
295+
* This context is automatically updated when the host sends
296+
* `ui/notifications/host-context-changed` notifications.
297+
*
298+
* Returns `undefined` if called before connection is established.
299+
*
300+
* @returns Host context, or `undefined` if not yet connected
301+
*
302+
* @example Access host context after connection
303+
* ```typescript
304+
* await app.connect(transport);
305+
* const context = app.getHostContext();
306+
* if (context === undefined) {
307+
* console.error("Not connected");
308+
* return;
309+
* }
310+
* if (context.theme === "dark") {
311+
* document.body.classList.add("dark-theme");
312+
* }
313+
* if (context.toolInfo) {
314+
* console.log("Tool:", context.toolInfo.tool.name);
315+
* }
316+
* ```
317+
*
318+
* @see {@link connect} for the initialization handshake
319+
* @see {@link onhostcontextchanged} for context change notifications
320+
* @see {@link McpUiHostContext} for the context structure
321+
*/
322+
getHostContext(): McpUiHostContext | undefined {
323+
return this._hostContext;
324+
}
325+
279326
/**
280327
* Convenience handler for receiving complete tool input from the host.
281328
*
@@ -463,7 +510,11 @@ export class App extends Protocol<Request, Notification, Result> {
463510
) {
464511
this.setNotificationHandler(
465512
McpUiHostContextChangedNotificationSchema,
466-
(n) => callback(n.params),
513+
(n) => {
514+
// Merge the partial update into the stored context
515+
this._hostContext = { ...this._hostContext, ...n.params };
516+
callback(n.params);
517+
},
467518
);
468519
}
469520

@@ -961,6 +1012,7 @@ export class App extends Protocol<Request, Notification, Result> {
9611012

9621013
this._hostCapabilities = result.hostCapabilities;
9631014
this._hostInfo = result.hostInfo;
1015+
this._hostContext = result.hostContext;
9641016

9651017
await this.notification(<McpUiInitializedNotification>{
9661018
method: "ui/notifications/initialized",

0 commit comments

Comments
 (0)