openclaw / src /agents /cli-runner /bundle-mcp-codex.user-config.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
e249c6d verified
Raw History Blame Contribute Delete
20.9 kB
/** 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");
});
});