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:
parent
736da14e38
commit
eaea3f9e07
2 changed files with 202 additions and 1 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
49
src/app.ts
49
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<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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue