Skip to content

exec: surface existing MCP server startup notifications in the JSONL event stream #17501

Description

@hampsterx

What feature would you like to see?

Emit the MCP server startup notifications that codex core already produces as first-class events in codex exec --json output, so that tools wrapping codex exec can observe per-server MCP lifecycle (starting, ready, failed, cancelled) without spawning and probing MCP servers themselves.

Why

External tools and agent frameworks that wrap codex exec --json currently have no way to tell:

They also cannot populate health dashboards or CI diagnostics without re-implementing the full MCP client path themselves (stdio process group management, docker cleanup, Windows taskkill /T, OAuth token store reuse, transport branching, env allowlists). That is tens of lines of hard-to-maintain lifecycle handling per host, all to rediscover data Codex already knows.

Concrete pain point: issue #17024 (local stdio MCP startup fails even though manual initialize succeeds). A user hitting that today gets no structured signal via exec --json. Codex knows the startup failed and the reason, but the JSONL stream stays silent. A bridge or dashboard cannot even display the failure message without screen-scraping human-readable output.

Closely related prior art:

Current state (verified in rust-v0.120.0)

The full pipeline from the MCP connection manager to the app-server protocol layer already exists and is already consumed by the TUI. codex exec --json is the one consumer that drops the notifications.

codex-rs/codex-mcp/src/mcp_connection_manager.rs (lines 747-814)
    emits per-server progress events
        |
        v
EventMsg::McpStartupUpdate(McpStartupUpdateEvent { server, status })
    status: McpStartupStatus { Starting | Ready | Failed { error } | Cancelled }
    (codex-rs/protocol/src/protocol.rs lines 1439, 3214, 3224)
        |
        v
codex-rs/app-server/src/bespoke_event_handling.rs (lines 234-262)
    translates core event to protocol notification (API v2)
        |
        v
ServerNotification::McpServerStatusUpdated
    wire name: "mcpServer/startupStatus/updated"
    payload: { name: String, status: McpServerStartupState, error: Option<String> }
    (codex-rs/app-server-protocol/src/protocol/v2.rs lines 5515-5532,
     registered in codex-rs/app-server-protocol/src/protocol/common.rs line 991)
        |
        +-> TUI: consumed by codex-rs/tui/src/chatwidget.rs (line 3063)
        |
        +-> exec JSONL: codex-rs/exec/src/event_processor_with_jsonl_output.rs line 581
                        `_ => CodexStatus::Running` catch-all, silently dropped

collect_thread_events in event_processor_with_jsonl_output.rs has no arm for ServerNotification::McpServerStatusUpdated. The notification falls through the _ => catch-all at the end of the match.

Proposal

Add four ThreadEvent variants (in codex-rs/exec/src/exec_events.rs) and handle the existing notification in collect_thread_events:

// in ThreadEvent enum
#[serde(rename = "mcp.server.init_started")]
McpServerInitStarted(McpServerInitStartedEvent),

#[serde(rename = "mcp.server.ready")]
McpServerReady(McpServerReadyEvent),

#[serde(rename = "mcp.server.failed")]
McpServerFailed(McpServerFailedEvent),

#[serde(rename = "mcp.server.cancelled")]
McpServerCancelled(McpServerCancelledEvent),

Payloads:

pub struct McpServerInitStartedEvent { pub name: String }

pub struct McpServerReadyEvent { pub name: String }

pub struct McpServerFailedEvent {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

pub struct McpServerCancelledEvent {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

Mapping from the existing McpServerStartupState enum:

  • Starting to mcp.server.init_started
  • Ready to mcp.server.ready
  • Failed to mcp.server.failed { name, error } (preserves notification.error passthrough)
  • Cancelled to mcp.server.cancelled { name, error } (preserves notification.error passthrough)

Cancelled is kept distinct from Failed to match how codex-rs/tui/src/chatwidget.rs:2932,3070 already treats the two separately. An interrupted startup is not the same as an initialization failure and consumers should be able to distinguish them.

Scope

Five files, +251 LOC, zero deletions, within a single crate (codex-rs/exec):

  • codex-rs/exec/src/exec_events.rs: new ThreadEvent variants and payload structs.
  • codex-rs/exec/src/event_processor_with_jsonl_output.rs: new arm in collect_thread_events before the _ => catch-all, plus imports.
  • codex-rs/exec/src/event_processor_with_jsonl_output_tests.rs: unit test covering all four state transitions.
  • codex-rs/exec/src/lib.rs: public re-exports of the four new payload structs (matches existing alphabetical re-export pattern for every other exec_events payload type).
  • codex-rs/exec/tests/event_processor_with_json_output.rs: integration test asserting both the enum mapping and the serialized JSON wire shape (type string values plus skip_serializing_if = "Option::is_none" behavior for the optional error field).

No changes to codex-core, codex-mcp, codex-protocol, codex-app-server, or codex-app-server-protocol. No new crate dependencies. No changes to any manually-maintained schema file.

Backwards compatibility

Purely additive. Existing codex exec --json consumers that match only on thread.started, turn.*, or item.* event types will see the new events as unknown type values and can safely ignore them. No existing event shape changes.

Alternatives considered

  • New codex mcp test [--json] subcommand (proposed in RFC 0001: MCP Management Overhaul - Comprehensive Improvements for Q4 2025 #3778 RFC 0001): adds a second surface that every external tool needs to integrate with separately. Reusing exec --json piggybacks on the stream they already consume.
  • Extend codex mcp list --json to include runtime state: possible but does not help callers that need live lifecycle updates during a turn (e.g. "a required server just failed, abort the turn cleanly").
  • Probe MCP servers directly from the external tool: what we have been doing. Requires replicating process group management, transport branching, OAuth token store reuse, and Windows and docker lifecycle handling. Fragile and duplicates Codex code.
  • Put timing (boot_ms) and tool listings (tools[]) in this first change: intentionally deferred. This proposal only surfaces data Codex already emits. A follow-up issue can propose adding boot_ms to McpServerStatusUpdatedNotification (cross-crate change, wire-type evolution); tool listings are already available via the existing mcpServerStatus/list RPC so consumers can call that after seeing ready.

Open questions

  1. Is codex exec --json considered a stable public API, or best-effort debug output? This proposal would benefit from an explicit stability statement in docs/exec.md for the new events.
  2. Should the events carry a timestamp field? Existing thread.*, turn.*, and item.* events do not, so omitting for consistency, but open to guidance.
  3. Should mcp.server.cancelled distinguish "user interrupted startup" from "startup deadline exceeded"? The current McpStartupStatus::Cancelled variant in core has no further discriminator; current draft preserves notification.error string passthrough.

Implementation

I have a draft patch applied against rust-v0.120.0 on a local feature branch feat/mcp-init-events-exec-json. Happy to open a PR if this proposal is directionally acceptable. Everything is in the codex-exec crate; no changes to codex-core, codex-mcp, or protocol crates.

Verification results:

  • cargo build -p codex-exec: clean
  • cargo test -p codex-exec: 40 lib + 62 integration, all passing (including new unit test and new integration test)
  • cargo clippy -p codex-exec --all-targets -- -D warnings: clean
  • cargo fmt -p codex-exec -- --check: clean

Context

This is driven by maintaining codex-mcp-bridge (an open-source MCP server that wraps codex exec) and specifically by the introspection phase of its design. Without the change, each external consumer has to re-implement MCP probing against their own config-parsing rules, including process group cleanup, transport branching, OAuth token store reuse, and OS-specific lifecycle quirks. With the change, they parse the JSONL they already consume.

No equivalent structured per-server MCP lifecycle stream exists in other MCP hosts I could verify (Claude Code's /mcp and claude mcp list are plain-text output, @modelcontextprotocol/sdk exposes init only via await client.connect() and UnauthorizedError, spec-level lifecycle is handshake-based). Codex is well-positioned to set a pattern here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestexecIssues related to the `codex exec` subcommandmcpIssues related to the use of model context protocol (MCP) servers

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions