Skip to content

Commit 21f4bc8

Browse files
committed
fix codex crash from nested mcp_servers tables
1 parent a07cbdd commit 21f4bc8

3 files changed

Lines changed: 71 additions & 1 deletion

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const readFileSync = vi.hoisted(() => vi.fn());
4+
vi.mock("node:fs", () => ({ readFileSync }));
5+
6+
const { CodexSettingsManager } = await import("./settings");
7+
8+
function serverNamesFor(toml: string): string[] {
9+
readFileSync.mockReturnValue(toml);
10+
return new CodexSettingsManager("/repo").getSettings().mcpServerNames.sort();
11+
}
12+
13+
describe("CodexSettingsManager MCP server names", () => {
14+
// Regression: a `[mcp_servers.<name>.env]` table was treated as its own
15+
// server, so the spawn emitted `mcp_servers.<name>.env.enabled=false`, which
16+
// sets a boolean on codex's string-typed env map. codex-acp then rejected the
17+
// whole config, crashed the session, and the host silently ran Claude/Opus.
18+
it("collapses nested mcp_servers sub-tables to the parent server name", () => {
19+
expect(
20+
serverNamesFor(
21+
[
22+
"[mcp_servers.node_repl]",
23+
'command = "node"',
24+
"[mcp_servers.node_repl.env]",
25+
'FOO = "bar"',
26+
"[mcp_servers.other]",
27+
'command = "x"',
28+
].join("\n"),
29+
),
30+
).toEqual(["node_repl", "other"]);
31+
});
32+
33+
it("keeps the inner name for a quoted dotted server key", () => {
34+
expect(
35+
serverNamesFor(['[mcp_servers."my.server"]', 'command = "x"'].join("\n")),
36+
).toEqual(["my.server"]);
37+
});
38+
39+
it("returns no servers when none are declared", () => {
40+
expect(serverNamesFor('model = "gpt-5.5"')).toEqual([]);
41+
});
42+
});

packages/agent/src/adapters/codex/settings.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ export class CodexSettingsManager {
7171
}
7272
}
7373

74+
/**
75+
* Extracts the server name from a `mcp_servers.<name>...` section path, taking
76+
* only the first key segment. A nested table like `mcp_servers.foo.env`
77+
* describes the `env` field of server `foo`, not a separate server, so it must
78+
* collapse to `foo`. Treating it as its own server emits
79+
* `mcp_servers.foo.env.enabled=false`, which sets a boolean on the string-typed
80+
* env map and makes codex-acp reject the whole config (it then crashes and the
81+
* host silently falls back to Claude). Quoted segments (`"a.b"`) keep their dots.
82+
*/
83+
function firstMcpServerName(sectionPath: string): string | null {
84+
const trimmed = sectionPath.trim();
85+
if (!trimmed) return null;
86+
const quoted = trimmed.match(/^(["'])(.*?)\1/);
87+
if (quoted) return quoted[2] ?? null;
88+
const dotIndex = trimmed.indexOf(".");
89+
return dotIndex === -1 ? trimmed : trimmed.slice(0, dotIndex);
90+
}
91+
7492
/**
7593
* Minimal TOML parser for codex config.toml.
7694
* Handles flat key=value pairs and [projects."path"] sections.
@@ -90,7 +108,10 @@ function parseCodexToml(content: string, cwd: string): CodexSettings {
90108
if (sectionMatch) {
91109
currentSection = sectionMatch[1] ?? "";
92110
if (currentSection.startsWith("mcp_servers.")) {
93-
mcpServerNames.add(currentSection.slice("mcp_servers.".length));
111+
const serverName = firstMcpServerName(
112+
currentSection.slice("mcp_servers.".length),
113+
);
114+
if (serverName) mcpServerNames.add(serverName);
94115
}
95116
continue;
96117
}

packages/agent/src/adapters/codex/spawn.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ function buildConfigArgs(options: CodexProcessOptions): string[] {
3636
// Disable the user's local MCPs one-by-one so Codex only uses the MCPs we
3737
// provide via ACP. We can't use `-c mcp_servers={}` because that makes Codex
3838
// ignore MCPs entirely, including the ones we inject later.
39+
//
40+
// Only bare-key names are emitted: codex's `-c` parser rejects quoted key
41+
// segments, so a name with a dot or other special character cannot be
42+
// expressed as `mcp_servers.<name>.enabled=false` without producing an
43+
// override that fails to load and crashes the whole codex session. Skipping
44+
// such a name leaves that server enabled (harmless) instead of killing codex.
3945
for (const name of options.settings?.mcpServerNames ?? []) {
46+
if (!/^[A-Za-z0-9_-]+$/.test(name)) continue;
4047
args.push("-c", `mcp_servers.${name}.enabled=false`);
4148
}
4249

0 commit comments

Comments
 (0)