Skip to content
CLI

stdio Persistent Mode: MCP Loading Status for the First Prompt (mcp_status)

For integrations that invoke codebuddy as a subprocess over a persistent stdio + stream-json connection (SDK / daemon / IDE backend). The goal is to receive the MCP connection status that is currently blocking the first prompt of a new session in real time, and optionally increase the timeout window for the initial prewait.

TL;DR

  1. Start a persistent stream-json connection (do not include -p):
    bash
    codebuddy --input-format stream-json --output-format stream-json -y \
      --mcp-config /path/to/mcp.json --strict-mcp-config
  2. To display the loading status of an MCP server, make it actually block the first prompt. Add "alwaysLoad": true (or "defer_loading": false) for it in mcp.json. A fully deferred MCP server does not block the first turn and is not displayed.
  3. Increase the initial prewait window (the stdio default is 2000ms):
    bash
    CODEBUDDY_FIRST_RUN_MCP_PREWAIT_TIMEOUT_MS=30000 codebuddy --input-format stream-json ...
  4. After the first user message is sent, stdout emits these events one by one as NDJSON: {type:"system", subtype:"mcp_status", event:"start"|"server"|"finish", ...}.

1. Starting in Persistent stdio Mode

bash
codebuddy \
  --input-format stream-json \
  --output-format stream-json \
  -y \
  --mcp-config /abs/path/to/mcp.json \
  --strict-mcp-config

Key points:

  • Do not include -p. -p (print) is a single-request mode and does not output mcp_status; only persistent stream-json mode forwards mcp_status to stdout.
  • You do not need to send initialize first: when the first user message is sent, the CLI automatically enters the initialized state and triggers the initial prewait, producing mcp_status.
  • Shape of the first user message (consistent with ACP/SDK clients):
    json
    {"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}

2. Displaying MCP Loading Means Making It Actually Block the First Prompt

The displayed set is always identical to the set that actually blocks the first prompt (aligned starting with PR #84819). Whether an MCP server blocks the first prompt depends on whether it is included in the prewait list:

Configuration in mcp.jsonIncluded in prewait list (blocking + displayed)
"alwaysLoad": true✅ Yes (most direct; highest priority; bypasses defer checks)
"defer_loading": false✅ Yes (explicitly declares the server as inline)
Omitted (deferred by default)❌ No — does not block the first turn (the model invokes ToolSearch/WaitForMcpServers when needed) and is not displayed

Example mcp.json (make slow-mcp block and appear in status events):

json
{
  "mcpServers": {
    "slow-mcp": {
      "type": "stdio",
      "command": "node",
      "args": ["/path/to/slow-mcp.mjs"],
      "alwaysLoad": true
    }
  }
}

Why this design: fully deferred MCP servers should not slow down the first prompt, so they neither block nor appear. In other words, "what is displayed is what is being awaited," avoiding the false impression that "N servers are loading" when the integration is not actually waiting for them.


3. Increasing the Initial Prewait Timeout

The initial prewait is a bounded wait window for the blocking MCP set before the first prompt is dispatched. When the window expires, the prompt proceeds; servers that are not connected are marked timeout, while reconciliation continues in the background.

ScenarioEnvironment variableDefault
stdio / SDK / headlessCODEBUDDY_FIRST_RUN_MCP_PREWAIT_TIMEOUT_MS2000
Interactive TUICODEBUDDY_TUI_FIRST_RUN_MCP_PREWAIT_TIMEOUT_MS30000
  • Increase the timeout (to give slow MCP servers more time during the first turn):
    bash
    CODEBUDDY_FIRST_RUN_MCP_PREWAIT_TIMEOUT_MS=30000 codebuddy --input-format stream-json ...
  • To skip the initial wait entirely (dispatch the first prompt immediately and connect all MCP servers in the background), set it to 0.
  • Accepted values: non-negative integer milliseconds. Empty or invalid values fall back to the default.

4. stdout Event Schema (system/mcp_status)

There are three event types, all emitted as NDJSON (one JSON object per line).

event: "start" — Initial Run Starts with the Blocking Set

json
{
  "type": "system", "subtype": "mcp_status", "event": "start",
  "servers": ["slow-mcp"],       // ★ MCP servers currently blocking the first prompt
  "total_count": 1,
  "uuid": "...", "__timestamp": "2026-08-07T09:13:00.000Z"
}

event: "server" — Individual MCP State Transition

json
{
  "type": "system", "subtype": "mcp_status", "event": "server",
  "name": "slow-mcp",
  "state": "connecting",// connecting | ready | failed | timeout
  "error": "...",                // May be present only when failed
  "completed": 0, "total": 1,
  "uuid": "...", "__timestamp": "..."
}

state values:

  • connecting: Connecting
  • ready: Connected successfully (normal terminal state)
  • failed: Connection failed (error terminal state)
  • timeout: Still not connected when the prewait window ended (error terminal state)

event: "finish" — Initial Run Ends with an Error Summary

json
{
  "type": "system", "subtype": "mcp_status", "event": "finish",
  "failed": [],                  // MCP servers that failed to connect
  "timed_out": ["slow-mcp"],     // MCP servers still not ready when the window expired or was interrupted
  "uuid": "...", "__timestamp": "..."
}

finish is triggered when the prewait deadline expires or the first turn is interrupted (for example, by a new user message). Receiving finish marks the end of the first-run status stream for this session; no further first-run events are emitted.


5. Integration Pseudocode

ts
const child = spawn('codebuddy', [
  '--input-format', 'stream-json', '--output-format', 'stream-json', '-y',
  '--mcp-config', mcpConfigPath, '--strict-mcp-config',
], { env: { ...process.env, CODEBUDDY_FIRST_RUN_MCP_PREWAIT_TIMEOUT_MS: '30000' } });

let buf = '';
child.stdout.on('data', chunk => {
  buf += chunk;
  let i;
  while ((i = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
    if (!line) continue;
    const msg = JSON.parse(line);
    if (msg.type === 'system' && msg.subtype === 'mcp_status') {
      switch (msg.event) {
        case 'start':  showLoading(msg.servers); break;      // Show "Loading: slow-mcp"
        case 'server': updateServer(msg.name, msg.state); break;
        case 'finish': hideLoading(msg.failed, msg.timed_out); break;
      }
    }
  }
});

// New session → send the first prompt (no need to send initialize first)
child.stdin.write(JSON.stringify({
  type: 'user',
  message: { role: 'user', content: [{ type: 'text', text: 'your first prompt' }] },
}) + '\n');

6. Notes

  • stdout is NDJSON (one object per line), not SSE event:/data: frames. Parse it line by line with JSON.parse.
  • Late subscribers can receive the most recent start event for mcp_status (idempotently). Even if you begin reading stdout shortly after the CLI starts, you will still receive the current run's start event and will not miss the first frame.
  • The --mcp-config file path must be within the CLI working directory; otherwise it is rejected with "MCP config escapes the admitted Workspace".
  • MCP tools that connect after the first turn are automatically incorporated into subsequent turns; the integration does not need to handle them separately.