authorgravatar for brycedbjork@gmail.comBryce Bjork <brycedbjork@gmail.com> 2026-10-12 23:02:15-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 22:02:15-05:00
logdc192851f0abfd812ffadc5b3eb1c5c152171cc5
tree79cb5efb15c0c504ba6a453d81b1e17b01a73c1f
parent5a65169bfc853b8817c3e53e223ffa33ac7d5467
signature Signed by PGP key B5690EEEBB952194

fix(server): Codex titles and branch names no longer start the user's MCP servers (#17058)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

2 files changed, 185 insertions(+), 13 deletions(-)

apps/server/src/textGeneration/CodexTextGeneration.test.ts+72
......@@ -40,6 +40,8 @@ interface FakeCodexInput {
4040 forbidArg?: string;
4141 stdinMustContain?: string;
4242 stdinMustNotContain?: string;
43 /** What `codex mcp list --json` answers; a number exits with that code instead. */
44 mcpServers?: ReadonlyArray<{ name: string; enabled: boolean }> | number;
4345}
4446
4547// The stub walks argv the way the shell script it replaced did: `--image`,
......@@ -56,6 +58,7 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) {
5658 forbidArg: input.forbidArg ?? null,
5759 stdinMustContain: input.stdinMustContain ?? null,
5860 stdinMustNotContain: input.stdinMustNotContain ?? null,
61 mcpServers: input.mcpServers ?? [],
5962 stderr: input.stderr ?? null,
6063 output: input.output,
6164 exitCode: input.exitCode ?? 0,
......@@ -69,6 +72,11 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) {
6972 'import * as NodeFS from "node:fs";',
7073 `const check = ${check};`,
7174 "const args = process.argv.slice(2);",
75 'if (args.includes("mcp") && args.includes("list")) {',
76 ' if (typeof check.mcpServers === "number") process.exit(check.mcpServers);',
77 " process.stdout.write(JSON.stringify(check.mcpServers));",
78 " process.exit(0);",
79 "}",
7280 'const originalArgs = ` ${args.join(" ")} `;',
7381 "let outputPath = null;",
7482 "let seenImage = false;",
......@@ -266,6 +274,70 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => {
266274 ),
267275 );
268276
277 it.effect("generates titles with the user's MCP servers and plugins off", () =>
278 withFakeCodexEnv(
279 {
280 output: JSON.stringify({ title: "Remove default shapes" }),
281 mcpServers: [
282 { name: "augmenter", enabled: true },
283 { name: "my.server", enabled: true },
284 { name: "already-off", enabled: false },
285 ],
286 requireArg:
287 '--config features.plugins=false --config features.apps=false --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}}',
288 },
289 (textGeneration) =>
290 Effect.gen(function* () {
291 const result = yield* textGeneration.generateThreadTitle({
292 cwd: process.cwd(),
293 message: "Augmenter task 1. Use the Augmenter MCP: link_thread when you start.",
294 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
295 });
296 expect(result.title).toBe("Remove default shapes");
297 }),
298 ),
299 );
300
301 it.effect("still generates when Codex cannot list its MCP servers", () =>
302 withFakeCodexEnv(
303 {
304 output: JSON.stringify({ branch: "remove-default-shapes" }),
305 mcpServers: 2,
306 requireArg: "--config features.plugins=false --config features.apps=false --ephemeral",
307 forbidArg: "mcp_servers=",
308 },
309 (textGeneration) =>
310 Effect.gen(function* () {
311 const result = yield* textGeneration.generateBranchName({
312 cwd: process.cwd(),
313 message: "Remove default shapes from characters",
314 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
315 });
316 expect(result.branch).toBe("remove-default-shapes");
317 }),
318 ),
319 );
320
321 it.effect("keeps plugins and apps off over launch args that turn them on", () =>
322 withFakeCodexEnv(
323 {
324 output: JSON.stringify({ title: "Remove default shapes" }),
325 launchArgs: "--config features.plugins=true --config features.apps=true",
326 requireArg:
327 "--config features.plugins=true --config features.apps=true --config features.plugins=false --config features.apps=false --ephemeral",
328 },
329 (textGeneration) =>
330 Effect.gen(function* () {
331 const result = yield* textGeneration.generateThreadTitle({
332 cwd: process.cwd(),
333 message: "Remove default shapes from characters",
334 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
335 });
336 expect(result.title).toBe("Remove default shapes");
337 }),
338 ),
339 );
340
269341 it.effect("passes exec-safe launch args into codex exec", () =>
270342 withFakeCodexEnv(
271343 {
apps/server/src/textGeneration/CodexTextGeneration.ts+113-13
......@@ -29,6 +29,30 @@ import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts";
2929import * as HostProcess from "@t3tools/shared/HostProcess";
3030
3131const CODEX_TIMEOUT_MS = 180_000;
32const CODEX_MCP_LIST_TIMEOUT_MS = 10_000;
33// Plugins and apps bring MCP servers of their own; the user's are listed and
34// turned off by name below. These follow the user's launch args, so they win
35// over a `-c features.plugins=true` there, which `--disable plugins` does not.
36const CODEX_NO_PLUGIN_ARGS = [
37 "--config",
38 "features.plugins=false",
39 "--config",
40 "features.apps=false",
41] as const;
42const CodexMcpServers = Schema.fromJsonString(
43 Schema.Array(Schema.Struct({ name: Schema.String, enabled: Schema.Boolean })),
44);
45const decodeCodexMcpServers = Schema.decodeEffect(CodexMcpServers);
46
47/**
48 * The `--config` value that turns off each MCP server Codex would start.
49 * `mcp_servers={}` merges into the user's servers rather than replacing them,
50 * so each is named; quoting the name keeps one with a dot a single key.
51 */
52const codexMcpServersOff = (names: ReadonlyArray<string>): string | null =>
53 names.length === 0
54 ? null
55 : `mcp_servers={${names.map((name) => `${JSON.stringify(name)}={enabled=false}`).join(",")}}`;
3256const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
3357/**
3458 * Build a Codex text-generation closure bound to a specific `CodexSettings`
......@@ -143,6 +167,67 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
143167 return imagePaths;
144168 });
145169
170 /**
171 * The MCP servers Codex would start for this configuration, by name. Text
172 * generation goes ahead with them listed as none if Codex cannot list them.
173 */
174 const listEnabledMcpServers = Effect.fn("listEnabledMcpServers")(
175 function* (input: {
176 readonly binary: string;
177 readonly cwd: string;
178 readonly env: NodeJS.ProcessEnv;
179 readonly launchArgs: ReadonlyArray<string>;
180 }) {
181 const spawnCommand = yield* resolveSpawnCommand(
182 input.binary,
183 [...input.launchArgs, ...CODEX_NO_PLUGIN_ARGS, "mcp", "list", "--json"],
184 { env: input.env },
185 );
186 const child = yield* commandSpawner.spawn(
187 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
188 env: input.env,
189 cwd: input.cwd,
190 shell: spawnCommand.shell,
191 }),
192 );
193 const [stdout, , exitCode] = yield* Effect.all(
194 [
195 child.stdout.pipe(Stream.decodeText(), Stream.mkString),
196 child.stderr.pipe(Stream.runDrain),
197 child.exitCode,
198 ],
199 { concurrency: "unbounded" },
200 );
201 if (exitCode !== 0) {
202 yield* Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", {
203 reason: "exit",
204 exitCode,
205 });
206 return [];
207 }
208 const servers = yield* decodeCodexMcpServers(stdout).pipe(
209 Effect.tapError(() =>
210 Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", {
211 reason: "decode",
212 }),
213 ),
214 );
215 return servers.filter((server) => server.enabled).map((server) => server.name);
216 },
217 Effect.scoped,
218 Effect.timeoutOption(CODEX_MCP_LIST_TIMEOUT_MS),
219 Effect.flatMap(
220 Option.match({
221 onNone: () =>
222 Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", {
223 reason: "timeout",
224 }).pipe(Effect.as<ReadonlyArray<string>>([])),
225 onSome: (names) => Effect.succeed(names),
226 }),
227 ),
228 Effect.orElseSucceed((): ReadonlyArray<string> => []),
229 );
230
146231 const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({
147232 operation,
148233 cwd,
......@@ -189,11 +274,36 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
189274 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
190275 DEFAULT_TEXT_GENERATION_REASONING_EFFORT;
191276 const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection);
277 const binary = effectiveConfig.binaryPath || "codex";
278 const env = {
279 ...effectiveEnvironment,
280 ...(effectiveConfig.homePath
281 ? {
282 CODEX_HOME: expandHomePath(
283 effectiveConfig.homePath,
284 yield* HostProcess.HomeDirectory,
285 ),
286 }
287 : {}),
288 };
289 const execLaunchArgs = codexExecLaunchArgs(launchArgs);
290 const mcpServersOff = yield* listEnabledMcpServers({
291 binary,
292 cwd,
293 env,
294 // `codex mcp` rejects --strict-config; the overrides still apply.
295 launchArgs: execLaunchArgs.filter((arg) => arg !== "--strict-config"),
296 }).pipe(Effect.map(codexMcpServersOff));
192297 const spawnCommand = yield* resolveSpawnCommand(
193 effectiveConfig.binaryPath || "codex",
298 binary,
194299 [
195300 "exec",
196 ...codexExecLaunchArgs(launchArgs),
301 ...execLaunchArgs,
302 // Text generation needs only the prompt. A user's MCP server would
303 // otherwise start and could act on it, as Claude's --strict-mcp-config
304 // prevents for Claude.
305 ...CODEX_NO_PLUGIN_ARGS,
306 ...(mcpServersOff ? ["--config", mcpServersOff] : []),
197307 "--ephemeral",
198308 "--skip-git-repo-check",
199309 "-s",
......@@ -213,17 +323,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
213323 { env: effectiveEnvironment },
214324 );
215325 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
216 env: {
217 ...effectiveEnvironment,
218 ...(effectiveConfig.homePath
219 ? {
220 CODEX_HOME: expandHomePath(
221 effectiveConfig.homePath,
222 yield* HostProcess.HomeDirectory,
223 ),
224 }
225 : {}),
226 },
326 env,
227327 cwd,
228328 shell: spawnCommand.shell,
229329 stdin: {