Files
codex/prs/bolinfest/PR-2373.md
2025-09-02 15:17:45 -07:00

62 KiB

PR #2373: Added MCP server command to enable authentication using ChatGPT

Description

This PR adds two new APIs for the MCP server: 1) loginChatGpt, and 2) cancelLoginChatGpt. The first starts a login server and returns a local URL that allows for browser-based authentication, and the second provides a way to cancel the login attempt. If the login attempt succeeds, a notification (in the form of an event) is sent to a subscriber.

I also added a timeout mechanism for the existing login server. The loginChatGpt code path uses a 10-minute timeout by default, so if the user fails to complete the login flow in that timeframe, the login server automatically shuts down. I tested the timeout code by manually setting the timeout to a much lower number and confirming that it works as expected when used e2e.

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 67f0199c4a..f118cb67a0 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -855,6 +855,7 @@ dependencies = [
  "assert_cmd",
  "codex-arg0",
  "codex-core",
+ "codex-login",
  "mcp-types",
  "mcp_test_support",
  "pretty_assertions",
diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs
index 7a5f0277e9..80fc0e821a 100644
--- a/codex-rs/login/src/lib.rs
+++ b/codex-rs/login/src/lib.rs
@@ -18,6 +18,7 @@ use std::time::Duration;
 
 pub use crate::server::LoginServer;
 pub use crate::server::ServerOptions;
+pub use crate::server::ShutdownHandle;
 pub use crate::server::run_login_server;
 pub use crate::token_data::TokenData;
 use crate::token_data::parse_id_token;
diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs
index 9365905f46..566b562d55 100644
--- a/codex-rs/login/src/server.rs
+++ b/codex-rs/login/src/server.rs
@@ -4,7 +4,9 @@ use std::path::PathBuf;
 use std::sync::Arc;
 use std::sync::atomic::AtomicBool;
 use std::sync::atomic::Ordering;
+use std::sync::mpsc;
 use std::thread;
+use std::time::Duration;
 
 use crate::AuthDotJson;
 use crate::get_auth_file;
@@ -27,6 +29,7 @@ pub struct ServerOptions {
     pub port: u16,
     pub open_browser: bool,
     pub force_state: Option<String>,
+    pub login_timeout: Option<Duration>,
 }
 
 impl ServerOptions {
@@ -38,16 +41,17 @@ impl ServerOptions {
             port: DEFAULT_PORT,
             open_browser: true,
             force_state: None,
+            login_timeout: None,
         }
     }
 }
 
-#[derive(Debug)]
 pub struct LoginServer {
     pub auth_url: String,
     pub actual_port: u16,
     pub server_handle: thread::JoinHandle<io::Result<()>>,
     pub shutdown_flag: Arc<AtomicBool>,
+    pub server: Arc<Server>,
 }
 
 impl LoginServer {
@@ -59,10 +63,34 @@ impl LoginServer {
     }
 
     pub fn cancel(&self) {
-        self.shutdown_flag.store(true, Ordering::SeqCst);
+        shutdown(&self.shutdown_flag, &self.server);
+    }
+
+    pub fn cancel_handle(&self) -> ShutdownHandle {
+        ShutdownHandle {
+            shutdown_flag: self.shutdown_flag.clone(),
+            server: self.server.clone(),
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct ShutdownHandle {
+    shutdown_flag: Arc<AtomicBool>,
+    server: Arc<Server>,
+}
+
+impl ShutdownHandle {
+    pub fn cancel(&self) {
+        shutdown(&self.shutdown_flag, &self.server);
     }
 }
 
+pub fn shutdown(shutdown_flag: &AtomicBool, server: &Server) {
+    shutdown_flag.store(true, Ordering::SeqCst);
+    server.unblock();
+}
+
 pub fn run_login_server(
     opts: ServerOptions,
     shutdown_flag: Option<Arc<AtomicBool>>,
@@ -80,6 +108,7 @@ pub fn run_login_server(
             ));
         }
     };
+    let server = Arc::new(server);
 
     let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
     let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state);
@@ -89,11 +118,35 @@ pub fn run_login_server(
     }
     let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
     let shutdown_flag_clone = shutdown_flag.clone();
+    let timeout_flag = Arc::new(AtomicBool::new(false));
+
+    // Channel used to signal completion to timeout watcher.
+    let (done_tx, done_rx) = mpsc::channel::<()>();
+
+    if let Some(timeout) = opts.login_timeout {
+        spawn_timeout_watcher(
+            done_rx,
+            timeout,
+            shutdown_flag.clone(),
+            timeout_flag.clone(),
+            server.clone(),
+        );
+    }
+
+    let server_for_thread = server.clone();
     let server_handle = thread::spawn(move || {
         while !shutdown_flag.load(Ordering::SeqCst) {
-            let req = match server.recv() {
+            let req = match server_for_thread.recv() {
                 Ok(r) => r,
-                Err(e) => return Err(io::Error::other(e)),
+                Err(e) => {
+                    // If we've been asked to shut down, break gracefully so that
+                    // we can report timeout or cancellation status uniformly.
+                    if shutdown_flag.load(Ordering::SeqCst) {
+                        break;
+                    } else {
+                        return Err(io::Error::other(e));
+                    }
+                }
             };
 
             let url_raw = req.url().to_string();
@@ -198,6 +251,9 @@ pub fn run_login_server(
                     }
                     let _ = req.respond(resp);
                     shutdown_flag.store(true, Ordering::SeqCst);
+
+                    // Login has succeeded, so disarm the timeout watcher.
+                    let _ = done_tx.send(());
                     return Ok(());
                 }
                 _ => {
@@ -205,7 +261,15 @@ pub fn run_login_server(
                 }
             }
         }
-        Err(io::Error::other("Login flow was not completed"))
+
+        // Login has failed or timed out, so disarm the timeout watcher.
+        let _ = done_tx.send(());
+
+        if timeout_flag.load(Ordering::SeqCst) {
+            Err(io::Error::other("Login timed out"))
+        } else {
+            Err(io::Error::other("Login was not completed"))
+        }
     });
 
     Ok(LoginServer {
@@ -213,9 +277,33 @@ pub fn run_login_server(
         actual_port,
         server_handle,
         shutdown_flag: shutdown_flag_clone,
+        server,
     })
 }
 
+/// Spawns a detached thread that waits for either a completion signal on `done_rx`
+/// or the specified `timeout` to elapse. If the timeout elapses first it marks
+/// the `shutdown_flag`, records `timeout_flag`, and unblocks the HTTP server so
+/// that the main server loop can exit promptly.
+fn spawn_timeout_watcher(
+    done_rx: mpsc::Receiver<()>,
+    timeout: Duration,
+    shutdown_flag: Arc<AtomicBool>,
+    timeout_flag: Arc<AtomicBool>,
+    server: Arc<Server>,
+) {
+    thread::spawn(move || {
+        if done_rx.recv_timeout(timeout).is_err()
+            && shutdown_flag
+                .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+                .is_ok()
+        {
+            timeout_flag.store(true, Ordering::SeqCst);
+            server.unblock();
+        }
+    });
+}
+
 fn build_authorize_url(
     issuer: &str,
     client_id: &str,
diff --git a/codex-rs/login/tests/login_server_e2e.rs b/codex-rs/login/tests/login_server_e2e.rs
index b3e124682c..6b7098b977 100644
--- a/codex-rs/login/tests/login_server_e2e.rs
+++ b/codex-rs/login/tests/login_server_e2e.rs
@@ -100,6 +100,7 @@ fn end_to_end_login_flow_persists_auth_json() {
         port: 0,
         open_browser: false,
         force_state: Some(state),
+        login_timeout: None,
     };
     let server = run_login_server(opts, None).unwrap();
     let login_port = server.actual_port;
@@ -158,6 +159,7 @@ fn creates_missing_codex_home_dir() {
         port: 0,
         open_browser: false,
         force_state: Some(state),
+        login_timeout: None,
     };
     let server = run_login_server(opts, None).unwrap();
     let login_port = server.actual_port;
diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml
index 2f618808c1..6274ba8e41 100644
--- a/codex-rs/mcp-server/Cargo.toml
+++ b/codex-rs/mcp-server/Cargo.toml
@@ -18,6 +18,7 @@ workspace = true
 anyhow = "1"
 codex-arg0 = { path = "../arg0" }
 codex-core = { path = "../core" }
+codex-login = { path = "../login" }
 mcp-types = { path = "../mcp-types" }
 schemars = "0.8.22"
 serde = { version = "1", features = ["derive"] }
diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs
index d930c03b71..3a859fbe48 100644
--- a/codex-rs/mcp-server/src/codex_message_processor.rs
+++ b/codex-rs/mcp-server/src/codex_message_processor.rs
@@ -1,6 +1,7 @@
 use std::collections::HashMap;
 use std::path::PathBuf;
 use std::sync::Arc;
+use std::time::Duration;
 
 use codex_core::CodexConversation;
 use codex_core::ConversationManager;
@@ -14,6 +15,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
 use codex_core::protocol::ReviewDecision;
 use mcp_types::JSONRPCErrorError;
 use mcp_types::RequestId;
+use tokio::sync::Mutex;
 use tokio::sync::oneshot;
 use tracing::error;
 use uuid::Uuid;
@@ -36,6 +38,9 @@ use crate::wire_format::ExecCommandApprovalResponse;
 use crate::wire_format::InputItem as WireInputItem;
 use crate::wire_format::InterruptConversationParams;
 use crate::wire_format::InterruptConversationResponse;
+use crate::wire_format::LOGIN_CHATGPT_COMPLETE_EVENT;
+use crate::wire_format::LoginChatGptCompleteNotification;
+use crate::wire_format::LoginChatGptResponse;
 use crate::wire_format::NewConversationParams;
 use crate::wire_format::NewConversationResponse;
 use crate::wire_format::RemoveConversationListenerParams;
@@ -46,6 +51,24 @@ use crate::wire_format::SendUserTurnParams;
 use crate::wire_format::SendUserTurnResponse;
 use codex_core::protocol::InputItem as CoreInputItem;
 use codex_core::protocol::Op;
+use codex_login::CLIENT_ID;
+use codex_login::ServerOptions as LoginServerOptions;
+use codex_login::ShutdownHandle;
+use codex_login::run_login_server;
+
+// Duration before a ChatGPT login attempt is abandoned.
+const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
+
+struct ActiveLogin {
+    shutdown_handle: ShutdownHandle,
+    login_id: Uuid,
+}
+
+impl ActiveLogin {
+    fn drop(&self) {
+        self.shutdown_handle.cancel();
+    }
+}
 
 /// Handles JSON-RPC messages for Codex conversations.
 pub(crate) struct CodexMessageProcessor {
@@ -53,6 +76,7 @@ pub(crate) struct CodexMessageProcessor {
     outgoing: Arc<OutgoingMessageSender>,
     codex_linux_sandbox_exe: Option<PathBuf>,
     conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
+    active_login: Arc<Mutex<Option<ActiveLogin>>>,
 }
 
 impl CodexMessageProcessor {
@@ -66,6 +90,7 @@ impl CodexMessageProcessor {
             outgoing,
             codex_linux_sandbox_exe,
             conversation_listeners: HashMap::new(),
+            active_login: Arc::new(Mutex::new(None)),
         }
     }
 
@@ -92,6 +117,134 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let opts = LoginServerOptions {
+            open_browser: false,
+            login_timeout: Some(LOGIN_CHATGPT_TIMEOUT),
+            ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
+        };
+
+        enum LoginChatGptReply {
+            Response(LoginChatGptResponse),
+            Error(JSONRPCErrorError),
+        }
+
+        let reply = match run_login_server(opts, None) {
+            Ok(server) => {
+                let login_id = Uuid::new_v4();
+
+                // Replace active login if present.
+                {
+                    let mut guard = self.active_login.lock().await;
+                    if let Some(existing) = guard.take() {
+                        existing.drop();
+                    }
+                    *guard = Some(ActiveLogin {
+                        shutdown_handle: server.cancel_handle(),
+                        login_id,
+                    });
+                }
+
+                let response = LoginChatGptResponse {
+                    login_id,
+                    auth_url: server.auth_url.clone(),
+                };
+
+                // Spawn background task to monitor completion.
+                let outgoing_clone = self.outgoing.clone();
+                let active_login = self.active_login.clone();
+                tokio::spawn(async move {
+                    let result =
+                        tokio::task::spawn_blocking(move || server.block_until_done()).await;
+                    let (success, error_msg) = match result {
+                        Ok(Ok(())) => (true, None),
+                        Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))),
+                        Err(join_err) => (
+                            false,
+                            Some(format!("failed to join login server thread: {join_err}")),
+                        ),
+                    };
+                    let notification = LoginChatGptCompleteNotification {
+                        login_id,
+                        success,
+                        error: error_msg,
+                    };
+                    let params = serde_json::to_value(&notification).ok();
+                    outgoing_clone
+                        .send_notification(OutgoingNotification {
+                            method: LOGIN_CHATGPT_COMPLETE_EVENT.to_string(),
+                            params,
+                        })
+                        .await;
+
+                    // Clear the active login if it matches this attempt. It may have been replaced or cancelled.
+                    let mut guard = active_login.lock().await;
+                    if guard.as_ref().map(|l| l.login_id) == Some(login_id) {
+                        *guard = None;
+                    }
+                });
+
+                LoginChatGptReply::Response(response)
+            }
+            Err(err) => LoginChatGptReply::Error(JSONRPCErrorError {
+                code: INTERNAL_ERROR_CODE,
+                message: format!("failed to start login server: {err}"),
+                data: None,
+            }),
+        };
+
+        match reply {
+            LoginChatGptReply::Response(resp) => {
+                self.outgoing.send_response(request_id, resp).await
+            }
+            LoginChatGptReply::Error(err) => self.outgoing.send_error(request_id, err).await,
+        }
+    }
+
+    async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) {
+        let mut guard = self.active_login.lock().await;
+        if guard.as_ref().map(|l| l.login_id) == Some(login_id) {
+            if let Some(active) = guard.take() {
+                active.drop();
+            }
+            drop(guard);
+            self.outgoing
+                .send_response(
+                    request_id,
+                    crate::wire_format::CancelLoginChatGptResponse {},
+                )
+                .await;
+        } else {
+            drop(guard);
+            let error = JSONRPCErrorError {
+                code: INVALID_REQUEST_ERROR_CODE,
+                message: format!("login id not found: {login_id}"),
+                data: None,
+            };
+            self.outgoing.send_error(request_id, error).await;
         }
     }
 
diff --git a/codex-rs/mcp-server/src/wire_format.rs b/codex-rs/mcp-server/src/wire_format.rs
index 2dca1b79b7..f8fb53b450 100644
--- a/codex-rs/mcp-server/src/wire_format.rs
+++ b/codex-rs/mcp-server/src/wire_format.rs
@@ -60,6 +60,15 @@ pub enum ClientRequest {
         request_id: RequestId,
         params: RemoveConversationListenerParams,
     },
+    LoginChatGpt {
+        #[serde(rename = "id")]
+        request_id: RequestId,
+    },
+    CancelLoginChatGpt {
+        #[serde(rename = "id")]
+        request_id: RequestId,
+        params: CancelLoginChatGptParams,
+    },
 }
 
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
@@ -122,6 +131,36 @@ pub struct AddConversationSubscriptionResponse {
 #[serde(rename_all = "camelCase")]
 pub struct RemoveConversationSubscriptionResponse {}
 
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct LoginChatGptResponse {
+    pub login_id: Uuid,
+    /// URL the client should open in a browser to initiate the OAuth flow.
+    pub auth_url: String,
+}
+
+// Event name for notifying client of login completion or failure.
+pub const LOGIN_CHATGPT_COMPLETE_EVENT: &str = "codex/event/login_chatgpt_complete";
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct LoginChatGptCompleteNotification {
+    pub login_id: Uuid,
+    pub success: bool,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub error: Option<String>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CancelLoginChatGptParams {
+    pub login_id: Uuid,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct CancelLoginChatGptResponse {}
+
 #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub struct SendUserMessageParams {

Review Comments

codex-rs/login/src/server.rs

@@ -50,6 +50,22 @@ pub struct LoginServer {
     pub shutdown_flag: Arc<AtomicBool>,
 }
 
+use std::mem::ManuallyDrop;

Please consolidate imports at the top of the file. Exceptions are unit tests and conditionally compiled imports.

@@ -50,6 +50,22 @@ pub struct LoginServer {
     pub shutdown_flag: Arc<AtomicBool>,
 }
 
+use std::mem::ManuallyDrop;
+
+impl Clone for LoginServer {

This does not seem like something that should implement Clone. Can you wrap it in Arc instead?

@@ -27,6 +30,7 @@ pub struct ServerOptions {
     pub port: u16,
     pub open_browser: bool,
     pub force_state: Option<String>,
+    pub login_timeout_secs: Option<u64>,

In Rust, prefer Duration so the units are unambiguous. Then you can rename to login_timeout.

@@ -89,6 +94,24 @@ pub fn run_login_server(
     }
     let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
     let shutdown_flag_clone = shutdown_flag.clone();
+    let timeout_flag = Arc::new(AtomicBool::new(false));
+    if let Some(secs) = opts.login_timeout_secs {
+        let shutdown_flag_for_timer = shutdown_flag.clone();
+        let timeout_flag_for_timer = timeout_flag.clone();
+
+        thread::spawn(move || {
+            thread::sleep(Duration::from_secs(secs));

This means that even when the login succeeds, this thread will still be hanging around for the duration of the timeout, correct? It would be nicer to use a different mechanism (like tokio::sync::Notify for the shutdown flag) and then here you could use tokio::select() or something to run with whichever async task finishes first.

Though apparently it was tricky to use Notify initially:

https://github.com/openai/codex/pull/2294#discussion_r2277693114

@@ -89,6 +94,24 @@ pub fn run_login_server(
     }
     let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
     let shutdown_flag_clone = shutdown_flag.clone();
+    let timeout_flag = Arc::new(AtomicBool::new(false));
+    if let Some(secs) = opts.login_timeout_secs {
+        let shutdown_flag_for_timer = shutdown_flag.clone();
+        let timeout_flag_for_timer = timeout_flag.clone();
+
+        thread::spawn(move || {
+            thread::sleep(Duration::from_secs(secs));
+            if !shutdown_flag_for_timer.load(Ordering::SeqCst) {

Would compare_exchange() be more appropriate so the read/write of shutdown_flag_for_timer is an atomic action?

@@ -89,6 +94,24 @@ pub fn run_login_server(
     }
     let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
     let shutdown_flag_clone = shutdown_flag.clone();
+    let timeout_flag = Arc::new(AtomicBool::new(false));
+    if let Some(secs) = opts.login_timeout_secs {
+        let shutdown_flag_for_timer = shutdown_flag.clone();
+        let timeout_flag_for_timer = timeout_flag.clone();
+
+        thread::spawn(move || {
+            thread::sleep(Duration::from_secs(secs));
+            if !shutdown_flag_for_timer.load(Ordering::SeqCst) {
+                timeout_flag_for_timer.store(true, Ordering::SeqCst);
+                shutdown_flag_for_timer.store(true, Ordering::SeqCst);
+
+                // Nudge server.recv() by issuing a minimal HTTP request so tiny_http returns.
+                if let Ok(mut stream) = TcpStream::connect(format!("127.0.0.1:{actual_port}")) {

If it timed out, are we certain this is still our server? Do we need this GET?

@@ -89,11 +118,35 @@ pub fn run_login_server(
     }
     let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
     let shutdown_flag_clone = shutdown_flag.clone();
+    let timeout_flag = Arc::new(AtomicBool::new(false));
+
+    // Channel used to signal completion to timeout watcher.
+    let (done_tx, done_rx) = mpsc::channel::<()>();

I was going to suggest this might be a candidate for std::sync::mpsc::oneshot, but it looks like there are two places where done_tx.send() could be called, so I guess this is not a "single producer" case?

@@ -89,11 +118,35 @@ pub fn run_login_server(
     }
     let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
     let shutdown_flag_clone = shutdown_flag.clone();
+    let timeout_flag = Arc::new(AtomicBool::new(false));
+
+    // Channel used to signal completion to timeout watcher.
+    let (done_tx, done_rx) = mpsc::channel::<()>();
+
+    if let Some(timeout) = opts.login_timeout {
+        spawn_timeout_watcher(
+            done_rx,
+            timeout,
+            shutdown_flag.clone(),
+            timeout_flag.clone(),
+            server.clone(),
+        );
+    }
+
+    let server_for_thread = server.clone();
     let server_handle = thread::spawn(move || {
         while !shutdown_flag.load(Ordering::SeqCst) {
-            let req = match server.recv() {
+            let req = match server_for_thread.recv() {
                 Ok(r) => r,
-                Err(e) => return Err(io::Error::other(e)),
+                Err(e) => {

Hmm, so in the case where unblock() is called by ShutdownHandle::shutdown(), it appears this will be an ordinary std::io::Error rather than something more specific:

e2215636a7/src/lib.rs (L421)

so checking shutdown_flag does seem to be the best wait to disambiguate the cause of the shutdown.

@@ -89,11 +118,35 @@ pub fn run_login_server(
     }
     let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
     let shutdown_flag_clone = shutdown_flag.clone();
+    let timeout_flag = Arc::new(AtomicBool::new(false));
+
+    // Channel used to signal completion to timeout watcher.
+    let (done_tx, done_rx) = mpsc::channel::<()>();
+
+    if let Some(timeout) = opts.login_timeout {
+        spawn_timeout_watcher(
+            done_rx,
+            timeout,
+            shutdown_flag.clone(),
+            timeout_flag.clone(),
+            server.clone(),
+        );
+    }
+
+    let server_for_thread = server.clone();

This should be addressed in a separate PR, but the closure passed to this thread::spawn() is quite large and hard to reason about: it should be moved to a separate function.

@@ -198,24 +251,59 @@ pub fn run_login_server(
                     }
                     let _ = req.respond(resp);
                     shutdown_flag.store(true, Ordering::SeqCst);
+
+                    // Signal completion to timeout watcher.
+                    let _ = done_tx.send(());
                     return Ok(());
                 }
                 _ => {
                     let _ = req.respond(Response::from_string("Not Found").with_status_code(404));
                 }
             }
         }
-        Err(io::Error::other("Login flow was not completed"))
+
+        // Signal completion to timeout watcher.
+        let _ = done_tx.send(());

Under what conditions have we reached this point? It's always when shutdown_flag is true, isn't it? So why are we updating the timeout watcher here: to cancel it?

@@ -198,24 +251,59 @@ pub fn run_login_server(
                     }
                     let _ = req.respond(resp);
                     shutdown_flag.store(true, Ordering::SeqCst);
+
+                    // Signal completion to timeout watcher.
+                    let _ = done_tx.send(());
                     return Ok(());
                 }
                 _ => {
                     let _ = req.respond(Response::from_string("Not Found").with_status_code(404));
                 }
             }
         }
-        Err(io::Error::other("Login flow was not completed"))
+
+        // Signal completion to timeout watcher.
+        let _ = done_tx.send(());
+
+        if timeout_flag.load(Ordering::SeqCst) {
+            Err(io::Error::other("Login timed out"))
+        } else {
+            Err(io::Error::other("Login was not completed"))
+        }
     });
 
     Ok(LoginServer {
         auth_url: auth_url.clone(),
         actual_port,
         server_handle,
         shutdown_flag: shutdown_flag_clone,
+        server,
     })
 }
 
+/// Spawns a detached thread that waits for either a completion signal on `done_rx`
+/// or the specified `timeout` to elapse. If the timeout elapses first it marks
+/// the `shutdown_flag`, records `timeout_flag`, and unblocks the HTTP server so
+/// that the main server loop can exit promptly.
+fn spawn_timeout_watcher(

This feels a bit off to me. I feel like part of the reason is that tiny_http gives us a synchronous API in the form of tiny_http::Server::recv(), but things would be easier if we used recv() to populate a tokio::sync::mspc channel of some sort. For example, we can use blocking_send() to map from a blocking stream to an async one:

fn spawn_accept_loop(
    server: Server,
    tx: mpsc::Sender<Request>,
) -> thread::JoinHandle<()> {
    std::thread::spawn(move || {
        loop {
            match server.recv() {
                Ok(req) => {
                    if tx.blocking_send(req).is_err() {
                        // Receiver dropped: exit accept loop.
                        break;
                    }
                }
                Err(err) => {
                    // Server crashed, or perhaps `server.unblock()` was called?
                    break;
                }
            }
        }
    });
}

Then we can have an async function that loops, consuming the rx corresponding to the tx passed into spawn_accept_loop() above that maps roughly to the closure passed to thread::spawn() in run_login_server().

Once that loop is its own async function, we could wrap it with tokio::time::timeout and wouldn't need quite so much machinery for imposing the timeout.

Sorry, I expect this is a much larger change than you were probably looking for when trying to add a "simple" timeout.

@easong-openai @pakrym-oai was anything like this considered when #2294 was done originally? Are there other reasons why tiny_http does not play nicely with async?

codex-rs/mcp-server/src/codex_message_processor.rs

@@ -46,13 +47,17 @@ use crate::wire_format::SendUserTurnParams;
 use crate::wire_format::SendUserTurnResponse;
 use codex_core::protocol::InputItem as CoreInputItem;
 use codex_core::protocol::Op;
+use codex_login::CLIENT_ID;
+use codex_login::ServerOptions as LoginServerOptions;
+use codex_login::run_login_server;
 
 /// Handles JSON-RPC messages for Codex conversations.
 pub(crate) struct CodexMessageProcessor {
     conversation_manager: Arc<ConversationManager>,
     outgoing: Arc<OutgoingMessageSender>,
     codex_linux_sandbox_exe: Option<PathBuf>,
     conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
+    login_server: Option<codex_login::LoginServer>,

Arc<Mutex> might be the way to go?

@@ -92,6 +98,68 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;

Does this not return until the user completes the login flow? If so, that means other messages won't get processed until login completes, which would not be good.

@@ -92,6 +98,68 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =

What if self.login_server is already Some?

@@ -92,6 +98,68 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let opts = LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string());
+        let outgoing = self.outgoing.clone();
+        match run_login_server(opts, None) {
+            Ok(server) => {
+                self.login_server = Some(server.clone());
+                tokio::spawn(async move {
+                    let result =

Should this send a notification with the server URL to the client?

Also, it feels like this request should support cancellation?

@@ -92,6 +110,124 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let mut opts = LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string());
+        // Optionally disable browser launch when running headless (set CODEX_LOGIN_DISABLE_BROWSER env var).
+        if std::env::var("CODEX_LOGIN_DISABLE_BROWSER").is_ok() {
+            opts.open_browser = false;
+        }
+        let outgoing = self.outgoing.clone();
+        match run_login_server(opts, None) {

Re: blocking the main messaging processing loop, but we are blocking on run_login_server(), right? Is that guaranteed to return relatively quickly?

@@ -46,13 +52,27 @@ use crate::wire_format::SendUserTurnParams;
 use crate::wire_format::SendUserTurnResponse;
 use codex_core::protocol::InputItem as CoreInputItem;
 use codex_core::protocol::Op;
+use codex_login::CLIENT_ID;
+use codex_login::ServerOptions as LoginServerOptions;
+use codex_login::run_login_server;
+use std::sync::atomic::AtomicBool;
+use std::sync::atomic::Ordering as AtomicOrdering;
+
+// Time before a ChatGPT login attempt is abandoned.
+const LOGIN_CHATGPT_TIMEOUT_MINUTES: u64 = 10;

Again, please prefer Duration.

@@ -46,13 +52,27 @@ use crate::wire_format::SendUserTurnParams;
 use crate::wire_format::SendUserTurnResponse;
 use codex_core::protocol::InputItem as CoreInputItem;
 use codex_core::protocol::Op;
+use codex_login::CLIENT_ID;
+use codex_login::ServerOptions as LoginServerOptions;
+use codex_login::run_login_server;
+use std::sync::atomic::AtomicBool;
+use std::sync::atomic::Ordering as AtomicOrdering;
+
+// Time before a ChatGPT login attempt is abandoned.
+const LOGIN_CHATGPT_TIMEOUT_MINUTES: u64 = 10;
 
 /// Handles JSON-RPC messages for Codex conversations.
+struct ActiveLogin {

Docstring is on the wrong item now.

@@ -46,13 +52,27 @@ use crate::wire_format::SendUserTurnParams;
 use crate::wire_format::SendUserTurnResponse;
 use codex_core::protocol::InputItem as CoreInputItem;
 use codex_core::protocol::Op;
+use codex_login::CLIENT_ID;
+use codex_login::ServerOptions as LoginServerOptions;
+use codex_login::run_login_server;
+use std::sync::atomic::AtomicBool;
+use std::sync::atomic::Ordering as AtomicOrdering;
+
+// Time before a ChatGPT login attempt is abandoned.
+const LOGIN_CHATGPT_TIMEOUT_MINUTES: u64 = 10;
 
 /// Handles JSON-RPC messages for Codex conversations.
+struct ActiveLogin {
+    shutdown_flag: Arc<AtomicBool>,
+    actual_port: u16,
+}
+
 pub(crate) struct CodexMessageProcessor {
     conversation_manager: Arc<ConversationManager>,
     outgoing: Arc<OutgoingMessageSender>,
     codex_linux_sandbox_exe: Option<PathBuf>,
     conversation_listeners: HashMap<Uuid, oneshot::Sender<()>>,
+    active_logins: Arc<Mutex<HashMap<Uuid, ActiveLogin>>>,

I think there should be only one global login for the session. It's not like we're really set up to support multiple users with distinct logins right now...

@@ -92,6 +113,125 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let mut opts = LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string());
+        opts.open_browser = false;
+        opts.login_timeout_secs = Some(LOGIN_CHATGPT_TIMEOUT_MINUTES * 60);
+
+        let outgoing = self.outgoing.clone();
+        match run_login_server(opts, None) {

We should maybe introduce a simple enum with two variants: JSONRPCResponse and JSONRPCError. Then this match expression could evaluate to a value of that type and we would have one outgoing_clone.send_ call at the end so it's easier to verify that all the branching paths in this function result in sending a response.

@@ -92,6 +113,125 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let mut opts = LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string());
+        opts.open_browser = false;
+        opts.login_timeout_secs = Some(LOGIN_CHATGPT_TIMEOUT_MINUTES * 60);
+
+        let outgoing = self.outgoing.clone();

Is this clone() necessary? I think only the later one is when you use it in tokio::spawn().

@@ -92,6 +113,125 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let mut opts = LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string());

Hmm, I would consider adding another constructor that lets you specify all the params or construct the struct directly. It's less common to have to use mut for this (but it's certainly valid).

@@ -92,6 +113,125 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let mut opts = LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string());
+        opts.open_browser = false;
+        opts.login_timeout_secs = Some(LOGIN_CHATGPT_TIMEOUT_MINUTES * 60);
+
+        let outgoing = self.outgoing.clone();
+        match run_login_server(opts, None) {
+            Ok(server) => {
+                let login_attempt_id = Uuid::new_v4();
+                let shutdown_flag = server.shutdown_flag.clone();
+                let port = server.actual_port;
+
+                // Record shutdown flag & port for later cancellation.
+                self.active_logins.lock().await.insert(
+                    login_attempt_id,
+                    ActiveLogin {
+                        shutdown_flag: shutdown_flag.clone(),
+                        actual_port: port,
+                    },
+                );
+                outgoing
+                    .send_response(
+                        request_id,
+                        LoginChatGptResponse {
+                            login_id: login_attempt_id,
+                            auth_url: server.auth_url.clone(),
+                        },
+                    )
+                    .await;
+                let outgoing_clone = outgoing.clone();
+                let active_logins = self.active_logins.clone();
+                tokio::spawn(async move {
+                    let result =
+                        tokio::task::spawn_blocking(move || server.block_until_done()).await;
+                    let (success, error_msg) = match result {
+                        Ok(Ok(())) => (true, None),
+                        Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))),
+                        Err(join_err) => (
+                            false,
+                            Some(format!("failed to join login server thread: {join_err}")),
+                        ),
+                    };
+                    let notification = LoginChatGptCompleteNotification {
+                        login_id: login_attempt_id,
+                        success,
+                        error: error_msg,
+                    };
+                    let params = serde_json::to_value(&notification).ok();
+                    outgoing_clone
+                        .send_notification(OutgoingNotification {
+                            method: LOGIN_CHATGPT_COMPLETE_EVENT.to_string(),
+                            params,
+                        })
+                        .await;
+                    // Remove from map if still present (may have been removed by cancel).
+                    let mut map = active_logins.lock().await;
+                    map.remove(&login_attempt_id);
+                });
+            }
+            Err(err) => {
+                let error = JSONRPCErrorError {
+                    code: INTERNAL_ERROR_CODE,
+                    message: format!("failed to start login server: {err}"),
+                    data: None,
+                };
+                self.outgoing.send_error(request_id, error).await;
+            }
+        }
+    }
+
+    async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) {
+        let mut map = self.active_logins.lock().await;
+        match map.remove(&login_id) {

I believe you are holding the lock longer than is necessary. Leverage blocks to force the lock guard to drop:

        let active_login = {
            let mut map = self.active_logins.lock().await;
            map.remove(&login_id)
        };
        match map.remove(active_login) {
@@ -92,6 +117,137 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let opts = LoginServerOptions {
+            open_browser: false,
+            login_timeout: Some(LOGIN_CHATGPT_TIMEOUT),
+            ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())

👍

@@ -92,6 +117,137 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let opts = LoginServerOptions {
+            open_browser: false,
+            login_timeout: Some(LOGIN_CHATGPT_TIMEOUT),
+            ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
+        };
+
+        enum LoginChatGptReply {
+            Response(LoginChatGptResponse),
+            Error(JSONRPCErrorError),
+        }
+
+        let reply = match run_login_server(opts, None) {
+            Ok(server) => {
+                let login_id = Uuid::new_v4();
+
+                // Replace active login if present.
+                {
+                    let mut guard = self.active_login.lock().await;
+                    if let Some(existing) = guard.take() {
+                        existing.cancel();
+                    }
+                    *guard = Some(ActiveLogin {
+                        shutdown_handle: server.cancel_handle(),
+                        login_id,
+                    });
+                }
+
+                let response = LoginChatGptResponse {
+                    login_id,
+                    auth_url: server.auth_url.clone(),
+                };
+
+                // Spawn background task to monitor completion.
+                let outgoing_clone = self.outgoing.clone();
+                let active_login = self.active_login.clone();
+                tokio::spawn(async move {
+                    let result =
+                        tokio::task::spawn_blocking(move || server.block_until_done()).await;
+                    let (success, error_msg) = match result {
+                        Ok(Ok(())) => (true, None),
+                        Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))),
+                        Err(join_err) => (
+                            false,
+                            Some(format!("failed to join login server thread: {join_err}")),
+                        ),
+                    };
+                    let notification = LoginChatGptCompleteNotification {
+                        login_id,
+                        success,
+                        error: error_msg,
+                    };
+                    let params = serde_json::to_value(&notification).ok();
+                    outgoing_clone
+                        .send_notification(OutgoingNotification {
+                            method: LOGIN_CHATGPT_COMPLETE_EVENT.to_string(),
+                            params,
+                        })
+                        .await;
+
+                    // Clear the active login if it matches this attempt. It may have been replaced or cancelled.
+                    let mut guard = active_login.lock().await;
+                    if guard.as_ref().map(|l| l.login_id) == Some(login_id) {
+                        *guard = None;
+                    }
+                });
+
+                LoginChatGptReply::Response(response)
+            }
+            Err(err) => LoginChatGptReply::Error(JSONRPCErrorError {
+                code: INTERNAL_ERROR_CODE,
+                message: format!("failed to start login server: {err}"),
+                data: None,
+            }),
+        };
+
+        match reply {
+            LoginChatGptReply::Response(resp) => {
+                self.outgoing.send_response(request_id, resp).await
+            }
+            LoginChatGptReply::Error(err) => self.outgoing.send_error(request_id, err).await,
+        }
+    }
+
+    async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) {
+        let mut guard = self.active_login.lock().await;
+        match guard.as_ref().map(|l| l.login_id) {
+            Some(current_id) if current_id == login_id => {
+                if let Some(active) = guard.take() {
+                    active.cancel();
+                }
+                drop(guard);
+                self.outgoing
+                    .send_response(
+                        request_id,
+                        crate::wire_format::CancelLoginChatGptResponse {},
+                    )
+                    .await;
+            }
+            _ => {

prefer None to _

@@ -92,6 +117,137 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let opts = LoginServerOptions {
+            open_browser: false,
+            login_timeout: Some(LOGIN_CHATGPT_TIMEOUT),
+            ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
+        };
+
+        enum LoginChatGptReply {
+            Response(LoginChatGptResponse),
+            Error(JSONRPCErrorError),
+        }
+
+        let reply = match run_login_server(opts, None) {
+            Ok(server) => {
+                let login_id = Uuid::new_v4();
+
+                // Replace active login if present.
+                {
+                    let mut guard = self.active_login.lock().await;
+                    if let Some(existing) = guard.take() {

FYI, I believe the take() will effectively make it so existing is Drop, so maybe ActiveLogin::cancel should be ActiveLogin::drop?

@@ -92,6 +117,137 @@ impl CodexMessageProcessor {
             ClientRequest::RemoveConversationListener { request_id, params } => {
                 self.remove_conversation_listener(request_id, params).await;
             }
+            ClientRequest::LoginChatGpt { request_id } => {
+                self.login_chatgpt(request_id).await;
+            }
+            ClientRequest::CancelLoginChatGpt { request_id, params } => {
+                self.cancel_login_chatgpt(request_id, params.login_id).await;
+            }
+        }
+    }
+
+    async fn login_chatgpt(&mut self, request_id: RequestId) {
+        let config =
+            match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) {
+                Ok(cfg) => cfg,
+                Err(err) => {
+                    let error = JSONRPCErrorError {
+                        code: INTERNAL_ERROR_CODE,
+                        message: format!("error loading config for login: {err}"),
+                        data: None,
+                    };
+                    self.outgoing.send_error(request_id, error).await;
+                    return;
+                }
+            };
+
+        let opts = LoginServerOptions {
+            open_browser: false,
+            login_timeout: Some(LOGIN_CHATGPT_TIMEOUT),
+            ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
+        };
+
+        enum LoginChatGptReply {
+            Response(LoginChatGptResponse),
+            Error(JSONRPCErrorError),
+        }
+
+        let reply = match run_login_server(opts, None) {
+            Ok(server) => {
+                let login_id = Uuid::new_v4();
+
+                // Replace active login if present.
+                {
+                    let mut guard = self.active_login.lock().await;
+                    if let Some(existing) = guard.take() {
+                        existing.cancel();
+                    }
+                    *guard = Some(ActiveLogin {
+                        shutdown_handle: server.cancel_handle(),
+                        login_id,
+                    });
+                }
+
+                let response = LoginChatGptResponse {
+                    login_id,
+                    auth_url: server.auth_url.clone(),
+                };
+
+                // Spawn background task to monitor completion.
+                let outgoing_clone = self.outgoing.clone();
+                let active_login = self.active_login.clone();
+                tokio::spawn(async move {
+                    let result =
+                        tokio::task::spawn_blocking(move || server.block_until_done()).await;
+                    let (success, error_msg) = match result {
+                        Ok(Ok(())) => (true, None),
+                        Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))),
+                        Err(join_err) => (
+                            false,
+                            Some(format!("failed to join login server thread: {join_err}")),
+                        ),
+                    };
+                    let notification = LoginChatGptCompleteNotification {
+                        login_id,
+                        success,
+                        error: error_msg,
+                    };
+                    let params = serde_json::to_value(&notification).ok();
+                    outgoing_clone
+                        .send_notification(OutgoingNotification {
+                            method: LOGIN_CHATGPT_COMPLETE_EVENT.to_string(),
+                            params,
+                        })
+                        .await;
+
+                    // Clear the active login if it matches this attempt. It may have been replaced or cancelled.
+                    let mut guard = active_login.lock().await;
+                    if guard.as_ref().map(|l| l.login_id) == Some(login_id) {
+                        *guard = None;
+                    }
+                });
+
+                LoginChatGptReply::Response(response)
+            }
+            Err(err) => LoginChatGptReply::Error(JSONRPCErrorError {
+                code: INTERNAL_ERROR_CODE,
+                message: format!("failed to start login server: {err}"),
+                data: None,
+            }),
+        };
+
+        match reply {
+            LoginChatGptReply::Response(resp) => {
+                self.outgoing.send_response(request_id, resp).await
+            }
+            LoginChatGptReply::Error(err) => self.outgoing.send_error(request_id, err).await,
+        }
+    }
+
+    async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) {
+        let mut guard = self.active_login.lock().await;
+        match guard.as_ref().map(|l| l.login_id) {
+            Some(current_id) if current_id == login_id => {
+                if let Some(active) = guard.take() {
+                    active.cancel();
+                }
+                drop(guard);
+                self.outgoing
+                    .send_response(
+                        request_id,
+                        crate::wire_format::CancelLoginChatGptResponse {},
+                    )
+                    .await;
+            }
+            _ => {

Ah thanks, missed that!

codex-rs/mcp-server/src/wire_format.rs

@@ -286,4 +325,72 @@ mod tests {
             serde_json::to_value(&request).unwrap(),
         );
     }
+
+    #[test]

FYI, I added a couple of tests originally just to verify I put #[serde(tag = "method", rename_all = "camelCase")] in the right place, but I don't know that we have to verify the serialized version of every message type. I think I have a better way to verify this sort of thing going forward, so don't feel like you have to add tests here to match the above, but feel free to keep them if you find them helpful.