app-server: `thread/start` emits duplicate MCP startup notifications, including a spurious `cancelled`, for servers that were only started once

Open 💬 0 comments Opened Aug 3, 2026 by hampsterx

What happens

A single thread/start on codex app-server emits more mcpServer/startupStatus/updated notifications than there were startup attempts.

Two things are duplicated, one always and one intermittently:

  • Always: every server reports its terminal state twice. ready (or failed) arrives, then arrives again at a later timestamp.
  • Intermittently: some or all servers also emit a second starting, and a cancelled in between.

So a consumer sees sequences like starting -> ready -> ready, or starting -> cancelled -> starting -> ready, for a server that started once and is healthy throughout. All of it carries a single threadId.

The cancelled is the part that causes real trouble, because in this flow it is transient and belongs to nothing: the server was not cancelled and did not restart.

The processes are not re-spawned. That is worth stating up front because the notification stream implies otherwise. With MCP servers that log every invocation, the spawn count is exactly one per server on every run, including runs where that server emitted two starting notifications. This is a notification-stream problem, not duplicated work.

Reproduction

codex-cli 0.146.0, Linux. Self-contained: a throwaway CODEX_HOME with three stdio MCP servers that append a line to a log each time they are executed, so notifications and actual spawns can be compared directly.

1. The fake MCP server (fake-mcp.mjs) - logs its spawn, handshakes, exposes no tools, idles:

import { appendFileSync } from "node:fs";
const name = process.argv[2] ?? "unnamed";
appendFileSync(process.env.SPAWN_LOG, `${Date.now()} ${name} pid=${process.pid}\n`);
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => {
  buf += c;
  for (;;) {
    const i = buf.indexOf("\n");
    if (i < 0) break;
    const line = buf.slice(0, i);
    buf = buf.slice(i + 1);
    if (!line.trim()) continue;
    let m; try { m = JSON.parse(line); } catch { continue; }
    if (m.method === "initialize") {
      process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, result: {
        protocolVersion: "2024-11-05", capabilities: { tools: {} },
        serverInfo: { name, version: "1.0.0" } } }) + "\n");
    } else if (m.method === "tools/list") {
      process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, result: { tools: [] } }) + "\n");
    } else if (typeof m.id === "number") {
      process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: m.id, result: {} }) + "\n");
    }
  }
});
setInterval(() => {}, 1 << 30);

2. $CODEX_HOME/config.toml, three servers pointing at it ($D is the directory holding the files, auth.json copied in from your real CODEX_HOME):

[mcp_servers.alpha]
command = "node"
args = ["$D/fake-mcp.mjs", "alpha"]
env = { SPAWN_LOG = "$D/spawns.log" }

# ... bravo and charlie identical

3. The driver (rounds.mjs) - initialize, initialized, thread/start, then group the startup notifications by server:

import { spawn } from "node:child_process";

const t0 = Date.now();
const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
child.stderr.on("data", () => {});
child.stdout.setEncoding("utf8");

const notes = [];
const pending = new Map();
let buf = "";
let id = 1;

child.stdout.on("data", (chunk) => {
  buf += chunk;
  for (;;) {
    const i = buf.indexOf("\n");
    if (i < 0) break;
    const line = buf.slice(0, i);
    buf = buf.slice(i + 1);
    if (!line.trim()) continue;
    let m;
    try { m = JSON.parse(line); } catch { continue; }
    if (typeof m.id === "number" && ("result" in m || "error" in m)) {
      pending.get(m.id)?.(m);
      pending.delete(m.id);
    } else if (m.method === "mcpServer/startupStatus/updated") {
      notes.push({ at: Date.now() - t0, ...m.params });
    }
  }
});

const req = (method, params = {}) =>
  new Promise((res) => {
    const n = id++;
    pending.set(n, res);
    child.stdin.write(JSON.stringify({ id: n, method, params }) + "\n");
  });

await req("initialize", { clientInfo: { name: "repro", title: "repro", version: "0.0.0" } });
child.stdin.write(JSON.stringify({ method: "initialized", params: {} }) + "\n");
await req("thread/start", { ephemeral: true });
await new Promise((r) => setTimeout(r, 8000));

const byServer = new Map();
for (const n of notes) {
  if (!byServer.has(n.name)) byServer.set(n.name, []);
  byServer.get(n.name).push(n);
}
console.log(`threadIds=${new Set(notes.map((n) => n.threadId)).size}`);
for (const [name, seq] of [...byServer].sort()) {
  console.log(`${name.padEnd(12)} ${seq.map((n) => `${n.status}@${n.at}`).join(" -> ")}`);
}
child.kill("SIGTERM");
process.exit(0);

4. Run, clearing the spawn log first:

rm -f $D/spawns.log && touch $D/spawns.log
CODEX_HOME=$D/home node rounds.mjs
cat $D/spawns.log

Result, one representative run:

threadIds=1
alpha        starting@302 -> ready@326 -> ready@1275
bravo        starting@302 -> ready@356 -> ready@1275
charlie      starting@302 -> ready@327 -> ready@1275
codex_apps   starting@302 -> starting@597 -> ready@1057 -> ready@1275
1785728499791 alpha pid=2095709
1785728499792 charlie pid=2095710
1785728499809 bravo pid=2095708

Three servers, three spawns, but each reports ready twice. The built-in codex_apps also gets a second starting.

On slower runs the second round is fuller. Same config, thread/start taking ~2.9s instead of ~110ms:

alpha, bravo, charlie, codex_apps:  4 notifications each, 2x starting each
spawns:  alpha=1 bravo=1 charlie=1

Still one spawn per server.

Scope

Across 12 runs on two configurations (the 3-server one above, and a real 12-server one):

  • Spawn count was one per server in every run, including every run with two starting notifications.
  • The duplicate terminal state appeared in all 12 runs. The second starting and the cancelled appeared in some runs and not others, sometimes for only a subset of servers in the same run.
  • It correlates with how long thread/start takes. Runs where it returned in ~100ms tended to produce one starting per server; runs at 2-10s produced two. This is a correlation over 12 runs, not a proven mechanism.
  • ephemeral: true and ephemeral: false both produce it.
  • Not the client's declared capabilities: omitting capabilities from initialize changes nothing.
  • thread/start is required. A session that calls initialize + initialized + mcpServerStatus/list and never starts a thread returns the full server list with zero startup notifications.
  • One threadId throughout, so this is not two threads racing.

Why this seems worth reporting

cancelled is ambiguous on the wire, and a consumer cannot resolve it.

Here it is transient and meaningless: the server was not cancelled, was not restarted, and reaches ready shortly after. But cancelled is presumably genuine when a thread is actually cancelled or the app-server shuts down. Nothing in the notification distinguishes the two.

That makes the obvious merge rules unsafe. "First terminal state wins" reports a healthy server as cancelled whenever the spurious cancelled arrives before its ready, which happened for most servers on the slower runs. "Stop draining once no server is starting" can fire in the gap between rounds. Both look correct against a one-round model and fail quietly against the real one. What survives is: order by arrival, discard cancelled outright, key on (threadId, name) - which is only safe because the transient case is currently the common one, and would break if cancelled ever needs to be believed.

The duplicate terminal state is milder but has the same shape: a consumer that treats the notification stream as an event log sees state changes that did not happen.

I am not proposing a fix. Whether the second round is intentional (a refresh superseding the initial boot, with the connection reused) or accidental is a maintainer call, and it determines whether the right answer is to suppress the duplicates or to make cancelled distinguishable.

Related issues and areas

  • #22059 (Collapse MCP startup notifications into a single aggregate readiness summary) is the closest neighbour and the two interact. It estimates the volume at "~2N notifications during a single thread bootstrap"; measured here it is 3N to 4N. The EventMsg::McpStartupComplete aggregate it asks to expose would also give consumers a readiness signal that does not require interpreting cancelled at all.
  • #29608 (Shut down superseded MCP managers on refresh, merged) is the same lifecycle area: it fixed superseded managers leaking their stdio processes. The measurements here suggest whatever supersedes on thread/start no longer leaks processes, which is consistent with that fix, but still announces itself on the notification stream. I have not established that the two are the same mechanism.
  • #30753 (duplicate MCP pools after RefreshMcpServers, Windows Desktop) and #32997 (duplicate MCP/app child processes) both report actual duplicate processes. This report is explicitly not that: spawn counts here are correct. Noting them so the distinction is clear rather than because they look like duplicates.

I searched open and closed issues for mcpServerStatus, startupStatus, McpServerStatusUpdated, app-server mcp server, duplicate MCP start and mcp server twice and did not find this reported.

Environment

  • codex-cli 0.146.0
  • Linux 7.0.0-28-generic x86_64, Node v22.22.0
  • Reproduced on a 3-server throwaway config (above) and on a 12-server config mixing stdio and HTTP servers
  • Driving codex app-server over stdio from a downstream client, not via the TUI or Desktop

View original on GitHub ↗