UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

837 lines (739 loc) • 23.4 kB
// @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; import { createMcpAppBridge, type McpAppBridge, type McpAppBridgeFrame, } from "./bridge"; import type { McpAppJsonRpcMessage, McpAppJsonRpcRequest, McpAppJsonRpcResponse, } from "./types"; import { MCP_APP_PROTOCOL_VERSION } from "./types"; type Captured = McpAppJsonRpcMessage; function makeFrame() { const captured: Captured[] = []; const iframe = document.createElement("iframe"); document.body.appendChild(iframe); const frame: McpAppBridgeFrame = { iframe, origin: "https://app.example", sendMessage: (data) => { captured.push(data as Captured); }, }; return { frame, captured }; } function deliver(bridge: McpAppBridge, message: McpAppJsonRpcMessage) { bridge.onMessage(new MessageEvent("message", { data: message })); } async function flush() { await new Promise((r) => setTimeout(r, 0)); } describe("createMcpAppBridge", () => { it("ignores requests and notifications after disposal", async () => { const { frame, captured } = makeFrame(); const callTool = vi.fn().mockResolvedValue({ ok: true }); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { callTool, onInitialized }, }); bridge.dispose(); expect(() => bridge.dispose()).not.toThrow(); deliver(bridge, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "search" }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); await flush(); expect(callTool).not.toHaveBeenCalled(); expect(onInitialized).not.toHaveBeenCalled(); expect(captured).toEqual([]); }); it("does not send a response after disposal", async () => { const { frame, captured } = makeFrame(); let resolveCall!: (value: unknown) => void; const callTool = vi.fn( () => new Promise((resolve) => { resolveCall = resolve; }), ); const bridge = createMcpAppBridge({ frame, handlers: { callTool } }); deliver(bridge, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "search" }, }); expect(callTool).toHaveBeenCalledOnce(); bridge.dispose(); resolveCall({ ok: true }); await flush(); expect(captured).toEqual([]); }); it("does not report an async error after disposal", async () => { const { frame, captured } = makeFrame(); let rejectCall!: (reason?: unknown) => void; const callTool = vi.fn( () => new Promise((_resolve, reject) => { rejectCall = reject; }), ); const onError = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { callTool, onError }, }); deliver(bridge, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "search" }, }); expect(callTool).toHaveBeenCalledOnce(); bridge.dispose(); rejectCall(new Error("tool failed")); await flush(); expect(onError).not.toHaveBeenCalled(); expect(captured).toEqual([]); }); it("does not send host notifications after disposal", () => { const { frame, captured } = makeFrame(); const bridge = createMcpAppBridge({ frame }); bridge.dispose(); bridge.notifyToolInput({ query: "hello" }); bridge.notifyToolResult({ answer: "world" }); bridge.notifyHostContextChanged({ theme: "dark" }); expect(captured).toEqual([]); }); it("responds to ui/initialize with host info, version, and capabilities", async () => { const { frame, captured } = makeFrame(); const bridge = createMcpAppBridge({ frame, hostInfo: { name: "test-host", version: "9.9.9" }, hostContext: { theme: "dark" }, handlers: { callTool: vi.fn(), sendMessage: vi.fn(), }, }); const req: McpAppJsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "ui/initialize", }; deliver(bridge, req); await flush(); expect(captured).toHaveLength(1); const res = captured[0] as McpAppJsonRpcResponse; expect(res.id).toBe(1); const result = res.result as Record<string, any>; expect(result["protocolVersion"]).toBe(MCP_APP_PROTOCOL_VERSION); expect(result["host"]).toEqual({ name: "test-host", version: "9.9.9" }); expect(result["hostContext"]).toEqual({ theme: "dark" }); expect(result["capabilities"]["tools"]).toBeDefined(); expect(result["capabilities"]["ui"]["sendMessage"]).toBe(true); expect(result["capabilities"]["ui"]["openLink"]).toBe(false); expect(result["hostInfo"]).toEqual({ name: "test-host", version: "9.9.9" }); expect(result["hostCapabilities"]).toEqual({ serverTools: {}, message: { text: {} }, }); bridge.dispose(); }); it("echoes the requested protocolVersion in the ui/initialize result", async () => { const { frame, captured } = makeFrame(); const bridge = createMcpAppBridge({ frame }); deliver(bridge, { jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { protocolVersion: "2026-01-26" }, }); await flush(); const result = (captured[0] as McpAppJsonRpcResponse).result as Record< string, any >; expect(result["protocolVersion"]).toBe("2026-01-26"); expect(result["hostCapabilities"]).toEqual({}); bridge.dispose(); }); it("passes view initialization metadata to onInitialized", () => { const { frame } = makeFrame(); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } }); const params = { protocolVersion: "2026-01-26", appInfo: { name: "example-app", version: "1.2.3" }, appCapabilities: { availableDisplayModes: ["inline", "fullscreen"], tools: { listChanged: true }, }, }; deliver(bridge, { jsonrpc: "2.0", id: 1, method: "ui/initialize", params, }); expect(onInitialized).not.toHaveBeenCalled(); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); expect(onInitialized).toHaveBeenCalledOnce(); expect(onInitialized).toHaveBeenCalledWith(params); bridge.dispose(); }); it("omits malformed initialization fields and keeps the protocol fallback", async () => { const { frame, captured } = makeFrame(); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } }); deliver(bridge, { jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { protocolVersion: 1, appInfo: { name: "malformed" }, appCapabilities: { availableDisplayModes: "fullscreen" }, }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); await flush(); expect(onInitialized).toHaveBeenCalledOnce(); expect(onInitialized).toHaveBeenCalledWith({ appCapabilities: {}, }); expect((captured[0] as McpAppJsonRpcResponse).result).toMatchObject({ protocolVersion: MCP_APP_PROTOCOL_VERSION, }); bridge.dispose(); }); it("keeps only the display modes the host recognizes", () => { const { frame } = makeFrame(); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } }); deliver(bridge, { jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { appCapabilities: { availableDisplayModes: ["inline", "sidebar"] }, }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); expect(onInitialized).toHaveBeenCalledWith({ appCapabilities: { availableDisplayModes: ["inline"] }, }); bridge.dispose(); }); it("reads a sparse display mode list without walking its length", () => { const { frame } = makeFrame(); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } }); const availableDisplayModes = ["inline"]; availableDisplayModes.length = 2 ** 32 - 1; deliver(bridge, { jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { appCapabilities: { availableDisplayModes } }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); expect(onInitialized).toHaveBeenCalledWith({ appCapabilities: { availableDisplayModes: ["inline"] }, }); bridge.dispose(); }); it("initializes when the display mode list carries its own methods", async () => { const { frame, captured } = makeFrame(); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } }); deliver(bridge, { jsonrpc: "2.0", id: 1, method: "ui/initialize", params: { appCapabilities: { availableDisplayModes: Object.assign(["inline", "pip"], { filter: 0, constructor: 0, }), }, }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); await flush(); expect((captured[0] as McpAppJsonRpcResponse).error).toBeUndefined(); expect(onInitialized).toHaveBeenCalledWith({ appCapabilities: { availableDisplayModes: ["inline", "pip"] }, }); bridge.dispose(); }); it("calls onInitialized with empty params when ui/initialize never arrived", () => { const { frame } = makeFrame(); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onInitialized } }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); expect(onInitialized).toHaveBeenCalledWith({}); bridge.dispose(); }); it("routes tools/call to handler", async () => { const { frame, captured } = makeFrame(); const callTool = vi.fn().mockResolvedValue({ ok: true }); const bridge = createMcpAppBridge({ frame, handlers: { callTool } }); deliver(bridge, { jsonrpc: "2.0", id: 7, method: "tools/call", params: { name: "search", arguments: { q: "hi" } }, }); await flush(); expect(callTool).toHaveBeenCalledWith({ name: "search", arguments: { q: "hi" }, }); expect(captured[0]).toEqual({ jsonrpc: "2.0", id: 7, result: { ok: true }, }); bridge.dispose(); }); it.each([ [ "throws", () => { throw new Error("error callback failed"); }, ], ["rejects", () => Promise.reject(new Error("error callback failed"))], ])( "returns an error response when onError %s", async (_behavior, onErrorCallback) => { const { frame, captured } = makeFrame(); const callTool = vi.fn().mockRejectedValue(new Error("tool failed")); const consoleError = vi .spyOn(console, "error") .mockImplementation(() => {}); const onError = vi.fn(onErrorCallback); const bridge = createMcpAppBridge({ frame, handlers: { callTool, onError }, }); deliver(bridge, { jsonrpc: "2.0", id: 8, method: "tools/call", params: { name: "search" }, }); await flush(); expect(onError).toHaveBeenCalledWith(new Error("tool failed")); expect(captured).toEqual([ { jsonrpc: "2.0", id: 8, error: { code: -32603, message: "tool failed" }, }, ]); expect(consoleError).toHaveBeenCalledWith( "[assistant-ui] MCP App onError callback threw an error", new Error("error callback failed"), ); consoleError.mockRestore(); bridge.dispose(); }, ); it("rejects tools/call for disallowed tool with -32602", async () => { const { frame, captured } = makeFrame(); const callTool = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { callTool, allowedTools: ["search"] }, }); deliver(bridge, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "delete_everything" }, }); await flush(); expect(callTool).not.toHaveBeenCalled(); const res = captured[0] as McpAppJsonRpcResponse; expect(res.error?.code).toBe(-32602); bridge.dispose(); }); it("returns -32601 when no callTool handler", async () => { const { frame, captured } = makeFrame(); const bridge = createMcpAppBridge({ frame }); deliver(bridge, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "x" }, }); await flush(); const res = captured[0] as McpAppJsonRpcResponse; expect(res.error?.code).toBe(-32601); bridge.dispose(); }); it("rejects tools/call with non-object arguments via -32602", async () => { const { frame, captured } = makeFrame(); const callTool = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { callTool } }); deliver(bridge, { jsonrpc: "2.0", id: 11, method: "tools/call", params: { name: "x", arguments: "not-an-object" }, }); await flush(); expect(callTool).not.toHaveBeenCalled(); expect((captured[0] as McpAppJsonRpcResponse).error?.code).toBe(-32602); bridge.dispose(); }); it("rejects requestDisplayMode with unknown mode via -32602", async () => { const { frame, captured } = makeFrame(); const requestDisplayMode = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { requestDisplayMode }, }); deliver(bridge, { jsonrpc: "2.0", id: 13, method: "requestDisplayMode", params: { mode: "sidebar" }, }); await flush(); expect(requestDisplayMode).not.toHaveBeenCalled(); expect((captured[0] as McpAppJsonRpcResponse).error?.code).toBe(-32602); bridge.dispose(); }); it("rejects openLink for non-http(s) schemes via -32602", async () => { const { frame, captured } = makeFrame(); const openLink = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { openLink } }); deliver(bridge, { jsonrpc: "2.0", id: 12, method: "openLink", params: { url: "javascript:alert(1)" }, }); await flush(); expect(openLink).not.toHaveBeenCalled(); expect((captured[0] as McpAppJsonRpcResponse).error?.code).toBe(-32602); bridge.dispose(); }); it("invokes onSizeChange / onInitialized for notifications", () => { const { frame } = makeFrame(); const onSizeChange = vi.fn(); const onInitialized = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onSizeChange, onInitialized }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/size_changed", params: { width: 320, height: 240 }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); expect(onSizeChange).toHaveBeenCalledWith({ width: 320, height: 240 }); expect(onInitialized).toHaveBeenCalled(); bridge.dispose(); }); it("isolates throwing notification handlers and reports their errors", () => { const { frame, captured } = makeFrame(); const initializedError = new Error("initialized callback failed"); const sizeChangeError = new Error("size change callback failed"); const teardownError = new Error("teardown callback failed"); const onError = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onInitialized: () => { throw initializedError; }, onSizeChange: () => { throw sizeChangeError; }, onLog: () => { throw "log callback failed"; }, onRequestTeardown: () => { throw teardownError; }, onError, }, }); expect(() => { deliver(bridge, { jsonrpc: "2.0", method: "notifications/initialized", }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/size_changed", }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/log", }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/request_teardown", }); }).not.toThrow(); expect(onError.mock.calls.map(([error]) => error)).toEqual([ initializedError, sizeChangeError, new Error("log callback failed"), teardownError, ]); expect(captured).toEqual([]); bridge.dispose(); }); it("notifyToolInput / notifyToolResult / notifyHostContextChanged post both legacy and 2026-01-26 notifications", () => { const { frame, captured } = makeFrame(); const bridge = createMcpAppBridge({ frame }); bridge.notifyToolInput({ a: 1 }); bridge.notifyToolResult({ ok: 1 }); bridge.notifyHostContextChanged({ theme: "light" }); expect(captured).toEqual([ { jsonrpc: "2.0", method: "notifications/tools/call/input", params: { input: { a: 1 } }, }, { jsonrpc: "2.0", method: "ui/notifications/tool-input", params: { arguments: { a: 1 } }, }, { jsonrpc: "2.0", method: "notifications/tools/call/result", params: { result: { ok: 1 } }, }, { jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { ok: 1 }, }, { jsonrpc: "2.0", method: "notifications/host_context/changed", params: { theme: "light" }, }, { jsonrpc: "2.0", method: "ui/notifications/host-context-changed", params: { theme: "light" }, }, ]); bridge.dispose(); }); it("wraps non-object and array tool results in a valid content block for the spec dialect", () => { const { frame, captured } = makeFrame(); const bridge = createMcpAppBridge({ frame }); bridge.notifyToolResult("done"); bridge.notifyToolResult([1, 2]); bridge.notifyToolInput(null); const spec = captured.filter((c) => (c as { method?: string }).method?.startsWith("ui/notifications/"), ); expect(spec).toEqual([ { jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { content: [{ type: "text", text: "done" }] }, }, { jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { content: [{ type: "text", text: "1,2" }] }, }, { jsonrpc: "2.0", method: "ui/notifications/tool-input", params: {}, }, ]); bridge.dispose(); }); it("routes resources/read and resources/list to handlers", async () => { const { frame, captured } = makeFrame(); const readResource = vi.fn().mockResolvedValue({ contents: [] }); const listResources = vi.fn().mockResolvedValue({ resources: [] }); const bridge = createMcpAppBridge({ frame, handlers: { readResource, listResources }, }); deliver(bridge, { jsonrpc: "2.0", id: 20, method: "resources/read", params: { uri: "ui://app/x" }, }); deliver(bridge, { jsonrpc: "2.0", id: 21, method: "resources/list", }); await flush(); expect(readResource).toHaveBeenCalledWith({ uri: "ui://app/x" }); expect(listResources).toHaveBeenCalled(); expect(captured.map((c) => (c as McpAppJsonRpcResponse).id)).toEqual([ 20, 21, ]); bridge.dispose(); }); it("returns -32601 for resources/read and resources/list when no handler", async () => { const { frame, captured } = makeFrame(); const bridge = createMcpAppBridge({ frame }); deliver(bridge, { jsonrpc: "2.0", id: 22, method: "resources/read", params: { uri: "ui://x" }, }); deliver(bridge, { jsonrpc: "2.0", id: 23, method: "resources/list" }); await flush(); expect((captured[0] as McpAppJsonRpcResponse).error?.code).toBe(-32601); expect((captured[1] as McpAppJsonRpcResponse).error?.code).toBe(-32601); bridge.dispose(); }); it("routes sendMessage and updateModelContext to handlers", async () => { const { frame, captured } = makeFrame(); const sendMessage = vi.fn().mockResolvedValue({ ok: true }); const updateModelContext = vi.fn().mockResolvedValue({ ok: true }); const bridge = createMcpAppBridge({ frame, handlers: { sendMessage, updateModelContext }, }); deliver(bridge, { jsonrpc: "2.0", id: 30, method: "sendMessage", params: { text: "hi" }, }); deliver(bridge, { jsonrpc: "2.0", id: 31, method: "updateModelContext", params: { foo: "bar" }, }); await flush(); expect(sendMessage).toHaveBeenCalledWith({ text: "hi" }); expect(updateModelContext).toHaveBeenCalledWith({ foo: "bar" }); expect(captured.map((c) => (c as McpAppJsonRpcResponse).id)).toEqual([ 30, 31, ]); bridge.dispose(); }); it("invokes onLog / onError / onRequestTeardown for notifications", () => { const { frame } = makeFrame(); const onLog = vi.fn(); const onError = vi.fn(); const onRequestTeardown = vi.fn(); const bridge = createMcpAppBridge({ frame, handlers: { onLog, onError, onRequestTeardown }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/log", params: { level: "info", message: "hello" }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/error", params: { message: "kaboom" }, }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/request_teardown", params: { reason: "done" }, }); expect(onLog).toHaveBeenCalledWith({ level: "info", message: "hello" }); expect(onError).toHaveBeenCalled(); expect(onRequestTeardown).toHaveBeenCalledWith({ reason: "done" }); bridge.dispose(); }); it("isolates a throwing onError from widget error notifications", () => { const { frame } = makeFrame(); const callbackError = new Error("error callback failed"); const consoleError = vi .spyOn(console, "error") .mockImplementation(() => {}); const onError = vi.fn(() => { throw callbackError; }); const bridge = createMcpAppBridge({ frame, handlers: { callTool: vi.fn(), onError }, }); expect(() => deliver(bridge, { jsonrpc: "2.0", method: "notifications/error", params: { message: "kaboom" }, }), ).not.toThrow(); expect(onError).toHaveBeenCalledWith(new Error("kaboom")); expect(consoleError).toHaveBeenCalledWith( "[assistant-ui] MCP App onError callback threw an error", callbackError, ); consoleError.mockRestore(); bridge.dispose(); }); it("preserves the onError receiver", () => { const { frame } = makeFrame(); const handlers = { calls: 0, onError() { expect(this).toBe(handlers); this.calls += 1; }, }; const bridge = createMcpAppBridge({ frame, handlers }); deliver(bridge, { jsonrpc: "2.0", method: "notifications/error", params: { message: "kaboom" }, }); expect(handlers.calls).toBe(1); bridge.dispose(); }); });