Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions src/app-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
49 changes: 48 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
LATEST_PROTOCOL_VERSION,
McpUiAppCapabilities,
McpUiHostCapabilities,
McpUiHostContext,
McpUiHostContextChangedNotification,
McpUiHostContextChangedNotificationSchema,
McpUiInitializedNotification,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = () => {};
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
},
);
}

Expand Down Expand Up @@ -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",
Expand Down
Loading