/** Tests projecting OpenClaw user MCP servers into Codex app-server config. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { buildCodexUserMcpServersThreadConfigPatchForRuntime } from "./bundle-mcp-codex.js"; const authMocks = vi.hoisted(() => ({ loadExecApprovalsReadOnlyAsync: vi.fn(), loadAuthProfileStoreForSecretsRuntime: vi.fn(), resolveApiKeyForProfile: vi.fn(), resolveMcpOAuthAccessToken: vi.fn(), })); vi.mock("../../infra/exec-approvals-store.js", () => ({ loadExecApprovalsReadOnlyAsync: authMocks.loadExecApprovalsReadOnlyAsync, })); vi.mock("../auth-profiles/store-runtime.js", () => ({ loadAuthProfileStoreForSecretsRuntime: authMocks.loadAuthProfileStoreForSecretsRuntime, })); vi.mock("../auth-profiles/oauth.js", () => ({ resolveApiKeyForProfile: authMocks.resolveApiKeyForProfile, })); vi.mock("../mcp-oauth.js", () => ({ resolveMcpOAuthAccessToken: authMocks.resolveMcpOAuthAccessToken, })); describe("buildCodexUserMcpServersThreadConfigPatchForRuntime", () => { beforeEach(() => { authMocks.loadExecApprovalsReadOnlyAsync .mockReset() .mockResolvedValue({ version: 1, agents: {} }); authMocks.loadAuthProfileStoreForSecretsRuntime.mockReset(); authMocks.resolveApiKeyForProfile.mockReset(); authMocks.resolveMcpOAuthAccessToken.mockReset(); }); it("projects exact-agent durable grants with server policy precedence in runtime preparation", async () => { const grants = ["auto", "unspecified", "prompt", "approve", "missing"].map((server) => ({ server, tool: "write.raw_tool", source: "allow-always", addedAt: 1, })); authMocks.loadExecApprovalsReadOnlyAsync.mockResolvedValue({ version: 1, agents: { main: { mcpTools: grants }, other: { mcpTools: [{ ...grants[0], tool: "other_tool" }] }, "*": { mcpTools: [{ ...grants[0], tool: "wildcard_tool" }] }, }, }); const cfg = { mcp: { servers: { auto: { command: "mcp", codex: { defaultToolsApprovalMode: "auto" } }, unspecified: { command: "mcp", toolFilter: { exclude: ["write.raw_tool"] } }, prompt: { command: "mcp", codex: { defaultToolsApprovalMode: "prompt" } }, approve: { command: "mcp", codex: { defaultToolsApprovalMode: "approve" } }, }, }, } satisfies OpenClawConfig; const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime(cfg, { agentId: "main", }); expect(patch?.mcp_servers.auto).toEqual({ command: "mcp", default_tools_approval_mode: "auto", tools: { "write.raw_tool": { approval_mode: "approve" } }, }); expect(patch?.mcp_servers.unspecified).toEqual({ command: "mcp", disabled_tools: ["write.raw_tool"], tools: { "write.raw_tool": { approval_mode: "approve" } }, }); expect(patch?.mcp_servers.prompt).toEqual({ command: "mcp", default_tools_approval_mode: "prompt", }); expect(patch?.mcp_servers.approve).toEqual({ command: "mcp", default_tools_approval_mode: "approve", }); expect(patch?.mcp_servers.missing).toBeUndefined(); expect(authMocks.loadExecApprovalsReadOnlyAsync).toHaveBeenCalledTimes(1); expect( (await buildCodexUserMcpServersThreadConfigPatchForRuntime(cfg))?.mcp_servers.auto, ).not.toHaveProperty("tools"); }); it("returns undefined when cfg has no mcp.servers (regression: #80814)", async () => { expect(await buildCodexUserMcpServersThreadConfigPatchForRuntime(undefined)).toBeUndefined(); expect( await buildCodexUserMcpServersThreadConfigPatchForRuntime({} as OpenClawConfig), ).toBeUndefined(); expect( await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: {} } as OpenClawConfig), ).toBeUndefined(); expect( await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: {} }, } as OpenClawConfig), ).toBeUndefined(); }); it("projects session server and tool overrides into user MCP config", async () => { const cfg = { mcp: { servers: { disabledDocs: { enabled: false, url: "https://disabled-docs.example.com/mcp", }, constructor: { url: "https://constructor.example.com/mcp", }, search: { url: "https://search.example.com/mcp", }, }, }, } satisfies OpenClawConfig; const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime(cfg, { toolOverrides: { mcpServers: { disabledDocs: true, search: false }, mcpToolsDeny: { disabledDocs: ["delete_page"] }, }, }); expect(patch).toEqual({ mcp_servers: { constructor: { url: "https://constructor.example.com/mcp", }, disabledDocs: { url: "https://disabled-docs.example.com/mcp", disabled_tools: ["delete_page"], }, }, }); }); it("projects a stdio user MCP server entry into mcp_servers (regression: #80814)", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { outlook: { transport: "stdio", command: "node", args: ["/opt/outlook-mcp/dist/index.js"], env: { OUTLOOK_USER: "alice@example.org" }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { outlook: { command: "node", args: ["/opt/outlook-mcp/dist/index.js"], env: { OUTLOOK_USER: "alice@example.org" }, }, }, }); }); it("projects a streamable-http user MCP server with bearer auth into mcp_servers", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { notes: { transport: "streamable-http", url: "https://notes.example.org/mcp", headers: { Authorization: "Bearer ${NOTES_TOKEN}", "x-tenant": "${NOTES_TENANT}", }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { notes: { url: "https://notes.example.org/mcp", bearer_token_env_var: "NOTES_TOKEN", env_http_headers: { "x-tenant": "NOTES_TENANT" }, }, }, }); }); it("projects Codex-specific default tool approval mode", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { search: { transport: "streamable-http", url: "https://mcp.example.com/mcp", codex: { defaultToolsApprovalMode: "approve", }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { search: { url: "https://mcp.example.com/mcp", default_tools_approval_mode: "approve", }, }, }); }); it("projects exact OpenClaw MCP tool filters into Codex-native tool filters", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { docs: { transport: "streamable-http", url: "https://docs.example.com/mcp", toolFilter: { include: ["search_docs", "read_docs"], exclude: ["delete_docs"], }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { docs: { url: "https://docs.example.com/mcp", enabled_tools: ["search_docs", "read_docs"], disabled_tools: ["delete_docs"], }, }, }); }); it("rejects wildcard OpenClaw MCP tool filters that Codex cannot project exactly", async () => { await expect( buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { docs: { transport: "streamable-http", url: "https://docs.example.com/mcp", toolFilter: { include: ["search_*"], }, }, }, }, } as unknown as OpenClawConfig), ).rejects.toThrow( 'Cannot project mcp.servers.docs.toolFilter.include pattern "search_*" into Codex enabled_tools', ); }); it("uses the Codex-native approval spelling when configured", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { search: { transport: "streamable-http", url: "https://mcp.example.com/mcp", codex: { default_tools_approval_mode: "prompt", }, }, }, }, } as unknown as OpenClawConfig); expect(patch?.mcp_servers.search).toMatchObject({ url: "https://mcp.example.com/mcp", default_tools_approval_mode: "prompt", }); }); it("filters Codex-scoped user MCP servers by OpenClaw agent id", async () => { // Agent-scoped MCP servers should follow the active OpenClaw agent, while // unscoped servers remain global. const cfg = { mcp: { servers: { atlas: { transport: "streamable-http", url: "https://atlas.example.com/mcp", codex: { agents: ["atlas"] }, }, apolo: { transport: "streamable-http", url: "https://apolo.example.com/mcp", codex: { agents: ["apolo"] }, }, global: { transport: "stdio", command: "node", args: ["global-mcp.js"], }, }, }, } as unknown as OpenClawConfig; const atlasPatch = await buildCodexUserMcpServersThreadConfigPatchForRuntime(cfg, { agentId: "atlas", }); expect(Object.keys(atlasPatch!.mcp_servers).toSorted()).toEqual(["atlas", "global"]); expect(atlasPatch!.mcp_servers.atlas).toMatchObject({ url: "https://atlas.example.com/mcp" }); expect(atlasPatch!.mcp_servers.global).toMatchObject({ command: "node", args: ["global-mcp.js"], }); const apoloPatch = await buildCodexUserMcpServersThreadConfigPatchForRuntime(cfg, { agentId: "apolo", }); expect(Object.keys(apoloPatch!.mcp_servers).toSorted()).toEqual(["apolo", "global"]); expect(apoloPatch!.mcp_servers.apolo).toMatchObject({ url: "https://apolo.example.com/mcp" }); }); it("returns undefined when all user MCP servers are scoped to other agents", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime( { mcp: { servers: { atlas: { transport: "streamable-http", url: "https://atlas.example.com/mcp", codex: { agents: ["atlas"] }, }, }, }, } as unknown as OpenClawConfig, { agentId: "apolo" }, ); expect(patch).toBeUndefined(); }); it("omits disabled user MCP servers from Codex app-server projection", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { disabled: { enabled: false, transport: "streamable-http", url: "https://disabled.example.com/mcp", }, enabled: { transport: "stdio", command: "node", args: ["enabled-mcp.js"], }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { enabled: { command: "node", args: ["enabled-mcp.js"], }, }, }); }); it("normalizes Codex agent scopes before matching", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime( { mcp: { servers: { atlas: { transport: "streamable-http", url: "https://atlas.example.com/mcp", codex: { agents: ["Atlas"] }, }, }, }, } as unknown as OpenClawConfig, { agentId: "ATLAS" }, ); expect(patch?.mcp_servers.atlas).toMatchObject({ url: "https://atlas.example.com/mcp", }); }); it("fails closed for empty or invalid Codex agent scopes", async () => { const cfg = { mcp: { servers: { empty: { transport: "streamable-http", url: "https://empty.example.com/mcp", codex: { agents: [] }, }, blank: { transport: "streamable-http", url: "https://blank.example.com/mcp", codex: { agents: [" "] }, }, invalid: { transport: "streamable-http", url: "https://invalid.example.com/mcp", codex: { agents: ["", 1, null, "!!!", "-main-"] }, }, global: { transport: "stdio", command: "node", args: ["global-mcp.js"], }, }, }, } as unknown as OpenClawConfig; const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime(cfg, { agentId: "atlas", }); expect(patch).toStrictEqual({ mcp_servers: { global: { command: "node", args: ["global-mcp.js"], }, }, }); }); it("omits scoped Codex MCP servers when no OpenClaw agent id is available", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { atlas: { transport: "streamable-http", url: "https://atlas.example.com/mcp", codex: { agents: ["atlas"] }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toBeUndefined(); }); it("preserves multiple user MCP servers as independent mcp_servers entries", async () => { const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { one: { transport: "stdio", command: "one" }, two: { transport: "stdio", command: "two" }, }, }, } as unknown as OpenClawConfig); expect(patch?.mcp_servers).toBeDefined(); expect(Object.keys(patch!.mcp_servers).toSorted()).toEqual(["one", "two"]); expect(patch!.mcp_servers.one).toMatchObject({ command: "one" }); expect(patch!.mcp_servers.two).toMatchObject({ command: "two" }); }); it("projects auth-profile backed user MCP servers with a fresh bearer header at runtime", async () => { authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValueOnce({ version: 1, profiles: { "ducktape:mcp": { type: "oauth", provider: "ducktape", access: "expired-access", refresh: "refresh-token-must-not-project", expires: 1, }, }, }); authMocks.resolveApiKeyForProfile.mockResolvedValueOnce({ apiKey: "fresh-access-token", provider: "ducktape", profileId: "ducktape:mcp", profileType: "oauth", credential: { type: "oauth", provider: "ducktape", access: "fresh-access-token", refresh: "refresh-token-must-not-project", expires: Date.now() + 60_000, }, }); const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { ducktape: { transport: "streamable-http", url: "https://agents.ducktape.xyz/mcp", auth: "oauth", oauth: { authProfileId: "ducktape:mcp" }, headers: { Authorization: "Bearer stale-access", "x-tenant": "keep", }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { ducktape: { url: "https://agents.ducktape.xyz/mcp", http_headers: { Authorization: "Bearer fresh-access-token", "x-tenant": "keep", }, }, }, }); expect(JSON.stringify(patch)).not.toContain("refresh-token-must-not-project"); }); it("projects MCP-native OAuth credentials into local Codex runtime config", async () => { authMocks.resolveMcpOAuthAccessToken.mockResolvedValueOnce("native-access-token"); const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { docs: { transport: "streamable-http", url: "https://mcp.example.com/mcp", auth: "oauth", oauth: { scope: "docs.read" }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { docs: { url: "https://mcp.example.com/mcp", http_headers: { Authorization: "Bearer native-access-token" }, }, }, }); }); it("omits MCP-native OAuth servers that still need authorization", async () => { authMocks.resolveMcpOAuthAccessToken.mockRejectedValueOnce( new Error('MCP server "gbrain" requires OAuth authorization.'), ); const onServerUnavailable = vi.fn(); const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime( { mcp: { servers: { gbrain: { transport: "streamable-http", url: "https://gbrain.example.com/mcp", auth: "oauth", }, localTools: { transport: "stdio", command: "local-tools", }, }, }, } as unknown as OpenClawConfig, { onServerUnavailable }, ); expect(patch).toStrictEqual({ mcp_servers: { localTools: { command: "local-tools" }, }, }); expect(onServerUnavailable).toHaveBeenCalledWith("gbrain", expect.any(Error)); }); it("omits MCP-native OAuth projection from a remote Codex app-server", async () => { const onServerUnavailable = vi.fn(); await expect( buildCodexUserMcpServersThreadConfigPatchForRuntime( { mcp: { servers: { docs: { transport: "streamable-http", url: "https://mcp.example.com/mcp", auth: "oauth", }, }, }, } as unknown as OpenClawConfig, { allowLiteralOAuthProjection: false, onServerUnavailable }, ), ).resolves.toBeUndefined(); expect(onServerUnavailable).toHaveBeenCalledWith("docs", expect.any(Error)); expect(authMocks.resolveMcpOAuthAccessToken).not.toHaveBeenCalled(); }); it("preserves tool filters while projecting auth-profile backed MCP bearers at runtime", async () => { authMocks.loadAuthProfileStoreForSecretsRuntime.mockReturnValueOnce({ version: 1, profiles: { "ducktape:mcp": { type: "oauth", provider: "ducktape", access: "expired-access", refresh: "refresh-token-must-not-project", expires: 1, }, }, }); authMocks.resolveApiKeyForProfile.mockResolvedValueOnce({ apiKey: "fresh-access-token", provider: "ducktape", profileId: "ducktape:mcp", profileType: "oauth", credential: { type: "oauth", provider: "ducktape", access: "fresh-access-token", refresh: "refresh-token-must-not-project", expires: Date.now() + 60_000, }, }); const patch = await buildCodexUserMcpServersThreadConfigPatchForRuntime({ mcp: { servers: { ducktape: { transport: "streamable-http", url: "https://agents.ducktape.xyz/mcp", auth: "oauth", oauth: { authProfileId: "ducktape:mcp" }, headers: { Authorization: "Bearer stale-access", }, toolFilter: { include: ["proof_echo", "proof_search"], exclude: ["admin_delete"], }, }, }, }, } as unknown as OpenClawConfig); expect(patch).toStrictEqual({ mcp_servers: { ducktape: { url: "https://agents.ducktape.xyz/mcp", http_headers: { Authorization: "Bearer fresh-access-token", }, enabled_tools: ["proof_echo", "proof_search"], disabled_tools: ["admin_delete"], }, }, }); expect(JSON.stringify(patch)).not.toContain("refresh-token-must-not-project"); }); });