Skip to content

Commit 1a95181

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 1a95181

File tree

2 files changed

+203
-1
lines changed

2 files changed

+203
-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: 48 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,10 @@ export class App extends Protocol<Request, Notification, Result> {
219221
console.log("Received ping:", request.params);
220222
return {};
221223
});
224+
225+
// Set up default handler to update _hostContext when notifications arrive.
226+
// Users can override this by setting onhostcontextchanged.
227+
this.onhostcontextchanged = () => {};
222228
}
223229

224230
/**
@@ -276,6 +282,42 @@ export class App extends Protocol<Request, Notification, Result> {
276282
return this._hostInfo;
277283
}
278284

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

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

9621008
this._hostCapabilities = result.hostCapabilities;
9631009
this._hostInfo = result.hostInfo;
1010+
this._hostContext = result.hostContext;
9641011

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

0 commit comments

Comments
 (0)