diff --git a/package.json b/package.json index a97b1221..c0a4986e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "start:example-mcp-server": "cd examples/simple-server && npm start", "start": "NODE_ENV=development npm run build && concurrently 'npm run start:example-host' 'npm run start:example-mcp-server'", "build": "bun build.bun.ts", + "test": "bun test", "prepare": "npm run build && husky", "docs": "typedoc", "docs:watch": "typedoc --watch", @@ -39,6 +40,7 @@ }, "author": "Olivier Chafik", "devDependencies": { + "@types/bun": "^1.3.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "concurrently": "^9.2.1", diff --git a/src/app-bridge.test.ts b/src/app-bridge.test.ts new file mode 100644 index 00000000..b2f943b5 --- /dev/null +++ b/src/app-bridge.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; +import { EmptyResultSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { App } from "./app"; +import { AppBridge, type McpUiHostCapabilities } from "./app-bridge"; + +/** Wait for pending microtasks to complete */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** + * Create a minimal mock MCP client for testing AppBridge. + * Only implements methods that AppBridge calls. + */ +function createMockClient( + serverCapabilities: ServerCapabilities = {}, +): Pick { + return { + getServerCapabilities: () => serverCapabilities, + request: async () => ({}) as never, + notification: async () => {}, + }; +} + +const testHostInfo = { name: "TestHost", version: "1.0.0" }; +const testAppInfo = { name: "TestApp", version: "1.0.0" }; +const testHostCapabilities: McpUiHostCapabilities = { + openLinks: {}, + serverTools: {}, + logging: {}, +}; + +describe("App <-> AppBridge integration", () => { + let app: App; + let bridge: AppBridge; + let appTransport: InMemoryTransport; + let bridgeTransport: InMemoryTransport; + + beforeEach(() => { + [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); + app = new App(testAppInfo, {}, { autoResize: false }); + bridge = new AppBridge( + createMockClient() as Client, + testHostInfo, + testHostCapabilities, + ); + }); + + afterEach(async () => { + await appTransport.close(); + await bridgeTransport.close(); + }); + + describe("initialization handshake", () => { + it("App.connect() triggers bridge.oninitialized", async () => { + let initializedFired = false; + + bridge.oninitialized = () => { + initializedFired = true; + }; + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + expect(initializedFired).toBe(true); + }); + + it("App receives host info and capabilities after connect", async () => { + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + const hostInfo = app.getHostVersion(); + expect(hostInfo).toEqual(testHostInfo); + + const hostCaps = app.getHostCapabilities(); + expect(hostCaps).toEqual(testHostCapabilities); + }); + + it("Bridge receives app info and capabilities after initialization", async () => { + const appCapabilities = { tools: { listChanged: true } }; + app = new App(testAppInfo, appCapabilities, { autoResize: false }); + + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + const appInfo = bridge.getAppVersion(); + expect(appInfo).toEqual(testAppInfo); + + const appCaps = bridge.getAppCapabilities(); + expect(appCaps).toEqual(appCapabilities); + }); + }); + + describe("Host -> App notifications", () => { + beforeEach(async () => { + await bridge.connect(bridgeTransport); + }); + + it("sendToolInput triggers app.ontoolinput", async () => { + const receivedArgs: unknown[] = []; + app.ontoolinput = (params) => { + receivedArgs.push(params.arguments); + }; + + await app.connect(appTransport); + await bridge.sendToolInput({ arguments: { location: "NYC" } }); + + expect(receivedArgs).toEqual([{ location: "NYC" }]); + }); + + it("sendToolInputPartial triggers app.ontoolinputpartial", async () => { + const receivedArgs: unknown[] = []; + app.ontoolinputpartial = (params) => { + receivedArgs.push(params.arguments); + }; + + await app.connect(appTransport); + await bridge.sendToolInputPartial({ arguments: { loc: "N" } }); + await bridge.sendToolInputPartial({ arguments: { location: "NYC" } }); + + expect(receivedArgs).toEqual([{ loc: "N" }, { location: "NYC" }]); + }); + + it("sendToolResult triggers app.ontoolresult", async () => { + const receivedResults: unknown[] = []; + app.ontoolresult = (params) => { + receivedResults.push(params); + }; + + await app.connect(appTransport); + await bridge.sendToolResult({ + content: [{ type: "text", text: "Weather: Sunny" }], + }); + + expect(receivedResults).toHaveLength(1); + expect(receivedResults[0]).toEqual({ + content: [{ type: "text", text: "Weather: Sunny" }], + }); + }); + + it("setHostContext triggers app.onhostcontextchanged", async () => { + const receivedContexts: unknown[] = []; + app.onhostcontextchanged = (params) => { + receivedContexts.push(params); + }; + + await app.connect(appTransport); + bridge.setHostContext({ theme: "dark" }); + await flush(); + + expect(receivedContexts).toEqual([{ theme: "dark" }]); + }); + + it("setHostContext only sends changed values", async () => { + const receivedContexts: unknown[] = []; + app.onhostcontextchanged = (params) => { + receivedContexts.push(params); + }; + + await app.connect(appTransport); + + bridge.setHostContext({ theme: "dark", locale: "en-US" }); + await flush(); + bridge.setHostContext({ theme: "dark", locale: "en-US" }); // No change + await flush(); + bridge.setHostContext({ theme: "light", locale: "en-US" }); // Only theme changed + await flush(); + + expect(receivedContexts).toEqual([ + { theme: "dark", locale: "en-US" }, + { theme: "light" }, + ]); + }); + }); + + describe("App -> Host notifications", () => { + beforeEach(async () => { + await bridge.connect(bridgeTransport); + }); + + it("app.sendSizeChange triggers bridge.onsizechange", async () => { + const receivedSizes: unknown[] = []; + bridge.onsizechange = (params) => { + receivedSizes.push(params); + }; + + await app.connect(appTransport); + await app.sendSizeChange({ width: 400, height: 600 }); + + expect(receivedSizes).toEqual([{ width: 400, height: 600 }]); + }); + + it("app.sendLog triggers bridge.onloggingmessage", async () => { + const receivedLogs: unknown[] = []; + bridge.onloggingmessage = (params) => { + receivedLogs.push(params); + }; + + await app.connect(appTransport); + await app.sendLog({ + level: "info", + data: "Test log message", + logger: "TestApp", + }); + + expect(receivedLogs).toHaveLength(1); + expect(receivedLogs[0]).toMatchObject({ + level: "info", + data: "Test log message", + logger: "TestApp", + }); + }); + }); + + describe("App -> Host requests", () => { + beforeEach(async () => { + await bridge.connect(bridgeTransport); + }); + + it("app.sendMessage triggers bridge.onmessage and returns result", async () => { + const receivedMessages: unknown[] = []; + bridge.onmessage = async (params) => { + receivedMessages.push(params); + return {}; + }; + + await app.connect(appTransport); + const result = await app.sendMessage({ + role: "user", + content: [{ type: "text", text: "Hello from app" }], + }); + + expect(receivedMessages).toHaveLength(1); + expect(receivedMessages[0]).toMatchObject({ + role: "user", + content: [{ type: "text", text: "Hello from app" }], + }); + expect(result).toEqual({}); + }); + + it("app.sendMessage returns error result when handler indicates error", async () => { + bridge.onmessage = async () => { + return { isError: true }; + }; + + await app.connect(appTransport); + const result = await app.sendMessage({ + role: "user", + content: [{ type: "text", text: "Test" }], + }); + + expect(result.isError).toBe(true); + }); + + it("app.sendOpenLink triggers bridge.onopenlink and returns result", async () => { + const receivedLinks: string[] = []; + bridge.onopenlink = async (params) => { + receivedLinks.push(params.url); + return {}; + }; + + await app.connect(appTransport); + const result = await app.sendOpenLink({ url: "https://example.com" }); + + expect(receivedLinks).toEqual(["https://example.com"]); + expect(result).toEqual({}); + }); + + it("app.sendOpenLink returns error when host denies", async () => { + bridge.onopenlink = async () => { + return { isError: true }; + }; + + await app.connect(appTransport); + const result = await app.sendOpenLink({ url: "https://blocked.com" }); + + expect(result.isError).toBe(true); + }); + }); + + describe("ping", () => { + it("App responds to ping from bridge", async () => { + await bridge.connect(bridgeTransport); + await app.connect(appTransport); + + // Bridge can send ping via the protocol's request method + const result = await bridge.request( + { method: "ping", params: {} }, + EmptyResultSchema, + ); + + expect(result).toEqual({}); + }); + }); +}); diff --git a/src/app-bridge.ts b/src/app-bridge.ts index 8e682f3c..c9edafe2 100644 --- a/src/app-bridge.ts +++ b/src/app-bridge.ts @@ -12,18 +12,18 @@ import { ListResourcesResultSchema, ListResourceTemplatesRequestSchema, ListResourceTemplatesResultSchema, - ReadResourceRequestSchema, - ReadResourceResultSchema, LoggingMessageNotification, LoggingMessageNotificationSchema, Notification, PingRequest, PingRequestSchema, PromptListChangedNotificationSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + Request, ResourceListChangedNotificationSchema, Result, ToolListChangedNotificationSchema, - Request, } from "@modelcontextprotocol/sdk/types.js"; import { Protocol, @@ -32,13 +32,16 @@ import { } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { - type McpUiToolInputNotification, - type McpUiToolResultNotification, type McpUiSandboxResourceReadyNotification, type McpUiSizeChangeNotification, + type McpUiToolInputNotification, + type McpUiToolInputPartialNotification, + type McpUiToolResultNotification, LATEST_PROTOCOL_VERSION, McpUiAppCapabilities, McpUiHostCapabilities, + McpUiHostContext, + McpUiHostContextChangedNotification, McpUiInitializedNotification, McpUiInitializedNotificationSchema, McpUiInitializeRequest, @@ -64,7 +67,9 @@ export { PostMessageTransport } from "./message-transport"; * * @see ProtocolOptions from @modelcontextprotocol/sdk for available options */ -export type HostOptions = ProtocolOptions; +export type HostOptions = ProtocolOptions & { + hostContext?: McpUiHostContext; +}; /** * Protocol versions supported by this AppBridge implementation. @@ -149,6 +154,7 @@ type RequestHandlerExtra = Parameters< export class AppBridge extends Protocol { private _appCapabilities?: McpUiAppCapabilities; private _appInfo?: Implementation; + private _hostContext: McpUiHostContext; /** * Create a new AppBridge instance. @@ -175,6 +181,8 @@ export class AppBridge extends Protocol { ) { super(options); + this._hostContext = options?.hostContext || {}; + this.setRequestHandler(McpUiInitializeRequestSchema, (request) => this._oninitialize(request), ); @@ -550,12 +558,64 @@ export class AppBridge extends Protocol { protocolVersion, hostCapabilities: this.getCapabilities(), hostInfo: this._hostInfo, - hostContext: { - // TODO - }, + hostContext: this._hostContext, }; } + /** + * Update the host context and notify the Guest UI of changes. + * + * Compares the new context with the current context and sends a + * `ui/notifications/host-context-changed` notification containing only the + * fields that have changed. If no fields have changed, no notification is sent. + * + * Common use cases include notifying the Guest UI when: + * - Theme changes (light/dark mode toggle) + * - Viewport size changes (window resize) + * - Display mode changes (inline/fullscreen) + * - Locale or timezone changes + * + * @param hostContext - The complete new host context state + * + * @example Update theme when user toggles dark mode + * ```typescript + * bridge.setHostContext({ theme: "dark" }); + * ``` + * + * @example Update multiple context fields + * ```typescript + * bridge.setHostContext({ + * theme: "dark", + * viewport: { width: 800, height: 600 } + * }); + * ``` + * + * @see {@link McpUiHostContext} for the context structure + * @see {@link McpUiHostContextChangedNotification} for the notification type + */ + setHostContext(hostContext: McpUiHostContext) { + const changes: McpUiHostContext = {}; + let hasChanges = false; + for (const key of Object.keys(hostContext) as Array< + keyof McpUiHostContext + >) { + const oldValue = this._hostContext[key]; + const newValue = hostContext[key]; + if (deepEqual(oldValue, newValue)) { + continue; + } + changes[key] = newValue as any; + hasChanges = true; + } + if (hasChanges) { + this._hostContext = hostContext; + this.notification(({ + method: "ui/notifications/host-context-changed", + params: changes, + }) as Notification); // Cast needed because McpUiHostContext is a params type that doesn't allow arbitrary keys. + } + } + /** * Send complete tool arguments to the Guest UI. * @@ -585,6 +645,40 @@ export class AppBridge extends Protocol { }); } + /** + * Send streaming partial tool arguments to the Guest UI. + * + * The host MAY send this notification zero or more times while tool arguments + * are being streamed, before {@link sendToolInput} is called with complete + * arguments. This enables progressive rendering of tool arguments in the + * Guest UI. + * + * The arguments represent best-effort recovery of incomplete JSON. Guest UIs + * SHOULD handle missing or changing fields gracefully between notifications. + * + * @param params - Partial tool call arguments (may be incomplete) + * + * @example Stream partial arguments as they arrive + * ```typescript + * // As streaming progresses... + * bridge.sendToolInputPartial({ arguments: { loc: "N" } }); + * bridge.sendToolInputPartial({ arguments: { location: "New" } }); + * bridge.sendToolInputPartial({ arguments: { location: "New York" } }); + * + * // When complete, send final input + * bridge.sendToolInput({ arguments: { location: "New York", units: "metric" } }); + * ``` + * + * @see {@link McpUiToolInputPartialNotification} for the notification type + * @see {@link sendToolInput} for sending complete arguments + */ + sendToolInputPartial(params: McpUiToolInputPartialNotification["params"]) { + return this.notification({ + method: "ui/notifications/tool-input-partial", + params, + }); + } + /** * Send tool execution result to the Guest UI. * @@ -780,3 +874,7 @@ export class AppBridge extends Protocol { return super.connect(transport); } } + +function deepEqual(a: any, b: any): boolean { + return JSON.stringify(a) === JSON.stringify(b); +}