TUI hangs forever at 'Loading MCP (x/y)' when 'resume --remote' has -c mcp_servers.* overrides the app-server doesn't have

Open 💬 2 comments Opened Jul 10, 2026 by kevingosse

What version of Codex CLI is running?

0.144.1 (npm) — also reproduced on 0.138.0

What subscription do you have?

n/a — the hang happens during MCP loading, before any model/auth interaction

Which model were you using?

n/a

What platform is your computer?

Microsoft Windows NT 10.0.26200.0 x64 (Windows 11 Pro)

What terminal emulator and version are you using (if applicable)?

Windows Terminal / plain cmd.exe (reproduces in both)

Codex doctor report

not available

What issue are you seeing?

codex resume --remote <ws-url> <thread-id> hangs forever at Loading MCP (x/y) when the client command carries -c mcp_servers.* config overrides that the app-server it attaches to was not started with. The TUI never becomes usable; it stays on the MCP loading indicator indefinitely (waited 10+ minutes).

Notes from debugging:

  • The configured MCP server itself is healthy (a local streamable-HTTP server that answers in milliseconds). While the TUI is frozen, no TCP connection is ever opened to the configured MCP URL, so it is not a slow/unresponsive server — it looks like a deadlock in the remote-attach handshake around the config/MCP sync.
  • RUST_LOG=codex_core=debug,rmcp=trace on the app-server shows nothing relevant after the attach (only unrelated plugin-manifest warnings at startup).

What steps can reproduce the bug?

Using any existing thread id (create one with a plain codex session first):

Terminal A — start an app-server without the MCP override:

codex -c check_for_update_on_startup=false app-server --listen ws://127.0.0.1:7777

Terminal B — attach to it with an MCP override on the client:

codex resume --remote ws://127.0.0.1:7777 <thread-id> -c mcp_servers.example.url=http://127.0.0.1:64442/stream -c mcp_servers.example.tool_timeout_sec=3600 -c mcp_servers.example.http_headers.X_AUTH=sometoken

→ stuck at Loading MCP (x/y) forever.

Full matrix tested (same thread, same machine):

| app-server flags | resume client flags | result |
|---|---|---|
| none | none | ✅ works |
| none | mcp_servers.* overrides | ❌ frozen at Loading MCP |
| mcp_servers.* overrides | none | ✅ works (and the MCP server's tools are available in the session) |
| mcp_servers.* overrides | same overrides | ✅ works |

A plain local codex resume <thread-id> (no --remote) with the exact same mcp_servers.* overrides also works fine — the hang is specific to remote attach.

What is the expected behavior?

Client-side mcp_servers.* overrides on resume --remote should either be applied to the session, or be ignored with a warning — not deadlock the TUI at MCP loading.

Additional information

Found via an IDE integration that spawns the app-server itself and attaches a TUI with an injected per-session MCP server config; the repro above is fully standalone (no IDE involved). Happy to run further diagnostics on request.

View original on GitHub ↗

2 Comments

mjlbach · 1 month ago

I reproduced this independently with a macOS Codex 0.144.1 TUI connected over WSS to a Linux remote app-server.

All six remote MCP startup streams reached a terminal ready state, and a protocol relay confirmed that every status notification reached the macOS client, but the TUI remained frozen at 5/6. Launching the same remote session with an isolated client CODEX_HOME containing no local MCP entries completed immediately.

Root cause

The TUI seeds mcp_startup_expected_servers from the client's local config.mcp_servers, even for AppServerTarget::Remote. Completion requires every expected name to appear in the app-server status stream. A client-only MCP name can never be reported by the remote app-server, making the completion predicate permanently false.

This also explains why bubblewrap/zAppGuard and the individual MCP transports appeared suspicious but were not causal: the remote servers had already initialized and their terminal statuses were delivered.

Proposed patch

Commit: https://github.com/mjlbach/codex/commit/9431af2abc371603a62010168011ee4046f6c977

Compare: https://github.com/openai/codex/compare/main...mjlbach:codex:agent/fix-remote-mcp-startup

For remote workspaces, the patch uses the existing reported-server behavior (an empty configured expected set), so startup settles from the servers actually reported by the app-server. Embedded sessions retain the existing config-derived completeness guard. It intentionally does not forward client MCP overrides to the remote app-server.

<details>
<summary>Patch (code and regression test)</summary>

diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs
index 06ed4320d8..5224093aa4 100644
--- a/codex-rs/tui/src/app/app_server_events.rs
+++ b/codex-rs/tui/src/app/app_server_events.rs
@@ -16,7 +16,17 @@ use codex_app_server_protocol::ServerNotification;
 use codex_app_server_protocol::ServerRequest;
 
 impl App {
-    pub(super) fn refresh_mcp_startup_expected_servers_from_config(&mut self) {
+    pub(super) fn refresh_mcp_startup_expected_servers(&mut self) {
+        // A remote app server owns its MCP configuration, so the TUI's local
+        // config cannot be used as the expected set for that startup round.
+        // An empty set lets the chat widget settle from the servers reported by
+        // the app server instead.
+        if self.app_server_target.uses_remote_workspace() {
+            self.chat_widget
+                .set_mcp_startup_expected_servers(std::iter::empty());
+            return;
+        }
+
         let enabled_config_mcp_servers: Vec<String> = self
             .config
             .mcp_servers
@@ -39,7 +49,7 @@ impl App {
                     skipped,
                     "app-server event consumer lagged; dropping ignored events"
                 );
-                self.refresh_mcp_startup_expected_servers_from_config();
+                self.refresh_mcp_startup_expected_servers();
                 self.chat_widget.finish_mcp_startup_after_lag();
             }
             AppServerEvent::ServerNotification(notification) => {
@@ -73,7 +83,7 @@ impl App {
                 }
             }
             ServerNotification::McpServerStatusUpdated(_) => {
-                self.refresh_mcp_startup_expected_servers_from_config();
+                self.refresh_mcp_startup_expected_servers();
             }
             ServerNotification::AccountRateLimitsUpdated(notification) => {
                 self.chat_widget
diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs
index d8d63575bb..bb747d002b 100644
--- a/codex-rs/tui/src/app/tests.rs
+++ b/codex-rs/tui/src/app/tests.rs
@@ -3552,6 +3552,56 @@ async fn primary_thread_ignores_child_mcp_startup_notifications() {
     );
 }
 
+#[tokio::test]
+async fn remote_mcp_startup_ignores_local_expected_servers() {
+    let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
+    let local_only_config = toml::from_str::<toml::Value>("command = 'true'")
+        .expect("test MCP config should parse")
+        .try_into()
+        .expect("test MCP config should deserialize");
+    app.config
+        .mcp_servers
+        .set(std::collections::HashMap::from([(
+            "local-only".to_string(),
+            local_only_config,
+        )]))
+        .expect("test MCP servers should accept any configuration");
+    app.app_server_target = crate::AppServerTarget::Remote {
+        endpoint: codex_app_server_client::RemoteAppServerEndpoint::WebSocket {
+            websocket_url: "ws://remote.example/rpc".to_string(),
+            auth_token: None,
+        },
+    };
+
+    app.refresh_mcp_startup_expected_servers();
+
+    app.chat_widget.handle_server_notification(
+        ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
+            thread_id: None,
+            name: "remote-only".to_string(),
+            status: McpServerStartupState::Starting,
+            error: None,
+            failure_reason: None,
+        }),
+        /*replay_kind*/ None,
+    );
+
+    assert!(app.chat_widget.is_task_running_for_test());
+
+    app.chat_widget.handle_server_notification(
+        ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
+            thread_id: None,
+            name: "remote-only".to_string(),
+            status: McpServerStartupState::Ready,
+            error: None,
+            failure_reason: None,
+        }),
+        /*replay_kind*/ None,
+    );
+
+    assert!(!app.chat_widget.is_task_running_for_test());
+}
+
 #[tokio::test]
 async fn app_scoped_mcp_startup_notifications_do_not_render_in_active_thread() {
     let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs
index b149fd53f3..5c3fefff6a 100644
--- a/codex-rs/tui/src/app/thread_routing.rs
+++ b/codex-rs/tui/src/app/thread_routing.rs
@@ -1316,7 +1316,7 @@ impl App {
         snapshot: ThreadEventSnapshot,
         resume_restored_queue: bool,
     ) {
-        self.refresh_mcp_startup_expected_servers_from_config();
+        self.refresh_mcp_startup_expected_servers();
         let should_buffer_replay = !snapshot.turns.is_empty() || !snapshot.events.is_empty();
         if should_buffer_replay {
             self.app_event_tx

</details>

Validation

  • just fmt
  • just test -p codex-tui
  • just fix -p codex-tui

The full codex-tui suite and required Clippy autofix completed successfully.

Juddd · 9 days ago

I can reproduce this locally with Codex 0.147.0 on both the client and the NAS.

Reproduction

I start the NAS app-server and expose it through an SSH local forward:

ssh -N -L 4500:127.0.0.1:4500 <nas-user>@<nas-host>
codex app-server --listen ws://127.0.0.1:4500

Then I attach the local TUI:

codex --remote ws://127.0.0.1:4500 resume

The local client has these enabled MCP entries:

  • ai-drawio
  • codegraph
  • lean-lsp
  • node_repl
  • wolframAI
  • github
  • openaiDeveloperDocs

The NAS app-server has only:

  • brave-search
  • github

The SSH tunnel and WebSocket connection are healthy: initialize, thread resume, and the app-server RPCs complete successfully. The NAS reports brave-search and github through starting -> ready, but the TUI remains stuck at Loading MCP indefinitely because the client-only MCP names never appear in the remote status stream.

Disabling the local-only MCP entries with -c mcp_servers.<name>.enabled=false allows the remote TUI to start. This reproduces the same local-vs-remote expected-server mismatch described in the root-cause analysis above.

The proposed fix in Avoid local MCP gating for remote startup also appears to address this exact reproduction.