Add remote runtime OAuth for MCP

Route Streamable HTTP OAuth discovery, registration, token exchange, and refresh through the selected MCP runtime HTTP client so remote MCP servers do not silently fall back to local network access. Keep browser launch and callback handling local while storing tokens in the existing local MCP OAuth store.

Add regression coverage for injected OAuth HTTP requests and stored-token refresh through the exec-server transport.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Ahmed Ibrahim
2026-04-27 09:33:42 +00:00
co-authored by Codex
parent 4f1d5f00f0
commit 177e39f4b7
22 changed files with 1500 additions and 350 deletions
+1
View File
@@ -29,6 +29,7 @@ pub use mcp::McpOAuthScopesSource;
pub use mcp::ResolvedMcpOAuthScopes;
pub use mcp::compute_auth_statuses;
pub use mcp::discover_supported_scopes;
pub use mcp::http_client_for_server;
pub use mcp::oauth_login_support;
pub use mcp::resolve_oauth_scopes;
pub use mcp::should_retry_without_scopes;
+136 -14
View File
@@ -4,14 +4,19 @@ 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;
use codex_rmcp_client::determine_streamable_http_auth_status;
use codex_rmcp_client::discover_streamable_http_oauth;
use codex_rmcp_client::determine_streamable_http_auth_status_with_client;
use codex_rmcp_client::discover_streamable_http_oauth_with_client;
use futures::future::join_all;
use std::sync::Arc;
use tracing::warn;
use crate::runtime::McpRuntimeEnvironment;
use super::CODEX_APPS_MCP_SERVER_NAME;
#[derive(Debug, Clone)]
@@ -49,7 +54,10 @@ pub struct McpAuthStatusEntry {
pub auth_status: McpAuthStatus,
}
pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport {
pub async fn oauth_login_support(
transport: &McpServerTransportConfig,
http_client: Arc<dyn HttpClient>,
) -> McpOAuthLoginSupport {
let McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
@@ -64,7 +72,13 @@ pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAu
return McpOAuthLoginSupport::Unsupported;
}
match discover_streamable_http_oauth(url, http_headers.clone(), env_http_headers.clone()).await
match discover_streamable_http_oauth_with_client(
url,
http_headers.clone(),
env_http_headers.clone(),
http_client,
)
.await
{
Ok(Some(discovery)) => McpOAuthLoginSupport::Supported(McpOAuthLoginConfig {
url: url.clone(),
@@ -79,8 +93,9 @@ pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAu
pub async fn discover_supported_scopes(
transport: &McpServerTransportConfig,
http_client: Arc<dyn HttpClient>,
) -> Option<Vec<String>> {
match oauth_login_support(transport).await {
match oauth_login_support(transport, http_client).await {
McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
}
@@ -129,6 +144,7 @@ pub async fn compute_auth_statuses<'a, I>(
servers: I,
store_mode: OAuthCredentialsStoreMode,
auth: Option<&CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
) -> HashMap<String, McpAuthStatusEntry>
where
I: IntoIterator<Item = (&'a String, &'a McpServerConfig)>,
@@ -136,6 +152,7 @@ where
let futures = servers.into_iter().map(|(name, config)| {
let name = name.clone();
let config = config.clone();
let runtime_environment = runtime_environment.clone();
let has_runtime_auth = name == CODEX_APPS_MCP_SERVER_NAME
&& auth.is_some_and(CodexAuth::uses_codex_backend)
&& matches!(
@@ -146,14 +163,21 @@ where
}
);
async move {
let auth_status =
match compute_auth_status(&name, &config, store_mode, has_runtime_auth).await {
Ok(status) => status,
Err(error) => {
warn!("failed to determine auth status for MCP server `{name}`: {error:?}");
McpAuthStatus::Unsupported
}
};
let auth_status = match compute_auth_status(
&name,
&config,
store_mode,
has_runtime_auth,
runtime_environment,
)
.await
{
Ok(status) => status,
Err(error) => {
warn!("failed to determine auth status for MCP server `{name}`: {error:?}");
McpAuthStatus::Unsupported
}
};
let entry = McpAuthStatusEntry {
config,
auth_status,
@@ -170,6 +194,7 @@ async fn compute_auth_status(
config: &McpServerConfig,
store_mode: OAuthCredentialsStoreMode,
has_runtime_auth: bool,
runtime_environment: McpRuntimeEnvironment,
) -> Result<McpAuthStatus> {
if !config.enabled {
return Ok(McpAuthStatus::Unsupported);
@@ -187,27 +212,57 @@ async fn compute_auth_status(
http_headers,
env_http_headers,
} => {
determine_streamable_http_auth_status(
let http_client = http_client_for_server(config, runtime_environment)?;
determine_streamable_http_auth_status_with_client(
server_name,
url,
bearer_token_env_var.as_deref(),
http_headers.clone(),
env_http_headers.clone(),
store_mode,
http_client,
)
.await
}
}
}
pub fn http_client_for_server(
config: &McpServerConfig,
runtime_environment: McpRuntimeEnvironment,
) -> Result<Arc<dyn HttpClient>> {
match config.experimental_environment.as_deref() {
None | Some("local") => Ok(Arc::new(ReqwestHttpClient)),
Some("remote") => {
let environment = runtime_environment.environment();
if !environment.is_remote() {
anyhow::bail!("remote MCP server requires a remote environment");
}
Ok(environment.get_http_client())
}
Some(environment) => anyhow::bail!("unsupported experimental_environment `{environment}`"),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::anyhow;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_exec_server::Environment;
use pretty_assertions::assert_eq;
use crate::runtime::McpRuntimeEnvironment;
use super::McpOAuthScopesSource;
use super::OAuthProviderError;
use super::ResolvedMcpOAuthScopes;
use super::http_client_for_server;
use super::resolve_oauth_scopes;
use super::should_retry_without_scopes;
@@ -318,4 +373,71 @@ mod tests {
&anyhow!("timed out waiting for OAuth callback"),
));
}
#[test]
fn local_server_uses_local_http_client_even_with_remote_runtime() {
let config = streamable_config(/*experimental_environment*/ None);
let runtime_environment = remote_runtime_environment();
assert!(http_client_for_server(&config, runtime_environment).is_ok());
}
#[test]
fn remote_server_uses_runtime_http_client_when_runtime_is_remote() {
let config = streamable_config(Some("remote"));
let runtime_environment = remote_runtime_environment();
assert!(http_client_for_server(&config, runtime_environment).is_ok());
}
#[tokio::test]
async fn remote_server_without_remote_runtime_returns_clear_error() {
let config = streamable_config(Some("remote"));
let runtime_environment = McpRuntimeEnvironment::new(
Arc::new(Environment::default_for_tests()),
PathBuf::from("/tmp"),
);
let Err(error) = http_client_for_server(&config, runtime_environment) else {
panic!("remote server should require remote runtime");
};
assert_eq!(
error.to_string(),
"remote MCP server requires a remote environment"
);
}
fn streamable_config(experimental_environment: Option<&str>) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "http://mcp.example.test/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: experimental_environment.map(str::to_string),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(30)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
fn remote_runtime_environment() -> McpRuntimeEnvironment {
McpRuntimeEnvironment::new(
Arc::new(
Environment::create_for_tests(Some("ws://127.0.0.1:65535".to_string()))
.expect("create remote environment"),
),
PathBuf::from("/tmp"),
)
}
}
+3
View File
@@ -5,6 +5,7 @@ pub use auth::McpOAuthScopesSource;
pub use auth::ResolvedMcpOAuthScopes;
pub use auth::compute_auth_statuses;
pub use auth::discover_supported_scopes;
pub use auth::http_client_for_server;
pub use auth::oauth_login_support;
pub use auth::resolve_oauth_scopes;
pub use auth::should_retry_without_scopes;
@@ -223,6 +224,7 @@ pub async fn read_mcp_resource(
mcp_servers.iter(),
config.mcp_oauth_credentials_store_mode,
auth,
runtime_environment.clone(),
)
.await;
let (tx_event, rx_event) = unbounded();
@@ -286,6 +288,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
mcp_servers.iter(),
config.mcp_oauth_credentials_store_mode,
auth,
runtime_environment.clone(),
)
.await;