@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
837 lines (739 loc) • 23.4 kB
text/typescript
// @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();
});
});