mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
Add remote MCP OAuth test script
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())?);
|
||||
|
||||
Executable
+244
@@ -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
|
||||
Reference in New Issue
Block a user