From c7ddfb8914fa0f31509d1da4d6a59070703c04e3 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 7 Oct 2025 21:29:47 -0700 Subject: [PATCH] Works --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/mcp_cmd.rs | 66 +++++++++++-- codex-rs/cli/tests/mcp_list.rs | 5 +- codex-rs/core/src/codex.rs | 11 ++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/mcp/auth.rs | 58 +++++++++++ codex-rs/core/src/mcp/mod.rs | 1 + codex-rs/protocol/src/protocol.rs | 24 +++++ codex-rs/rmcp-client/Cargo.toml | 1 + codex-rs/rmcp-client/src/auth_status.rs | 125 ++++++++++++++++++++++++ codex-rs/rmcp-client/src/lib.rs | 3 + codex-rs/rmcp-client/src/oauth.rs | 8 ++ codex-rs/tui/src/chatwidget.rs | 6 +- codex-rs/tui/src/history_cell.rs | 9 +- 14 files changed, 306 insertions(+), 13 deletions(-) create mode 100644 codex-rs/core/src/mcp/auth.rs create mode 100644 codex-rs/core/src/mcp/mod.rs create mode 100644 codex-rs/rmcp-client/src/auth_status.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8c992a6f8e..0c12880c94 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1352,6 +1352,7 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "codex-protocol", "dirs", "futures", "keyring", diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 0cf3c0e228..a488ba75ca 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -13,6 +13,8 @@ use codex_core::config::load_global_mcp_servers; use codex_core::config::write_global_mcp_servers; use codex_core::config_types::McpServerConfig; use codex_core::config_types::McpServerTransportConfig; +use codex_core::mcp::auth::compute_auth_statuses; +use codex_core::protocol::McpAuthStatus; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::perform_oauth_login; @@ -339,11 +341,20 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let mut entries: Vec<_> = config.mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + let auth_statuses = compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ) + .await; if list_args.json { let json_entries: Vec<_> = entries .into_iter() .map(|(name, cfg)| { + let auth_status = auth_statuses + .get(name.as_str()) + .copied() + .unwrap_or(McpAuthStatus::Unsupported); let transport = match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ "type": "stdio", @@ -372,6 +383,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> "tool_timeout_sec": cfg .tool_timeout_sec .map(|timeout| timeout.as_secs_f64()), + "auth_status": auth_status, }) }) .collect(); @@ -385,8 +397,8 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 4]> = Vec::new(); - let mut http_rows: Vec<[String; 3]> = Vec::new(); + let mut stdio_rows: Vec<[String; 5]> = Vec::new(); + let mut http_rows: Vec<[String; 4]> = Vec::new(); for (name, cfg) in entries { match &cfg.transport { @@ -409,23 +421,46 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .join(", ") } }; - stdio_rows.push([name.clone(), command.clone(), args_display, env_display]); + let status = auth_statuses + .get(name.as_str()) + .copied() + .unwrap_or(McpAuthStatus::Unsupported) + .to_string(); + stdio_rows.push([ + name.clone(), + command.clone(), + args_display, + env_display, + status, + ]); } McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, } => { + let status = auth_statuses + .get(name.as_str()) + .copied() + .unwrap_or(McpAuthStatus::Unsupported) + .to_string(); http_rows.push([ name.clone(), url.clone(), bearer_token_env_var.clone().unwrap_or("-".to_string()), + status, ]); } } } if !stdio_rows.is_empty() { - let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; + let mut widths = [ + "Name".len(), + "Command".len(), + "Args".len(), + "Env".len(), + "Auth".len(), + ]; for row in &stdio_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -433,28 +468,32 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: } if !http_rows.is_empty() { - let mut widths = ["Name".len(), "Url".len(), "Bearer Token Env Var".len()]; + let mut widths = [ + "Name".len(), + "Url".len(), + "Bearer Token Env Var".len(), + "Auth".len(), + ]; for row in &http_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -472,24 +516,28 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: Result<()> { assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); assert!(stdout.contains("TOKEN=secret")); + assert!(stdout.contains("Auth")); + assert!(stdout.contains("Unsupported")); let mut list_json_cmd = codex_command(codex_home.path())?; let json_output = list_json_cmd.args(["mcp", "list", "--json"]).output()?; @@ -76,7 +78,8 @@ fn list_and_get_render_expected_output() -> Result<()> { } }, "startup_timeout_sec": null, - "tool_timeout_sec": null + "tool_timeout_sec": null, + "auth_status": "unsupported" } ] ) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 356e25ed3a..cae47cb322 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -57,6 +57,7 @@ use crate::exec_command::WriteStdinParams; use crate::executor::Executor; use crate::executor::ExecutorConfig; use crate::executor::normalize_exec_result; +use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_family::find_family_for_model; use crate::openai_model_info::get_model_info; @@ -1403,10 +1404,18 @@ async fn submission_loop( // This is a cheap lookup from the connection manager's cache. let tools = sess.services.mcp_connection_manager.list_all_tools(); + let auth_statuses = compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ) + .await; let event = Event { id: sub_id, msg: EventMsg::McpListToolsResponse( - crate::protocol::McpListToolsResponseEvent { tools }, + crate::protocol::McpListToolsResponseEvent { + tools, + auth_statuses, + }, ), }; sess.send_event(event).await; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 08baa2bdc6..201d8feb4d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -32,6 +32,7 @@ pub mod executor; mod flags; pub mod git_info; pub mod landlock; +pub mod mcp; mod mcp_connection_manager; mod mcp_tool_call; mod message_history; diff --git a/codex-rs/core/src/mcp/auth.rs b/codex-rs/core/src/mcp/auth.rs new file mode 100644 index 0000000000..dbb9db804f --- /dev/null +++ b/codex-rs/core/src/mcp/auth.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; + +use anyhow::Result; +use codex_protocol::protocol::McpAuthStatus; +use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_rmcp_client::determine_streamable_http_auth_status; +use futures::future::join_all; +use tracing::warn; + +use crate::config_types::McpServerConfig; +use crate::config_types::McpServerTransportConfig; + +pub async fn compute_auth_statuses<'a, I>( + servers: I, + store_mode: OAuthCredentialsStoreMode, +) -> HashMap +where + I: IntoIterator, +{ + let futures = servers.into_iter().map(|(name, config)| { + let name = name.clone(); + let config = config.clone(); + async move { + let status = match compute_auth_status(&name, &config, store_mode).await { + Ok(status) => status, + Err(error) => { + warn!("failed to determine auth status for MCP server `{name}`: {error:?}"); + McpAuthStatus::Unsupported + } + }; + (name, status) + } + }); + + join_all(futures).await.into_iter().collect() +} + +async fn compute_auth_status( + server_name: &str, + config: &McpServerConfig, + store_mode: OAuthCredentialsStoreMode, +) -> Result { + match &config.transport { + McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported), + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + } => { + determine_streamable_http_auth_status( + server_name, + url, + bearer_token_env_var.as_deref(), + store_mode, + ) + .await + } + } +} diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs new file mode 100644 index 0000000000..0e4a05d597 --- /dev/null +++ b/codex-rs/core/src/mcp/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3b6520be67..1ae32e5122 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1243,6 +1243,30 @@ pub struct GetHistoryEntryResponseEvent { pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, + /// Authentication status for each configured MCP server. + pub auth_statuses: std::collections::HashMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth, +} + +impl fmt::Display for McpAuthStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + McpAuthStatus::Unsupported => "Unsupported", + McpAuthStatus::NotLoggedIn => "Not logged in", + McpAuthStatus::BearerToken => "Bearer token", + McpAuthStatus::OAuth => "OAuth", + }; + f.write_str(text) + } } /// Response payload for `Op::ListCustomPrompts`. diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index ddbc70563e..99a609b339 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -12,6 +12,7 @@ axum = { workspace = true, default-features = false, features = [ "http1", "tokio", ] } +codex-protocol = { workspace = true } keyring = { workspace = true, features = [ "apple-native", "crypto-rust", diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs new file mode 100644 index 0000000000..0281c0ffe8 --- /dev/null +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -0,0 +1,125 @@ +use std::time::Duration; + +use anyhow::Error; +use anyhow::Result; +use codex_protocol::protocol::McpAuthStatus; +use reqwest::Client; +use reqwest::StatusCode; +use reqwest::Url; +use serde::Deserialize; +use tracing::debug; + +use crate::OAuthCredentialsStoreMode; +use crate::oauth::has_oauth_tokens; + +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); +const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version"; +const OAUTH_DISCOVERY_VERSION: &str = "2024-11-05"; + +/// Determine the authentication status for a streamable HTTP MCP server. +pub async fn determine_streamable_http_auth_status( + server_name: &str, + url: &str, + bearer_token_env_var: Option<&str>, + store_mode: OAuthCredentialsStoreMode, +) -> Result { + if bearer_token_env_var.is_some() { + return Ok(McpAuthStatus::BearerToken); + } + + if has_oauth_tokens(server_name, url, store_mode)? { + return Ok(McpAuthStatus::OAuth); + } + + match supports_oauth_login(url).await { + Ok(true) => Ok(McpAuthStatus::NotLoggedIn), + Ok(false) => Ok(McpAuthStatus::Unsupported), + Err(error) => { + debug!( + "failed to detect OAuth support for MCP server `{server_name}` at {url}: {error:?}" + ); + Ok(McpAuthStatus::Unsupported) + } + } +} + +/// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login. +async fn supports_oauth_login(url: &str) -> Result { + let base_url = Url::parse(url)?; + let client = Client::builder().timeout(DISCOVERY_TIMEOUT).build()?; + + let mut last_error: Option = None; + for candidate_path in discovery_paths(base_url.path()) { + let mut discovery_url = base_url.clone(); + discovery_url.set_path(&candidate_path); + + let response = match client + .get(discovery_url.clone()) + .header(OAUTH_DISCOVERY_HEADER, OAUTH_DISCOVERY_VERSION) + .send() + .await + { + Ok(response) => response, + Err(err) => { + last_error = Some(err.into()); + continue; + } + }; + + if response.status() != StatusCode::OK { + continue; + } + + let metadata = match response.json::().await { + Ok(metadata) => metadata, + Err(err) => { + last_error = Some(err.into()); + continue; + } + }; + + if metadata.authorization_endpoint.is_some() && metadata.token_endpoint.is_some() { + return Ok(true); + } + } + + if let Some(err) = last_error { + debug!("OAuth discovery requests failed for {url}: {err:?}"); + } + + Ok(false) +} + +#[derive(Debug, Deserialize)] +struct OAuthDiscoveryMetadata { + #[serde(default)] + authorization_endpoint: Option, + #[serde(default)] + token_endpoint: Option, +} + +/// Implements RFC 8414 section 3.1 for discovering well-known oauth endpoints. +/// This is a requirement for MCP servers to support OAuth. +/// https://datatracker.ietf.org/doc/html/rfc8414#section-3.1 +/// https://github.com/modelcontextprotocol/rust-sdk/blob/main/crates/rmcp/src/transport/auth.rs#L182 +fn discovery_paths(base_path: &str) -> Vec { + let trimmed = base_path.trim_start_matches('/').trim_end_matches('/'); + let canonical = "/.well-known/oauth-authorization-server".to_string(); + + if trimmed.is_empty() { + return vec![canonical]; + } + + let mut candidates = Vec::new(); + let mut push_unique = |candidate: String| { + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + }; + + push_unique(format!("{canonical}/{trimmed}")); + push_unique(format!("/{trimmed}/.well-known/oauth-authorization-server")); + push_unique(canonical); + + candidates +} diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 0d15584f0f..05412da184 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -1,3 +1,4 @@ +mod auth_status; mod find_codex_home; mod logging_client_handler; mod oauth; @@ -5,6 +6,8 @@ mod perform_oauth_login; mod rmcp_client; mod utils; +pub use auth_status::determine_streamable_http_auth_status; +pub use codex_protocol::protocol::McpAuthStatus; pub use oauth::OAuthCredentialsStoreMode; pub use oauth::StoredOAuthTokens; pub use oauth::WrappedOAuthTokenResponse; diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index 0534847651..afa0e907b1 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -162,6 +162,14 @@ pub(crate) fn load_oauth_tokens( } } +pub(crate) fn has_oauth_tokens( + server_name: &str, + url: &str, + store_mode: OAuthCredentialsStoreMode, +) -> Result { + Ok(load_oauth_tokens(server_name, url, store_mode)?.is_some()) +} + fn load_oauth_tokens_from_keyring_with_fallback_to_file( keyring_store: &K, server_name: &str, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2237c678c7..973b0e3ba2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1906,7 +1906,11 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + ev.tools, + &ev.auth_statuses, + )); } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7c4d18e1a3..0e7a3177d5 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -21,6 +21,7 @@ use codex_core::config::Config; use codex_core::config_types::McpServerTransportConfig; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::protocol::FileChange; +use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; @@ -849,7 +850,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { /// Render MCP tools grouped by connection using the fully-qualified tool names. pub(crate) fn new_mcp_tools_output( config: &Config, - tools: std::collections::HashMap, + tools: HashMap, + auth_statuses: &HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ "/mcp".magenta().into(), @@ -873,7 +875,12 @@ pub(crate) fn new_mcp_tools_output( .collect(); names.sort(); + let status = auth_statuses + .get(server.as_str()) + .copied() + .unwrap_or(McpAuthStatus::Unsupported); lines.push(vec![" • Server: ".into(), server.clone().into()].into()); + lines.push(vec![" • Auth: ".into(), status.to_string().into()].into()); match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => {