Add remote MCP OAuth test script

This commit is contained in:
starr-openai
2026-05-28 23:07:15 -07:00
parent 4cb6acecef
commit ace11e21df
15 changed files with 458 additions and 95 deletions
@@ -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;
@@ -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
};
@@ -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) => {
+3
View File
@@ -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;
+68 -14
View File
@@ -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<dyn HttpClient>,
) -> 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<Vec<String>> {
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<Vec<String>> {
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<String, McpAuthStatusEntry>
where
I: IntoIterator<Item = (&'a String, &'a EffectiveMcpServer)>,
{
compute_auth_statuses_with_http_client_resolver(servers, store_mode, auth, |_, _| {
Ok(Arc::new(ReqwestHttpClient) as Arc<dyn HttpClient>)
})
.await
}
pub async fn compute_auth_statuses_with_runtime_context<'a, I>(
servers: I,
store_mode: OAuthCredentialsStoreMode,
auth: Option<&CodexAuth>,
runtime_context: McpRuntimeContext,
) -> HashMap<String, McpAuthStatusEntry>
where
I: IntoIterator<Item = (&'a String, &'a EffectiveMcpServer)>,
{
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<String, McpAuthStatusEntry>
where
I: IntoIterator<Item = (&'a String, &'a EffectiveMcpServer)>,
F: Fn(&str, &McpServerConfig) -> Result<Arc<dyn HttpClient>, 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<Arc<dyn HttpClient>, String>,
) -> Result<McpAuthStatus> {
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,
+5 -2
View File
@@ -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,
+68 -56
View File
@@ -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<RmcpClient>,
pub(crate) server_info: McpServerInfo,
pub(crate) tools: Vec<ToolInfo>,
pub(crate) tool_filter: ToolFilter,
pub(crate) tool_timeout: Option<Duration>,
@@ -121,7 +124,8 @@ impl ManagedClient {
#[derive(Clone)]
pub(crate) struct AsyncManagedClient {
pub(crate) client: Shared<BoxFuture<'static, Result<ManagedClient, StartupOutcomeError>>>,
pub(crate) startup_snapshot: Option<Vec<ToolInfo>>,
pub(crate) cached_tool_info_snapshot: Option<Vec<ToolInfo>>,
pub(crate) cached_server_info: Option<McpServerInfo>,
pub(crate) startup_complete: Arc<AtomicBool>,
pub(crate) tool_plugin_provenance: Arc<ToolPluginProvenance>,
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<Vec<ToolInfo>> {
fn cached_tool_info_snapshot_while_initializing(&self) -> Option<Vec<ToolInfo>> {
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<Duration>, // 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]
+2 -2
View File
@@ -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(),
+12 -10
View File
@@ -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,
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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(),
@@ -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<dyn std::error::Error>> {
}
}),
)
.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<dyn std::error::Error>> {
Ok(())
}
async fn fake_oauth_authorize(RawQuery(query): RawQuery) -> Result<Redirect, StatusCode> {
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::<HashMap<_, _>>();
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<serde_json::Value> {
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(
@@ -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<BasicErrorResponseType>,
OAuthTokenResponse,
StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>,
StandardTokenIntrospectionResponse<VendorExtraTokenFields, BasicTokenType>,
StandardRevocableToken,
StandardErrorResponse<RevocationErrorResponseType>,
EndpointSet,
@@ -75,6 +74,14 @@ type OAuthClient = oauth2::Client<
EndpointSet,
>;
type OAuthClientBuilder = oauth2::Client<
StandardErrorResponse<BasicErrorResponseType>,
OAuthTokenResponse,
StandardTokenIntrospectionResponse<VendorExtraTokenFields, BasicTokenType>,
StandardRevocableToken,
StandardErrorResponse<RevocationErrorResponseType>,
>;
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())?);
+244
View File
@@ -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 - <<PY >/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 - <<PY >/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" <<EOF
model_provider = "mock_provider"
mcp_oauth_credentials_store = "file"
mcp_oauth_callback_port = ${CALLBACK_PORT}
mcp_oauth_callback_url = "http://127.0.0.1:${CALLBACK_PORT}/callback"
[model_providers.mock_provider]
name = "Mock"
base_url = "http://127.0.0.1:1/v1"
wire_api = "responses"
[mcp_servers.remote-oauth]
url = "${REMOTE_MCP_URL}"
environment_id = "remote"
[mcp_servers.remote-oauth.oauth]
client_id = "codex-app-server-test"
EOF
cat > "${CODEX_HOME}/environments.toml" <<EOF
include_local = false
[[environments]]
id = "remote"
url = "ws://127.0.0.1:${EXEC_SERVER_PORT}"
EOF
CODEX_BIN="${CODEX_BIN}" \
CODEX_HOME="${CODEX_HOME}" \
python3 - <<'PY'
import json
import os
import subprocess
import sys
import time
import urllib.request
def send(proc, payload):
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
def read_message(proc, timeout_s=15):
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
line = proc.stdout.readline()
if line:
return json.loads(line)
if proc.poll() is not None:
stderr = proc.stderr.read()
raise SystemExit(f"codex app-server exited: {stderr}")
time.sleep(0.01)
raise SystemExit("timed out waiting for codex app-server message")
def read_until(proc, predicate, timeout_s=15):
deadline = time.monotonic() + timeout_s
seen = []
while time.monotonic() < deadline:
message = read_message(proc, timeout_s=max(0.1, deadline - time.monotonic()))
seen.append(message)
if predicate(message):
return message
raise SystemExit(f"timed out waiting for matching message: {seen!r}")
def request(proc, request_id, method, params):
send(proc, {"id": request_id, "method": method, "params": params})
message = read_until(proc, lambda message: message.get("id") == request_id)
if "error" in message:
raise SystemExit(f"{method} failed: {message['error']}")
return message["result"]
codex_bin = os.environ["CODEX_BIN"]
codex_home = os.environ["CODEX_HOME"]
proc = subprocess.Popen(
[codex_bin, "app-server", "--listen", "stdio://"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={**os.environ, "CODEX_HOME": codex_home},
)
assert proc.stdin is not None
assert proc.stdout is not None
assert proc.stderr is not None
initialize = request(
proc,
1,
"initialize",
{
"clientInfo": {"name": "remote-mcp-oauth-e2e", "version": "0.1.0"},
"capabilities": {"experimentalApi": True},
},
)
if "userAgent" not in initialize:
raise SystemExit(f"initialize response missing userAgent: {initialize!r}")
send(proc, {"method": "initialized"})
status = request(
proc,
2,
"mcpServerStatus/list",
{"detail": "toolsAndAuthOnly"},
)
entry = status["data"][0]
if entry["name"] != "remote-oauth" or entry["authStatus"] != "notLoggedIn":
raise SystemExit(f"unexpected pre-login status: {status!r}")
login = request(proc, 3, "mcpServer/oauth/login", {"name": "remote-oauth"})
authorization_url = login["authorizationUrl"]
with urllib.request.urlopen(authorization_url, timeout=15) as response:
if response.status != 200:
raise SystemExit(f"unexpected authorize response: {response.status}")
completed = read_until(
proc,
lambda message: message.get("method") == "mcpServer/oauthLogin/completed",
)
params = completed.get("params") or {}
if params != {"name": "remote-oauth", "success": True}:
raise SystemExit(f"unexpected oauth completion notification: {completed!r}")
status = request(
proc,
4,
"mcpServerStatus/list",
{"detail": "toolsAndAuthOnly"},
)
entry = status["data"][0]
if entry["authStatus"] != "oAuth":
raise SystemExit(f"unexpected post-login status: {status!r}")
proc.terminate()
proc.wait(timeout=5)
print("remote MCP OAuth E2E passed")
PY