Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions codex-rs/app-server/src/request_processors/mcp_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,12 @@ impl McpRequestProcessor {
})?;

let discovered_scopes = if scopes.is_none() && server.scopes.is_none() {
discover_supported_scopes_with_http_client(&server.transport, Arc::clone(&http_client))
.await
discover_supported_scopes_with_http_client(
&server.transport,
Arc::clone(&http_client),
codex_rmcp_client::OAuthDiscoveryTimeout::Requested,
)
.await
} else {
None
};
Expand Down Expand Up @@ -250,11 +254,7 @@ impl McpRequestProcessor {
let mcp_manager = self.thread_manager.mcp_manager();
let auth = self.auth_manager.auth().await;
let (mcp_config, runtime_context) = match thread {
Some(thread) => {
let mcp_config = thread.runtime_mcp_config(&config).await;
let (_, runtime_context) = thread.current_mcp_config_and_runtime_context().await;
(mcp_config, runtime_context)
}
Some(thread) => thread.runtime_mcp_config_and_context(&config).await,
None => {
let mcp_config = mcp_manager.runtime_config(&config).await;
let runtime_context = McpRuntimeContext::new(
Expand Down
196 changes: 196 additions & 0 deletions codex-rs/cli/tests/mcp_list.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::path::Path;
use std::time::Duration;
use std::time::Instant;

use anyhow::Result;
use codex_config::types::McpServerTransportConfig;
Expand All @@ -10,13 +15,35 @@ use pretty_assertions::assert_eq;
use serde_json::Value as JsonValue;
use serde_json::json;
use tempfile::TempDir;
#[cfg(target_os = "macos")]
use wiremock::Mock;
#[cfg(target_os = "macos")]
use wiremock::MockServer;
#[cfg(target_os = "macos")]
use wiremock::ResponseTemplate;
#[cfg(target_os = "macos")]
use wiremock::matchers::method;
#[cfg(target_os = "macos")]
use wiremock::matchers::path;

fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
Ok(cmd)
}

async fn configure_http_oauth_server(codex_home: &Path, url: &str) -> Result<()> {
let mut servers = load_global_mcp_servers(codex_home).await?;
servers.insert(
"oauth".to_string(),
toml::from_str(&format!("url = \"{url}\""))?,
);
ConfigEditsBuilder::new(codex_home)
.replace_mcp_servers(&servers)
.apply_blocking()?;
Ok(())
}

#[test]
fn list_shows_empty_state() -> Result<()> {
let codex_home = TempDir::new()?;
Expand All @@ -30,6 +57,175 @@ fn list_shows_empty_state() -> Result<()> {
Ok(())
}

#[tokio::test]
async fn list_discovers_local_oauth_server_through_environment_proxy() -> Result<()> {
let codex_home = TempDir::new()?;
configure_http_oauth_server(codex_home.path(), "http://mcp-proxy.invalid/mcp").await?;

let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let proxy_url = format!("http://{}", listener.local_addr()?);
let proxy = std::thread::spawn(move || -> Result<Vec<String>> {
let resource_metadata = json!({
"resource": "http://mcp-proxy.invalid/mcp",
"authorization_servers": ["http://mcp-proxy.invalid"],
})
.to_string();
let authorization_metadata = json!({
"authorization_endpoint": "https://oauth.example/authorize",
"token_endpoint": "https://oauth.example/token",
})
.to_string();
let responses = [
concat!(
"HTTP/1.1 401 Unauthorized\r\n",
"www-authenticate: Bearer resource_metadata=\"http://mcp-proxy.invalid/oauth-resource\"\r\n",
"content-length: 0\r\n",
"connection: close\r\n\r\n"
)
.to_string(),
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{resource_metadata}",
resource_metadata.len()
),
format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{authorization_metadata}",
authorization_metadata.len()
),
];

let mut requests = Vec::new();
for response in responses {
let deadline = Instant::now() + Duration::from_secs(30);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
anyhow::ensure!(
Instant::now() < deadline,
"proxy did not receive OAuth discovery request {}",
requests.len() + 1
);
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error.into()),
}
};
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
let mut request = Vec::new();
let mut buffer = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let bytes_read = stream.read(&mut buffer)?;
anyhow::ensure!(bytes_read > 0, "proxy request ended before its headers");
request.extend_from_slice(&buffer[..bytes_read]);
anyhow::ensure!(
request.len() <= 64 * 1024,
"proxy request headers are too large"
);
}
let request = String::from_utf8(request)?;
requests.push(request.lines().next().unwrap_or_default().to_string());
stream.write_all(response.as_bytes())?;
}

Ok(requests)
});

let mut command = codex_command(codex_home.path())?;
command
.env("HTTP_PROXY", &proxy_url)
.env("http_proxy", &proxy_url)
.env_remove("HTTPS_PROXY")
.env_remove("https_proxy")
.env_remove("ALL_PROXY")
.env_remove("all_proxy")
.env_remove("NO_PROXY")
.env_remove("no_proxy")
.args([
"-c",
"mcp_oauth_credentials_store=\"file\"",
"mcp",
"list",
"--json",
]);
let output = command.output()?;
assert!(
output.status.success(),
"mcp list failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let proxy_requests = proxy
.join()
.expect("OAuth discovery proxy thread should finish")?;

assert_eq!(
proxy_requests,
vec![
"GET http://mcp-proxy.invalid/mcp HTTP/1.1",
"GET http://mcp-proxy.invalid/oauth-resource HTTP/1.1",
"GET http://mcp-proxy.invalid/.well-known/oauth-authorization-server HTTP/1.1",
]
);
let entries: JsonValue = serde_json::from_slice(&output.stdout)?;
assert_eq!(entries[0]["name"], "oauth");
assert_eq!(
entries[0]["auth_status"],
"not_logged_in",
"OAuth discovery failed after proxy requests {proxy_requests:?}; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
Ok(())
}

#[cfg(target_os = "macos")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_with_macos_proxy_resolution_does_not_panic() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/.well-known/oauth-authorization-server/mcp"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"authorization_endpoint": "https://oauth.example/authorize",
"token_endpoint": "https://oauth.example/token",
})))
.expect(2)
.mount(&server)
.await;
configure_http_oauth_server(codex_home.path(), &format!("{}/mcp", server.uri())).await?;

for respect_system_proxy in [false, true] {
let system_proxy_override = format!("features.respect_system_proxy={respect_system_proxy}");
let mut command = codex_command(codex_home.path())?;
command
.env_remove("HTTP_PROXY")
.env_remove("http_proxy")
.env_remove("HTTPS_PROXY")
.env_remove("https_proxy")
.env_remove("ALL_PROXY")
.env_remove("all_proxy")
.env_remove("NO_PROXY")
.env_remove("no_proxy")
.args([
"-c",
&system_proxy_override,
"-c",
"mcp_oauth_credentials_store=\"file\"",
"mcp",
"list",
"--json",
]);
let output = command.output()?;
assert!(
output.status.success(),
"macOS proxy resolution should not panic with respect_system_proxy={respect_system_proxy}: {}",
String::from_utf8_lossy(&output.stderr)
);
let entries: JsonValue = serde_json::from_slice(&output.stdout)?;
assert_eq!(entries[0]["auth_status"], "not_logged_in");
}
Ok(())
}

#[tokio::test]
async fn list_and_get_render_expected_output() -> Result<()> {
let codex_home = TempDir::new()?;
Expand Down
55 changes: 25 additions & 30 deletions codex-rs/codex-mcp/src/mcp/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::HttpClient;
use codex_login::CodexAuth;
use codex_rmcp_client::McpAuthState;
use codex_rmcp_client::OAuthDiscoveryTimeout;
use codex_rmcp_client::OAuthProviderError;
use codex_rmcp_client::determine_streamable_http_auth_status;
use codex_rmcp_client::determine_streamable_http_auth_status_with_http_client;
use codex_rmcp_client::discover_streamable_http_oauth;
use codex_rmcp_client::discover_streamable_http_oauth_with_http_client;
Expand Down Expand Up @@ -80,6 +80,7 @@ pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAu
pub async fn oauth_login_support_with_http_client(
transport: &McpServerTransportConfig,
http_client: Arc<dyn HttpClient>,
discovery_timeout: OAuthDiscoveryTimeout,
) -> McpOAuthLoginSupport {
let Some(mut config) = oauth_login_candidate(transport) else {
return McpOAuthLoginSupport::Unsupported;
Expand All @@ -89,6 +90,7 @@ pub async fn oauth_login_support_with_http_client(
config.http_headers.clone(),
config.env_http_headers.clone(),
http_client,
discovery_timeout,
)
.await
{
Expand Down Expand Up @@ -134,8 +136,9 @@ pub async fn discover_supported_scopes(
pub async fn discover_supported_scopes_with_http_client(
transport: &McpServerTransportConfig,
http_client: Arc<dyn HttpClient>,
discovery_timeout: OAuthDiscoveryTimeout,
) -> Option<Vec<String>> {
match oauth_login_support_with_http_client(transport, http_client).await {
match oauth_login_support_with_http_client(transport, http_client, discovery_timeout).await {
McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
}
Expand Down Expand Up @@ -255,35 +258,27 @@ async fn compute_auth_status(
http_headers,
env_http_headers,
} => {
if config.is_local_environment() {
determine_streamable_http_auth_status(
server_name,
url,
bearer_token_env_var.as_deref(),
http_headers.clone(),
env_http_headers.clone(),
store_mode,
keyring_backend_kind,
)
.boxed()
.await
let http_client = runtime_context
.resolve_http_client(server_name, config)
.map_err(anyhow::Error::msg)?;
let discovery_timeout = if config.is_local_environment() {
OAuthDiscoveryTimeout::LOCAL
} else {
let http_client = runtime_context
.resolve_http_client(server_name, config)
.map_err(anyhow::Error::msg)?;
determine_streamable_http_auth_status_with_http_client(
server_name,
url,
bearer_token_env_var.as_deref(),
http_headers.clone(),
env_http_headers.clone(),
store_mode,
keyring_backend_kind,
http_client,
)
.boxed()
.await
}
OAuthDiscoveryTimeout::Requested
};
determine_streamable_http_auth_status_with_http_client(
server_name,
url,
bearer_token_env_var.as_deref(),
http_headers.clone(),
env_http_headers.clone(),
store_mode,
keyring_backend_kind,
http_client,
discovery_timeout,
)
.boxed()
.await
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions codex-rs/core/src/codex_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,18 +601,20 @@ impl CodexThread {
self.session.get_config().await
}

/// Resolves the MCP runtime configuration using this thread's extension data.
pub async fn runtime_mcp_config(&self, config: &crate::config::Config) -> codex_mcp::McpConfig {
self.session.runtime_mcp_config(config).await
/// Resolves MCP configuration and environment bindings from the same config snapshot.
pub async fn runtime_mcp_config_and_context(
&self,
config: &crate::config::Config,
) -> (codex_mcp::McpConfig, codex_mcp::McpRuntimeContext) {
self.session.runtime_mcp_config_and_context(config).await
}

/// Captures the exact MCP config and environment bindings for the current thread state.
pub async fn current_mcp_config_and_runtime_context(
&self,
) -> (Arc<codex_mcp::McpConfig>, codex_mcp::McpRuntimeContext) {
let config = self.session.get_config().await;
let (mcp_config, runtime_context) =
self.session.runtime_mcp_config_and_context(&config).await;
let (mcp_config, runtime_context) = self.runtime_mcp_config_and_context(&config).await;
(Arc::new(mcp_config), runtime_context)
}

Expand Down
Loading
Loading