From ace11e21df5498f3420369274a0aaf87f2749778 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Thu, 28 May 2026 22:32:35 -0700 Subject: [PATCH] Add remote MCP OAuth test script --- codex-rs/app-server/src/request_processors.rs | 2 +- .../src/request_processors/mcp_processor.rs | 2 +- .../src/request_processors/plugins.rs | 10 +- codex-rs/codex-mcp/src/lib.rs | 3 + codex-rs/codex-mcp/src/mcp/auth.rs | 82 +++++- codex-rs/codex-mcp/src/mcp/mod.rs | 7 +- codex-rs/codex-mcp/src/rmcp_client.rs | 124 +++++---- codex-rs/core/src/connectors.rs | 4 +- codex-rs/core/src/mcp_skill_dependencies.rs | 22 +- codex-rs/core/src/session/mcp.rs | 2 +- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/session.rs | 2 +- .../src/bin/test_streamable_http_server.rs | 32 +++ .../rmcp-client/src/perform_oauth_login.rs | 15 +- scripts/test-remote-mcp-oauth-e2e.sh | 244 ++++++++++++++++++ 15 files changed, 458 insertions(+), 95 deletions(-) create mode 100755 scripts/test-remote-mcp-oauth-e2e.sh diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 6ef961fa6e..27533fa597 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -350,7 +350,7 @@ use codex_mcp::McpRuntimeContext; use codex_mcp::McpServerStatusSnapshot; use codex_mcp::McpSnapshotDetail; use codex_mcp::collect_mcp_server_status_snapshot_with_detail; -use codex_mcp::discover_supported_scopes; +use codex_mcp::discover_supported_scopes_with_runtime_context; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 451af75e7e..1d23b78fab 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -152,7 +152,7 @@ impl McpRequestProcessor { }; let discovered_scopes = if scopes.is_none() && server.scopes.is_none() { - discover_supported_scopes(&name, server, &runtime_context).await + discover_supported_scopes_with_runtime_context(&name, server, &runtime_context).await } else { None }; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 8290dfee56..4ff3d6d11c 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -10,7 +10,7 @@ use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; use codex_mcp::McpOAuthLoginSupport; -use codex_mcp::oauth_login_support; +use codex_mcp::oauth_login_support_with_runtime_context; use codex_mcp::should_retry_without_scopes; use codex_rmcp_client::perform_oauth_login_silent_with_http_client; @@ -1570,7 +1570,13 @@ impl PluginRequestProcessor { config.cwd.to_path_buf(), ); for (name, server) in plugin_mcp_servers { - let oauth_config = match oauth_login_support(&name, &server, &runtime_context).await { + let oauth_config = match oauth_login_support_with_runtime_context( + &name, + &server, + &runtime_context, + ) + .await + { McpOAuthLoginSupport::Supported(config) => config, McpOAuthLoginSupport::Unsupported => continue, McpOAuthLoginSupport::Unknown(err) => { diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 83419175d4..5dd8be45a6 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -42,8 +42,11 @@ pub use mcp::McpOAuthLoginSupport; pub use mcp::McpOAuthScopesSource; pub use mcp::ResolvedMcpOAuthScopes; pub use mcp::compute_auth_statuses; +pub use mcp::compute_auth_statuses_with_runtime_context; pub use mcp::discover_supported_scopes; +pub use mcp::discover_supported_scopes_with_runtime_context; pub use mcp::oauth_login_support; +pub use mcp::oauth_login_support_with_runtime_context; pub use mcp::resolve_oauth_scopes; pub use mcp::should_retry_without_scopes; diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index 65ab20d391..e876eef6d2 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; +use std::sync::Arc; use anyhow::Result; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::HttpClient; +use codex_exec_server::ReqwestHttpClient; use codex_login::CodexAuth; use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::OAuthProviderError; @@ -52,17 +55,32 @@ pub struct McpAuthStatusEntry { pub auth_status: McpAuthStatus, } -pub async fn oauth_login_support( +pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport { + oauth_login_support_with_http_client(transport, Arc::new(ReqwestHttpClient)).await +} + +pub async fn oauth_login_support_with_runtime_context( server_name: &str, config: &McpServerConfig, runtime_context: &McpRuntimeContext, +) -> McpOAuthLoginSupport { + let http_client = match runtime_context.resolve_streamable_http_client(server_name, config) { + Ok(http_client) => http_client, + Err(err) => return McpOAuthLoginSupport::Unknown(anyhow::anyhow!(err)), + }; + oauth_login_support_with_http_client(&config.transport, http_client).await +} + +async fn oauth_login_support_with_http_client( + transport: &McpServerTransportConfig, + http_client: Arc, ) -> McpOAuthLoginSupport { let McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, http_headers, env_http_headers, - } = &config.transport + } = transport else { return McpOAuthLoginSupport::Unsupported; }; @@ -71,11 +89,6 @@ pub async fn oauth_login_support( return McpOAuthLoginSupport::Unsupported; } - let http_client = match runtime_context.resolve_streamable_http_client(server_name, config) { - Ok(http_client) => http_client, - Err(err) => return McpOAuthLoginSupport::Unknown(anyhow::anyhow!(err)), - }; - match discover_streamable_http_oauth_with_http_client( url, http_headers.clone(), @@ -96,11 +109,20 @@ pub async fn oauth_login_support( } pub async fn discover_supported_scopes( + transport: &McpServerTransportConfig, +) -> Option> { + match oauth_login_support(transport).await { + McpOAuthLoginSupport::Supported(config) => config.discovered_scopes, + McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None, + } +} + +pub async fn discover_supported_scopes_with_runtime_context( server_name: &str, config: &McpServerConfig, runtime_context: &McpRuntimeContext, ) -> Option> { - match oauth_login_support(server_name, config, runtime_context).await { + match oauth_login_support_with_runtime_context(server_name, config, runtime_context).await { McpOAuthLoginSupport::Supported(config) => config.discovered_scopes, McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None, } @@ -149,15 +171,48 @@ pub async fn compute_auth_statuses<'a, I>( servers: I, store_mode: OAuthCredentialsStoreMode, auth: Option<&CodexAuth>, +) -> HashMap +where + I: IntoIterator, +{ + compute_auth_statuses_with_http_client_resolver(servers, store_mode, auth, |_, _| { + Ok(Arc::new(ReqwestHttpClient) as Arc) + }) + .await +} + +pub async fn compute_auth_statuses_with_runtime_context<'a, I>( + servers: I, + store_mode: OAuthCredentialsStoreMode, + auth: Option<&CodexAuth>, runtime_context: McpRuntimeContext, ) -> HashMap where I: IntoIterator, +{ + compute_auth_statuses_with_http_client_resolver( + servers, + store_mode, + auth, + move |name, config| runtime_context.resolve_streamable_http_client(name, config), + ) + .await +} + +async fn compute_auth_statuses_with_http_client_resolver<'a, I, F>( + servers: I, + store_mode: OAuthCredentialsStoreMode, + auth: Option<&CodexAuth>, + resolve_http_client: F, +) -> HashMap +where + I: IntoIterator, + F: Fn(&str, &McpServerConfig) -> Result, String> + Clone, { let futures = servers.into_iter().map(|(name, server)| { let name = name.clone(); let config = server.configured_config().cloned(); - let runtime_context = runtime_context.clone(); + let resolve_http_client = resolve_http_client.clone(); let has_runtime_auth = name == CODEX_APPS_MCP_SERVER_NAME && auth.is_some_and(CodexAuth::uses_codex_backend) && config.as_ref().is_some_and(|config| { @@ -177,7 +232,7 @@ where config, store_mode, has_runtime_auth, - &runtime_context, + &resolve_http_client, ) .await { @@ -208,7 +263,7 @@ async fn compute_auth_status( config: &McpServerConfig, store_mode: OAuthCredentialsStoreMode, has_runtime_auth: bool, - runtime_context: &McpRuntimeContext, + resolve_http_client: &impl Fn(&str, &McpServerConfig) -> Result, String>, ) -> Result { if !config.enabled { return Ok(McpAuthStatus::Unsupported); @@ -226,9 +281,8 @@ async fn compute_auth_status( http_headers, env_http_headers, } => { - let http_client = runtime_context - .resolve_streamable_http_client(server_name, config) - .map_err(anyhow::Error::msg)?; + let http_client = + resolve_http_client(server_name, config).map_err(anyhow::Error::msg)?; determine_streamable_http_auth_status( server_name, url, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 81a9f2318e..5f0be7e529 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -4,8 +4,11 @@ pub use auth::McpOAuthLoginSupport; pub use auth::McpOAuthScopesSource; pub use auth::ResolvedMcpOAuthScopes; pub use auth::compute_auth_statuses; +pub use auth::compute_auth_statuses_with_runtime_context; pub use auth::discover_supported_scopes; +pub use auth::discover_supported_scopes_with_runtime_context; pub use auth::oauth_login_support; +pub use auth::oauth_login_support_with_runtime_context; pub use auth::resolve_oauth_scopes; pub use auth::should_retry_without_scopes; @@ -272,7 +275,7 @@ pub async fn read_mcp_resource( let mut mcp_servers = effective_mcp_servers(config, auth); let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); - let auth_statuses = compute_auth_statuses( + let auth_statuses = compute_auth_statuses_with_runtime_context( mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, auth, @@ -339,7 +342,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( }; } - let auth_status_entries = compute_auth_statuses( + let auth_status_entries = compute_auth_statuses_with_runtime_context( mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, auth, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index ce15fd205c..35f351e931 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -20,6 +20,7 @@ use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::filter_disallowed_codex_apps_tools; use crate::codex_apps::load_cached_codex_apps_tools; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; @@ -45,6 +46,7 @@ use codex_async_utils::OrCancelExt; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::protocol::Event; use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; @@ -83,6 +85,7 @@ const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[ #[derive(Clone)] pub(crate) struct ManagedClient { pub(crate) client: Arc, + pub(crate) server_info: McpServerInfo, pub(crate) tools: Vec, pub(crate) tool_filter: ToolFilter, pub(crate) tool_timeout: Option, @@ -121,7 +124,8 @@ impl ManagedClient { #[derive(Clone)] pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, - pub(crate) startup_snapshot: Option>, + pub(crate) cached_tool_info_snapshot: Option>, + pub(crate) cached_server_info: Option, pub(crate) startup_complete: Arc, pub(crate) tool_plugin_provenance: Arc, pub(crate) cancel_token: CancellationToken, @@ -148,11 +152,16 @@ impl AsyncManagedClient { .configured_config() .map(ToolFilter::from_config) .unwrap_or_default(); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot( &server_name, codex_apps_tools_cache_context.as_ref(), - ) - .map(|tools| filter_tools(tools, &tool_filter)); + ); + let cached_tool_info_snapshot = + cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter)); + let cached_server_info = load_startup_cached_codex_apps_server_info( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); @@ -205,7 +214,7 @@ impl AsyncManagedClient { outcome }; let client = fut.boxed().shared(); - if startup_snapshot.is_some() { + if cached_tool_info_snapshot.is_some() { let startup_task = client.clone(); tokio::spawn(async move { let _ = startup_task.await; @@ -214,7 +223,8 @@ impl AsyncManagedClient { Self { client, - startup_snapshot, + cached_tool_info_snapshot, + cached_server_info, startup_complete, tool_plugin_provenance, cancel_token, @@ -236,9 +246,9 @@ impl AsyncManagedClient { } } - fn startup_snapshot_while_initializing(&self) -> Option> { + fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { if !self.startup_complete.load(Ordering::Acquire) { - return self.startup_snapshot.clone(); + return self.cached_tool_info_snapshot.clone(); } None } @@ -296,12 +306,13 @@ impl AsyncManagedClient { }; // Keep cache payloads raw; plugin provenance is resolved per-session at read time. - let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() + { Some(startup_tools) } else { match self.client().await { Ok(client) => Some(client.listed_tools()), - Err(_) => self.startup_snapshot.clone(), + Err(_) => self.cached_tool_info_snapshot.clone(), } }; tools.map(annotate_tools) @@ -468,26 +479,13 @@ async fn start_server_task( codex_apps_tools_cache_context, client_elicitation_capability, } = params; - let params = InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(client_elicitation_capability), - tasks: None, - }, - client_info: Implementation { - name: "codex-mcp-client".to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: Some("Codex".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - }; + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + let params = InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -517,9 +515,11 @@ async fn start_server_task( fetch_start.elapsed(), &[], ); + let server_info = mcp_server_info_from_implementation(initialize_result.server_info); write_cached_codex_apps_tools_if_needed( &server_name, codex_apps_tools_cache_context.as_ref(), + &server_info, &tools, ); if server_name == CODEX_APPS_MCP_SERVER_NAME { @@ -533,6 +533,7 @@ async fn start_server_task( let managed = ManagedClient { client: Arc::clone(&client), + server_info, tools, tool_timeout: Some(tool_timeout), tool_filter, @@ -544,6 +545,22 @@ async fn start_server_task( Ok(managed) } +fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { + McpServerInfo { + name: server_info.name, + title: server_info.title, + version: server_info.version, + description: server_info.description, + icons: server_info.icons.map(|icons| { + icons + .into_iter() + .filter_map(|icon| serde_json::to_value(icon).ok()) + .collect() + }), + website_url: server_info.website_url, + } +} + struct StartServerTaskParams { startup_timeout: Option, // TODO: cancel_token should handle this. tool_timeout: Duration, @@ -645,32 +662,27 @@ mod tests { use rmcp::model::Meta; fn tool_with_connector_meta() -> RmcpTool { - RmcpTool { - name: "capture_file_upload".to_string().into(), - title: None, - description: Some("test tool".to_string().into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: Some(Meta( - serde_json::json!({ - "connector_id": "connector_gmail", - "connector_name": "Gmail", - "connector_display_name": "Gmail", - "connector_description": "Mail connector", - "connectorDescription": "Mail connector", - "connectorFutureField": "future connector metadata", - "CONNECTOR_UPPERCASE": "uppercase connector metadata", - "openai/fileParams": ["file"], - "custom": "kept" - }) - .as_object() - .expect("object") - .clone(), - )), - } + RmcpTool::new( + "capture_file_upload", + "test tool", + Arc::new(JsonObject::default()), + ) + .with_meta(Meta( + serde_json::json!({ + "connector_id": "connector_gmail", + "connector_name": "Gmail", + "connector_display_name": "Gmail", + "connector_description": "Mail connector", + "connectorDescription": "Mail connector", + "connectorFutureField": "future connector metadata", + "CONNECTOR_UPPERCASE": "uppercase connector metadata", + "openai/fileParams": ["file"], + "custom": "kept" + }) + .as_object() + .expect("object") + .clone(), + )) } #[test] diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 928f0c15f5..bc8c14df3e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -39,7 +39,7 @@ use codex_mcp::McpRuntimeContext; use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; -use codex_mcp::compute_auth_statuses; +use codex_mcp::compute_auth_statuses_with_runtime_context; use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; @@ -252,7 +252,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( }); } - let auth_status_entries = compute_auth_statuses( + let auth_status_entries = compute_auth_statuses_with_runtime_context( mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, auth.as_ref(), diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 92cee8919f..b7cb7da1b9 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -23,7 +23,7 @@ use codex_mcp::ElicitationReviewerHandle; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::McpPermissionPromptAutoApproveContext; use codex_mcp::mcp_permission_prompt_is_auto_approved; -use codex_mcp::oauth_login_support; +use codex_mcp::oauth_login_support_with_runtime_context; use codex_mcp::resolve_oauth_scopes; use codex_mcp::should_retry_without_scopes; @@ -149,15 +149,17 @@ pub(crate) async fn maybe_install_mcp_dependencies( }; for (name, server_config) in added { - let oauth_config = match oauth_login_support(&name, &server_config, &runtime_context).await - { - McpOAuthLoginSupport::Supported(config) => config, - McpOAuthLoginSupport::Unsupported => continue, - McpOAuthLoginSupport::Unknown(err) => { - warn!("MCP server may or may not require login for dependency {name}: {err}"); - continue; - } - }; + let oauth_config = + match oauth_login_support_with_runtime_context(&name, &server_config, &runtime_context) + .await + { + McpOAuthLoginSupport::Supported(config) => config, + McpOAuthLoginSupport::Unsupported => continue, + McpOAuthLoginSupport::Unknown(err) => { + warn!("MCP server may or may not require login for dependency {name}: {err}"); + continue; + } + }; let resolved_scopes = resolve_oauth_scopes( /*explicit_scopes*/ None, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 570a86d46b..df2f90e87a 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -334,7 +334,7 @@ impl Session { turn_context.cwd.to_path_buf(), ), }; - let auth_statuses = compute_auth_statuses( + let auth_statuses = compute_auth_statuses_with_runtime_context( mcp_servers.iter(), store_mode, auth.as_ref(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8c535cd728..ec460d8286 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -315,7 +315,7 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; use codex_git_utils::get_git_repo_root; -use codex_mcp::compute_auth_statuses; +use codex_mcp::compute_auth_statuses_with_runtime_context; use codex_mcp::effective_mcp_servers_from_configured; use codex_mcp::host_owned_codex_apps_enabled; use codex_otel::SessionTelemetry; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 343454d457..5947203715 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -631,7 +631,7 @@ impl Session { environment_manager_for_mcp, config_for_mcp.cwd.to_path_buf(), ); - let auth_statuses = compute_auth_statuses( + let auth_statuses = compute_auth_statuses_with_runtime_context( mcp_servers.iter(), config_for_mcp.mcp_oauth_credentials_store_mode, auth.as_ref(), diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 2384d39473..c06e967b70 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -9,6 +9,7 @@ use std::time::Duration; use axum::Router; use axum::body::Body; use axum::extract::Json; +use axum::extract::RawQuery; use axum::extract::State; use axum::http::HeaderMap; use axum::http::HeaderValue; @@ -21,6 +22,7 @@ use axum::http::header::HOST; use axum::http::header::WWW_AUTHENTICATE; use axum::middleware; use axum::middleware::Next; +use axum::response::Redirect; use axum::response::Response; use axum::routing::get; use axum::routing::post; @@ -153,6 +155,8 @@ async fn main() -> Result<(), Box> { } }), ) + .route("/oauth/authorize", get(fake_oauth_authorize)) + .route("/oauth/token", post(fake_oauth_token)) .nest_service( "/mcp", StreamableHttpService::new( @@ -179,6 +183,34 @@ async fn main() -> Result<(), Box> { Ok(()) } +async fn fake_oauth_authorize(RawQuery(query): RawQuery) -> Result { + let params = reqwest::Url::parse(&format!( + "http://localhost/?{}", + query.as_deref().ok_or(StatusCode::BAD_REQUEST)? + )) + .map_err(|_| StatusCode::BAD_REQUEST)? + .query_pairs() + .into_owned() + .collect::>(); + let redirect_uri = params.get("redirect_uri").ok_or(StatusCode::BAD_REQUEST)?; + let state = params.get("state").ok_or(StatusCode::BAD_REQUEST)?; + let mut callback_url = + reqwest::Url::parse(redirect_uri).map_err(|_| StatusCode::BAD_REQUEST)?; + callback_url + .query_pairs_mut() + .append_pair("code", "test-authorization-code") + .append_pair("state", state); + Ok(Redirect::temporary(callback_url.as_str())) +} + +async fn fake_oauth_token() -> Json { + Json(json!({ + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "token_type": "bearer", + })) +} + impl ServerHandler for TestToolServer { fn get_info(&self) -> ServerInfo { ServerInfo::new( diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 2356a12b08..eecff9ee9c 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -17,7 +17,6 @@ use oauth2::AuthorizationCode; use oauth2::ClientId; use oauth2::ClientSecret; use oauth2::CsrfToken; -use oauth2::EmptyExtraTokenFields; use oauth2::EndpointNotSet; use oauth2::EndpointSet; use oauth2::HttpRequest; @@ -32,12 +31,12 @@ use oauth2::StandardErrorResponse; use oauth2::StandardRevocableToken; use oauth2::StandardTokenIntrospectionResponse; use oauth2::TokenUrl; -use oauth2::basic::BasicClient; use oauth2::basic::BasicErrorResponseType; use oauth2::basic::BasicTokenType; use reqwest::Url; use reqwest::header::HeaderMap; use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; use sha2::Digest; use sha2::Sha256; use tiny_http::Response; @@ -65,7 +64,7 @@ struct OauthHeaders { type OAuthClient = oauth2::Client< StandardErrorResponse, OAuthTokenResponse, - StandardTokenIntrospectionResponse, + StandardTokenIntrospectionResponse, StandardRevocableToken, StandardErrorResponse, EndpointSet, @@ -75,6 +74,14 @@ type OAuthClient = oauth2::Client< EndpointSet, >; +type OAuthClientBuilder = oauth2::Client< + StandardErrorResponse, + OAuthTokenResponse, + StandardTokenIntrospectionResponse, + StandardRevocableToken, + StandardErrorResponse, +>; + struct OAuthState { client: OAuthClient, client_id: String, @@ -98,7 +105,7 @@ impl OAuthState { client_id, client_secret, } = client; - let mut client = BasicClient::new(ClientId::new(client_id.clone())) + let mut client = OAuthClientBuilder::new(ClientId::new(client_id.clone())) .set_auth_uri(AuthUrl::new(metadata.authorization_endpoint)?) .set_token_uri(TokenUrl::new(metadata.token_endpoint)?) .set_redirect_uri(RedirectUrl::new(redirect_uri.to_string())?); diff --git a/scripts/test-remote-mcp-oauth-e2e.sh b/scripts/test-remote-mcp-oauth-e2e.sh new file mode 100755 index 0000000000..9272286f6a --- /dev/null +++ b/scripts/test-remote-mcp-oauth-e2e.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-${REPO_ROOT}/codex-rs/target}" +REMOTE_SERVER_BIN="${REMOTE_SERVER_BIN:-${CARGO_TARGET_DIR}/debug/test_streamable_http_server}" +CODEX_BIN="${CODEX_BIN:-${CARGO_TARGET_DIR}/debug/codex}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-remote-mcp-oauth-e2e.XXXXXX")" +CODEX_HOME="${TMP_DIR}/codex-home" +EXEC_SERVER_PORT="" +EXEC_SERVER_LOG_FILE="${TMP_DIR}/exec-server.log" +EXEC_SERVER_PID="" +REMOTE_BOUND_ADDR_FILE="${TMP_DIR}/remote-mcp.addr" +REMOTE_LOG_FILE="${TMP_DIR}/remote-mcp.log" +REMOTE_SERVER_PID="" + +cleanup() { + if [[ -n "${REMOTE_SERVER_PID}" ]]; then + kill "${REMOTE_SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${EXEC_SERVER_PID}" ]]; then + kill "${EXEC_SERVER_PID}" >/dev/null 2>&1 || true + fi + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +if [[ ! -x "${CODEX_BIN}" || ! -x "${REMOTE_SERVER_BIN}" ]]; then + ( + cd "${REPO_ROOT}/codex-rs" + cargo build -p codex-cli --bin codex -p codex-rmcp-client --bin test_streamable_http_server + ) +fi + +EXEC_SERVER_PORT="$( + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +)" +"${CODEX_BIN}" exec-server --listen "ws://127.0.0.1:${EXEC_SERVER_PORT}" \ + >"${EXEC_SERVER_LOG_FILE}" 2>&1 & +EXEC_SERVER_PID="$!" + +deadline=$((SECONDS + 10)) +while (( SECONDS < deadline )); do + if python3 - </dev/null 2>&1 +import socket + +with socket.create_connection(("127.0.0.1", ${EXEC_SERVER_PORT}), timeout=0.2): + pass +PY + then + break + fi + sleep 0.05 +done +if ! python3 - </dev/null 2>&1 +import socket + +with socket.create_connection(("127.0.0.1", ${EXEC_SERVER_PORT}), timeout=0.2): + pass +PY +then + cat "${EXEC_SERVER_LOG_FILE}" >&2 || true + echo "timed out waiting for exec-server" >&2 + exit 1 +fi + +MCP_STREAMABLE_HTTP_BIND_ADDR='127.0.0.1:0' \ +MCP_STREAMABLE_HTTP_BOUND_ADDR_FILE="${REMOTE_BOUND_ADDR_FILE}" \ +"${REMOTE_SERVER_BIN}" >"${REMOTE_LOG_FILE}" 2>&1 & +REMOTE_SERVER_PID="$!" + +deadline=$((SECONDS + 10)) +REMOTE_BOUND_ADDR="" +while (( SECONDS < deadline )); do + if REMOTE_BOUND_ADDR="$(cat "${REMOTE_BOUND_ADDR_FILE}" 2>/dev/null)"; then + break + fi + sleep 0.05 +done +if [[ -z "${REMOTE_BOUND_ADDR}" ]]; then + cat "${REMOTE_LOG_FILE}" >&2 || true + echo "timed out waiting for remote MCP OAuth test server" >&2 + exit 1 +fi +REMOTE_PORT="${REMOTE_BOUND_ADDR##*:}" +REMOTE_MCP_URL="http://127.0.0.1:${REMOTE_PORT}/mcp" +CALLBACK_PORT="$( + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("0.0.0.0", 0)) + print(sock.getsockname()[1]) +PY +)" + +mkdir -p "${CODEX_HOME}" +cat > "${CODEX_HOME}/config.toml" < "${CODEX_HOME}/environments.toml" <