Skip to content

Commit eaea3f9

Browse files
ochafikclaude
andauthored
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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent 736da14 commit eaea3f9

File tree

2 files changed

+202
-1
lines changed

2 files changed

+202
-1
lines changed

src/app-bridge.test.ts

Lines changed: 154 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,128 @@ 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+
// Send partial update: only theme changes
341+
newBridge.sendHostContextChange({ theme: "dark" });
342+
await flush();
343+
344+
// Send another partial update: only viewport changes
345+
newBridge.sendHostContextChange({ viewport: { width: 1024, height: 768 } });
346+
await flush();
347+
348+
// getHostContext should have accumulated all updates:
349+
// - locale from initial (unchanged)
350+
// - theme from first partial update
351+
// - viewport from second partial update
352+
const context = newApp.getHostContext();
353+
expect(context?.theme).toBe("dark");
354+
expect(context?.locale).toBe("en-US");
355+
expect(context?.viewport).toEqual({ width: 1024, height: 768 });
356+
357+
await newAppTransport.close();
358+
await newBridgeTransport.close();
359+
});
360+
207361
it("sendResourceTeardown triggers app.onteardown", async () => {
208362
let teardownCalled = false;
209363
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)