import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Server, type ServerCapabilities } from "@modelcontextprotocol/server"; import { z } from "zod/v4"; import { App } from "./app.js"; import { LATEST_PROTOCOL_VERSION } from "./types.js"; import { AppBridge, buildAllowAttribute, getToolUiResourceUri, isToolVisibilityModelOnly, isToolVisibilityAppOnly, McpUiOpenLinkResultSchema, type McpUiHostCapabilities, } from "./app-bridge.js"; /** 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< Client, | "getServerCapabilities" | "request" | "notification" | "setNotificationHandler" > { return { getServerCapabilities: () => serverCapabilities, request: async () => ({}) as never, notification: async () => {}, setNotificationHandler: () => {}, }; } const testHostInfo = { name: "TestHost", version: "1.0.0" }; const testAppInfo = { name: "TestApp", version: "1.0.0" }; const testHostCapabilities: McpUiHostCapabilities = { experimental: { "com.example/host-extension": { version: 1 }, }, 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("sends only the Apps handshake on the wire and gates the Apps-ready callback", async () => { const methods: string[] = []; let releaseAppsInitialized!: () => void; let reachedAppsInitialized!: () => void; const appsInitializedGate = new Promise((resolve) => { releaseAppsInitialized = resolve; }); const reachedGate = new Promise((resolve) => { reachedAppsInitialized = resolve; }); const originalSend = appTransport.send.bind(appTransport); appTransport.send = async (message, options) => { if ("method" in message) methods.push(message.method); if ( "method" in message && message.method === "ui/notifications/initialized" ) { reachedAppsInitialized(); await appsInitializedGate; } return originalSend(message, options); }; let singularCalls = 0; let listenerCalls = 0; bridge.oninitialized = () => singularCalls++; bridge.addEventListener("initialized", () => listenerCalls++); await bridge.connect(bridgeTransport); const connecting = app.connect(appTransport); await reachedGate; expect(methods).toEqual([ "ui/initialize", "ui/notifications/initialized", ]); expect(bridge.getAppVersion()).toEqual(testAppInfo); expect(singularCalls).toBe(0); expect(listenerCalls).toBe(0); releaseAppsInitialized(); await connecting; expect(singularCalls).toBe(1); expect(listenerCalls).toBe(1); }); 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); expect(bridge.getCapabilities()).toEqual(testHostCapabilities); }); it("Bridge receives app info and capabilities after initialization", async () => { const appCapabilities = { experimental: { "com.example/app-extension": { version: 1 }, }, 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); }); 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", containerDimensions: { width: 800, maxHeight: 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", () => { 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("sendToolCancelled triggers app.ontoolcancelled", async () => { const receivedCancellations: unknown[] = []; app.ontoolcancelled = (params) => { receivedCancellations.push(params); }; await app.connect(appTransport); await bridge.sendToolCancelled({ reason: "User cancelled the operation", }); expect(receivedCancellations).toHaveLength(1); expect(receivedCancellations[0]).toEqual({ reason: "User cancelled the operation", }); }); it("sendToolCancelled works without reason", async () => { const receivedCancellations: unknown[] = []; app.ontoolcancelled = (params) => { receivedCancellations.push(params); }; await app.connect(appTransport); await bridge.sendToolCancelled({}); expect(receivedCancellations).toHaveLength(1); expect(receivedCancellations[0]).toEqual({}); }); 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" }, ]); }); 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", containerDimensions: { width: 800, maxHeight: 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 containerDimensions change newBridge.sendHostContextChange({ containerDimensions: { width: 1024, maxHeight: 768 }, }); await flush(); // getHostContext should have accumulated all updates: // - locale from initial (unchanged) // - theme from first partial update // - containerDimensions from second partial update const context = newApp.getHostContext(); expect(context?.theme).toBe("dark"); expect(context?.locale).toBe("en-US"); expect(context?.containerDimensions).toEqual({ width: 1024, maxHeight: 768, }); await newAppTransport.close(); await newBridgeTransport.close(); }); it("teardownResource triggers app.onteardown", async () => { let teardownCalled = false; app.onteardown = async () => { teardownCalled = true; return {}; }; await app.connect(appTransport); await bridge.teardownResource({}); expect(teardownCalled).toBe(true); }); it("teardownResource waits for async cleanup", async () => { const cleanupSteps: string[] = []; app.onteardown = async () => { cleanupSteps.push("start"); await new Promise((resolve) => setTimeout(resolve, 10)); cleanupSteps.push("done"); return {}; }; await app.connect(appTransport); await bridge.teardownResource({}); expect(cleanupSteps).toEqual(["start", "done"]); }); }); describe("App -> Host notifications", () => { beforeEach(async () => { await bridge.connect(bridgeTransport); }); it("app.sendSizeChanged triggers bridge.onsizechange", async () => { const receivedSizes: unknown[] = []; bridge.onsizechange = (params) => { receivedSizes.push(params); }; await app.connect(appTransport); await app.sendSizeChanged({ 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", }); }); it("app.updateModelContext triggers bridge.onupdatemodelcontext and returns result", async () => { const receivedContexts: unknown[] = []; bridge.onupdatemodelcontext = async (params) => { receivedContexts.push(params); return {}; }; await app.connect(appTransport); const result = await app.updateModelContext({ content: [{ type: "text", text: "User selected 3 items" }], }); expect(receivedContexts).toHaveLength(1); expect(receivedContexts[0]).toMatchObject({ content: [{ type: "text", text: "User selected 3 items" }], }); expect(result).toEqual({}); }); it("app.updateModelContext works with multiple content blocks", async () => { const receivedContexts: unknown[] = []; bridge.onupdatemodelcontext = async (params) => { receivedContexts.push(params); return {}; }; await app.connect(appTransport); const result = await app.updateModelContext({ content: [ { type: "text", text: "Filter applied" }, { type: "text", text: "Category: electronics" }, ], }); expect(receivedContexts).toHaveLength(1); expect(receivedContexts[0]).toMatchObject({ content: [ { type: "text", text: "Filter applied" }, { type: "text", text: "Category: electronics" }, ], }); expect(result).toEqual({}); }); it("app.updateModelContext works with structuredContent", async () => { const receivedContexts: unknown[] = []; bridge.onupdatemodelcontext = async (params) => { receivedContexts.push(params); return {}; }; await app.connect(appTransport); const result = await app.updateModelContext({ structuredContent: { selectedItems: 3, total: 150.0, currency: "USD" }, }); expect(receivedContexts).toHaveLength(1); expect(receivedContexts[0]).toMatchObject({ structuredContent: { selectedItems: 3, total: 150.0, currency: "USD" }, }); expect(result).toEqual({}); }); it("app.updateModelContext throws when handler throws", async () => { bridge.onupdatemodelcontext = async () => { throw new Error("Context update failed"); }; await app.connect(appTransport); expect( app.updateModelContext({ content: [{ type: "text", text: "Test" }], }), ).rejects.toThrow("Context update failed"); }); it("app.requestTeardown allows host to initiate teardown flow", async () => { const events: string[] = []; bridge.onrequestteardown = async () => { events.push("teardown-requested"); await bridge.teardownResource({}); events.push("teardown-complete"); }; app.onteardown = async () => { events.push("persist-unsaved-state"); return {}; }; await app.connect(appTransport); await app.requestTeardown(); await flush(); expect(events).toEqual([ "teardown-requested", "persist-unsaved-state", "teardown-complete", ]); }); }); 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.openLink 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.openLink({ url: "https://example.com" }); expect(receivedLinks).toEqual(["https://example.com"]); expect(result).toEqual({}); }); it("app.openLink returns error when host denies", async () => { bridge.onopenlink = async () => { return { isError: true }; }; await app.connect(appTransport); const result = await app.openLink({ url: "https://blocked.com" }); expect(result.isError).toBe(true); }); it("rejects invalid params for a custom ui request", async () => { bridge.onopenlink = async () => ({}); await app.connect(appTransport); await expect( app.request( { method: "ui/open-link", params: { url: 42 }, }, McpUiOpenLinkResultSchema, ), ).rejects.toThrow(); }); it("rejects invalid results from a custom ui handler", async () => { bridge.onopenlink = async () => ({ isError: "yes" }) as never; await app.connect(appTransport); await expect( app.openLink({ url: "https://example.com" }), ).rejects.toThrow(); }); }); describe("deprecated method aliases", () => { beforeEach(async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); }); it("app.sendOpenLink is an alias for app.openLink", async () => { expect(app.sendOpenLink).toBe(app.openLink); }); it("bridge.sendResourceTeardown is a deprecated alias for bridge.teardownResource", () => { expect(bridge.sendResourceTeardown).toBe(bridge.teardownResource); }); it("app.sendOpenLink works as deprecated alias", async () => { const receivedLinks: string[] = []; bridge.onopenlink = async (params) => { receivedLinks.push(params.url); return {}; }; await app.sendOpenLink({ url: "https://example.com" }); expect(receivedLinks).toEqual(["https://example.com"]); }); }); describe("double-connect guard", () => { it("AppBridge.connect() throws if already connected", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); // Attempting to connect again with a different transport should throw const [, secondBridgeTransport] = InMemoryTransport.createLinkedPair(); expect(bridge.connect(secondBridgeTransport)).rejects.toThrow( "AppBridge is already connected", ); }); it("App.connect() throws if already connected", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); // Attempting to connect again should throw const [secondAppTransport] = InMemoryTransport.createLinkedPair(); expect(app.connect(secondAppTransport)).rejects.toThrow( "App is already connected", ); }); it("AppBridge.connect() throws even when called with the same transport", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); // Should throw regardless of whether it's the same or a different transport expect(bridge.connect(bridgeTransport)).rejects.toThrow( "AppBridge is already connected", ); }); }); 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: {} }); expect(result).toEqual({}); }); }); describe("App tool registration", () => { beforeEach(async () => { app = new App( testAppInfo, { tools: { listChanged: true } }, { autoResize: false }, ); await bridge.connect(bridgeTransport); }); it("registerTool creates a registered tool", async () => { const InputSchema = z.object({ name: z.string() }); const OutputSchema = z.object({ greeting: z.string() }); const tool = app.registerTool( "greet", { title: "Greet User", description: "Greets a user by name", inputSchema: InputSchema, outputSchema: OutputSchema, }, async (args: any) => ({ content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], structuredContent: { greeting: `Hello, ${args.name}!` }, }), ); expect(tool.title).toBe("Greet User"); expect(tool.description).toBe("Greets a user by name"); expect(tool.enabled).toBe(true); }); it("registered tool can be enabled and disabled", async () => { await app.connect(appTransport); const tool = app.registerTool( "test-tool", { description: "Test tool", }, async (_extra: any) => ({ content: [] }), ); expect(tool.enabled).toBe(true); tool.disable(); expect(tool.enabled).toBe(false); tool.enable(); expect(tool.enabled).toBe(true); }); it("registered tool can be updated", async () => { await app.connect(appTransport); const tool = app.registerTool( "test-tool", { description: "Original description", }, async (_extra: any) => ({ content: [] }), ); expect(tool.description).toBe("Original description"); tool.update({ description: "Updated description" }); expect(tool.description).toBe("Updated description"); }); it("registered tool can be removed", async () => { await app.connect(appTransport); const tool = app.registerTool( "test-tool", { description: "Test tool", }, async (_extra: any) => ({ content: [] }), ); tool.remove(); // Tool should no longer be registered (internal check) }); it("registerTool throws on duplicate name", () => { app.registerTool("dup", {}, async () => ({ content: [] })); expect(() => app.registerTool("dup", {}, async () => ({ content: [] })), ).toThrow(/already registered/); }); it("enable/disable/update/remove pre-connect do not throw", () => { const tool = app.registerTool("t", {}, async () => ({ content: [] })); expect(() => tool.disable()).not.toThrow(); expect(() => tool.enable()).not.toThrow(); expect(() => tool.update({ description: "x" })).not.toThrow(); expect(() => tool.remove()).not.toThrow(); }); it("callback without inputSchema receives extra as first arg", async () => { await app.connect(appTransport); let receivedExtra: any; app.registerTool("noargs", {}, async (extra: any) => { receivedExtra = extra; return { content: [] }; }); await bridge.callTool({ name: "noargs", arguments: {} }); expect(receivedExtra).toBeDefined(); expect(receivedExtra.mcpReq.signal).toBeInstanceOf(AbortSignal); }); it("isError result skips output schema validation", async () => { await app.connect(appTransport); app.registerTool( "errs", { outputSchema: z.object({ ok: z.boolean() }) }, async () => ({ content: [{ type: "text" as const, text: "boom" }], isError: true, }), ); const res = await bridge.callTool({ name: "errs", arguments: {} }); expect(res.isError).toBe(true); expect(res.structuredContent).toBeUndefined(); }); it("stale handle remove() does not delete a re-registered tool", async () => { const t1 = app.registerTool("phoenix", {}, async () => ({ content: [] })); t1.remove(); app.registerTool("phoenix", {}, async () => ({ content: [] })); t1.remove(); await app.connect(appTransport); const list = await bridge.listTools({}); expect(list.tools.map((t) => t.name)).toContain("phoenix"); }); it("host omitting arguments defaults to empty object", async () => { await app.connect(appTransport); let received: unknown; app.registerTool( "noargs2", { inputSchema: z.object({}) }, async (args) => { received = args; return { content: [] }; }, ); await bridge.callTool({ name: "noargs2" }); expect(received).toEqual({}); }); it("update({inputSchema}) is honored by handler validation", async () => { await app.connect(appTransport); const tool = app.registerTool( "evolving", { inputSchema: z.object({ a: z.string() }) }, async (args: any) => ({ content: [{ type: "text" as const, text: JSON.stringify(args) }], }), ); expect( bridge.callTool({ name: "evolving", arguments: { a: 123 } }), ).rejects.toThrow(/Invalid input/); tool.update({ inputSchema: z.object({ a: z.number() }) }); const result = await bridge.callTool({ name: "evolving", arguments: { a: 123 }, }); expect(result.content[0]).toEqual({ type: "text", text: '{"a":123}' }); }); it("tool throws error when disabled and called", async () => { await app.connect(appTransport); const tool = app.registerTool( "test-tool", { description: "Test tool", }, async (_extra: any) => ({ content: [] }), ); tool.disable(); const mockExtra = { signal: new AbortController().signal, requestId: "test", sendNotification: async () => {}, sendRequest: async () => ({}), } as any; expect((tool.handler as any)(mockExtra)).rejects.toThrow( "Tool test-tool is disabled", ); }); it("tool validates input schema", async () => { const InputSchema = z.object({ name: z.string() }); const tool = app.registerTool( "greet", { inputSchema: InputSchema, }, async (args: any) => ({ content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], }), ); // Create a mock RequestHandlerExtra const mockExtra = { signal: new AbortController().signal, requestId: "test", sendNotification: async () => {}, sendRequest: async () => ({}), } as any; // Valid input should work expect( (tool.handler as any)({ name: "Alice" }, mockExtra), ).resolves.toBeDefined(); // Invalid input should fail expect( (tool.handler as any)({ invalid: "field" }, mockExtra), ).rejects.toThrow("Invalid input for tool greet"); }); it("tool validates output schema", async () => { const OutputSchema = z.object({ greeting: z.string() }); const tool = app.registerTool( "greet", { outputSchema: OutputSchema, }, async (_extra: any) => ({ content: [{ type: "text" as const, text: "Hello!" }], structuredContent: { greeting: "Hello!" }, }), ); // Create a mock RequestHandlerExtra const mockExtra = { signal: new AbortController().signal, requestId: "test", sendNotification: async () => {}, sendRequest: async () => ({}), } as any; // Valid output should work expect((tool.handler as any)(mockExtra)).resolves.toBeDefined(); }); it("tool enable/disable/update/remove trigger sendToolListChanged", async () => { await app.connect(appTransport); const tool = app.registerTool( "test-tool", { description: "Test tool", }, async (_extra: any) => ({ content: [] }), ); // The methods should not throw when connected expect(() => tool.disable()).not.toThrow(); expect(() => tool.enable()).not.toThrow(); expect(() => tool.update({ description: "Updated" })).not.toThrow(); expect(() => tool.remove()).not.toThrow(); }); }); describe("AppBridge -> App tool requests", () => { beforeEach(async () => { await bridge.connect(bridgeTransport); }); it("bridge.callTool calls app.oncalltool handler", async () => { // App needs tool capabilities to handle tool calls const appCapabilities = { tools: {} }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); const receivedCalls: unknown[] = []; app.oncalltool = async (params) => { receivedCalls.push(params); return { content: [{ type: "text", text: `Executed: ${params.name}` }], }; }; await app.connect(appTransport); const result = await bridge.callTool({ name: "test-tool", arguments: { foo: "bar" }, }); expect(receivedCalls).toHaveLength(1); expect(receivedCalls[0]).toMatchObject({ name: "test-tool", arguments: { foo: "bar" }, }); expect(result.content).toEqual([ { type: "text", text: "Executed: test-tool" }, ]); }); it("bridge.listTools calls app.onlisttools handler", async () => { // App needs tool capabilities to handle tool list requests const appCapabilities = { tools: {} }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); const receivedCalls: unknown[] = []; app.onlisttools = async (params, _extra) => { receivedCalls.push(params); return { tools: [ { name: "tool1", description: "First tool", inputSchema: { type: "object", properties: {} }, }, { name: "tool2", description: "Second tool", inputSchema: { type: "object", properties: {} }, }, { name: "tool3", description: "Third tool", inputSchema: { type: "object", properties: {} }, }, ], }; }; await app.connect(appTransport); const result = await bridge.listTools({}); expect(receivedCalls).toHaveLength(1); expect(result.tools).toHaveLength(3); expect(result.tools[0].name).toBe("tool1"); expect(result.tools[1].name).toBe("tool2"); expect(result.tools[2].name).toBe("tool3"); }); }); describe("App tool capabilities", () => { it("App with tool capabilities can handle tool calls", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); const receivedCalls: unknown[] = []; app.oncalltool = async (params) => { receivedCalls.push(params); return { content: [{ type: "text", text: "Success" }], }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); await bridge.callTool({ name: "test-tool", arguments: {}, }); expect(receivedCalls).toHaveLength(1); }); it("registered tool is invoked via oncalltool", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); const tool = app.registerTool( "greet", { description: "Greets user", inputSchema: z.object({ name: z.string() }), }, async (args: any) => ({ content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], }), ); app.oncalltool = async (params, extra) => { if (params.name === "greet") { return await (tool.handler as any)(params.arguments || {}, extra); } throw new Error(`Unknown tool: ${params.name}`); }; await bridge.connect(bridgeTransport); await app.connect(appTransport); const result = await bridge.callTool({ name: "greet", arguments: { name: "Alice" }, }); expect(result.content).toEqual([{ type: "text", text: "Hello, Alice!" }]); }); }); describe("Automatic request handlers", () => { beforeEach(async () => { await bridge.connect(bridgeTransport); }); describe("oncalltool automatic handler", () => { it("automatically calls registered tool without manual oncalltool setup", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); // Register a tool app.registerTool( "greet", { description: "Greets user", inputSchema: z.object({ name: z.string() }), }, async (args: any) => ({ content: [{ type: "text" as const, text: `Hello, ${args.name}!` }], }), ); await app.connect(appTransport); // Call the tool through bridge - should work automatically const result = await bridge.callTool({ name: "greet", arguments: { name: "Bob" }, }); expect(result.content).toEqual([{ type: "text", text: "Hello, Bob!" }]); }); it("throws error when calling non-existent tool", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); // Register a tool to initialize handlers app.registerTool("existing-tool", {}, async (_args: any) => ({ content: [], })); await app.connect(appTransport); // Try to call a tool that doesn't exist expect( bridge.callTool({ name: "nonexistent", arguments: {}, }), ).rejects.toThrow("Tool nonexistent not found"); }); it("handles multiple registered tools correctly", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); // Register multiple tools app.registerTool( "add", { description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }), }, async (args: any) => ({ content: [ { type: "text" as const, text: `Result: ${args.a + args.b}`, }, ], structuredContent: { result: args.a + args.b }, }), ); app.registerTool( "multiply", { description: "Multiply two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }), }, async (args: any) => ({ content: [ { type: "text" as const, text: `Result: ${args.a * args.b}`, }, ], structuredContent: { result: args.a * args.b }, }), ); await app.connect(appTransport); // Call first tool const addResult = await bridge.callTool({ name: "add", arguments: { a: 5, b: 3 }, }); expect(addResult.content).toEqual([ { type: "text", text: "Result: 8" }, ]); // Call second tool const multiplyResult = await bridge.callTool({ name: "multiply", arguments: { a: 5, b: 3 }, }); expect(multiplyResult.content).toEqual([ { type: "text", text: "Result: 15" }, ]); }); it("respects tool enable/disable state", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); const tool = app.registerTool( "test-tool", { description: "Test tool", }, async (_args: any) => ({ content: [{ type: "text" as const, text: "Success" }], }), ); await app.connect(appTransport); // Should work when enabled expect( bridge.callTool({ name: "test-tool", arguments: {} }), ).resolves.toBeDefined(); // Disable tool tool.disable(); // Should throw when disabled expect( bridge.callTool({ name: "test-tool", arguments: {} }), ).rejects.toThrow("Tool test-tool is disabled"); }); it("validates input schema through automatic handler", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); app.registerTool( "strict-tool", { description: "Requires specific input", inputSchema: z.object({ required: z.string(), optional: z.number().optional(), }) as any, }, async (args: any) => ({ content: [{ type: "text" as const, text: `Got: ${args.required}` }], }), ); await app.connect(appTransport); // Valid input should work expect( bridge.callTool({ name: "strict-tool", arguments: { required: "hello" }, }), ).resolves.toBeDefined(); // Invalid input should fail expect( bridge.callTool({ name: "strict-tool", arguments: { wrong: "field" }, }), ).rejects.toThrow("Invalid input for tool strict-tool"); }); it("validates output schema through automatic handler", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); app.registerTool( "validated-output", { description: "Has output validation", outputSchema: z.object({ status: z.enum(["success", "error"]), }) as any, }, async (_args: any) => ({ content: [{ type: "text" as const, text: "Done" }], structuredContent: { status: "success" }, }), ); await app.connect(appTransport); // Valid output should work const result = await bridge.callTool({ name: "validated-output", arguments: {}, }); expect(result).toBeDefined(); }); it("works after tool is removed and re-registered", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); const tool = app.registerTool( "dynamic-tool", {}, async (_args: any) => ({ content: [{ type: "text" as const, text: "Version 1" }], }), ); await app.connect(appTransport); // First version let result = await bridge.callTool({ name: "dynamic-tool", arguments: {}, }); expect(result.content).toEqual([{ type: "text", text: "Version 1" }]); // Remove tool tool.remove(); // Should fail after removal expect( bridge.callTool({ name: "dynamic-tool", arguments: {} }), ).rejects.toThrow("Tool dynamic-tool not found"); // Re-register with different behavior app.registerTool("dynamic-tool", {}, async (_args: any) => ({ content: [{ type: "text" as const, text: "Version 2" }], })); // Should work with new version result = await bridge.callTool({ name: "dynamic-tool", arguments: {}, }); expect(result.content).toEqual([{ type: "text", text: "Version 2" }]); }); }); describe("onlisttools automatic handler", () => { it("automatically returns list of registered tool names", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); // Register some tools app.registerTool("tool1", {}, async (_args: any) => ({ content: [], })); app.registerTool("tool2", {}, async (_args: any) => ({ content: [], })); app.registerTool("tool3", {}, async (_args: any) => ({ content: [], })); await app.connect(appTransport); const result = await bridge.listTools({}); expect(result.tools).toHaveLength(3); expect(result.tools.map((t) => t.name)).toContain("tool1"); expect(result.tools.map((t) => t.name)).toContain("tool2"); expect(result.tools.map((t) => t.name)).toContain("tool3"); }); it("emits core MCP Tool fields (title, outputSchema only when provided)", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); app.registerTool( "with-output", { title: "With Output", description: "has structured output", outputSchema: z.object({ ok: z.boolean() }), }, async () => ({ content: [], structuredContent: { ok: true }, }), ); app.registerTool( "no-output", { description: "no structured output" }, async () => ({ content: [] }), ); await app.connect(appTransport); const result = await bridge.listTools({}); const byName = Object.fromEntries(result.tools.map((t) => [t.name, t])); expect(byName["with-output"].title).toBe("With Output"); expect(byName["with-output"].inputSchema).toBeDefined(); expect(byName["with-output"].outputSchema).toBeDefined(); // outputSchema is optional in core MCP — omitted when not declared expect(byName["no-output"]).not.toHaveProperty("outputSchema"); expect(byName["no-output"].inputSchema).toBeDefined(); }); it("accepts any Standard Schema implementation, not only zod", async () => { // Hand-rolled StandardSchemaWithJSON — proves registerTool has no // zod-specific runtime path. Any library implementing the spec // (ArkType, Valibot, …) works the same way. type Point = { x: number; y: number }; const PointSchema = { "~standard": { version: 1 as const, vendor: "test", types: undefined as | undefined | { readonly input: Point; readonly output: Point }, validate: (v: unknown) => typeof v === "object" && v !== null && typeof (v as any).x === "number" && typeof (v as any).y === "number" ? { value: v as { x: number; y: number } } : { issues: [{ message: "expected {x:number,y:number}" }] }, jsonSchema: { input: () => ({ type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], }), output: () => ({ type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, }), }, }, }; const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); app.registerTool( "translate", { inputSchema: PointSchema, outputSchema: PointSchema }, async ({ x, y }) => ({ content: [], structuredContent: { x: x + 1, y: y + 1 }, }), ); await app.connect(appTransport); const list = await bridge.listTools({}); expect(list.tools[0].inputSchema).toEqual({ type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"], }); expect(list.tools[0].outputSchema).toBeDefined(); const ok = await bridge.callTool({ name: "translate", arguments: { x: 1, y: 2 }, }); expect(ok.structuredContent).toEqual({ x: 2, y: 3 }); expect( bridge.callTool({ name: "translate", arguments: { x: "bad" } }), ).rejects.toThrow(/Invalid input for tool translate/); }); it("rejects listTools when a tool schema does not implement Standard JSON Schema", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); app.registerTool( "broken", { inputSchema: { "~standard": { version: 1 as const, vendor: "mystery", validate: () => ({ value: {} }), }, }, }, async () => ({ content: [] }), ); await app.connect(appTransport); expect(bridge.listTools({})).rejects.toThrow( /does not implement Standard JSON Schema/, ); }); it("returns empty list when no tools registered", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); // Register a tool to ensure handlers are initialized const dummyTool = app.registerTool("dummy", {}, async () => ({ content: [], })); await app.connect(appTransport); // Remove the tool after connecting dummyTool.remove(); const result = await bridge.listTools({}); expect(result.tools).toEqual([]); }); it("updates list when tools are added", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); await app.connect(appTransport); // Register then remove a tool to initialize handlers const dummy = app.registerTool("init", {}, async () => ({ content: [], })); dummy.remove(); // Initially no tools let result = await bridge.listTools({}); expect(result.tools).toEqual([]); // Add a tool app.registerTool("new-tool", {}, async (_args: any) => ({ content: [], })); // Should now include the new tool result = await bridge.listTools({}); expect(result.tools.map((t) => t.name)).toEqual(["new-tool"]); // Add another tool app.registerTool("another-tool", {}, async (_args: any) => ({ content: [], })); // Should now include both tools result = await bridge.listTools({}); expect(result.tools).toHaveLength(2); expect(result.tools.map((t) => t.name)).toContain("new-tool"); expect(result.tools.map((t) => t.name)).toContain("another-tool"); }); it("updates list when tools are removed", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); const tool1 = app.registerTool("tool1", {}, async (_args: any) => ({ content: [], })); const tool2 = app.registerTool("tool2", {}, async (_args: any) => ({ content: [], })); app.registerTool("tool3", {}, async (_args: any) => ({ content: [], })); await app.connect(appTransport); // Initially all three tools let result = await bridge.listTools({}); expect(result.tools).toHaveLength(3); // Remove one tool tool2.remove(); // Should now have two tools result = await bridge.listTools({}); expect(result.tools).toHaveLength(2); expect(result.tools.map((t) => t.name)).toContain("tool1"); expect(result.tools.map((t) => t.name)).toContain("tool3"); expect(result.tools.map((t) => t.name)).not.toContain("tool2"); // Remove another tool tool1.remove(); // Should now have one tool result = await bridge.listTools({}); expect(result.tools.map((t) => t.name)).toEqual(["tool3"]); }); it("only includes enabled tools in list", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); app.registerTool("enabled-tool", {}, async (_args: any) => ({ content: [], })); const tool2 = app.registerTool( "disabled-tool", {}, async (_args: any) => ({ content: [], }), ); await app.connect(appTransport); // Disable one tool after connecting tool2.disable(); const result = await bridge.listTools({}); // Only enabled tool should be in the list expect(result.tools).toHaveLength(1); expect(result.tools.map((t) => t.name)).toContain("enabled-tool"); expect(result.tools.map((t) => t.name)).not.toContain("disabled-tool"); }); }); describe("Integration: automatic handlers with tool lifecycle", () => { it("handles complete tool lifecycle: register -> call -> update -> call -> remove", async () => { const appCapabilities = { tools: { listChanged: true } }; app = new App(testAppInfo, appCapabilities, { autoResize: false }); await app.connect(appTransport); // Register tool const tool = app.registerTool( "counter", { description: "A counter tool", }, async (_args: any) => ({ content: [{ type: "text" as const, text: "Count: 1" }], structuredContent: { count: 1 }, }), ); // List should include the tool let listResult = await bridge.listTools({}); expect(listResult.tools.map((t) => t.name)).toContain("counter"); // Call the tool let callResult = await bridge.callTool({ name: "counter", arguments: {}, }); expect(callResult.content).toEqual([ { type: "text", text: "Count: 1" }, ]); // Update tool description tool.update({ description: "An updated counter tool" }); // Should still be callable callResult = await bridge.callTool({ name: "counter", arguments: {}, }); expect(callResult).toBeDefined(); // Remove tool tool.remove(); // Should no longer be in list listResult = await bridge.listTools({}); expect(listResult.tools.map((t) => t.name)).not.toContain("counter"); // Should no longer be callable expect( bridge.callTool({ name: "counter", arguments: {} }), ).rejects.toThrow("Tool counter not found"); }); it("multiple apps can have separate tool registries", async () => { const appCapabilities = { tools: { listChanged: true } }; // Create two separate apps const app1 = new App( { name: "App1", version: "1.0.0" }, appCapabilities, { autoResize: false }, ); const app2 = new App( { name: "App2", version: "1.0.0" }, appCapabilities, { autoResize: false }, ); // Create separate transports for each app const [app1Transport, bridge1Transport] = InMemoryTransport.createLinkedPair(); const [app2Transport, bridge2Transport] = InMemoryTransport.createLinkedPair(); const bridge1 = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); const bridge2 = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); // Register different tools in each app app1.registerTool("app1-tool", {}, async (_args: any) => ({ content: [{ type: "text" as const, text: "From App1" }], })); app2.registerTool("app2-tool", {}, async (_args: any) => ({ content: [{ type: "text" as const, text: "From App2" }], })); await bridge1.connect(bridge1Transport); await bridge2.connect(bridge2Transport); await app1.connect(app1Transport); await app2.connect(app2Transport); // Each app should only see its own tools const list1 = await bridge1.listTools({}); expect(list1.tools.map((t) => t.name)).toEqual(["app1-tool"]); const list2 = await bridge2.listTools({}); expect(list2.tools.map((t) => t.name)).toEqual(["app2-tool"]); // Each app should only be able to call its own tools expect( bridge1.callTool({ name: "app1-tool", arguments: {} }), ).resolves.toBeDefined(); expect( bridge1.callTool({ name: "app2-tool", arguments: {} }), ).rejects.toThrow("Tool app2-tool not found"); // Clean up await app1Transport.close(); await bridge1Transport.close(); await app2Transport.close(); await bridge2Transport.close(); }); }); }); describe("AppBridge without MCP client (manual handlers)", () => { let app: App; let bridge: AppBridge; let appTransport: InMemoryTransport; let bridgeTransport: InMemoryTransport; beforeEach(() => { [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); app = new App(testAppInfo, {}, { autoResize: false }); // Pass null instead of a client - manual handler registration bridge = new AppBridge(null, testHostInfo, testHostCapabilities); }); afterEach(async () => { await appTransport.close(); await bridgeTransport.close(); }); it("connect() works without client", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); // Initialization should still work const hostInfo = app.getHostVersion(); expect(hostInfo).toEqual(testHostInfo); }); it("oncalltool setter registers handler for tools/call requests", async () => { const toolCall = { name: "test-tool", arguments: { arg: "value" } }; const resultContent = [{ type: "text" as const, text: "result" }]; const receivedCalls: unknown[] = []; bridge.oncalltool = async (params) => { receivedCalls.push(params); return { content: resultContent }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); // App calls a tool via callServerTool const result = await app.callServerTool(toolCall); expect(receivedCalls).toHaveLength(1); expect(receivedCalls[0]).toMatchObject(toolCall); expect(result.content).toEqual(resultContent); }); it("oncreatesamplingmessage setter registers handler for sampling/createMessage requests", async () => { // Re-create bridge with sampling capability so App's capability check passes bridge = new AppBridge(null, testHostInfo, { ...testHostCapabilities, sampling: { tools: {} }, }); const receivedParams: unknown[] = []; bridge.oncreatesamplingmessage = async (params) => { receivedParams.push(params); return { role: "assistant", content: { type: "text", text: "Hello from the model" }, model: "test-model", stopReason: "endTurn", }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); expect(app.getHostCapabilities()?.sampling?.tools).toEqual({}); const result = await app.createSamplingMessage({ messages: [{ role: "user", content: { type: "text", text: "Hi" } }], maxTokens: 50, }); expect(receivedParams).toHaveLength(1); expect(receivedParams[0]).toMatchObject({ maxTokens: 50 }); expect(result.model).toEqual("test-model"); expect(result.content).toEqual({ type: "text", text: "Hello from the model", }); }); it("ondownloadfile setter registers handler for ui/download-file requests", async () => { const downloadParams = { contents: [ { type: "resource" as const, resource: { uri: "file:///export.json", mimeType: "application/json", text: '{"key":"value"}', }, }, ], }; const receivedRequests: unknown[] = []; bridge.ondownloadfile = async (params) => { receivedRequests.push(params); return {}; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); const result = await app.downloadFile(downloadParams); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(downloadParams); expect(result).toEqual({}); }); it("callServerTool throws a helpful error when called with a string instead of params object", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); expect( // @ts-expect-error intentionally testing wrong usage app.callServerTool("my_tool"), ).rejects.toThrow( 'callServerTool() expects an object as its first argument, but received a string ("my_tool"). ' + 'Did you mean: callServerTool({ name: "my_tool", arguments: { ... } })?', ); }); describe("pre-handshake guard (claude-ai-mcp#149)", () => { const guardMsg = /called before connect\(\) completed the ui\/initialize handshake/; let warnSpy: ReturnType>; beforeEach(() => { warnSpy = spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); const warnings = () => warnSpy.mock.calls.map((c) => String(c[0])); it("callServerTool warns when called before connect() completes", async () => { bridge.oncalltool = async () => ({ content: [] }); await bridge.connect(bridgeTransport); const connecting = app.connect(appTransport); // Handshake is in flight; _initializedSent is still false. await app.callServerTool({ name: "t", arguments: {} }).catch(() => {}); const appSide = warnings().filter((m) => guardMsg.test(m)); expect(appSide).toHaveLength(1); expect(appSide[0]).toContain("App.callServerTool()"); await connecting; }); it("callServerTool does not warn after connect() resolves", async () => { bridge.oncalltool = async () => ({ content: [] }); await bridge.connect(bridgeTransport); await app.connect(appTransport); await app.callServerTool({ name: "t", arguments: {} }); expect(warnings()).toEqual([]); }); it("sendMessage and readServerResource also warn before handshake", async () => { await app.sendMessage({ role: "user", content: [] }).catch(() => {}); await app.readServerResource({ uri: "test://r" }).catch(() => {}); const appSide = warnings().filter((m) => guardMsg.test(m)); expect(appSide).toHaveLength(2); expect(appSide[0]).toContain("App.sendMessage()"); expect(appSide[1]).toContain("App.readServerResource()"); }); it("throws instead of warning when strict: true", async () => { const strictApp = new App( testAppInfo, {}, { autoResize: false, strict: true }, ); await expect( strictApp.callServerTool({ name: "t", arguments: {} }), ).rejects.toThrow(guardMsg); expect(warnings()).toEqual([]); }); describe("late handler registration", () => { const lateMsg = /handler registered after connect\(\) completed the ui\/initialize handshake/; it("warns when ontoolresult is set after connect() resolves", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); app.ontoolresult = () => {}; expect(warnings()).toHaveLength(1); expect(warnings()[0]).toMatch(lateMsg); expect(warnings()[0]).toContain('"toolresult"'); }); it("warns when addEventListener('toolinput', …) is called after connect()", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); app.addEventListener("toolinput", () => {}); expect(warnings()).toHaveLength(1); expect(warnings()[0]).toContain('"toolinput"'); }); it("does not warn for handlers set before connect()", async () => { app.ontoolinput = () => {}; app.addEventListener("toolresult", () => {}); await bridge.connect(bridgeTransport); await app.connect(appTransport); expect(warnings()).toEqual([]); }); it("does not warn for hostcontextchanged (repeating event)", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); app.onhostcontextchanged = () => {}; app.addEventListener("hostcontextchanged", () => {}); expect(warnings()).toEqual([]); }); it("does not warn when clearing a handler (set to undefined)", async () => { app.ontoolinput = () => {}; await bridge.connect(bridgeTransport); await app.connect(appTransport); app.ontoolinput = undefined; expect(warnings()).toEqual([]); }); it("does not warn on re-registration when a handler existed pre-connect", async () => { const h = () => {}; app.addEventListener("toolresult", h); await bridge.connect(bridgeTransport); await app.connect(appTransport); // React-style: useEffect cleanup removes, dep change re-adds. app.removeEventListener("toolresult", h); app.addEventListener("toolresult", () => {}); expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]); }); it("warns once for the first late registration, not on subsequent ones", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); app.addEventListener("toolinput", () => {}); app.addEventListener("toolinput", () => {}); app.addEventListener("toolinput", () => {}); const late = warnings().filter((m) => lateMsg.test(m)); expect(late).toHaveLength(1); expect(late[0]).toContain('"toolinput"'); }); it("tracks first-handler per event independently", async () => { app.addEventListener("toolinput", () => {}); await bridge.connect(bridgeTransport); await app.connect(appTransport); app.addEventListener("toolinput", () => {}); // had pre-connect handler → silent app.addEventListener("toolresult", () => {}); // first reg, late → warns const late = warnings().filter((m) => lateMsg.test(m)); expect(late).toHaveLength(1); expect(late[0]).toContain('"toolresult"'); }); it("throws instead of warning when strict: true", async () => { const [strictAppT, strictBridgeT] = InMemoryTransport.createLinkedPair(); const strictBridge = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); const strictApp = new App( testAppInfo, {}, { autoResize: false, strict: true }, ); // Pre-connect registration for toolcancelled — later swap must not throw. strictApp.addEventListener("toolcancelled", () => {}); await strictBridge.connect(strictBridgeT); await strictApp.connect(strictAppT); expect(() => { strictApp.ontoolresult = () => {}; }).toThrow(lateMsg); expect(() => { strictApp.addEventListener("toolinput", () => {}); }).toThrow(lateMsg); // Swapping a handler that existed pre-connect is allowed under strict. expect(() => { strictApp.addEventListener("toolcancelled", () => {}); }).not.toThrow(); expect(warnings().filter((m) => lateMsg.test(m))).toEqual([]); }); }); it("AppBridge warns on a second ui/initialize (View double-mount)", async () => { await bridge.connect(bridgeTransport); await app.connect(appTransport); expect(warnings()).toEqual([]); // Simulate a second View instance re-running the handshake. appTransport.send({ jsonrpc: "2.0", id: 99, method: "ui/initialize", params: { protocolVersion: LATEST_PROTOCOL_VERSION, appInfo: testAppInfo, appCapabilities: {}, }, }); await flush(); const doubleInit = warnings().filter((m) => /second ui\/initialize/.test(m), ); expect(doubleInit).toHaveLength(1); }); it("close() stops further notification delivery (StrictMode cleanup relies on this)", async () => { const received: unknown[] = []; app.addEventListener("toolresult", (r) => received.push(r)); await bridge.connect(bridgeTransport); await app.connect(appTransport); await bridge.sendToolResult({ content: [{ type: "text", text: "before" }], }); expect(received).toHaveLength(1); await app.close(); await bridge .sendToolResult({ content: [{ type: "text", text: "after" }] }) .catch(() => {}); expect(received).toHaveLength(1); }); it("AppBridge warns on tools/call from a View that skipped the handshake", async () => { bridge.oncalltool = async () => ({ content: [] }); await bridge.connect(bridgeTransport); // Simulate a hand-rolled View (no SDK, no handshake) sending tools/call. await appTransport.start(); appTransport.send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "t", arguments: {} }, }); await flush(); expect(warnings()).toHaveLength(1); expect(warnings()[0]).toContain( "received 'tools/call' before ui/notifications/initialized", ); }); it("AppBridge does not warn after initialized is received", async () => { bridge.oncalltool = async () => ({ content: [] }); await bridge.connect(bridgeTransport); await app.connect(appTransport); await app.callServerTool({ name: "t", arguments: {} }); expect(warnings()).toEqual([]); }); }); it("onlistresources setter registers handler for resources/list requests", async () => { const requestParams = {}; const resources = [{ uri: "test://resource", name: "Test" }]; const receivedRequests: unknown[] = []; bridge.onlistresources = async (params) => { receivedRequests.push(params); return { resources }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); // App sends resources/list request via the protocol's request method const result = await app.request({ method: "resources/list", params: requestParams, }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); expect(result.resources).toEqual(resources); }); it("onlistresources handles listServerResources() calls from App", async () => { const resources = [ { uri: "test://res-1", name: "Resource 1" }, { uri: "test://res-2", name: "Resource 2", mimeType: "video/mp4" }, ]; const receivedRequests: unknown[] = []; bridge.onlistresources = async (params) => { receivedRequests.push(params); return { resources }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); const result = await app.listServerResources(); expect(receivedRequests).toHaveLength(1); expect(result.resources).toEqual(resources); }); it("onreadresource setter registers handler for resources/read requests", async () => { const requestParams = { uri: "test://resource" }; const contents = [{ uri: "test://resource", text: "content" }]; const receivedRequests: unknown[] = []; bridge.onreadresource = async (params) => { receivedRequests.push(params); return { contents: [{ uri: params.uri, text: "content" }] }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); const result = await app.request({ method: "resources/read", params: requestParams, }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); expect(result.contents).toEqual(contents); }); it("onreadresource handles readServerResource() calls from App", async () => { const requestParams = { uri: "videos://bunny-1mb" }; const contents = [ { uri: "videos://bunny-1mb", blob: "dmlkZW9kYXRh", mimeType: "video/mp4", }, ]; const receivedRequests: unknown[] = []; bridge.onreadresource = async (params) => { receivedRequests.push(params); return { contents }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); const result = await app.readServerResource(requestParams); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); expect(result.contents).toEqual(contents); }); it("onlistresourcetemplates setter registers handler for resources/templates/list requests", async () => { const requestParams = {}; const resourceTemplates = [ { uriTemplate: "test://{id}", name: "Test Template" }, ]; const receivedRequests: unknown[] = []; bridge.onlistresourcetemplates = async (params) => { receivedRequests.push(params); return { resourceTemplates }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); const result = await app.request({ method: "resources/templates/list", params: requestParams, }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); expect(result.resourceTemplates).toEqual(resourceTemplates); }); it("onlistprompts setter registers handler for prompts/list requests", async () => { const requestParams = {}; const prompts = [{ name: "test-prompt" }]; const receivedRequests: unknown[] = []; bridge.onlistprompts = async (params) => { receivedRequests.push(params); return { prompts }; }; await bridge.connect(bridgeTransport); await app.connect(appTransport); const result = await app.request({ method: "prompts/list", params: requestParams, }); expect(receivedRequests).toHaveLength(1); expect(receivedRequests[0]).toMatchObject(requestParams); expect(result.prompts).toEqual(prompts); }); it("sendToolListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; bridge.oncalltool = async () => ({ content: [] }); app.setNotificationHandler( "notifications/tools/list_changed", (notification) => { receivedNotifications.push(notification.params); }, ); await bridge.connect(bridgeTransport); await app.connect(appTransport); bridge.sendToolListChanged(); await flush(); expect(receivedNotifications).toHaveLength(1); }); it("sendResourceListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; bridge.onlistresources = async () => ({ resources: [] }); app.setNotificationHandler( "notifications/resources/list_changed", (notification) => { receivedNotifications.push(notification.params); }, ); await bridge.connect(bridgeTransport); await app.connect(appTransport); bridge.sendResourceListChanged(); await flush(); expect(receivedNotifications).toHaveLength(1); }); it("sendPromptListChanged sends notification to app", async () => { const receivedNotifications: unknown[] = []; bridge.onlistprompts = async () => ({ prompts: [] }); app.setNotificationHandler( "notifications/prompts/list_changed", (notification) => { receivedNotifications.push(notification.params); }, ); await bridge.connect(bridgeTransport); await app.connect(appTransport); bridge.sendPromptListChanged(); await flush(); expect(receivedNotifications).toHaveLength(1); }); }); }); describe("AppBridge outer v2 Client proxy", () => { let outerClient: Client; let outerServer: Server; let outerClientTransport: InMemoryTransport; let outerServerTransport: InMemoryTransport; let outerInitializeCount: number; beforeEach(async () => { [outerClientTransport, outerServerTransport] = InMemoryTransport.createLinkedPair(); outerInitializeCount = 0; const originalSend = outerClientTransport.send.bind(outerClientTransport); outerClientTransport.send = async (message, options) => { if ("method" in message && message.method === "initialize") { outerInitializeCount++; } return originalSend(message, options); }; outerServer = new Server( { name: "ActualServer", version: "1.0.0" }, { capabilities: { tools: { listChanged: true }, resources: { listChanged: true }, prompts: { listChanged: true }, }, supportedProtocolVersions: ["2025-11-25"], }, ); outerServer.setRequestHandler("tools/call", async (request) => ({ content: [ { type: "text", text: `outer:${request.params.name}`, }, ], })); outerClient = new Client( { name: "HostOuterClient", version: "1.0.0" }, { capabilities: {}, versionNegotiation: { mode: "legacy" }, supportedProtocolVersions: ["2025-11-25"], }, ); await outerServer.connect(outerServerTransport); await outerClient.connect(outerClientTransport); }); afterEach(async () => { await outerClient.close().catch(() => {}); await outerServer.close().catch(() => {}); }); it("proxies through the outer Client without reconnecting it", async () => { const bridge = new AppBridge( outerClient, testHostInfo, testHostCapabilities, ); const app = new App(testAppInfo, {}, { autoResize: false }); let [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); await bridge.connect(bridgeTransport); await app.connect(appTransport); expect( await app.callServerTool({ name: "first", arguments: {} }), ).toMatchObject({ content: [{ type: "text", text: "outer:first" }], }); expect(outerInitializeCount).toBe(1); await app.close(); await bridge.close(); [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); await bridge.connect(bridgeTransport); await app.connect(appTransport); expect( await app.callServerTool({ name: "second", arguments: {} }), ).toMatchObject({ content: [{ type: "text", text: "outer:second" }], }); expect(outerInitializeCount).toBe(1); expect(outerClient.getServerVersion()).toMatchObject({ name: "ActualServer", version: "1.0.0", }); await app.close(); await bridge.close(); }); it("propagates View cancellation to the actual outer Server", async () => { let started!: () => void; let aborted!: () => void; const startedPromise = new Promise((resolve) => { started = resolve; }); const abortedPromise = new Promise((resolve) => { aborted = resolve; }); outerServer.setRequestHandler("tools/call", async (_request, context) => { started(); await new Promise((resolve) => { context.mcpReq.signal.addEventListener( "abort", () => { aborted(); resolve(); }, { once: true }, ); }); return { content: [] }; }); const bridge = new AppBridge( outerClient, testHostInfo, testHostCapabilities, ); const app = new App(testAppInfo, {}, { autoResize: false }); const [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); await bridge.connect(bridgeTransport); await app.connect(appTransport); const controller = new AbortController(); const result = app.callServerTool( { name: "cancel-me", arguments: {} }, { signal: controller.signal }, ); await startedPromise; controller.abort("test cancellation"); await abortedPromise; await expect(result).rejects.toThrow(); await app.close(); await bridge.close(); }); it("forwards outer list-changed notifications to the App", async () => { const bridge = new AppBridge( outerClient, testHostInfo, testHostCapabilities, ); const app = new App(testAppInfo, {}, { autoResize: false }); const [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); const received: string[] = []; app.setNotificationHandler("notifications/tools/list_changed", () => { received.push("tools"); }); app.setNotificationHandler("notifications/resources/list_changed", () => { received.push("resources"); }); app.setNotificationHandler("notifications/prompts/list_changed", () => { received.push("prompts"); }); await bridge.connect(bridgeTransport); await app.connect(appTransport); await outerServer.sendToolListChanged(); await outerServer.sendResourceListChanged(); await outerServer.sendPromptListChanged(); await flush(); expect(received).toEqual(["tools", "resources", "prompts"]); await app.close(); await bridge.close(); }); }); describe("getToolUiResourceUri", () => { describe("new nested format (_meta.ui.resourceUri)", () => { it("extracts resourceUri from _meta.ui.resourceUri", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: "ui://server/app.html" }, }, }; expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html"); }); it("extracts resourceUri when visibility is also present", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: "ui://server/app.html", visibility: ["model"], }, }, }; expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html"); }); }); describe("deprecated flat format (_meta['ui/resourceUri'])", () => { it("extracts resourceUri from deprecated format", () => { const tool = { name: "test-tool", _meta: { "ui/resourceUri": "ui://server/app.html" }, }; expect(getToolUiResourceUri(tool)).toBe("ui://server/app.html"); }); }); describe("format precedence", () => { it("prefers new nested format over deprecated format", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: "ui://server/new.html" }, "ui/resourceUri": "ui://server/old.html", }, }; expect(getToolUiResourceUri(tool)).toBe("ui://server/new.html"); }); }); describe("missing resourceUri", () => { it("returns undefined when no resourceUri in empty _meta", () => { const tool = { name: "test-tool", _meta: {} }; expect(getToolUiResourceUri(tool)).toBeUndefined(); }); it("returns undefined when _meta is missing", () => { const tool = {} as { _meta?: Record }; expect(getToolUiResourceUri(tool)).toBeUndefined(); }); it("returns undefined for app-only tools with visibility but no resourceUri", () => { const tool = { name: "refresh-stats", _meta: { ui: { visibility: ["app"] }, }, }; expect(getToolUiResourceUri(tool)).toBeUndefined(); }); }); describe("validation", () => { it("throws for invalid URI (not starting with ui://)", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: "https://example.com" } }, }; expect(() => getToolUiResourceUri(tool)).toThrow( "Invalid UI resource URI", ); }); it("throws for non-string resourceUri", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: 123 } }, }; expect(() => getToolUiResourceUri(tool)).toThrow( "Invalid UI resource URI", ); }); it("throws for null resourceUri", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: null } }, }; expect(() => getToolUiResourceUri(tool)).toThrow( "Invalid UI resource URI", ); }); }); }); describe("isToolVisibilityModelOnly", () => { describe("returns true", () => { it("when visibility is exactly ['model']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["model"] } }, }; expect(isToolVisibilityModelOnly(tool)).toBe(true); }); }); describe("returns false", () => { it("when visibility is ['app']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["app"] } }, }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); it("when visibility is ['model', 'app']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["model", "app"] } }, }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); it("when visibility is ['app', 'model']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["app", "model"] } }, }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); it("when visibility is empty array", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: [] } }, }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); it("when visibility is undefined", () => { const tool = { name: "test-tool", _meta: { ui: {} }, }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); it("when _meta.ui is missing", () => { const tool = { name: "test-tool", _meta: {}, }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); it("when _meta is missing", () => { const tool = { name: "test-tool" }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); it("when tool has resourceUri but no visibility", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: "ui://server/app.html" } }, }; expect(isToolVisibilityModelOnly(tool)).toBe(false); }); }); }); describe("isToolVisibilityAppOnly", () => { describe("returns true", () => { it("when visibility is exactly ['app']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["app"] } }, }; expect(isToolVisibilityAppOnly(tool)).toBe(true); }); }); describe("returns false", () => { it("when visibility is ['model']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["model"] } }, }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); it("when visibility is ['model', 'app']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["model", "app"] } }, }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); it("when visibility is ['app', 'model']", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: ["app", "model"] } }, }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); it("when visibility is empty array", () => { const tool = { name: "test-tool", _meta: { ui: { visibility: [] } }, }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); it("when visibility is undefined", () => { const tool = { name: "test-tool", _meta: { ui: {} }, }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); it("when _meta.ui is missing", () => { const tool = { name: "test-tool", _meta: {}, }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); it("when _meta is missing", () => { const tool = { name: "test-tool" }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); it("when tool has resourceUri but no visibility", () => { const tool = { name: "test-tool", _meta: { ui: { resourceUri: "ui://server/app.html" } }, }; expect(isToolVisibilityAppOnly(tool)).toBe(false); }); }); describe("addEventListener / removeEventListener", () => { let app: App; let bridge: AppBridge; let appTransport: InMemoryTransport; let bridgeTransport: InMemoryTransport; beforeEach(async () => { [appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair(); app = new App(testAppInfo, {}, { autoResize: false }); bridge = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); await bridge.connect(bridgeTransport); }); afterEach(async () => { await appTransport.close(); await bridgeTransport.close(); }); it("App.addEventListener fires multiple listeners for the same event", async () => { const a: unknown[] = []; const b: unknown[] = []; app.addEventListener("hostcontextchanged", (p) => a.push(p)); app.addEventListener("hostcontextchanged", (p) => b.push(p)); await app.connect(appTransport); bridge.setHostContext({ theme: "dark" }); await flush(); expect(a).toEqual([{ theme: "dark" }]); expect(b).toEqual([{ theme: "dark" }]); }); it("App notification setters replace (DOM onclick model)", async () => { const a: unknown[] = []; const b: unknown[] = []; const first = (p: unknown) => a.push(p); app.ontoolinput = first; expect(app.ontoolinput).toBe(first); app.ontoolinput = (p) => b.push(p); await app.connect(appTransport); await bridge.sendToolInput({ arguments: { x: 1 } }); await flush(); // Second assignment replaced the first (like el.onclick) expect(a).toEqual([]); expect(b).toEqual([{ arguments: { x: 1 } }]); }); it("App notification setter coexists with addEventListener", async () => { const a: unknown[] = []; const b: unknown[] = []; app.ontoolinput = (p) => a.push(p); app.addEventListener("toolinput", (p) => b.push(p)); await app.connect(appTransport); await bridge.sendToolInput({ arguments: { x: 1 } }); await flush(); // Both the on* handler and addEventListener listener fire expect(a).toEqual([{ arguments: { x: 1 } }]); expect(b).toEqual([{ arguments: { x: 1 } }]); }); it("App notification getter returns the on* handler", () => { expect(app.ontoolinput).toBeUndefined(); const handler = () => {}; app.ontoolinput = handler; expect(app.ontoolinput).toBe(handler); }); it("App notification setter can be cleared with undefined", async () => { const a: unknown[] = []; app.ontoolinput = (p) => a.push(p); expect(app.ontoolinput).toBeDefined(); app.ontoolinput = undefined; await app.connect(appTransport); await bridge.sendToolInput({ arguments: { x: 1 } }); await flush(); expect(a).toEqual([]); expect(app.ontoolinput).toBeUndefined(); }); it("App.removeEventListener stops a listener from firing", async () => { const a: unknown[] = []; const listener = (p: unknown) => a.push(p); app.addEventListener("toolinput", listener); app.removeEventListener("toolinput", listener); await app.connect(appTransport); await bridge.sendToolInput({ arguments: {} }); await flush(); expect(a).toEqual([]); }); it("App merges hostcontext before listeners fire", async () => { let seen: unknown; app.addEventListener("hostcontextchanged", () => { seen = app.getHostContext(); }); await app.connect(appTransport); bridge.setHostContext({ theme: "dark" }); await flush(); expect(seen).toEqual({ theme: "dark" }); }); it("AppBridge.addEventListener fires multiple listeners", async () => { let a = 0; let b = 0; bridge.addEventListener("initialized", () => a++); bridge.addEventListener("initialized", () => b++); await app.connect(appTransport); expect(a).toBe(1); expect(b).toBe(1); }); it("on* request setters have replace semantics (no throw)", () => { app.onteardown = async () => ({}); expect(() => { app.onteardown = async () => ({}); }).not.toThrow(); }); it("on* request setters have getters", () => { expect(app.onteardown).toBeUndefined(); const handler = async () => ({}); app.onteardown = handler; expect(app.onteardown).toBe(handler); }); it("direct setRequestHandler throws when called twice", () => { const bridge2 = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); const params = z.object({}); bridge2.setRequestHandler("test/method", { params }, () => ({})); expect(() => { bridge2.setRequestHandler("test/method", { params }, () => ({})); }).toThrow(/already registered/); }); it("direct setRequestHandler cannot silently replace an on* host handler", () => { const bridge2 = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); bridge2.onopenlink = async () => ({}); expect(() => { bridge2.setRequestHandler( "ui/open-link", { params: z.object({}) }, () => ({}), ); }).toThrow(/already registered/); }); it("direct setNotificationHandler throws for event-mapped methods", () => { const bridge2 = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); bridge2.onsizechange = () => {}; expect(() => { bridge2.setNotificationHandler( "ui/notifications/size-changed", { params: z.object({}) }, () => {}, ); }).toThrow(/already registered/); }); it("removeRequestHandler releases the method so an on* setter can re-register", () => { const bridge2 = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); bridge2.removeRequestHandler("tools/call"); expect(() => { bridge2.setRequestHandler("tools/call", async () => ({ content: [] })); }).not.toThrow(); expect(() => { bridge2.oncalltool = async () => ({ content: [] }); }).not.toThrow(); }); it("oncreatesamplingmessage has a getter, replace semantics, and a replace warning", () => { const bridge2 = new AppBridge( createMockClient() as Client, testHostInfo, testHostCapabilities, ); expect(bridge2.oncreatesamplingmessage).toBeUndefined(); const first = async () => ({ role: "assistant" as const, content: { type: "text" as const, text: "" }, model: "m", }); bridge2.oncreatesamplingmessage = first; expect(bridge2.oncreatesamplingmessage).toBe(first); const warn = spyOn(console, "warn").mockImplementation(() => {}); try { expect(() => { bridge2.oncreatesamplingmessage = first; }).not.toThrow(); expect(warn).toHaveBeenCalledWith( expect.stringContaining("oncreatesamplingmessage handler replaced"), ); } finally { warn.mockRestore(); } bridge2.oncreatesamplingmessage = undefined; expect(bridge2.oncreatesamplingmessage).toBeUndefined(); }); }); }); describe("buildAllowAttribute", () => { describe("returns empty string", () => { it("when permissions is undefined", () => { expect(buildAllowAttribute(undefined)).toBe(""); }); it("when permissions object is empty", () => { expect(buildAllowAttribute({})).toBe(""); }); }); describe("returns a single permission directive", () => { it("when only camera is set", () => { expect(buildAllowAttribute({ camera: {} })).toBe("camera"); }); it("when only microphone is set", () => { expect(buildAllowAttribute({ microphone: {} })).toBe("microphone"); }); it("when only geolocation is set", () => { expect(buildAllowAttribute({ geolocation: {} })).toBe("geolocation"); }); it("when only clipboardWrite is set, maps to clipboard-write", () => { expect(buildAllowAttribute({ clipboardWrite: {} })).toBe( "clipboard-write", ); }); }); describe("returns multiple directives joined with '; '", () => { it("when camera and microphone are set", () => { expect(buildAllowAttribute({ camera: {}, microphone: {} })).toBe( "camera; microphone", ); }); it("when all permissions are set", () => { expect( buildAllowAttribute({ camera: {}, microphone: {}, geolocation: {}, clipboardWrite: {}, }), ).toBe("camera; microphone; geolocation; clipboard-write"); }); }); });