Ctrl K
ComunidadGuías…

server/chat-apps-mcp.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";

import { APP_LIMITS, APP_MIME, type AppPayload } from "../shared/app.ts";
import { createChatApps, type ChatApps } from "./chat-apps.ts";
import { APP_WRITE_DECLINED, createChatAppsMcp, type ChatAppsMcp, type ChatAppsMcpOAuth, type ChatAppsMcpStore } from "./chat-apps-mcp.ts";
import { createAgentCard } from "./chat-widgets.ts";
import type { McpServerSpec } from "./contracts.ts";
import { McpOAuthManager, withMcpSignIn } from "./mcp-oauth.ts";
import { probeMcpServer, SIGN_IN_CHECK_TOOL } from "./mcp-probe.ts";
import type { McpRpcRequest } from "./routes/chat-apps.ts";
import type { BotRecord, Message } from "./store.ts";
import { removeTempDir } from "./testing/cleanup.ts";
import { startFakeHttpMcp, type FakeHttpMcp } from "./testing/fake-http-mcp-server.ts";
import { startFakeOAuth } from "./testing/fake-oauth-server.ts";

const FAKE_APP = fileURLToPath(new URL("./testing/fake-stdio-app-server.ts", import.meta.url));
const THREAD = "thread-scout";
const ROOM = "thread-room";
const BOARD_URI = "ui://fake-app/board.html";
const board = (env: Record<string, string> = {}) => ({ command: process.execPath, args: ["--experimental-strip-types", "--no-warnings", FAKE_APP], env });

const REPORT = {
  tool: "mcp_app",
  server: "board",
  serverInfo: { name: "fake-app", title: "Fake App", version: "1" },
  definition: { name: "show_board", title: "Show board", inputSchema: { type: "object" }, annotations: { readOnlyHint: true }, _meta: { ui: { resourceUri: BOARD_URI } } },
  call: { arguments: { cards: 3 }, toolUseId: "toolu_1" },
  result: { content: [{ type: "text", text: "Board with 3 cards" }], structuredContent: { cards: 3 }, _meta: { "fake-app/at": "now" } },
};

const cleanup: Array<() => Promise<void> | void> = [];
afterEach(async () => { for (const step of cleanup.splice(0).reverse()) await step(); });

function setup(options: { keys?: Parameters<typeof createChatAppsMcp>[0]["keys"]; servers?: Record<string, Record<string, unknown>>; scope?: BotRecord["toolScope"]; approval?: "ask" | "full"; needsSignIn?: string[]; probe?: typeof probeMcpServer; oauth?: ChatAppsMcpOAuth; startupProbes?: boolean; probeAs?: (server: { bot?: string }) => { id?: string; cwd?: string } } = {}) {
  const dataDir = mkdtempSync(join(tmpdir(), "omb-chat-apps-mcp-"));
  cleanup.push(() => removeTempDir(dataDir));
  const threads = new Map<string, Message[]>();
  const rows = (threadId: string) => threads.get(threadId) ?? threads.set(threadId, []).get(threadId)!;
  let next = 1;
  const bot = { id: "bot-scout", name: "Scout", color: "blue", threadId: THREAD, approvalMode: options.approval ?? "ask", ...(options.scope ? { toolScope: options.scope } : {}) } as unknown as BotRecord;
  const store: ChatAppsMcpStore = {
    bot: (id: string) => (id === bot.id ? bot : undefined),
    botByThread: (threadId: string) => (threadId === THREAD ? bot : undefined),
    groupByThread: (threadId: string) => (threadId === ROOM ? { id: "room-1" } : undefined),
    messagesFor: rows,
    appendMessage: vi.fn((threadId: string, message: Omit<Message, "id" | "at">): Message => {
      const full = { ...message, id: `m${next++}`, at: 1 } as Message;
      rows(threadId).push(full);
      return full;
    }),
    patchMessage: (threadId: string, messageId: string, patch: Partial<Message>) => {
      const list = rows(threadId);
      const index = list.findIndex((message) => message.id === messageId);
      if (index === -1) return null;
      list[index] = { ...list[index]!, ...patch };
      return list[index]!;
    },
    projectBotForTask: (botId: string) => (botId === bot.id ? bot : null),
  } as unknown as ChatAppsMcpStore;
  const servers: Record<string, Record<string, unknown>> = options.servers ?? { board: { ...board(), enabled: true } };
  const apps: ChatApps = createChatApps({ store: store as never, dataDir, sweeps: false, references: () => [] });
  const lines: string[] = [];
  const mcp: ChatAppsMcp = createChatAppsMcp({
    store, apps,
    storedServers: () => servers,
    mountedServers: () => Object.fromEntries(Object.entries(servers).filter(([, server]) => server.enabled !== false).map(([name, server]) => {
      const { enabled: _enabled, apps: _apps, ...spec } = server;
      return [name, ("url" in spec ? { headers: {}, ...spec } : { args: [], env: {}, ...spec }) as McpServerSpec];
    })),
    oauth: options.oauth ?? { authState: (name) => (options.needsSignIn?.includes(name) ? "needs-sign-in" : "none"), accessToken: async () => null, requireSignIn: () => {} },
    startupProbes: options.startupProbes ?? false,
    ...(options.probe ? { probe: options.probe } : {}),
    ...(options.probeAs ? { probeAs: options.probeAs } : {}),
    ...(options.keys ? { keys: options.keys } : {}),
    log: (line) => lines.push(line),
  });
  cleanup.push(() => { mcp.close(); apps.close(); });
  const record = (input: Record<string, unknown> = REPORT, threadId = THREAD, authored?: (id: string) => void) =>
    mcp.recordMcpAppCall({ bot, threadId, input, ...(authored ? { authored } : {}) });
  const row = (id: string, threadId = THREAD) => rows(threadId).find((message) => message.id === id) as Message & { app: AppPayload };
  const rpc = (messageId: string, method: McpRpcRequest["method"], params: Record<string, unknown> = {}) =>
    mcp.rpc({ auth: { kind: "loopback" } as never, bot, threadId: THREAD, row: row(messageId), method, params });
  return { store, apps, mcp, bot, servers, rows, row, record, rpc, lines };
}

describe("recordMcpAppCall", () => {
  it("turns a report into an app row: the page read from the server, its CSP filtered, the call stored for the frame", async () => {
    const { record, row, apps, mcp } = setup();
    const authored = vi.fn();
    expect(await record(REPORT, THREAD, authored)).toEqual({ ok: true, messageId: "m1" });
    expect(authored).toHaveBeenCalledWith("m1");
    const message = row("m1");
    expect(message).toMatchObject({ role: "bot", kind: "app", text: "Fake App · Show board\nBoard with 3 cards" });
    expect(message.app).toEqual({
      v: 1, source: "mcp", title: "Show board", doc: expect.stringMatching(/^[0-9a-f]{64}$/), rev: 0, bytes: expect.any(Number), border: true,
      network: { connect: ["https://api.fake-app.example"], resources: ["https://cdn.fake-app.example"], refused: ["https://bad.example/path"] },
      mcp: { server: "board", serverTitle: "Fake App", tool: "show_board", resourceUri: BOARD_URI },
    });
    const doc = apps.appDocument(THREAD, "m1")!;
    expect(doc.srcdoc).toContain("<h1>Fake board</h1>");
    expect(doc.csp).toContain("connect-src https://api.fake-app.example");
    expect(doc.csp).not.toContain("bad.example");
    expect(doc).toMatchObject({
      tool: { name: "show_board", _meta: { ui: { resourceUri: BOARD_URI } } },
      toolInput: { cards: 3 },
      toolResult: REPORT.result,
      can: { serverTools: true, serverResources: true },
    });
    expect(doc).not.toHaveProperty("approval");
    expect(doc).not.toHaveProperty("blocked");
    expect(mcp.catalog.get("board", board())).toMatchObject({ apps: true, tools: { show_board: { resourceUri: BOARD_URI, readOnly: true } } });
  }, 30_000);

  it("arrives through the internal card route's createAgentCard", async () => {
    const { apps, bot } = setup();
    const result = await createAgentCard({ store: { groupByThread: () => undefined, messagesFor: () => [], appendMessage: () => { throw new Error("unused"); }, patchMessage: () => null },
      bot: { id: bot.id, name: bot.name, color: "blue" }, threadId: THREAD, input: { ...REPORT, tool: "mcp_app" }, apps });
    expect(result).toEqual({ ok: true, messageId: "m1" });
    expect(await createAgentCard({ store: { groupByThread: () => undefined, messagesFor: () => [], appendMessage: () => { throw new Error("unused"); }, patchMessage: () => null },
      bot: { id: bot.id, name: bot.name, color: "blue" }, threadId: THREAD, input: { tool: "mcp_app" } })).toEqual({ ok: false, status: 400, error: "MCP apps are not available on this computer." });
  }, 30_000);

  it("shows a room member's app in the room as that bot's row, view-only for everyone", async () => {
    const { record, row, apps } = setup();
    expect(await record(REPORT, ROOM)).toEqual({ ok: true, messageId: "m1" });
    expect(row("m1", ROOM)).toMatchObject({ role: "bot", kind: "app", from: { botId: "bot-scout", name: "Scout", color: "blue" }, app: { source: "mcp", title: "Show board" } });
    const doc = apps.appDocument(ROOM, "m1")!;
    expect(doc.srcdoc).toContain("<h1>Fake board</h1>");
    expect(doc).toMatchObject({ room: true, toolResult: REPORT.result, can: { serverTools: false, serverResources: false, message: false, modelContext: false, openLinks: true } });
    expect(doc).not.toHaveProperty("blocked");
  }, 30_000);

  it("writes no row for an unmounted server, one outside the bot's scope, or one whose apps are off (rule 17)", async () => {
    const base = setup();
    expect(await base.record({ ...REPORT, server: "elsewhere" })).toMatchObject({ ok: false, status: 404 });
    expect(await base.record({ ...REPORT, definition: { ...REPORT.definition, _meta: {} } })).toMatchObject({ ok: false, status: 400 });
    expect(await base.record({ ...REPORT, result: { content: "x" } })).toMatchObject({ ok: false, status: 400 });
    const scoped = setup({ scope: { deny: ["mcp:board:*"] } });
    expect(await scoped.record()).toMatchObject({ ok: false, status: 404 });
    const off = setup({ servers: { board: { ...board(), enabled: true, apps: false } } });
    expect(await off.record()).toMatchObject({ ok: false, status: 409 });
    const disabled = setup({ servers: { board: { ...board(), enabled: false } } });
    expect(await disabled.record()).toMatchObject({ ok: false, status: 404 });
    for (const { rows } of [base, scoped, off, disabled]) {
      expect(rows(THREAD)).toEqual([]);
      expect(rows(ROOM)).toEqual([]);
    }
  });

  it("still writes the row, with its text and why, when the page cannot be read", async () => {
    const { record, row, apps } = setup({ servers: { board: { ...board({ FAKE_APP_MIME: "text/html" }), enabled: true } } });
    expect(await record()).toEqual({ ok: true, messageId: "m1" });
    expect(row("m1")).toMatchObject({ text: "Fake App · Show board\nBoard with 3 cards", app: { doc: "", blocked: "unavailable", mcp: { tool: "show_board" } } });
    expect(apps.appDocument(THREAD, "m1")).toBeNull();
    const big = setup({ servers: { board: { ...board({ FAKE_APP_HTML_BYTES: String(APP_LIMITS.mcpHtmlBytes + 10) }), enabled: true } } });
    await big.record();
    expect(big.row("m1").app.blocked).toBe("too-large");
  }, 30_000);
});

describe("access", () => {
  it("opens server calls while the server is configured, enabled, signed in and has apps on; otherwise says why", async () => {
    const live = setup();
    await live.record();
    const access = () => live.mcp.access(live.row("m1"), THREAD);
    expect(access()).toEqual({ can: { serverTools: true, serverResources: true, message: true, openLinks: true, modelContext: true } });
    live.servers.board = { ...board(), enabled: true, apps: false };
    expect(access()).toMatchObject({ can: { serverTools: false, serverResources: false, message: true }, blocked: { reason: "apps-off", text: "Apps from Fake App are switched off in Settings" } });
    live.servers.board = { ...board(), enabled: false };
    expect(access().blocked?.reason).toBe("server-disabled");
    delete live.servers.board;
    expect(access().blocked?.reason).toBe("server-removed");
    expect(live.apps.appDocument(THREAD, "m1")).toMatchObject({ can: { serverTools: false }, blocked: { reason: "server-removed" } });

    const signedOut = setup({ servers: { draw: { type: "http", url: "https://draw.example/mcp", enabled: true } }, needsSignIn: ["draw"] });
    const message = signedOut.store.appendMessage(THREAD, { role: "bot", kind: "app", text: "x",
      app: { v: 1, source: "mcp", title: "View", doc: "", rev: 0, bytes: 0, border: true, network: { connect: [], resources: [] }, mcp: { server: "draw", tool: "create_view", resourceUri: "ui://draw/v.html" } } });
    expect(signedOut.mcp.access(signedOut.row(message.id), THREAD).blocked).toEqual({ reason: "signed-out", text: "Sign in to draw again" });
  }, 30_000);
});

describe("rpc", () => {
  it("runs read-only and app-only tools at any level, and declines the rest outside Full access with no card (rule 15)", async () => {
    const { record, rpc, lines } = setup();
    await record();
    expect(await rpc("m1", "tools/call", { name: "show_board", arguments: { cards: 5 } })).toEqual({
      status: 200, body: { content: [{ type: "text", text: "Board with 5 cards" }], structuredContent: { cards: 5 }, _meta: { "fake-app/at": "2026-10-14T00:00:00Z" } },
    });
    // the app's own plumbing: app-only, not destructive
    expect(await rpc("m1", "tools/call", { name: "save_board", arguments: { state: "s" } })).toMatchObject({ status: 200, body: { content: [{ type: "text", text: "Saved" }] } });
    // app-only but destructive, and model-visible without readOnlyHint: the bot asks first, so the app may not
    expect(await rpc("m1", "tools/call", { name: "wipe_board", arguments: {} })).toEqual({
      status: 403, body: { error: { code: -32000, message: APP_WRITE_DECLINED } },
    });
    expect(await rpc("m1", "tools/call", { name: "paint_board", arguments: { color: "red" } })).toEqual({
      status: 403, body: { error: { code: -32000, message: APP_WRITE_DECLINED } },
    });
    // every call is logged, never its arguments
    expect(lines.filter((line) => line.startsWith("rpc "))).toEqual([
      "rpc bot=bot-scout server=board method=tools/call tool=show_board → 200",
      "rpc bot=bot-scout server=board method=tools/call tool=save_board → 200",
      "rpc bot=bot-scout server=board method=tools/call tool=wipe_board → 403",
      "rpc bot=bot-scout server=board method=tools/call tool=paint_board → 403",
    ]);
  }, 30_000);

  it("runs a write in Full access without asking", async () => {
    const { record, rpc } = setup({ approval: "full" });
    await record();
    expect(await rpc("m1", "tools/call", { name: "paint_board", arguments: { color: "blue" } })).toMatchObject({ status: 200, body: { content: [{ type: "text", text: "Painted blue" }] } });
    expect(await rpc("m1", "tools/call", { name: "wipe_board", arguments: {} })).toMatchObject({ status: 200 });
  }, 30_000);

  it("keeps a change the card made itself on its row, so a reopened conversation shows it done (#4532)", async () => {
    const { record, rpc, row } = setup({ approval: "full" });
    await record();
    // looking and the app's own plumbing are not changes
    await rpc("m1", "tools/call", { name: "show_board", arguments: { cards: 5 } });
    await rpc("m1", "tools/call", { name: "save_board", arguments: { state: "s" } });
    expect(row("m1").app.done).toBeUndefined();
    expect(await rpc("m1", "tools/call", { name: "paint_board", arguments: { color: "blue" } })).toMatchObject({ status: 200 });
    expect(row("m1").app.done).toEqual({ tool: "Paint board", text: "Painted blue", at: expect.any(Number) });
    // the row keeps everything else it had
    expect(row("m1").app).toMatchObject({ source: "mcp", mcp: { server: "board", tool: "show_board" } });
  }, 30_000);

  it("keeps no secret from the result in the note", async () => {
    const { record, rpc, row } = setup({ approval: "full" });
    await record();
    await rpc("m1", "tools/call", { name: "paint_board", arguments: { color: "ghp_abcdefghijklmnopqrstuvwxyz0123456789" } });
    expect(JSON.stringify(row("m1").app.done)).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz0123456789");
  }, 30_000);

  it("records nothing when the change is declined", async () => {
    const { record, rpc, row } = setup();
    await record();
    expect(await rpc("m1", "tools/call", { name: "paint_board", arguments: { color: "red" } })).toMatchObject({ status: 403 });
    expect(row("m1").app.done).toBeUndefined();
  }, 30_000);

  it("refuses a tool the server lacks, one its view may not call, one outside the selection, and bad arguments", async () => {
    const { record, rpc } = setup({ scope: { allow: ["mcp:board:show_board", "mcp:board:peek_board"] } });
    await record();
    expect(await rpc("m1", "tools/call", { name: "nope", arguments: {} })).toEqual({ status: 403, body: { error: { code: -32602, message: "This app's server has no tool by that name." } } });
    expect(await rpc("m1", "tools/call", { name: "peek_board", arguments: {} })).toEqual({ status: 403, body: { error: { code: -32602, message: "That tool is not available to apps." } } });
    expect(await rpc("m1", "tools/call", { name: "paint_board", arguments: { color: "red" } })).toEqual({ status: 403, body: { error: { code: -32602, message: "The bot's tool selection does not include that tool." } } });
    // app-only tools need only the server
    expect((await rpc("m1", "tools/call", { name: "save_board", arguments: {} })).status).toBe(200);
    expect((await rpc("m1", "tools/call", { name: "show_board", arguments: { cards: "three" } })).body).toMatchObject({ error: { code: -32602, message: expect.stringMatching(/^Invalid arguments: /) } });
    expect((await rpc("m1", "tools/call", { name: "show_board", arguments: { pad: "x".repeat(APP_LIMITS.rpcArgumentsBytes) } })).body).toEqual({ error: { code: -32602, message: "Those arguments are too large." } });
    expect((await rpc("m1", "tools/call", { arguments: {} })).status).toBe(400);
    // tools/list: what the app may call, after the selection
    expect(((await rpc("m1", "tools/list")).body.tools as Array<{ name: string }>).map((tool) => tool.name)).toEqual(["show_board", "save_board", "wipe_board"]);
    const read = await rpc("m1", "resources/read", { uri: BOARD_URI });
    expect(read).toMatchObject({ status: 200, body: { contents: [{ uri: BOARD_URI, mimeType: APP_MIME }] } });
    expect((await rpc("m1", "resources/list")).body).toMatchObject({ resources: [{ uri: BOARD_URI }] });
    expect(await rpc("m1", "resources/read", { uri: "ui://fake-app/missing.html" })).toEqual({ status: 502, body: { error: { code: -32002, message: "Resource not found" } } });
  }, 30_000);

  it("answers 410 once the server is removed or switched off, and 404 for a row with no server", async () => {
    const { record, rpc, servers, store, row, mcp, bot } = setup();
    await record();
    servers.board = { ...board(), enabled: true, apps: false };
    expect(await rpc("m1", "tools/list")).toEqual({ status: 410, body: { error: { code: -32000, message: "Apps from Fake App are switched off in Settings" }, blocked: { reason: "apps-off", text: "Apps from Fake App are switched off in Settings" } } });
    delete servers.board;
    expect((await rpc("m1", "tools/list")).body).toMatchObject({ blocked: { reason: "server-removed" } });
    const page = store.appendMessage(THREAD, { role: "bot", kind: "app", text: "x", app: { v: 1, source: "html", title: "p", doc: "", rev: 0, bytes: 0, border: true, network: { connect: [], resources: [] } } });
    expect((await mcp.rpc({ auth: {} as never, bot, threadId: THREAD, row: row(page.id), method: "tools/list", params: {} })).status).toBe(404);
  }, 30_000);

  it("answers 401 with the server to sign in to, when sign-in is due or the server refuses the credentials", async () => {
    let fake: FakeHttpMcp | undefined = await startFakeHttpMcp({ requireHeader: { name: "x-key", value: "right" }, tools: [] });
    cleanup.push(async () => { await fake?.close(); fake = undefined; });
    const draw = { type: "http", url: fake.url, enabled: true };
    const { rpc, store } = setup({ servers: { draw } });
    const app = { v: 1 as const, source: "mcp" as const, title: "View", doc: "", rev: 0, bytes: 0, border: true, network: { connect: [], resources: [] }, mcp: { server: "draw", tool: "create_view", resourceUri: "ui://draw/v.html" } };
    const message = store.appendMessage(THREAD, { role: "bot", kind: "app", text: "x", app });
    expect(await rpc(message.id, "tools/list")).toEqual({ status: 401, body: { error: { code: -32000, message: "Sign-in needed" }, signIn: { server: "draw" } } });
    const due = setup({ servers: { draw }, needsSignIn: ["draw"] });
    const other = due.store.appendMessage(THREAD, { role: "bot", kind: "app", text: "x", app });
    expect(await due.rpc(other.id, "tools/list")).toMatchObject({ status: 401, body: { signIn: { server: "draw" } } });
  }, 30_000);

  it("takes at most 60 calls a minute from one app row", async () => {
    const { record, rpc } = setup();
    await record();
    for (let index = 0; index < APP_LIMITS.rpcPerMinute; index += 1) expect((await rpc("m1", "tools/list")).status).toBe(200);
    expect(await rpc("m1", "tools/list")).toEqual({ status: 429, body: { error: { code: -32000, message: "Too many calls from this app. Wait a minute." } } });
  }, 30_000);
});

describe("mounts and probes", () => {
  it("mounts a server through an observer only once its catalog says it has apps and its switch is on", async () => {
    const probe = vi.fn(async () => ({ ok: false as const, error: "offline" }));
    const { mcp, servers } = setup({ probe });
    expect(mcp.turnMounts({ board: board() })).toEqual({});
    // a turn that found it unknown asks for one background probe
    await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1));
    mcp.catalog.recordList("board", board(), [{ name: "show_board", _meta: { ui: { resourceUri: BOARD_URI } } }], "ok");
    expect(mcp.turnMounts({ board: board(), plain: { command: "x", args: [], env: {} } })).toEqual({ mcpApps: { board: { extension: true } } });
    mcp.catalog.recordList("board", board(), [{ name: "show_board", _meta: { ui: { resourceUri: BOARD_URI } } }], "refused");
    expect(mcp.turnMounts({ board: board() })).toEqual({ mcpApps: { board: { extension: false } } });
    servers.board = { ...board(), enabled: true, apps: false };
    expect(mcp.turnMounts({ board: board() })).toEqual({});
    // a turn that finds it unknown again within ten minutes asks for none
    servers.board = { ...board(), enabled: true };
    mcp.catalog.forget("board");
    expect(mcp.turnMounts({ board: board() })).toEqual({});
    await new Promise((resolve) => setTimeout(resolve, 50));
    expect(probe).toHaveBeenCalledTimes(1);
  });

  it("starts a command server for its background probe the way the bots' turns start it, never beside the server", async () => {
    const probe = vi.fn(async () => ({ ok: false as const, error: "offline" }));
    const probeAs = vi.fn((server: { bot?: string }) => (server.bot ? { id: server.bot, cwd: `/data/workspaces/${server.bot}` } : {}));
    const { mcp, servers } = setup({ probe, probeAs, servers: { board: { ...board(), enabled: true }, own: { ...board(), enabled: true, bot: "bot-scout" } } });
    expect(mcp.turnMounts({ board: board(), own: board() })).toEqual({});
    await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(2));
    // `asBot` is what makes the probe ask for the bot box (server/mcp-probe.ts); without it the command runs unboxed.
    expect(probe.mock.calls.map((call) => (call as unknown as [unknown, unknown, unknown, { asBot?: unknown }])[3].asBot)).toEqual([{}, { id: "bot-scout", cwd: "/data/workspaces/bot-scout" }]);
    expect(Object.keys(servers)).toEqual(["board", "own"]);
  });

  it("tells Settings which stored servers have been seen to offer an app", () => {
    const { mcp, servers } = setup({ probe: vi.fn(async () => ({ ok: false as const, error: "offline" })) });
    servers.board = { ...board(), enabled: true };
    expect(mcp.hasApps("board")).toBeUndefined();
    mcp.catalog.recordList("board", board(), [{ name: "show_board", _meta: { ui: { resourceUri: BOARD_URI } } }], "ok");
    expect(mcp.hasApps("board")).toBe(true);
    mcp.catalog.recordList("board", board(), [{ name: "plain_tool" }], "ok");
    expect(mcp.hasApps("board")).toBe(false);
    expect(mcp.hasApps("nowhere")).toBeUndefined();
  });

  it("learns a server's apps from Settings → Test, and records a refused extension", async () => {
    const { mcp } = setup();
    const stored = { ...board(), enabled: true };
    expect(await probeMcpServer(stored, 15_000, undefined, mcp.probeOptions("board", stored))).toMatchObject({ ok: true });
    expect(mcp.catalog.get("board", stored)).toMatchObject({ apps: true, uiExtension: "ok", tools: { show_board: { readOnly: true }, save_board: { visibility: ["app"] }, peek_board: { visibility: ["model"] } } });
    const strict = { ...board({ FAKE_APP_STRICT: "1" }), enabled: true };
    expect(await probeMcpServer(strict, 15_000, undefined, mcp.probeOptions("strict", strict))).toMatchObject({ ok: true });
    expect(mcp.catalog.get("strict", strict)).toMatchObject({ apps: true, uiExtension: "refused" });
    // a server that shows its UI only to a client that asks
    const adapting = { ...board({ FAKE_APP_ADAPT: "1" }), enabled: true };
    await probeMcpServer(adapting, 15_000, undefined, mcp.probeOptions("adapting", adapting));
    expect(mcp.catalog.get("adapting", adapting)?.apps).toBe(true);
    // apps off: no extension, nothing recorded
    expect(mcp.probeOptions("off", { ...adapting, apps: false })).toEqual({});
  }, 60_000);

  it("probes a server a turn finds unknown in the background, at most every ten minutes", async () => {
    const { mcp } = setup();
    expect(mcp.turnMounts({ board: board() })).toEqual({});
    await vi.waitFor(() => expect(mcp.catalog.get("board", board())?.apps).toBe(true), { timeout: 20_000 });
    expect(mcp.turnMounts({ board: board() })).toEqual({ mcpApps: { board: { extension: true } } });
    mcp.forgetServer("board");
    expect(mcp.turnMounts({ board: board() })).toEqual({});
    await new Promise((resolve) => setTimeout(resolve, 1_000));
    expect(mcp.catalog.get("board", board())).toBeUndefined();
  }, 30_000);

  it("probes a saved URL server in the background, never starts a command server no turn would, and forgets a removed one", async () => {
    const fake = await startFakeHttpMcp({ tools: [{ name: "create_view", inputSchema: { type: "object" }, _meta: { ui: { resourceUri: "ui://draw/v.html" } } }] });
    cleanup.push(() => fake.close());
    const probe = vi.fn(probeMcpServer);
    const draw = { type: "http", url: fake.url, enabled: true };
    const { mcp } = setup({ servers: { board: { ...board(), enabled: true }, draw }, probe });
    mcp.serverChanged("board");
    // a bot whose selection excludes the server does not start it either
    mcp.turnMounts({ board: board() }, { allow: ["mcp:draw:*"] });
    mcp.serverChanged("draw");
    await vi.waitFor(() => expect(mcp.catalog.get("draw", draw)?.apps).toBe(true), { timeout: 20_000 });
    expect(probe.mock.calls.map(([server]) => "url" in server ? "draw" : "board")).toEqual(["draw"]);
    mcp.forgetServer("draw");
    expect(mcp.catalog.get("draw", draw)).toBeUndefined();
  }, 30_000);
});

describe("a server whose key is kept in the Vault", () => {
  it("is probed with the value read then, and not at all when its key is off", async () => {
    const probe = vi.fn<typeof probeMcpServer>(async () => ({ ok: false as const, error: "offline" }));
    const pointer = "vault:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
    const server = { type: "http", url: "https://mcp.example.test/mcp", headers: { Authorization: `Bearer ${pointer}` }, enabled: true };
    let open = true;
    const keys = {
      warm: vi.fn(async () => {}),
      resolve: vi.fn((_name: string, target: Record<string, unknown>) => (open ? { ok: true, server: { ...target, headers: { Authorization: "Bearer sk_fake-probe" } } } : { ok: false, reason: "removed", why: "gone" })),
    };
    const { mcp, lines } = setup({ servers: { board: server }, probe, keys: keys as never });
    mcp.serverChanged("board");
    await vi.waitFor(() => expect(probe).toHaveBeenCalled());
    expect((probe.mock.calls[0]![0] as unknown as { headers: Record<string, string> }).headers).toEqual({ Authorization: "Bearer sk_fake-probe" });
    expect(JSON.stringify(lines)).not.toContain("sk_fake-probe");
    probe.mockClear();
    open = false;
    mcp.serverChanged("board");
    await vi.waitFor(() => expect(keys.resolve).toHaveBeenCalledTimes(2));
    await new Promise<void>((done) => { setTimeout(done, 20); });
    expect(probe).not.toHaveBeenCalled();
  });
});

describe("a server that lists tools before anyone signs in", () => {
  const buy = { name: "buy", description: "Buy something" };

  async function agentcardShaped() {
    const issuer = await startFakeOAuth();
    const fake = await startFakeHttpMcp({ guard: "tools/call", acceptBearer: issuer.isValid, wwwAuthenticate: issuer.challenge, tools: [buy] });
    cleanup.push(() => fake.close(), () => issuer.close());
    return fake;
  }

  /** Starts the app with the saved servers, lets the 20 second startup probe fire, and returns the sign-in book it used. */
  async function startupProbe(servers: Record<string, Record<string, unknown>>) {
    vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
    cleanup.push(() => { vi.useRealTimers(); });
    const dataDir = mkdtempSync(join(tmpdir(), "omb-mcp-oauth-"));
    cleanup.push(() => removeTempDir(dataDir));
    const oauth = new McpOAuthManager({ file: join(dataDir, "mcp-oauth.json") });
    cleanup.push(() => oauth.dispose());
    const probe = vi.fn(probeMcpServer);
    setup({ servers, oauth, probe, startupProbes: true });
    await vi.advanceTimersByTimeAsync(20_000);
    await vi.waitFor(() => expect(probe).toHaveBeenCalled(), { timeout: 10_000 });
    await vi.waitFor(() => expect(probe.mock.results.length).toBeGreaterThan(0));
    const result = await probe.mock.results[0]!.value;
    await new Promise((resolve) => setImmediate(resolve));
    return { oauth, result };
  }

  it("marks an installed server that was never signed in, and drops it from bots' turns", async () => {
    const fake = await agentcardShaped();
    const agentcard = { type: "http", url: fake.url, enabled: true };
    const { oauth, result } = await startupProbe({ agentcard });
    expect(result).toMatchObject({ ok: false, auth: "required" });
    expect(oauth.authState("agentcard", fake.url)).toBe("needs-sign-in");
    expect(await withMcpSignIn({ agentcard: { type: "http", url: fake.url, headers: {} } }, oauth)).toEqual({});
    expect(fake.calls).toEqual([]);
  });

  it("never signs a signed-in server out when its Bearer is refused in the background", async () => {
    const issuer = await startFakeOAuth();
    const fake = await startFakeHttpMcp({ guard: "every-request", acceptBearer: () => false, wwwAuthenticate: issuer.challenge, tools: [buy] });
    cleanup.push(() => fake.close(), () => issuer.close());
    const dataDir = mkdtempSync(join(tmpdir(), "omb-mcp-oauth-"));
    cleanup.push(() => removeTempDir(dataDir));
    const oauth = new McpOAuthManager({ file: join(dataDir, "mcp-oauth.json") });
    cleanup.push(() => oauth.dispose());
    oauth.adopt("agentcard", fake.url, { url: fake.url, state: "signed-in", tokens: { access: "at_refused", expiresAt: Date.now() + 3_600_000 } });
    const probe = vi.fn(probeMcpServer);
    const { mcp } = setup({ servers: { agentcard: { type: "http", url: fake.url, enabled: true } }, oauth, probe });
    mcp.serverChanged("agentcard");
    await vi.waitFor(() => expect(probe).toHaveBeenCalled());
    expect(await probe.mock.results[0]!.value).toMatchObject({ ok: false, auth: "required" });
    await new Promise((resolve) => setImmediate(resolve));
    expect(oauth.authState("agentcard", fake.url)).toBe("signed-in");
    expect(await oauth.accessToken("agentcard", fake.url)).toBe("at_refused");
  });

  it("leaves a server that carries a key alone", async () => {
    const issuer = await startFakeOAuth();
    const fake = await startFakeHttpMcp({ requireHeader: { name: "x-api-key", value: "right" }, wwwAuthenticate: issuer.challenge, tools: [buy] });
    cleanup.push(() => fake.close(), () => issuer.close());
    const keyed = { type: "http", url: fake.url, headers: { "X-Api-Key": "wrong" }, enabled: true };
    const { oauth, result } = await startupProbe({ keyed });
    expect(result).toMatchObject({ ok: false, auth: "required" });
    expect(oauth.authState("keyed", fake.url)).toBe("none");
  });

  it("leaves an anonymous server alone when it answers the check with a JSON-RPC error", async () => {
    const fake = await startFakeHttpMcp({ tools: [buy], callError: () => ({ code: -32602, message: `Tool ${SIGN_IN_CHECK_TOOL} not found` }) });
    cleanup.push(() => fake.close());
    const { oauth, result } = await startupProbe({ open: { type: "http", url: fake.url, enabled: true } });
    expect(result).toMatchObject({ ok: true });
    expect(oauth.authState("open", fake.url)).toBe("none");
  });
});