Reuse MCP connections across runtime refreshes (#34952)

## Why

Refreshing MCP runtime state should not restart an unchanged, ready server or
relist its tools.

## What changed

- Reconcile refreshed MCP configuration against the published connection set
  and reuse connections whose transport, environment, authentication, OAuth
  credentials, and client capabilities are unchanged.
- Keep tool filters, timeouts, metadata, and plugin provenance in the published
  server view so those settings can change without reconnecting.
- Update elicitation authority in place for reused connections, and reconnect
  when connection-defining inputs or live OAuth credentials change.

## Testing

Added coverage for unchanged-server reuse, view-only and elicitation-policy
updates, connection identity changes, OAuth credential changes, and avoiding
redundant tool listing.

GitOrigin-RevId: f04f5db5fbdde127b6a14f6aa9673112c0b557e7
This commit is contained in:
jif authored and copyberry committed 2026-07-23 14:55:31 +00:00
1 parent 808d3c2702
commit e19e65317a
26 files changed
+1954 -811

No files matched your search

@@ -70,8 +70,7 @@ impl AppsRequestProcessor {
); );
let startup_timeout = mcp_servers let startup_timeout = mcp_servers
.get(CODEX_APPS_MCP_SERVER_NAME) .get(CODEX_APPS_MCP_SERVER_NAME)
.and_then(|server| server.configured_config()) .and_then(|server| server.config().startup_timeout_sec)
.and_then(|config| config.startup_timeout_sec)
.unwrap_or(CONNECTOR_RUNTIME_REFRESH_TIMEOUT); .unwrap_or(CONNECTOR_RUNTIME_REFRESH_TIMEOUT);
let runtime_context = McpRuntimeContext::new( let runtime_context = McpRuntimeContext::new(
self.thread_manager.environment_manager(), self.thread_manager.environment_manager(),
@@ -144,14 +144,12 @@ impl McpRequestProcessor {
} }
}; };
let effective_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref()); let effective_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref());
let Some(server) = effective_servers let Some(server) = effective_servers.get(&name) else {
.get(&name)
.and_then(codex_mcp::EffectiveMcpServer::configured_config)
else {
return Err(invalid_request(format!( return Err(invalid_request(format!(
"No MCP server named '{name}' found." "No MCP server named '{name}' found."
))); )));
}; };
let server = server.config();
let (url, http_headers, env_http_headers) = match &server.transport { let (url, http_headers, env_http_headers) = match &server.transport {
McpServerTransportConfig::StreamableHttp { McpServerTransportConfig::StreamableHttp {
-2
View File
@@ -26,7 +26,6 @@ use crate::connection_manager::McpConnectionSet;
use crate::rmcp_client::ManagedClient; use crate::rmcp_client::ManagedClient;
use crate::server::McpServerMetadata; use crate::server::McpServerMetadata;
use crate::server::McpServerOrigin; use crate::server::McpServerOrigin;
use crate::tools::ToolFilter;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
const SERVER_NAME: &str = "docs"; const SERVER_NAME: &str = "docs";
@@ -88,7 +87,6 @@ async fn test_step(
website_url: None, website_url: None,
}, },
tools: vec![tool.clone()], tools: vec![tool.clone()],
tool_filter: ToolFilter::default(),
tool_timeout: None, tool_timeout: None,
server_instructions: None, server_instructions: None,
server_supports_sandbox_state_meta_capability: supports_sandbox_state_meta, server_supports_sandbox_state_meta_capability: supports_sandbox_state_meta,
+376 -354
View File
@@ -7,46 +7,46 @@
#[path = "connection_manager/required.rs"] #[path = "connection_manager/required.rs"]
mod required; mod required;
#[path = "connection_manager/resources.rs"]
mod resources;
#[path = "connection_manager/startup.rs"]
mod startup;
#[path = "connection_manager/tool_catalog.rs"] #[path = "connection_manager/tool_catalog.rs"]
mod tool_catalog; mod tool_catalog;
use startup::chatgpt_auth_provider_for_server;
use startup::emit_update;
use startup::mcp_init_error_display;
use startup::mcp_startup_failure_reason;
use startup::should_share_codex_apps_tools_cache;
pub use tool_catalog::tool_is_model_visible;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::time::Duration; use std::time::Duration;
use crate::binding_clients::McpBindingClients;
use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestManager;
use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::ElicitationRequestRouter;
use crate::elicitation::ElicitationReviewerHandle;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance; use crate::mcp::ToolPluginProvenance;
use crate::rmcp_client::AsyncManagedClient; use crate::rmcp_client::AsyncManagedClient;
use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT; use crate::rmcp_client::DEFAULT_TOOL_TIMEOUT;
use crate::rmcp_client::ManagedClient; use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::StartupOutcomeError; use crate::rmcp_client::StartupOutcomeError;
use crate::rmcp_client::prepare_codex_apps_tools_for_model;
use crate::rmcp_client::prepare_regular_mcp_tools_for_model;
use crate::runtime::McpPublicationGate; use crate::runtime::McpPublicationGate;
use crate::runtime::McpRuntimeContext; use crate::runtime::McpRuntimeInput;
use crate::server::EffectiveMcpServer; use crate::server::McpServerConnectionIdentity;
use crate::server::McpServerMetadata; use crate::server::McpServerMetadata;
use crate::tool_catalog_cache::McpToolCatalogCache; use crate::tools::ToolFilter;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
use crate::tools::filter_tools;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use anyhow::anyhow; use anyhow::anyhow;
use async_channel::Sender;
use codex_api::SharedAuthProvider;
use codex_config::Constrained;
use codex_config::McpServerAuth;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig; use codex_config::McpServerTransportConfig;
use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_connectors::ConnectorRuntimeContextKey;
use codex_connectors::ConnectorRuntimeManager;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::McpServerInfo;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
@@ -55,113 +55,176 @@ use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpStartupCompleteEvent; use codex_protocol::protocol::McpStartupCompleteEvent;
use codex_protocol::protocol::McpStartupFailure; use codex_protocol::protocol::McpStartupFailure;
use codex_protocol::protocol::McpStartupFailureReason;
use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupStatus;
use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_rmcp_client::McpAuthState;
use codex_rmcp_client::McpLoginRequirement;
use codex_rmcp_client::determine_streamable_http_auth_status_from_credentials; use codex_rmcp_client::determine_streamable_http_auth_status_from_credentials;
use rmcp::model::ElicitationCapability;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use serde_json::Value as JsonValue;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio::task::JoinSet; use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::warn; use tracing::warn;
const MCP_UI_META_KEY: &str = "ui"; #[cfg(test)]
const MCP_UI_VISIBILITY_META_KEY: &str = "visibility"; use codex_config::Constrained;
const MCP_UI_MODEL_VISIBILITY: &str = "model"; #[cfg(test)]
use codex_protocol::protocol::McpStartupFailureReason;
#[cfg(test)]
use codex_rmcp_client::McpAuthState;
#[cfg(test)]
use codex_rmcp_client::McpLoginRequirement;
/// Returns whether a tool may be included in model-facing tool declarations. pub(crate) struct McpServerConnection {
/// identity: Option<McpServerConnectionIdentity>,
/// Tools without visibility metadata remain visible. client: AsyncManagedClient,
/// Tools with visibility metadata are hidden unless they explicitly include `model`.
///
/// <https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#resource-discovery>
pub fn tool_is_model_visible(tool: &ToolInfo) -> bool {
let Some(visibility) = tool
.tool
.meta
.as_deref()
.and_then(|meta| meta.get(MCP_UI_META_KEY))
.and_then(JsonValue::as_object)
.and_then(|ui| ui.get(MCP_UI_VISIBILITY_META_KEY))
.and_then(JsonValue::as_array)
else {
return true;
};
visibility
.iter()
.any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY))
} }
/// A thin wrapper around a set of running [`RmcpClient`] instances. impl McpServerConnection {
async fn reusable_client(
&self,
desired: &McpServerConnectionIdentity,
) -> Option<ManagedClient> {
let current = self.identity.as_ref()?;
if !current.has_same_connection_config(desired) {
return None;
}
if !self.client.startup_complete.load(Ordering::Acquire) {
return None;
}
let client = self.client.client().await.ok()?;
let Ok(desired_credentials) = desired.oauth_credentials() else {
return Some(client);
};
let reusable = match client.client.managed_oauth_credentials().await {
Some(live_credentials) => &live_credentials == desired_credentials,
None => current
.oauth_credentials()
.is_ok_and(|startup_credentials| startup_credentials == desired_credentials),
};
if reusable { Some(client) } else { None }
}
pub(crate) async fn client(&self) -> Result<ManagedClient, StartupOutcomeError> {
self.client.client().await
}
async fn shutdown(&self) {
self.client.shutdown().await;
}
fn cancel_startup(&self) {
if !self.client.startup_complete.load(Ordering::Acquire) {
self.client.cancel_token.cancel();
}
}
}
impl Drop for McpServerConnection {
fn drop(&mut self) {
self.client.cancel_token.cancel();
}
}
#[derive(Clone)]
struct McpServerView {
connection: Arc<McpServerConnection>,
metadata: McpServerMetadata,
tool_filter: ToolFilter,
tool_timeout: Option<Duration>,
}
impl McpServerView {
async fn listed_tools(
&self,
tool_plugin_provenance: &ToolPluginProvenance,
) -> Option<Vec<ToolInfo>> {
let tools = self.connection.client.listed_tools().await?;
let tools = filter_tools(tools, &self.tool_filter);
Some(if self.connection.client.is_codex_apps_mcp_server {
prepare_codex_apps_tools_for_model(tools, tool_plugin_provenance)
} else {
prepare_regular_mcp_tools_for_model(tools, tool_plugin_provenance)
})
}
}
/// A published view over a set of running MCP server connections.
pub(crate) struct McpConnectionSet { pub(crate) struct McpConnectionSet {
clients: HashMap<String, AsyncManagedClient>, servers: HashMap<String, McpServerView>,
server_metadata: HashMap<String, McpServerMetadata>,
required_servers: Vec<String>, required_servers: Vec<String>,
tool_catalog_revision: Arc<RwLock<u64>>, tool_catalog_revision: Arc<RwLock<u64>>,
codex_apps_tools_override: RwLock<Option<Vec<ToolInfo>>>, codex_apps_tools_override: RwLock<Option<Vec<ToolInfo>>>,
codex_apps_refresh_lock: Mutex<()>, codex_apps_refresh_lock: Mutex<()>,
tool_plugin_provenance: Arc<ToolPluginProvenance>, tool_plugin_provenance: Arc<ToolPluginProvenance>,
prefix_mcp_tool_names: bool, prefix_mcp_tool_names: bool,
startup_cancellation_token: CancellationToken, elicitation_requests: ElicitationRequestManager,
} }
impl McpConnectionSet { impl McpConnectionSet {
/// Creates an MCP connection manager. Threadless callers can pass no `tx_event`; startup /// Creates an MCP connection manager. Threadless callers can pass no `tx_event`; startup
/// notifications are then skipped and interactive elicitations are declined. /// notifications are then skipped and interactive elicitations are declined.
#[allow(clippy::too_many_arguments)]
pub async fn new( pub async fn new(
mcp_servers: &HashMap<String, EffectiveMcpServer>, previous: Option<&Self>,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
approval_policy: &Constrained<AskForApproval>,
submit_id: String,
tx_event: Option<Sender<Event>>,
startup_cancellation_token: CancellationToken,
initial_permission_profile: PermissionProfile,
runtime_context: McpRuntimeContext,
codex_home: PathBuf,
codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
tool_catalog_cache: McpToolCatalogCache,
codex_apps_tools_cache_key: ConnectorRuntimeContextKey,
prefix_mcp_tool_names: bool,
client_elicitation_capability: ElicitationCapability,
supports_openai_form_elicitation: bool,
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
codex_apps_auth_manager: Option<Arc<AuthManager>>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
elicitation_router: ElicitationRequestRouter,
publication_gate: McpPublicationGate, publication_gate: McpPublicationGate,
input: McpRuntimeInput,
elicitation_router: ElicitationRequestRouter,
) -> Self { ) -> Self {
let mut clients = HashMap::new(); let McpRuntimeInput {
let mut server_metadata = HashMap::new(); config,
plugins_available: _,
ready_selected_capability_roots: _,
mcp_servers,
submit_id,
tx_event,
startup_cancellation_token,
runtime_context,
codex_apps_tools_cache,
tool_catalog_cache,
codex_apps_tools_cache_key,
supports_openai_form_elicitation,
auth,
codex_apps_auth_manager,
elicitation_reviewer,
elicitation_lifecycle,
} = input;
let store_mode = config.mcp_oauth_credentials_store_mode;
let keyring_backend_kind = config.auth_keyring_backend_kind;
let approval_policy = &config.approval_policy;
let initial_permission_profile = config.permission_profile.clone();
let codex_home = config.codex_home.clone();
let prefix_mcp_tool_names = config.prefix_mcp_tool_names;
let client_elicitation_capability = config.client_elicitation_capability.clone();
let tool_plugin_provenance = crate::mcp::tool_plugin_provenance(&config);
let auth = auth.as_ref();
let mut servers = HashMap::new();
let mut required_servers = mcp_servers let mut required_servers = mcp_servers
.iter() .iter()
.filter(|(_, server)| server.enabled() && server.required()) .filter(|(_, server)| server.enabled() && server.required())
.map(|(server_name, _)| server_name.clone()) .map(|(server_name, _)| server_name.clone())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
required_servers.sort(); required_servers.sort();
let mut reused_ready = Vec::new();
let mut join_set = JoinSet::new(); let mut join_set = JoinSet::new();
let elicitation_requests = ElicitationRequestManager::new( let reusable_previous = previous.filter(|previous| {
approval_policy.value(), !previous.servers.is_empty()
initial_permission_profile, && previous.elicitation_requests.update(
elicitation_reviewer, approval_policy.value(),
elicitation_lifecycle, initial_permission_profile.clone(),
elicitation_router, elicitation_reviewer.clone(),
); elicitation_lifecycle.clone(),
)
});
let elicitation_requests = if let Some(previous) = reusable_previous {
previous.elicitation_requests.clone()
} else {
ElicitationRequestManager::new(
approval_policy.value(),
initial_permission_profile,
elicitation_reviewer,
elicitation_lifecycle,
elicitation_router,
)
};
let tool_plugin_provenance = Arc::new(tool_plugin_provenance); let tool_plugin_provenance = Arc::new(tool_plugin_provenance);
let startup_submit_id = submit_id; let startup_submit_id = submit_id;
let startup_publication_gate = publication_gate.clone();
let static_chatgpt_auth_provider = auth let static_chatgpt_auth_provider = auth
.filter(|auth| auth.uses_codex_backend()) .filter(|auth| auth.uses_codex_backend())
.map(codex_model_provider::auth_provider_from_auth); .map(codex_model_provider::auth_provider_from_auth);
@@ -170,31 +233,30 @@ impl McpConnectionSet {
codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth) codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth)
}) })
}); });
let mcp_servers = mcp_servers.clone();
for (server_name, server) in mcp_servers for (server_name, server) in mcp_servers
.into_iter() .into_iter()
.filter(|(_, server)| server.enabled()) .filter(|(_, server)| server.enabled())
{ {
server_metadata.insert(server_name.clone(), McpServerMetadata::from(&server)); let metadata = McpServerMetadata::from(&server);
let cancel_token = startup_cancellation_token.child_token(); let configured_config = server.config().clone();
let configured_config = server.configured_config().cloned(); let configured_tool_filter = ToolFilter::from_config(&configured_config);
let resolved_environment = configured_config.as_ref().map_or_else( let configured_tool_timeout = Some(
|| Ok(None), configured_config
|config| runtime_context.resolve_server_environment(&server_name, config), .tool_timeout_sec
.unwrap_or(DEFAULT_TOOL_TIMEOUT),
); );
let resolved_environment =
runtime_context.resolve_server_environment(&server_name, &configured_config);
// For built-in Codex Apps, `CODEX_CONNECTORS_TOKEN` is a debug // For built-in Codex Apps, `CODEX_CONNECTORS_TOKEN` is a debug
// override: it supplies runtime auth but bypasses the shared tools // override: it supplies runtime auth but bypasses the shared tools
// cache. // cache.
let uses_env_bearer_token = let uses_env_bearer_token = match &configured_config.transport {
configured_config McpServerTransportConfig::StreamableHttp {
.as_ref() bearer_token_env_var,
.is_some_and(|config| match &config.transport { ..
McpServerTransportConfig::StreamableHttp { } => bearer_token_env_var.is_some(),
bearer_token_env_var, McpServerTransportConfig::Stdio { .. } => false,
.. };
} => bearer_token_env_var.is_some(),
McpServerTransportConfig::Stdio { .. } => false,
});
let shares_codex_apps_tools_cache = let shares_codex_apps_tools_cache =
should_share_codex_apps_tools_cache(&server_name, uses_env_bearer_token); should_share_codex_apps_tools_cache(&server_name, uses_env_bearer_token);
let codex_apps_tools_cache_context = shares_codex_apps_tools_cache.then(|| { let codex_apps_tools_cache_context = shares_codex_apps_tools_cache.then(|| {
@@ -220,14 +282,49 @@ impl McpConnectionSet {
} else { } else {
chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider) chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider)
}; };
let connection_identity = McpServerConnectionIdentity::new(
&server_name,
&server,
store_mode,
keyring_backend_kind,
&resolved_environment,
&runtime_context,
runtime_auth_provider.as_ref(),
auth,
shares_codex_apps_tools_cache
.then(|| (codex_home.clone(), codex_apps_tools_cache_key.clone())),
client_elicitation_capability.clone(),
supports_openai_form_elicitation,
);
if let Some(previous_view) =
reusable_previous.and_then(|previous| previous.servers.get(&server_name))
{
let connection = Arc::clone(&previous_view.connection);
if connection
.reusable_client(&connection_identity)
.await
.is_some()
{
servers.insert(
server_name.clone(),
McpServerView {
connection,
metadata,
tool_filter: configured_tool_filter,
tool_timeout: configured_tool_timeout,
},
);
reused_ready.push(server_name);
continue;
}
}
let cancel_token = startup_cancellation_token.child_token();
let tool_catalog_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { let tool_catalog_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME {
None None
} else if let Some(config) = configured_config.as_ref() } else if let Ok(environment) = resolved_environment.as_ref() {
&& let Ok(environment) = resolved_environment.as_ref()
{
tool_catalog_cache.context( tool_catalog_cache.context(
&server_name, &server_name,
config, &configured_config,
&runtime_context, &runtime_context,
environment.as_ref(), environment.as_ref(),
&client_elicitation_capability, &client_elicitation_capability,
@@ -248,14 +345,24 @@ impl McpConnectionSet {
elicitation_requests.clone(), elicitation_requests.clone(),
codex_apps_tools_cache_context, codex_apps_tools_cache_context,
tool_catalog_cache_context, tool_catalog_cache_context,
Arc::clone(&tool_plugin_provenance),
runtime_context.clone(), runtime_context.clone(),
resolved_environment, resolved_environment,
runtime_auth_provider, runtime_auth_provider,
client_elicitation_capability.clone(), client_elicitation_capability.clone(),
supports_openai_form_elicitation, supports_openai_form_elicitation,
); );
clients.insert(server_name.clone(), async_managed_client.clone()); servers.insert(
server_name.clone(),
McpServerView {
connection: Arc::new(McpServerConnection {
identity: Some(connection_identity),
client: async_managed_client.clone(),
}),
metadata,
tool_filter: configured_tool_filter,
tool_timeout: configured_tool_timeout,
},
);
let tx_event = tx_event.clone(); let tx_event = tx_event.clone();
let submit_id = startup_submit_id.clone(); let submit_id = startup_submit_id.clone();
let publication_gate = publication_gate.clone(); let publication_gate = publication_gate.clone();
@@ -281,34 +388,33 @@ impl McpConnectionSet {
if let Some(tx_event) = tx_event.as_ref() { if let Some(tx_event) = tx_event.as_ref() {
let auth_state = match &outcome { let auth_state = match &outcome {
Err(error) if error.is_authentication_required() && !has_runtime_auth => { Err(error) if error.is_authentication_required() && !has_runtime_auth => {
configured_config.as_ref().and_then(|config| { match &configured_config.transport {
let McpServerTransportConfig::StreamableHttp { McpServerTransportConfig::StreamableHttp {
url, url,
bearer_token_env_var, bearer_token_env_var,
http_headers, http_headers,
env_http_headers, env_http_headers,
} = &config.transport } => {
else { match determine_streamable_http_auth_status_from_credentials(
return None; &server_name,
}; url,
match determine_streamable_http_auth_status_from_credentials( bearer_token_env_var.as_deref(),
&server_name, http_headers.clone(),
url, env_http_headers.clone(),
bearer_token_env_var.as_deref(), store_mode,
http_headers.clone(), keyring_backend_kind,
env_http_headers.clone(), ) {
store_mode, Ok(auth_state) => auth_state,
keyring_backend_kind, Err(error) => {
) { warn!(
Ok(auth_state) => auth_state, "failed to read stored auth status for MCP server `{server_name}`: {error:?}"
Err(error) => { );
warn!( None
"failed to read stored auth status for MCP server `{server_name}`: {error:?}" }
);
None
} }
} }
}) McpServerTransportConfig::Stdio { .. } => None,
}
} }
Ok(_) | Err(_) => None, Ok(_) | Err(_) => None,
}; };
@@ -322,7 +428,7 @@ impl McpConnectionSet {
let reason = mcp_startup_failure_reason(auth_state, error); let reason = mcp_startup_failure_reason(auth_state, error);
let error_str = mcp_init_error_display( let error_str = mcp_init_error_display(
server_name.as_str(), server_name.as_str(),
configured_config.as_ref(), Some(&configured_config),
error, error,
); );
McpStartupStatus::Failed { McpStartupStatus::Failed {
@@ -354,23 +460,37 @@ impl McpConnectionSet {
}); });
} }
let manager = Self { let manager = Self {
clients, servers,
server_metadata,
required_servers, required_servers,
tool_catalog_revision: Arc::new(RwLock::new(0)), tool_catalog_revision: Arc::new(RwLock::new(0)),
codex_apps_tools_override: RwLock::new(None), codex_apps_tools_override: RwLock::new(None),
codex_apps_refresh_lock: Mutex::new(()), codex_apps_refresh_lock: Mutex::new(()),
tool_plugin_provenance, tool_plugin_provenance,
prefix_mcp_tool_names, prefix_mcp_tool_names,
startup_cancellation_token, elicitation_requests: elicitation_requests.clone(),
}; };
let summary_publication_gate = publication_gate;
tokio::spawn(async move { tokio::spawn(async move {
let outcomes = join_set.join_all().await; let outcomes = join_set.join_all().await;
if !startup_publication_gate.wait().await {
return;
}
if let Some(tx_event) = tx_event { if let Some(tx_event) = tx_event {
let mut summary = McpStartupCompleteEvent::default(); if !summary_publication_gate.wait().await {
return;
}
let mut summary = McpStartupCompleteEvent {
ready: reused_ready,
..Default::default()
};
for server_name in &summary.ready {
let _ = emit_update(
startup_submit_id.as_str(),
&tx_event,
McpStartupUpdateEvent {
server: server_name.clone(),
status: McpStartupStatus::Ready,
},
)
.await;
}
for (server_name, outcome) in outcomes { for (server_name, outcome) in outcomes {
match outcome { match outcome {
Ok(_) => summary.ready.push(server_name), Ok(_) => summary.ready.push(server_name),
@@ -394,40 +514,68 @@ impl McpConnectionSet {
manager manager
} }
pub fn empty(prefix_mcp_tool_names: bool) -> Self { #[cfg(test)]
fn new_uninitialized_with_permission_profile(
approval_policy: &Constrained<AskForApproval>,
permission_profile: &PermissionProfile,
prefix_mcp_tool_names: bool,
) -> Self {
Self { Self {
clients: HashMap::new(), servers: HashMap::new(),
server_metadata: HashMap::new(),
required_servers: Vec::new(), required_servers: Vec::new(),
tool_catalog_revision: Arc::new(RwLock::new(0)), tool_catalog_revision: Arc::new(RwLock::new(0)),
codex_apps_tools_override: RwLock::new(None), codex_apps_tools_override: RwLock::new(None),
codex_apps_refresh_lock: Mutex::new(()), codex_apps_refresh_lock: Mutex::new(()),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
prefix_mcp_tool_names, prefix_mcp_tool_names,
startup_cancellation_token: CancellationToken::new(), elicitation_requests: ElicitationRequestManager::new(
approval_policy.value(),
permission_profile.clone(),
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
),
}
}
pub fn empty(prefix_mcp_tool_names: bool) -> Self {
Self {
servers: HashMap::new(),
required_servers: Vec::new(),
tool_catalog_revision: Arc::new(RwLock::new(0)),
codex_apps_tools_override: RwLock::new(None),
codex_apps_refresh_lock: Mutex::new(()),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
prefix_mcp_tool_names,
elicitation_requests: ElicitationRequestManager::new(
AskForApproval::Never,
PermissionProfile::default(),
/*reviewer*/ None,
/*lifecycle*/ None,
ElicitationRequestRouter::default(),
),
} }
} }
pub fn has_servers(&self) -> bool { pub fn has_servers(&self) -> bool {
!self.clients.is_empty() !self.servers.is_empty()
}
pub(crate) fn cancel_startup(&self) {
self.startup_cancellation_token.cancel();
} }
pub(crate) fn contains_server(&self, server_name: &str) -> bool { pub(crate) fn contains_server(&self, server_name: &str) -> bool {
self.clients.contains_key(server_name) self.servers.contains_key(server_name)
} }
/// Stop all MCP clients owned by this manager and terminate stdio server processes. /// Stop all MCP clients owned by this manager and terminate stdio server processes.
pub async fn shutdown(&self) { pub async fn shutdown(&self) {
self.startup_cancellation_token.cancel(); let connections = self
let clients = self.clients.values().cloned().collect::<Vec<_>>(); .servers
.values()
.map(|view| Arc::clone(&view.connection))
.collect::<Vec<_>>();
// Keep cleanup alive if an interrupt cancels the refresh that requested it. // Keep cleanup alive if an interrupt cancels the refresh that requested it.
let shutdown_task = tokio::spawn(async move { let shutdown_task = tokio::spawn(async move {
for client in clients { for connection in connections {
client.shutdown().await; connection.shutdown().await;
} }
}); });
if let Err(error) = shutdown_task.await { if let Err(error) = shutdown_task.await {
@@ -435,6 +583,12 @@ impl McpConnectionSet {
} }
} }
pub(crate) fn cancel_startup(&self) {
for view in self.servers.values() {
view.connection.cancel_startup();
}
}
pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> {
self.tool_plugin_provenance self.tool_plugin_provenance
.plugin_id_for_mcp_server_name(server_name) .plugin_id_for_mcp_server_name(server_name)
@@ -446,53 +600,16 @@ impl McpConnectionSet {
} }
pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { pub async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool {
let Some(async_managed_client) = self.clients.get(server_name) else { let Some(view) = self.servers.get(server_name) else {
return false; return false;
}; };
match tokio::time::timeout(timeout, async_managed_client.client()).await { match tokio::time::timeout(timeout, view.connection.client()).await {
Ok(Ok(_)) => true, Ok(Ok(_)) => true,
Ok(Err(_)) | Err(_) => false, Ok(Err(_)) | Err(_) => false,
} }
} }
pub async fn list_all_resources(
&self,
include_server: impl Fn(&str) -> bool,
) -> HashMap<String, Vec<rmcp::model::Resource>> {
self.ready_clients_matching(&include_server)
.await
.list_all_resources(|_| true)
.await
}
pub async fn list_all_resource_templates(
&self,
include_server: impl Fn(&str) -> bool,
) -> HashMap<String, Vec<rmcp::model::ResourceTemplate>> {
self.ready_clients_matching(&include_server)
.await
.list_all_resource_templates(|_| true)
.await
}
async fn ready_clients_matching(
&self,
include_server: &impl Fn(&str) -> bool,
) -> McpBindingClients {
let mut clients = HashMap::new();
for (server, client) in self
.clients
.iter()
.filter(|(server, _)| include_server(server))
{
if let Ok(client) = client.client().await {
clients.insert(server.clone(), Arc::new(client));
}
}
McpBindingClients::new(clients)
}
/// Invoke the tool indicated by the (server, tool) pair. /// Invoke the tool indicated by the (server, tool) pair.
pub async fn call_tool( pub async fn call_tool(
&self, &self,
@@ -501,16 +618,24 @@ impl McpConnectionSet {
arguments: Option<serde_json::Value>, arguments: Option<serde_json::Value>,
meta: Option<serde_json::Value>, meta: Option<serde_json::Value>,
) -> Result<CallToolResult> { ) -> Result<CallToolResult> {
let client = self.client_by_name(server).await?; let view = self
if !client.tool_filter.allows(tool) { .servers
.get(server)
.ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?;
if !view.tool_filter.allows(tool) {
return Err(anyhow!( return Err(anyhow!(
"tool '{tool}' is disabled for MCP server '{server}'" "tool '{tool}' is disabled for MCP server '{server}'"
)); ));
} }
let client = view
.connection
.client()
.await
.context("failed to get client")?;
let result: rmcp::model::CallToolResult = client let result: rmcp::model::CallToolResult = client
.client .client
.call_tool(tool.to_string(), arguments, meta, client.tool_timeout) .call_tool(tool.to_string(), arguments, meta, view.tool_timeout)
.await .await
.with_context(|| format!("tool call failed for `{server}/{tool}`"))?; .with_context(|| format!("tool call failed for `{server}/{tool}`"))?;
@@ -531,45 +656,13 @@ impl McpConnectionSet {
}) })
} }
/// List resources from the specified server.
pub async fn list_resources(
&self,
server: &str,
params: Option<PaginatedRequestParams>,
) -> Result<ListResourcesResult> {
let managed = self.client_by_name(server).await?;
let timeout = managed.tool_timeout;
managed
.client
.list_resources(params, timeout)
.await
.with_context(|| format!("resources/list failed for `{server}`"))
}
/// Read a resource from the specified server.
pub async fn read_resource(
&self,
server: &str,
params: ReadResourceRequestParams,
) -> Result<ReadResourceResult> {
let managed = self.client_by_name(server).await?;
let client = managed.client.clone();
let timeout = managed.tool_timeout;
let uri = params.uri.clone();
client
.read_resource(params, timeout)
.await
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
}
/// Returns presentation metadata from the current connection. /// Returns presentation metadata from the current connection.
/// Codex Apps metadata may come from its existing cache; regular MCP server information is /// Codex Apps metadata may come from its existing cache; regular MCP server information is
/// connection-specific, so pending regular clients are awaited. /// connection-specific, so pending regular clients are awaited.
pub(crate) async fn list_available_server_infos(&self) -> HashMap<String, McpServerInfo> { pub(crate) async fn list_available_server_infos(&self) -> HashMap<String, McpServerInfo> {
let mut server_infos = HashMap::new(); let mut server_infos = HashMap::new();
for (server_name, client) in &self.clients { for (server_name, view) in &self.servers {
let client = &view.connection.client;
if !client.startup_complete.load(Ordering::Acquire) if !client.startup_complete.load(Ordering::Acquire)
&& let Some(server_info) = client.cached_server_info.clone() && let Some(server_info) = client.cached_server_info.clone()
{ {
@@ -590,136 +683,65 @@ impl McpConnectionSet {
server_infos server_infos
} }
async fn client_by_name(&self, name: &str) -> Result<ManagedClient> { #[cfg(test)]
self.clients fn new_uninitialized(
.get(name) approval_policy: &Constrained<AskForApproval>,
.ok_or_else(|| anyhow!("unknown MCP server '{name}'"))? permission_profile: &Constrained<PermissionProfile>,
.client() prefix_mcp_tool_names: bool,
.await ) -> Self {
.context("failed to get client") Self::new_uninitialized_with_permission_profile(
approval_policy,
permission_profile.get(),
prefix_mcp_tool_names,
)
} }
#[cfg(test)] #[cfg(test)]
fn new_uninitialized( fn insert_test_client(&mut self, name: impl Into<String>, client: AsyncManagedClient) {
_approval_policy: &Constrained<AskForApproval>, let name = name.into();
_permission_profile: &Constrained<PermissionProfile>, self.servers.insert(
prefix_mcp_tool_names: bool, name,
) -> Self { McpServerView {
Self::empty(prefix_mcp_tool_names) tool_filter: ToolFilter::default(),
} connection: Arc::new(McpServerConnection {
} identity: None,
client,
impl Drop for McpConnectionSet { }),
fn drop(&mut self) { metadata: McpServerMetadata {
self.startup_cancellation_token.cancel(); environment_id: String::new(),
self.clients.clear(); pollutes_memory: true,
} origin: None,
} supports_parallel_tool_calls: false,
default_tools_approval_mode: None,
/// Makes ChatGPT authentication available to servers that explicitly opt in. tool_approval_modes: HashMap::new(),
/// The HTTP transport applies it only when no configured authorization resolves. },
fn chatgpt_auth_provider_for_server( tool_timeout: None,
server: &EffectiveMcpServer, },
chatgpt_auth_provider: Option<SharedAuthProvider>, );
) -> Option<SharedAuthProvider> {
if !server
.configured_config()
.is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt))
{
return None;
}
chatgpt_auth_provider
}
fn should_share_codex_apps_tools_cache(server_name: &str, uses_env_bearer_token: bool) -> bool {
server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token
}
async fn emit_update(
submit_id: &str,
tx_event: &Sender<Event>,
update: McpStartupUpdateEvent,
) -> Result<(), async_channel::SendError<Event>> {
tx_event
.send(Event {
id: submit_id.to_string(),
msg: EventMsg::McpStartupUpdate(update),
})
.await
}
fn mcp_startup_failure_reason(
auth_state: Option<McpAuthState>,
error: &StartupOutcomeError,
) -> Option<McpStartupFailureReason> {
if !error.is_authentication_required() {
return None;
} }
match auth_state { #[cfg(test)]
Some(McpAuthState::LoggedOut(McpLoginRequirement::Reauthentication)) => { fn test_client(&self, name: &str) -> &AsyncManagedClient {
Some(McpStartupFailureReason::ReauthenticationRequired) &self.servers[name].connection.client
}
Some(
McpAuthState::Unsupported
| McpAuthState::LoggedOut(McpLoginRequirement::Login)
| McpAuthState::BearerToken
| McpAuthState::OAuth,
)
| None => None,
} }
}
fn mcp_init_error_display( #[cfg(test)]
server_name: &str, fn set_test_server_metadata(&mut self, name: &str, metadata: McpServerMetadata) {
config: Option<&McpServerConfig>, self.servers
err: &StartupOutcomeError, .get_mut(name)
) -> String { .expect("test server exists")
if let Some(McpServerTransportConfig::StreamableHttp { .metadata = metadata;
url,
bearer_token_env_var,
http_headers,
..
}) = config.map(|config| &config.transport)
&& url == "https://api.githubcopilot.com/mcp/"
&& bearer_token_env_var.is_none()
&& http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true)
{
format!(
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
)
} else if is_mcp_client_auth_required_error(err) {
format!(
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
)
} else if is_mcp_client_startup_timeout_error(err) {
let startup_timeout_secs = config
.and_then(|config| config.startup_timeout_sec)
.unwrap_or(DEFAULT_STARTUP_TIMEOUT)
.as_secs();
format!(
"MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX"
)
} else {
format!("MCP client for `{server_name}` failed to start: {err:#}")
} }
}
fn is_mcp_client_auth_required_error(error: &StartupOutcomeError) -> bool { #[cfg(test)]
match error { fn shares_test_connection_with(&self, other: &Self, name: &str) -> bool {
StartupOutcomeError::Failed { error, .. } => error.contains("Auth required"), let Some(left) = self.servers.get(name) else {
_ => false, return false;
} };
} let Some(right) = other.servers.get(name) else {
return false;
fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool { };
match error { Arc::ptr_eq(&left.connection, &right.connection)
StartupOutcomeError::Failed { error, .. } => {
error.contains("request timed out")
|| error.contains("timed out handshaking with MCP server")
|| error.contains("MCP client startup timed out")
}
_ => false,
} }
} }
@@ -16,7 +16,7 @@ impl McpConnectionSet {
let failures = async { let failures = async {
let mut failures = Vec::new(); let mut failures = Vec::new();
for server_name in &self.required_servers { for server_name in &self.required_servers {
let Some(async_managed_client) = self.clients.get(server_name).cloned() else { let Some(view) = self.servers.get(server_name) else {
failures.push(McpStartupFailure { failures.push(McpStartupFailure {
server: server_name.clone(), server: server_name.clone(),
error: format!("required MCP server `{server_name}` was not initialized"), error: format!("required MCP server `{server_name}` was not initialized"),
@@ -24,7 +24,7 @@ impl McpConnectionSet {
continue; continue;
}; };
match async_managed_client.client().await { match view.connection.client().await {
Ok(_) => {} Ok(_) => {}
Err(error) => failures.push(McpStartupFailure { Err(error) => failures.push(McpStartupFailure {
server: server_name.clone(), server: server_name.clone(),
@@ -0,0 +1,183 @@
use std::collections::HashMap;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::Resource;
use rmcp::model::ResourceTemplate;
use tokio::task::JoinSet;
use tracing::warn;
use super::McpConnectionSet;
use crate::rmcp_client::ManagedClient;
impl McpConnectionSet {
/// Returns resources from servers selected by `include_server`.
pub async fn list_all_resources(
&self,
include_server: impl Fn(&str) -> bool,
) -> HashMap<String, Vec<Resource>> {
let mut join_set = JoinSet::new();
for (server_name, view) in self
.servers
.iter()
.filter(|(server_name, _)| include_server(server_name))
{
let server_name = server_name.clone();
let Ok(managed_client) = view.connection.client().await else {
continue;
};
let timeout = view.tool_timeout;
let client = managed_client.client;
join_set.spawn(async move {
let mut resources = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map(|next| {
PaginatedRequestParams::default().with_cursor(Some(next.clone()))
});
let response = match client.list_resources(params, timeout).await {
Ok(result) => result,
Err(error) => return (server_name, Err(error)),
};
resources.extend(response.resources);
match response.next_cursor {
Some(next) if cursor.as_ref() == Some(&next) => {
return (
server_name,
Err(anyhow!("resources/list returned duplicate cursor")),
);
}
Some(next) => cursor = Some(next),
None => return (server_name, Ok(resources)),
}
}
});
}
let mut resources = HashMap::new();
while let Some(result) = join_set.join_next().await {
match result {
Ok((server_name, Ok(server_resources))) => {
resources.insert(server_name, server_resources);
}
Ok((server_name, Err(error))) => {
warn!("Failed to list resources for MCP server '{server_name}': {error:#}");
}
Err(error) => {
warn!("Task panic when listing resources for MCP server: {error:#}");
}
}
}
resources
}
/// Returns resource templates from servers selected by `include_server`.
pub async fn list_all_resource_templates(
&self,
include_server: impl Fn(&str) -> bool,
) -> HashMap<String, Vec<ResourceTemplate>> {
let mut join_set = JoinSet::new();
for (server_name, view) in self
.servers
.iter()
.filter(|(server_name, _)| include_server(server_name))
{
let server_name = server_name.clone();
let Ok(managed_client) = view.connection.client().await else {
continue;
};
let timeout = view.tool_timeout;
let client = managed_client.client;
join_set.spawn(async move {
let mut templates = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map(|next| {
PaginatedRequestParams::default().with_cursor(Some(next.clone()))
});
let response = match client.list_resource_templates(params, timeout).await {
Ok(result) => result,
Err(error) => return (server_name, Err(error)),
};
templates.extend(response.resource_templates);
match response.next_cursor {
Some(next) if cursor.as_ref() == Some(&next) => {
return (
server_name,
Err(anyhow!(
"resources/templates/list returned duplicate cursor"
)),
);
}
Some(next) => cursor = Some(next),
None => return (server_name, Ok(templates)),
}
}
});
}
let mut templates = HashMap::new();
while let Some(result) = join_set.join_next().await {
match result {
Ok((server_name, Ok(server_templates))) => {
templates.insert(server_name, server_templates);
}
Ok((server_name, Err(error))) => {
warn!(
"Failed to list resource templates for MCP server '{server_name}': {error:#}"
);
}
Err(error) => {
warn!("Task panic when listing resource templates for MCP server: {error:#}");
}
}
}
templates
}
pub async fn list_resources(
&self,
server: &str,
params: Option<PaginatedRequestParams>,
) -> Result<ListResourcesResult> {
let (managed, timeout) = self.client_by_name(server).await?;
managed
.client
.list_resources(params, timeout)
.await
.with_context(|| format!("resources/list failed for `{server}`"))
}
pub async fn read_resource(
&self,
server: &str,
params: ReadResourceRequestParams,
) -> Result<ReadResourceResult> {
let (managed, timeout) = self.client_by_name(server).await?;
let uri = params.uri.clone();
managed
.client
.read_resource(params, timeout)
.await
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
}
async fn client_by_name(&self, name: &str) -> Result<(ManagedClient, Option<Duration>)> {
let view = self
.servers
.get(name)
.ok_or_else(|| anyhow!("unknown MCP server '{name}'"))?;
let client = view
.connection
.client()
.await
.context("failed to get client")?;
Ok((client, view.tool_timeout))
}
}
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use anyhow::Result;
use async_channel::Sender;
use codex_api::SharedAuthProvider;
use codex_config::McpServerAuth;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpStartupFailureReason;
use codex_protocol::protocol::McpStartupUpdateEvent;
use codex_rmcp_client::McpAuthState;
use codex_rmcp_client::McpLoginRequirement;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT;
use crate::rmcp_client::StartupOutcomeError;
use crate::server::EffectiveMcpServer;
/// Makes ChatGPT authentication available to servers that explicitly opt in.
pub(super) fn chatgpt_auth_provider_for_server(
server: &EffectiveMcpServer,
chatgpt_auth_provider: Option<SharedAuthProvider>,
) -> Option<SharedAuthProvider> {
if !matches!(&server.config().auth, McpServerAuth::ChatGpt) {
return None;
}
chatgpt_auth_provider
}
pub(super) fn should_share_codex_apps_tools_cache(
server_name: &str,
uses_env_bearer_token: bool,
) -> bool {
server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token
}
pub(super) async fn emit_update(
submit_id: &str,
tx_event: &Sender<Event>,
update: McpStartupUpdateEvent,
) -> Result<(), async_channel::SendError<Event>> {
tx_event
.send(Event {
id: submit_id.to_string(),
msg: EventMsg::McpStartupUpdate(update),
})
.await
}
pub(super) fn mcp_startup_failure_reason(
auth_state: Option<McpAuthState>,
error: &StartupOutcomeError,
) -> Option<McpStartupFailureReason> {
if !error.is_authentication_required() {
return None;
}
match auth_state {
Some(McpAuthState::LoggedOut(McpLoginRequirement::Reauthentication)) => {
Some(McpStartupFailureReason::ReauthenticationRequired)
}
Some(
McpAuthState::Unsupported
| McpAuthState::LoggedOut(McpLoginRequirement::Login)
| McpAuthState::BearerToken
| McpAuthState::OAuth,
)
| None => None,
}
}
pub(super) fn mcp_init_error_display(
server_name: &str,
config: Option<&McpServerConfig>,
error: &StartupOutcomeError,
) -> String {
if let Some(McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
http_headers,
..
}) = config.map(|config| &config.transport)
&& url == "https://api.githubcopilot.com/mcp/"
&& bearer_token_env_var.is_none()
&& http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true)
{
format!(
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
)
} else if matches!(
error,
StartupOutcomeError::Failed { error, .. } if error.contains("Auth required")
) {
format!(
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
)
} else if matches!(
error,
StartupOutcomeError::Failed { error, .. }
if error.contains("request timed out")
|| error.contains("timed out handshaking with MCP server")
|| error.contains("MCP client startup timed out")
) {
let startup_timeout_secs = config
.and_then(|config| config.startup_timeout_sec)
.unwrap_or(DEFAULT_STARTUP_TIMEOUT)
.as_secs();
format!(
"MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX"
)
} else {
format!("MCP client for `{server_name}` failed to start: {error:#}")
}
}
@@ -12,31 +12,63 @@ use tracing::trace;
use tracing::trace_span; use tracing::trace_span;
use super::McpConnectionSet; use super::McpConnectionSet;
use super::McpServerMetadata;
use crate::binding::McpBinding; use crate::binding::McpBinding;
use crate::binding::PreparedMcpCall; use crate::binding::PreparedMcpCall;
use crate::binding_clients::McpBindingClients; use crate::binding_clients::McpBindingClients;
use crate::codex_apps::prepare_openai_file_params_for_model;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::rmcp_client::CODEX_APPS_REFRESH_DURATION_METRIC; use crate::rmcp_client::CODEX_APPS_REFRESH_DURATION_METRIC;
use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC; use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC;
use crate::rmcp_client::ManagedClient; use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::list_tools_for_client_uncached; use crate::rmcp_client::list_tools_for_client_uncached;
use crate::rmcp_client::prepare_codex_apps_tools_for_model;
use crate::runtime::emit_duration; use crate::runtime::emit_duration;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
use crate::tools::filter_tools; use crate::tools::filter_tools;
use crate::tools::normalize_tools_for_model_with_prefix; use crate::tools::normalize_tools_for_model_with_prefix;
const MCP_UI_META_KEY: &str = "ui";
const MCP_UI_VISIBILITY_META_KEY: &str = "visibility";
const MCP_UI_MODEL_VISIBILITY: &str = "model";
/// Returns whether a tool may be included in model-facing tool declarations.
///
/// Tools without visibility metadata remain visible. Tools with visibility
/// metadata are hidden unless they explicitly include `model`.
///
/// <https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#resource-discovery>
pub fn tool_is_model_visible(tool: &ToolInfo) -> bool {
let Some(visibility) = tool
.tool
.meta
.as_deref()
.and_then(|meta| meta.get(MCP_UI_META_KEY))
.and_then(serde_json::Value::as_object)
.and_then(|ui| ui.get(MCP_UI_VISIBILITY_META_KEY))
.and_then(serde_json::Value::as_array)
else {
return true;
};
visibility
.iter()
.any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY))
}
impl McpConnectionSet { impl McpConnectionSet {
/// Returns all tools with model-visible names normalized. /// Returns all tools with model-visible names normalized.
#[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))] #[instrument(level = "trace", skip_all, fields(mcp_server_count = self.servers.len()))]
pub async fn list_all_tools(&self) -> Vec<ToolInfo> { pub async fn list_all_tools(&self) -> Vec<ToolInfo> {
let mut tools = Vec::new(); let mut tools = Vec::new();
let mut available_server_count = 0; let mut available_server_count = 0;
let mut unavailable_server_count = 0; let mut unavailable_server_count = 0;
for (server_name, managed_client) in &self.clients { for (server_name, view) in &self.servers {
managed_client.reconnect_failed_startup().await; view.connection.client.reconnect_failed_startup().await;
let has_cached_tools = managed_client.has_cached_tools(); let has_cached_tools = view.connection.client.has_cached_tools();
let startup_complete = managed_client.startup_complete.load(Ordering::Acquire); let startup_complete = view
.connection
.client
.startup_complete
.load(Ordering::Acquire);
let catalog_override = if server_name == CODEX_APPS_MCP_SERVER_NAME { let catalog_override = if server_name == CODEX_APPS_MCP_SERVER_NAME {
self.codex_apps_tools_override.read().await.clone() self.codex_apps_tools_override.read().await.clone()
} else { } else {
@@ -44,8 +76,14 @@ impl McpConnectionSet {
}; };
let Some(server_tools) = async { let Some(server_tools) = async {
match catalog_override { match catalog_override {
Some(tools) => Some(managed_client.prepare_tools(tools)), Some(tools) => {
None => managed_client.listed_tools().await, let tools = filter_tools(tools, &view.tool_filter);
Some(prepare_codex_apps_tools_for_model(
tools,
&self.tool_plugin_provenance,
))
}
None => view.listed_tools(&self.tool_plugin_provenance).await,
} }
} }
.instrument(trace_span!( .instrument(trace_span!(
@@ -69,7 +107,7 @@ impl McpConnectionSet {
tools.extend( tools.extend(
server_tools server_tools
.into_iter() .into_iter()
.map(|tool| self.with_server_metadata(tool)), .map(|tool| Self::with_server_metadata(tool, &view.metadata)),
); );
} }
let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names); let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names);
@@ -94,27 +132,41 @@ impl McpConnectionSet {
let revision = self.tool_catalog_revision.read().await; let revision = self.tool_catalog_revision.read().await;
let mut listed_tools = Vec::new(); let mut listed_tools = Vec::new();
let mut clients = std::collections::HashMap::new(); let mut clients = std::collections::HashMap::new();
for (server_name, managed_client) in &self.clients { for (server_name, view) in &self.servers {
if !view
.connection
.client
.startup_complete
.load(Ordering::Acquire)
{
let _ = view.connection.client.client().await;
}
view.connection.client.reconnect_failed_startup().await;
let Ok(mut client) = view.connection.client.client().await else {
trace!(server_name = %server_name, "omitting MCP server without an exact ready client");
continue;
};
client.tool_timeout = view.tool_timeout;
let catalog_override = if server_name == CODEX_APPS_MCP_SERVER_NAME { let catalog_override = if server_name == CODEX_APPS_MCP_SERVER_NAME {
self.codex_apps_tools_override.read().await.clone() self.codex_apps_tools_override.read().await.clone()
} else { } else {
None None
}; };
let Some((client, server_tools)) = managed_client let server_tools = catalog_override.unwrap_or_else(|| client.tools.clone());
.capture_ready_client_and_tools(catalog_override) let server_tools = filter_tools(server_tools, &view.tool_filter);
.await let server_tools = if server_name == CODEX_APPS_MCP_SERVER_NAME {
else { prepare_codex_apps_tools_for_model(server_tools, &self.tool_plugin_provenance)
trace!( } else {
server_name = %server_name, crate::rmcp_client::prepare_regular_mcp_tools_for_model(
"omitting MCP server without an exact ready client" server_tools,
); &self.tool_plugin_provenance,
continue; )
}; };
clients.insert(server_name.clone(), client); clients.insert(server_name.clone(), Arc::new(client));
listed_tools.extend( listed_tools.extend(
server_tools server_tools
.into_iter() .into_iter()
.map(|tool| self.with_server_metadata(tool)), .map(|tool| Self::with_server_metadata(tool, &view.metadata)),
); );
} }
let clients = Arc::new(McpBindingClients::new(clients)); let clients = Arc::new(McpBindingClients::new(clients));
@@ -165,6 +217,7 @@ impl McpConnectionSet {
tool_catalog_revision: u64, tool_catalog_revision: u64,
) -> Option<PreparedMcpCall> { ) -> Option<PreparedMcpCall> {
let server_name = &tool_info.server_name; let server_name = &tool_info.server_name;
let view = self.servers.get(server_name)?;
Some(PreparedMcpCall::new( Some(PreparedMcpCall::new(
Arc::clone(self), Arc::clone(self),
client, client,
@@ -172,7 +225,7 @@ impl McpConnectionSet {
tool_catalog_revision, tool_catalog_revision,
Arc::clone(&self.tool_catalog_revision), Arc::clone(&self.tool_catalog_revision),
tool_info.clone(), tool_info.clone(),
self.server_metadata.get(server_name)?.clone(), view.metadata.clone(),
self.plugin_id_for_mcp_server_name(server_name) self.plugin_id_for_mcp_server_name(server_name)
.map(str::to_string), .map(str::to_string),
self.is_selected_plugin_mcp_server(server_name), self.is_selected_plugin_mcp_server(server_name),
@@ -184,13 +237,15 @@ impl McpConnectionSet {
clippy::await_holding_invalid_type, clippy::await_holding_invalid_type,
reason = "catalog publication must remain serialized with captured tool calls" reason = "catalog publication must remain serialized with captured tool calls"
)] )]
pub(crate) async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<Vec<ToolInfo>> { pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<Vec<ToolInfo>> {
let _refresh = self.codex_apps_refresh_lock.lock().await; let _refresh = self.codex_apps_refresh_lock.lock().await;
let refresh_start = Instant::now(); let refresh_start = Instant::now();
let managed_client = self let view = self
.clients .servers
.get(CODEX_APPS_MCP_SERVER_NAME) .get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))? .ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))?;
let managed_client = view
.connection
.client() .client()
.await .await
.context("failed to get client")?; .context("failed to get client")?;
@@ -208,7 +263,7 @@ impl McpConnectionSet {
/*is_codex_apps_mcp_server*/ true, /*is_codex_apps_mcp_server*/ true,
/*codex_apps_refresh_trigger*/ "explicit", /*codex_apps_refresh_trigger*/ "explicit",
&managed_client.client, &managed_client.client,
managed_client.tool_timeout, view.tool_timeout,
managed_client.server_instructions.as_deref(), managed_client.server_instructions.as_deref(),
) )
.await .await
@@ -237,12 +292,12 @@ impl McpConnectionSet {
list_start.elapsed(), list_start.elapsed(),
&[("cache", "miss")], &[("cache", "miss")],
); );
let tools = filter_tools(tools, &managed_client.tool_filter) let tools = prepare_codex_apps_tools_for_model(
.into_iter() filter_tools(tools, &view.tool_filter),
.map(|mut tool| { &self.tool_plugin_provenance,
prepare_openai_file_params_for_model(&mut tool); )
self.with_server_metadata(tool) .into_iter()
}); .map(|tool| Self::with_server_metadata(tool, &view.metadata));
let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names); let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names);
emit_duration( emit_duration(
CODEX_APPS_REFRESH_DURATION_METRIC, CODEX_APPS_REFRESH_DURATION_METRIC,
@@ -252,13 +307,7 @@ impl McpConnectionSet {
Ok(tools) Ok(tools)
} }
fn with_server_metadata(&self, mut tool: ToolInfo) -> ToolInfo { fn with_server_metadata(mut tool: ToolInfo, metadata: &McpServerMetadata) -> ToolInfo {
let Some(metadata) = self.server_metadata.get(&tool.server_name) else {
tool.supports_parallel_tool_calls = false;
tool.server_origin = None;
return tool;
};
tool.supports_parallel_tool_calls = metadata.supports_parallel_tool_calls; tool.supports_parallel_tool_calls = metadata.supports_parallel_tool_calls;
tool.server_origin = metadata tool.server_origin = metadata
.origin .origin
File diff suppressed because it is too large. Load diff
+48 -14
View File
@@ -7,6 +7,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -116,14 +117,19 @@ impl ElicitationRequestRouter {
} }
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct ElicitationRequestManager { pub(crate) struct ElicitationAuthority {
router: ElicitationRequestRouter, pub(crate) approval_policy: AskForApproval,
approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile,
permission_profile: PermissionProfile,
reviewer: Option<ElicitationReviewerHandle>, reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>, lifecycle: Option<ElicitationLifecycle>,
} }
#[derive(Clone)]
pub(crate) struct ElicitationRequestManager {
router: ElicitationRequestRouter,
pub(crate) authority: Arc<StdMutex<ElicitationAuthority>>,
}
impl ElicitationRequestManager { impl ElicitationRequestManager {
pub(crate) fn new( pub(crate) fn new(
approval_policy: AskForApproval, approval_policy: AskForApproval,
@@ -134,11 +140,32 @@ impl ElicitationRequestManager {
) -> Self { ) -> Self {
Self { Self {
router, router,
authority: Arc::new(StdMutex::new(ElicitationAuthority {
approval_policy,
permission_profile,
reviewer,
lifecycle,
})),
}
}
pub(crate) fn update(
&self,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
reviewer: Option<ElicitationReviewerHandle>,
lifecycle: Option<ElicitationLifecycle>,
) -> bool {
let Ok(mut authority) = self.authority.lock() else {
return false;
};
*authority = ElicitationAuthority {
approval_policy, approval_policy,
permission_profile, permission_profile,
reviewer, reviewer,
lifecycle, lifecycle,
} };
true
} }
pub(crate) fn make_sender( pub(crate) fn make_sender(
@@ -147,18 +174,12 @@ impl ElicitationRequestManager {
tx_event: Option<Sender<Event>>, tx_event: Option<Sender<Event>>,
) -> SendElicitation { ) -> SendElicitation {
let router = self.router.clone(); let router = self.router.clone();
let approval_policy = self.approval_policy; let authority = self.authority.clone();
let permission_profile = self.permission_profile.clone();
let reviewer = self.reviewer.clone();
let lifecycle = self.lifecycle.clone();
Box::new(move |id, elicitation| { Box::new(move |id, elicitation| {
let router = router.clone(); let router = router.clone();
let tx_event = tx_event.clone(); let tx_event = tx_event.clone();
let server_name = server_name.clone(); let server_name = server_name.clone();
let approval_policy = approval_policy; let authority = authority.clone();
let permission_profile = permission_profile.clone();
let reviewer = reviewer.clone();
let lifecycle = lifecycle.clone();
async move { async move {
if router.auto_deny() { if router.auto_deny() {
return Ok(ElicitationResponse { return Ok(ElicitationResponse {
@@ -168,6 +189,19 @@ impl ElicitationRequestManager {
}); });
} }
let Ok(authority) = authority.lock().map(|authority| authority.clone()) else {
return Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
});
};
let ElicitationAuthority {
approval_policy,
permission_profile,
reviewer,
lifecycle,
} = authority;
if mcp_permission_prompt_is_auto_approved( if mcp_permission_prompt_is_auto_approved(
approval_policy, approval_policy,
&permission_profile, &permission_profile,
@@ -189,7 +223,7 @@ impl ElicitationRequestManager {
}); });
} }
if let Some(reviewer) = reviewer.as_ref() { if let Some(reviewer) = reviewer {
let request = ElicitationReviewRequest { let request = ElicitationReviewRequest {
server_name: server_name.clone(), server_name: server_name.clone(),
request_id: id.clone(), request_id: id.clone(),
+27 -35
View File
@@ -192,46 +192,38 @@ where
{ {
let futures = servers.into_iter().map(|(name, server)| { let futures = servers.into_iter().map(|(name, server)| {
let name = name.clone(); let name = name.clone();
let config = server.configured_config().cloned(); let config = server.config().clone();
let runtime_context = runtime_context.clone(); let runtime_context = runtime_context.clone();
let has_runtime_auth = config let has_runtime_auth = matches!(&config.auth, McpServerAuth::ChatGpt)
.as_ref()
.is_some_and(|config| matches!(&config.auth, McpServerAuth::ChatGpt))
&& auth.is_some_and(CodexAuth::uses_codex_backend) && auth.is_some_and(CodexAuth::uses_codex_backend)
&& config.as_ref().is_some_and(|config| { && matches!(
matches!( &config.transport,
&config.transport, McpServerTransportConfig::StreamableHttp {
McpServerTransportConfig::StreamableHttp { bearer_token_env_var: None,
bearer_token_env_var: None, ..
.. }
} );
)
});
async move { async move {
let auth_state = match config.as_ref() { let auth_state = match compute_auth_status(
Some(config) => { &name,
match compute_auth_status( &config,
&name, store_mode,
config, keyring_backend_kind,
store_mode, has_runtime_auth,
keyring_backend_kind, &runtime_context,
has_runtime_auth, )
&runtime_context, .await
) {
.await Ok(status) => status,
{ Err(error) => {
Ok(status) => status, warn!("failed to determine auth status for MCP server `{name}`: {error:?}");
Err(error) => { McpAuthState::Unsupported
warn!(
"failed to determine auth status for MCP server `{name}`: {error:?}"
);
McpAuthState::Unsupported
}
}
} }
None => McpAuthState::Unsupported,
}; };
let entry = McpAuthStatusEntry { config, auth_state }; let entry = McpAuthStatusEntry {
config: Some(config),
auth_state,
};
(name, entry) (name, entry)
} }
}); });
+54 -52
View File
@@ -17,6 +17,7 @@ use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::env; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStack;
@@ -49,7 +50,9 @@ use tokio_util::sync::CancellationToken;
use crate::ResolvedMcpCatalog; use crate::ResolvedMcpCatalog;
use crate::connection_manager::McpConnectionSet; use crate::connection_manager::McpConnectionSet;
use crate::runtime::McpPublicationGate;
use crate::runtime::McpRuntimeContext; use crate::runtime::McpRuntimeContext;
use crate::runtime::McpRuntimeInput;
use crate::server::EffectiveMcpServer; use crate::server::EffectiveMcpServer;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
@@ -319,30 +322,30 @@ pub async fn read_mcp_resource(
let mut mcp_servers = effective_mcp_servers(config, auth); let mut mcp_servers = effective_mcp_servers(config, auth);
mcp_servers.retain(|name, _| name == server); mcp_servers.retain(|name, _| name == server);
let cancel_token = CancellationToken::new(); let cancel_token = CancellationToken::new();
let mut runtime_config = config.clone();
runtime_config.permission_profile = PermissionProfile::default();
let manager = McpConnectionSet::new( let manager = McpConnectionSet::new(
&mcp_servers, /*previous*/ None,
config.mcp_oauth_credentials_store_mode, McpPublicationGate::already_published(),
config.auth_keyring_backend_kind, McpRuntimeInput {
&config.approval_policy, config: Arc::new(runtime_config),
String::new(), plugins_available: false,
/*tx_event*/ None, ready_selected_capability_roots: Vec::new(),
cancel_token.clone(), mcp_servers,
PermissionProfile::default(), submit_id: String::new(),
runtime_context, tx_event: None,
config.codex_home.clone(), startup_cancellation_token: cancel_token.clone(),
codex_apps_tools_cache, runtime_context,
tool_catalog_cache, codex_apps_tools_cache,
connector_runtime_context_key(auth), tool_catalog_cache,
config.prefix_mcp_tool_names, codex_apps_tools_cache_key: connector_runtime_context_key(auth),
config.client_elicitation_capability.clone(), supports_openai_form_elicitation: false,
/*supports_openai_form_elicitation*/ false, auth: auth.cloned(),
tool_plugin_provenance(config), codex_apps_auth_manager: None,
auth, elicitation_reviewer: None,
/*codex_apps_auth_manager*/ None, elicitation_lifecycle: None,
/*elicitation_reviewer*/ None, },
/*elicitation_lifecycle*/ None,
crate::elicitation::ElicitationRequestRouter::default(), crate::elicitation::ElicitationRequestRouter::default(),
crate::runtime::McpPublicationGate::already_published(),
) )
.await; .await;
@@ -373,7 +376,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
detail: McpSnapshotDetail, detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot { ) -> McpServerStatusSnapshot {
let mcp_servers = effective_mcp_servers(config, auth); let mcp_servers = effective_mcp_servers(config, auth);
let tool_plugin_provenance = tool_plugin_provenance(config);
if mcp_servers.is_empty() { if mcp_servers.is_empty() {
return McpServerStatusSnapshot { return McpServerStatusSnapshot {
server_infos: HashMap::new(), server_infos: HashMap::new(),
@@ -397,35 +399,35 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
let server_names = mcp_servers.keys().cloned().collect(); let server_names = mcp_servers.keys().cloned().collect();
let cancel_token = CancellationToken::new(); let cancel_token = CancellationToken::new();
let mcp_connection_set = McpConnectionSet::new( let mut runtime_config = config.clone();
&mcp_servers, runtime_config.permission_profile = PermissionProfile::default();
config.mcp_oauth_credentials_store_mode, let mcp_connection_manager = McpConnectionSet::new(
config.auth_keyring_backend_kind, /*previous*/ None,
&config.approval_policy, McpPublicationGate::already_published(),
submit_id, McpRuntimeInput {
/*tx_event*/ None, config: Arc::new(runtime_config),
cancel_token.clone(), plugins_available: false,
PermissionProfile::default(), ready_selected_capability_roots: Vec::new(),
runtime_context, mcp_servers,
config.codex_home.clone(), submit_id,
codex_apps_tools_cache, tx_event: None,
tool_catalog_cache, startup_cancellation_token: cancel_token.clone(),
connector_runtime_context_key(auth), runtime_context,
config.prefix_mcp_tool_names, codex_apps_tools_cache,
config.client_elicitation_capability.clone(), tool_catalog_cache,
/*supports_openai_form_elicitation*/ false, codex_apps_tools_cache_key: connector_runtime_context_key(auth),
tool_plugin_provenance, supports_openai_form_elicitation: false,
auth, auth: auth.cloned(),
/*codex_apps_auth_manager*/ None, codex_apps_auth_manager: None,
/*elicitation_reviewer*/ None, elicitation_reviewer: None,
/*elicitation_lifecycle*/ None, elicitation_lifecycle: None,
},
crate::elicitation::ElicitationRequestRouter::default(), crate::elicitation::ElicitationRequestRouter::default(),
crate::runtime::McpPublicationGate::already_published(),
) )
.await; .await;
let snapshot = collect_mcp_server_status_snapshot_from_manager( let snapshot = collect_mcp_server_status_snapshot_from_manager(
&mcp_connection_set, &mcp_connection_manager,
auth_status_entries, auth_status_entries,
server_names, server_names,
detail, detail,
@@ -652,27 +654,27 @@ fn convert_mcp_resource_templates(
} }
async fn collect_mcp_server_status_snapshot_from_manager( async fn collect_mcp_server_status_snapshot_from_manager(
mcp_connection_set: &McpConnectionSet, mcp_connection_manager: &McpConnectionSet,
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>, auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
server_names: Vec<String>, server_names: Vec<String>,
detail: McpSnapshotDetail, detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot { ) -> McpServerStatusSnapshot {
let ((server_infos, tools), resources, resource_templates) = tokio::join!( let ((server_infos, tools), resources, resource_templates) = tokio::join!(
async { async {
let server_infos = mcp_connection_set.list_available_server_infos().await; let server_infos = mcp_connection_manager.list_available_server_infos().await;
let tools = mcp_connection_set.list_all_tools().await; let tools = mcp_connection_manager.list_all_tools().await;
(server_infos, tools) (server_infos, tools)
}, },
async { async {
if detail.include_resources() { if detail.include_resources() {
mcp_connection_set.list_all_resources(|_| true).await mcp_connection_manager.list_all_resources(|_| true).await
} else { } else {
HashMap::new() HashMap::new()
} }
}, },
async { async {
if detail.include_resources() { if detail.include_resources() {
mcp_connection_set mcp_connection_manager
.list_all_resource_templates(|_| true) .list_all_resource_templates(|_| true)
.await .await
} else { } else {
+3 -9
View File
@@ -440,15 +440,9 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
.get(CODEX_APPS_MCP_SERVER_NAME) .get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps server should exist"); .expect("codex apps server should exist");
let sample = sample let sample = sample.config();
.configured_config() let docs = docs.config();
.expect("configured server should retain transport"); let codex_apps = codex_apps.config();
let docs = docs
.configured_config()
.expect("configured server should retain transport");
let codex_apps = codex_apps
.configured_config()
.expect("codex apps should use configured transport");
match &sample.transport { match &sample.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => { McpServerTransportConfig::StreamableHttp { url, .. } => {
+10 -93
View File
@@ -29,12 +29,9 @@ use crate::openai_docs_source_attribution::maybe_with_openai_docs_source_attribu
use crate::runtime::McpRuntimeContext; use crate::runtime::McpRuntimeContext;
use crate::runtime::emit_duration; use crate::runtime::emit_duration;
use crate::server::EffectiveMcpServer; use crate::server::EffectiveMcpServer;
use crate::server::McpServerLaunch;
use crate::tool_catalog_cache::McpToolCatalogCacheContext; use crate::tool_catalog_cache::McpToolCatalogCacheContext;
use crate::tool_catalog_cache::McpToolCatalogFetchTicket; use crate::tool_catalog_cache::McpToolCatalogFetchTicket;
use crate::tools::ToolFilter;
use crate::tools::ToolInfo; use crate::tools::ToolInfo;
use crate::tools::filter_tools;
use anyhow::Result; use anyhow::Result;
use anyhow::anyhow; use anyhow::anyhow;
use async_channel::Sender; use async_channel::Sender;
@@ -109,7 +106,6 @@ pub(crate) struct ManagedClient {
pub(crate) client: Arc<RmcpClient>, pub(crate) client: Arc<RmcpClient>,
pub(crate) server_info: McpServerInfo, pub(crate) server_info: McpServerInfo,
pub(crate) tools: Vec<ToolInfo>, pub(crate) tools: Vec<ToolInfo>,
pub(crate) tool_filter: ToolFilter,
pub(crate) tool_timeout: Option<Duration>, pub(crate) tool_timeout: Option<Duration>,
pub(crate) server_instructions: Option<String>, pub(crate) server_instructions: Option<String>,
pub(crate) server_supports_sandbox_state_meta_capability: bool, pub(crate) server_supports_sandbox_state_meta_capability: bool,
@@ -129,7 +125,7 @@ impl ManagedClient {
total_start.elapsed(), total_start.elapsed(),
&[("cache", "hit")], &[("cache", "hit")],
); );
return filter_tools(tools, &self.tool_filter); return tools;
} }
if self.codex_apps_tools_cache_context.is_some() { if self.codex_apps_tools_cache_context.is_some() {
@@ -140,7 +136,7 @@ impl ManagedClient {
); );
} }
filter_tools(self.tools.clone(), &self.tool_filter) self.tools.clone()
} }
} }
@@ -310,13 +306,9 @@ impl ManagedClientStartup {
startup_complete, startup_complete,
} = self.clone(); } = self.clone();
let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME;
let tool_filter = server
.configured_config()
.map(ToolFilter::from_config)
.unwrap_or_default();
let startup_timeout = server let startup_timeout = server
.configured_config() .config()
.and_then(|config| config.startup_timeout_sec) .startup_timeout_sec
.unwrap_or(DEFAULT_STARTUP_TIMEOUT); .unwrap_or(DEFAULT_STARTUP_TIMEOUT);
let cancel_token_for_fut = cancel_token; let cancel_token_for_fut = cancel_token;
let tool_catalog_fetch_ticket = tool_catalog_cache_context let tool_catalog_fetch_ticket = tool_catalog_cache_context
@@ -356,11 +348,6 @@ impl ManagedClientStartup {
StartServerTaskParams { StartServerTaskParams {
is_codex_apps_mcp_server, is_codex_apps_mcp_server,
startup_timeout: Some(startup_timeout), startup_timeout: Some(startup_timeout),
tool_timeout: server
.configured_config()
.and_then(|config| config.tool_timeout_sec)
.unwrap_or(DEFAULT_TOOL_TIMEOUT),
tool_filter,
tx_event, tx_event,
elicitation_requests, elicitation_requests,
codex_apps_tools_cache_context, codex_apps_tools_cache_context,
@@ -404,10 +391,8 @@ pub(crate) struct AsyncManagedClient {
pub(crate) cached_server_info: Option<McpServerInfo>, pub(crate) cached_server_info: Option<McpServerInfo>,
pub(crate) codex_apps_tools_cache_context: Option<ConnectorRuntimeContext<ToolInfo>>, pub(crate) codex_apps_tools_cache_context: Option<ConnectorRuntimeContext<ToolInfo>>,
pub(crate) tool_catalog_cache_context: Option<McpToolCatalogCacheContext>, pub(crate) tool_catalog_cache_context: Option<McpToolCatalogCacheContext>,
pub(crate) tool_filter: ToolFilter,
pub(crate) startup_complete: Arc<AtomicBool>, pub(crate) startup_complete: Arc<AtomicBool>,
pub(crate) startup_reconnect: Option<Arc<CodexAppsStartupReconnect>>, pub(crate) startup_reconnect: Option<Arc<CodexAppsStartupReconnect>>,
pub(crate) tool_plugin_provenance: Arc<ToolPluginProvenance>,
pub(crate) cancel_token: CancellationToken, pub(crate) cancel_token: CancellationToken,
} }
@@ -427,7 +412,6 @@ impl AsyncManagedClient {
elicitation_requests: ElicitationRequestManager, elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<ConnectorRuntimeContext<ToolInfo>>, codex_apps_tools_cache_context: Option<ConnectorRuntimeContext<ToolInfo>>,
tool_catalog_cache_context: Option<McpToolCatalogCacheContext>, tool_catalog_cache_context: Option<McpToolCatalogCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
runtime_context: McpRuntimeContext, runtime_context: McpRuntimeContext,
resolved_environment: std::result::Result<Option<Arc<Environment>>, String>, resolved_environment: std::result::Result<Option<Arc<Environment>>, String>,
runtime_auth_provider: Option<SharedAuthProvider>, runtime_auth_provider: Option<SharedAuthProvider>,
@@ -437,10 +421,6 @@ impl AsyncManagedClient {
let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME;
let reconnect_server_name = server_name.clone(); let reconnect_server_name = server_name.clone();
let reconnect_tx_event = tx_event.clone(); let reconnect_tx_event = tx_event.clone();
let tool_filter = server
.configured_config()
.map(ToolFilter::from_config)
.unwrap_or_default();
let cached_server_info = if is_codex_apps_mcp_server { let cached_server_info = if is_codex_apps_mcp_server {
codex_apps_tools_cache_context codex_apps_tools_cache_context
.as_ref() .as_ref()
@@ -484,10 +464,8 @@ impl AsyncManagedClient {
cached_server_info, cached_server_info,
codex_apps_tools_cache_context, codex_apps_tools_cache_context,
tool_catalog_cache_context, tool_catalog_cache_context,
tool_filter,
startup_complete, startup_complete,
startup_reconnect, startup_reconnect,
tool_plugin_provenance,
cancel_token, cancel_token,
} }
} }
@@ -503,50 +481,6 @@ impl AsyncManagedClient {
self.client.clone().await self.client.clone().await
} }
/// Captures the ready client revision that is current now.
///
/// A recovered Codex Apps connection replaces the failed startup future for
/// future steps, but cannot reroute a call that was already prepared.
fn ready_client_snapshot(&self) -> Option<ManagedClientFuture> {
if let Some(client) = self
.startup_reconnect
.as_ref()
.and_then(|reconnect| reconnect.current_client())
{
return Some(futures::future::ready(Ok(client)).boxed().shared());
}
match self.client.peek() {
Some(Ok(_)) => Some(self.client.clone()),
Some(Err(_)) | None => None,
}
}
/// Captures one ready client and derives its model-visible tools.
///
/// A fresh client waits for initial startup even when metadata is cached.
/// After a failed startup, cached tools remain available to metadata-only
/// callers while recovery runs, but a model step still requires one exact
/// ready client.
pub(crate) async fn capture_ready_client_and_tools(
&self,
catalog_override: Option<Vec<ToolInfo>>,
) -> Option<(Arc<ManagedClient>, Vec<ToolInfo>)> {
if !self.startup_complete.load(Ordering::Acquire) {
let _ = self.client().await;
}
self.reconnect_failed_startup().await;
let client = if self.has_cached_tools() {
self.ready_client_snapshot()?
} else {
self.client().await.ok()?;
self.ready_client_snapshot()?
};
let managed_client = Arc::new(client.await.ok()?);
let tools = catalog_override.unwrap_or_else(|| managed_client.tools.clone());
let tools = filter_tools(tools, &managed_client.tool_filter);
Some((Arc::clone(&managed_client), self.prepare_tools(tools)))
}
pub(crate) async fn reconnect_failed_startup(&self) { pub(crate) async fn reconnect_failed_startup(&self) {
let Some(startup_reconnect) = self.startup_reconnect.as_ref() else { let Some(startup_reconnect) = self.startup_reconnect.as_ref() else {
return; return;
@@ -589,20 +523,11 @@ impl AsyncManagedClient {
.as_ref() .as_ref()
.and_then(McpToolCatalogCacheContext::current_tools) .and_then(McpToolCatalogCacheContext::current_tools)
}) })
.map(|tools| filter_tools(tools, &self.tool_filter))
}
pub(crate) fn prepare_tools(&self, tools: Vec<ToolInfo>) -> Vec<ToolInfo> {
if self.is_codex_apps_mcp_server {
prepare_codex_apps_tools_for_model(tools, &self.tool_plugin_provenance)
} else {
prepare_regular_mcp_tools_for_model(tools, &self.tool_plugin_provenance)
}
} }
pub(crate) async fn listed_tools(&self) -> Option<Vec<ToolInfo>> { pub(crate) async fn listed_tools(&self) -> Option<Vec<ToolInfo>> {
// Plugin provenance is resolved per-session rather than stored in shared cache payloads. // Plugin provenance is resolved per-session rather than stored in shared cache payloads.
let tools = if !self.startup_complete.load(Ordering::Acquire) if !self.startup_complete.load(Ordering::Acquire)
&& let Some(startup_tools) = self.cached_tools() && let Some(startup_tools) = self.cached_tools()
{ {
Some(startup_tools) Some(startup_tools)
@@ -612,8 +537,7 @@ impl AsyncManagedClient {
Err(_) if self.is_codex_apps_mcp_server => self.cached_tools(), Err(_) if self.is_codex_apps_mcp_server => self.cached_tools(),
Err(_) => None, Err(_) => None,
} }
}?; }
Some(self.prepare_tools(tools))
} }
} }
@@ -696,7 +620,7 @@ pub(crate) async fn list_tools_for_client_uncached(
/// Presents declared Codex Apps file parameters to the model as local-path inputs and adds plugin /// Presents declared Codex Apps file parameters to the model as local-path inputs and adds plugin
/// names to each tool. Plugin membership is resolved by connector ID, falling back to the MCP /// names to each tool. Plugin membership is resolved by connector ID, falling back to the MCP
/// server when absent. /// server when absent.
fn prepare_codex_apps_tools_for_model( pub(crate) fn prepare_codex_apps_tools_for_model(
mut tools: Vec<ToolInfo>, mut tools: Vec<ToolInfo>,
tool_plugin_provenance: &ToolPluginProvenance, tool_plugin_provenance: &ToolPluginProvenance,
) -> Vec<ToolInfo> { ) -> Vec<ToolInfo> {
@@ -750,7 +674,7 @@ fn add_plugin_provenance_to_tool(tool: &mut ToolInfo, plugin_names: &[String]) {
} }
/// Adds server-scoped plugin names to regular MCP tools without changing their input schemas. /// Adds server-scoped plugin names to regular MCP tools without changing their input schemas.
fn prepare_regular_mcp_tools_for_model( pub(crate) fn prepare_regular_mcp_tools_for_model(
mut tools: Vec<ToolInfo>, mut tools: Vec<ToolInfo>,
tool_plugin_provenance: &ToolPluginProvenance, tool_plugin_provenance: &ToolPluginProvenance,
) -> Vec<ToolInfo> { ) -> Vec<ToolInfo> {
@@ -902,8 +826,6 @@ async fn start_server_task(
let StartServerTaskParams { let StartServerTaskParams {
is_codex_apps_mcp_server, is_codex_apps_mcp_server,
startup_timeout, startup_timeout,
tool_timeout,
tool_filter,
tx_event, tx_event,
elicitation_requests, elicitation_requests,
codex_apps_tools_cache_context, codex_apps_tools_cache_context,
@@ -984,8 +906,7 @@ async fn start_server_task(
client: Arc::clone(&client), client: Arc::clone(&client),
server_info, server_info,
tools: client_tools, tools: client_tools,
tool_timeout: Some(tool_timeout), tool_timeout: None,
tool_filter,
server_instructions: initialize_result.instructions, server_instructions: initialize_result.instructions,
server_supports_sandbox_state_meta_capability, server_supports_sandbox_state_meta_capability,
codex_apps_tools_cache_context, codex_apps_tools_cache_context,
@@ -1032,8 +953,6 @@ fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServer
struct StartServerTaskParams { struct StartServerTaskParams {
is_codex_apps_mcp_server: bool, is_codex_apps_mcp_server: bool,
startup_timeout: Option<Duration>, // TODO: cancel_token should handle this. startup_timeout: Option<Duration>, // TODO: cancel_token should handle this.
tool_timeout: Duration,
tool_filter: ToolFilter,
tx_event: Option<Sender<Event>>, tx_event: Option<Sender<Event>>,
elicitation_requests: ElicitationRequestManager, elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<ConnectorRuntimeContext<ToolInfo>>, codex_apps_tools_cache_context: Option<ConnectorRuntimeContext<ToolInfo>>,
@@ -1053,9 +972,7 @@ async fn make_rmcp_client(
resolved_environment: std::result::Result<Option<Arc<Environment>>, String>, resolved_environment: std::result::Result<Option<Arc<Environment>>, String>,
runtime_auth_provider: Option<SharedAuthProvider>, runtime_auth_provider: Option<SharedAuthProvider>,
) -> Result<RmcpClient, StartupOutcomeError> { ) -> Result<RmcpClient, StartupOutcomeError> {
let config = match server.launch() { let config = server.config().clone();
McpServerLaunch::Configured(config) => config.as_ref().clone(),
};
let resolved_environment = let resolved_environment =
resolved_environment.map_err(|err| StartupOutcomeError::from(anyhow!(err)))?; resolved_environment.map_err(|err| StartupOutcomeError::from(anyhow!(err)))?;
let is_local_environment = config.is_local_environment(); let is_local_environment = config.is_local_environment();
+9 -53
View File
@@ -138,14 +138,21 @@ impl McpRuntime {
runtime runtime
} }
/// Rebuilds configured servers and publishes their immutable runtime snapshot. /// Reconciles configured servers and publishes their immutable runtime snapshot.
pub async fn replace(&self, input: McpRuntimeInput) { pub async fn replace(&self, input: McpRuntimeInput) {
let current = self.current.load_full();
let (publish, publication_gate) = McpPublicationGate::pending(); let (publish, publication_gate) = McpPublicationGate::pending();
let config = Arc::clone(&input.config); let config = Arc::clone(&input.config);
let plugins_available = input.plugins_available; let plugins_available = input.plugins_available;
let ready_selected_capability_roots = input.ready_selected_capability_roots.clone(); let ready_selected_capability_roots = input.ready_selected_capability_roots.clone();
let connections = Arc::new( let connections = Arc::new(
Self::materialize(input, self.elicitation_router.clone(), publication_gate).await, McpConnectionSet::new(
Some(current.connections.as_ref()),
publication_gate,
input,
self.elicitation_router.clone(),
)
.await,
); );
self.current.store(Arc::new(PublishedMcpRuntime { self.current.store(Arc::new(PublishedMcpRuntime {
connections, connections,
@@ -250,57 +257,6 @@ impl McpRuntime {
pub async fn shutdown(&self) { pub async fn shutdown(&self) {
self.latest_connections().shutdown().await; self.latest_connections().shutdown().await;
} }
async fn materialize(
input: McpRuntimeInput,
elicitation_router: ElicitationRequestRouter,
publication_gate: McpPublicationGate,
) -> McpConnectionSet {
let McpRuntimeInput {
config,
plugins_available: _,
ready_selected_capability_roots: _,
mcp_servers,
submit_id,
tx_event,
startup_cancellation_token,
runtime_context,
codex_apps_tools_cache,
tool_catalog_cache,
codex_apps_tools_cache_key,
supports_openai_form_elicitation,
auth,
codex_apps_auth_manager,
elicitation_reviewer,
elicitation_lifecycle,
} = input;
McpConnectionSet::new(
&mcp_servers,
config.mcp_oauth_credentials_store_mode,
config.auth_keyring_backend_kind,
&config.approval_policy,
submit_id,
tx_event,
startup_cancellation_token,
config.permission_profile.clone(),
runtime_context,
config.codex_home.clone(),
codex_apps_tools_cache,
tool_catalog_cache,
codex_apps_tools_cache_key,
config.prefix_mcp_tool_names,
config.client_elicitation_capability.clone(),
supports_openai_form_elicitation,
crate::mcp::tool_plugin_provenance(&config),
auth.as_ref(),
codex_apps_auth_manager,
elicitation_reviewer,
elicitation_lifecycle,
elicitation_router,
publication_gate,
)
.await
}
} }
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+201 -40
View File
@@ -1,49 +1,211 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use crate::runtime::McpRuntimeContext;
use codex_api::SharedAuthProvider;
use codex_config::AppToolApproval; use codex_config::AppToolApproval;
use codex_config::McpServerConfig; use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig; use codex_config::McpServerTransportConfig;
use codex_config::types::AuthKeyringBackendKind;
/// The runtime launch strategy for an effective MCP server. use codex_config::types::OAuthCredentialsStoreMode;
#[derive(Debug, Clone)] use codex_connectors::ConnectorRuntimeContextKey;
pub(crate) enum McpServerLaunch { use codex_exec_server::Environment;
Configured(Box<McpServerConfig>), use codex_login::CodexAuth;
} use codex_rmcp_client::StoredOAuthTokens;
use codex_rmcp_client::stored_oauth_credentials;
use rmcp::model::ElicitationCapability;
use tracing::warn;
/// MCP server after runtime additions have been applied. /// MCP server after runtime additions have been applied.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EffectiveMcpServer { pub struct EffectiveMcpServer {
launch: McpServerLaunch, config: McpServerConfig,
} }
impl EffectiveMcpServer { impl EffectiveMcpServer {
pub fn configured(config: McpServerConfig) -> Self { pub fn configured(config: McpServerConfig) -> Self {
Self { Self { config }
launch: McpServerLaunch::Configured(Box::new(config)),
}
} }
pub(crate) fn launch(&self) -> &McpServerLaunch { pub fn config(&self) -> &McpServerConfig {
&self.launch &self.config
}
pub fn configured_config(&self) -> Option<&McpServerConfig> {
match &self.launch {
McpServerLaunch::Configured(config) => Some(config.as_ref()),
}
} }
pub fn enabled(&self) -> bool { pub fn enabled(&self) -> bool {
match &self.launch { self.config.enabled
McpServerLaunch::Configured(config) => config.enabled,
}
} }
pub fn required(&self) -> bool { pub fn required(&self) -> bool {
match &self.launch { self.config.required
McpServerLaunch::Configured(config) => config.required, }
}
/// Inputs that determine the identity of a live MCP connection.
///
/// Tool policy and presentation metadata intentionally do not appear here:
/// those belong to a publication and can change without reconnecting.
#[derive(Clone)]
pub(crate) struct McpServerConnectionIdentity {
transport: McpServerTransportConfig,
environment_id: String,
oauth_store: Option<(OAuthCredentialsStoreMode, AuthKeyringBackendKind)>,
oauth_credentials: Result<Option<StoredOAuthTokens>, String>,
resolved_environment: Result<Option<Arc<Environment>>, String>,
local_stdio_fallback_cwd: Option<PathBuf>,
referenced_environment_variables: Vec<(String, Option<OsString>)>,
runtime_auth: Option<CodexAuth>,
runtime_auth_token: Option<String>,
codex_apps_cache_identity: Option<(PathBuf, ConnectorRuntimeContextKey)>,
client_elicitation_capability: ElicitationCapability,
supports_openai_form_elicitation: bool,
}
impl McpServerConnectionIdentity {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
server_name: &str,
server: &EffectiveMcpServer,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
resolved_environment: &Result<Option<Arc<Environment>>, String>,
runtime_context: &McpRuntimeContext,
runtime_auth_provider: Option<&SharedAuthProvider>,
auth: Option<&CodexAuth>,
codex_apps_cache_identity: Option<(PathBuf, ConnectorRuntimeContextKey)>,
client_elicitation_capability: ElicitationCapability,
supports_openai_form_elicitation: bool,
) -> Self {
let config = server.config();
let stored_oauth_url = if runtime_auth_provider.is_none() {
match &config.transport {
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var: None,
..
} => Some(url),
McpServerTransportConfig::StreamableHttp {
bearer_token_env_var: Some(_),
..
}
| McpServerTransportConfig::Stdio { .. } => None,
}
} else {
None
};
let oauth_credentials = stored_oauth_url.map_or(Ok(None), |url| {
stored_oauth_credentials(server_name, url, store_mode, keyring_backend_kind).map_err(
|error| {
warn!(server_name, %error, "failed to read stored MCP OAuth credentials");
error.to_string()
},
)
});
let local_stdio_fallback_cwd = (config.is_local_environment()
&& matches!(
config.transport,
McpServerTransportConfig::Stdio { cwd: None, .. }
))
.then(|| runtime_context.local_stdio_fallback_cwd());
let referenced_environment_variables = referenced_environment_variables(config);
let runtime_auth = runtime_auth_provider.and(auth).cloned();
let runtime_auth_token = runtime_auth.as_ref().and_then(|auth| auth.get_token().ok());
Self {
transport: config.transport.clone(),
environment_id: config.environment_id.clone(),
oauth_store: stored_oauth_url
.is_some()
.then_some((store_mode, keyring_backend_kind)),
oauth_credentials,
resolved_environment: resolved_environment.clone(),
local_stdio_fallback_cwd,
referenced_environment_variables,
runtime_auth,
runtime_auth_token,
codex_apps_cache_identity,
client_elicitation_capability,
supports_openai_form_elicitation,
} }
} }
pub(crate) fn has_same_connection_config(&self, other: &Self) -> bool {
let same_runtime_auth = match (&self.runtime_auth, &other.runtime_auth) {
(Some(CodexAuth::AgentIdentity(left)), Some(CodexAuth::AgentIdentity(right))) => {
left.record() == right.record()
}
(Some(left), Some(right)) => {
left == right
&& left.get_account_id() == right.get_account_id()
&& left.get_chatgpt_user_id() == right.get_chatgpt_user_id()
&& left.is_fedramp_account() == right.is_fedramp_account()
}
(None, None) => true,
(Some(_), None) | (None, Some(_)) => false,
};
self.transport == other.transport
&& self.environment_id == other.environment_id
&& self.oauth_store == other.oauth_store
&& same_resolved_environment(&self.resolved_environment, &other.resolved_environment)
&& self.local_stdio_fallback_cwd == other.local_stdio_fallback_cwd
&& self.referenced_environment_variables == other.referenced_environment_variables
&& same_runtime_auth
&& self.runtime_auth_token == other.runtime_auth_token
&& self.codex_apps_cache_identity == other.codex_apps_cache_identity
&& self.client_elicitation_capability == other.client_elicitation_capability
&& self.supports_openai_form_elicitation == other.supports_openai_form_elicitation
}
pub(crate) fn oauth_credentials(&self) -> Result<&Option<StoredOAuthTokens>, &String> {
self.oauth_credentials.as_ref()
}
}
impl PartialEq for McpServerConnectionIdentity {
fn eq(&self, other: &Self) -> bool {
self.has_same_connection_config(other) && self.oauth_credentials == other.oauth_credentials
}
}
fn same_resolved_environment(
left: &Result<Option<Arc<Environment>>, String>,
right: &Result<Option<Arc<Environment>>, String>,
) -> bool {
match (left, right) {
(Ok(Some(left)), Ok(Some(right))) => Arc::ptr_eq(left, right),
(Ok(None), Ok(None)) => true,
(Err(left), Err(right)) => left == right,
(Ok(_), Ok(_)) | (Ok(_), Err(_)) | (Err(_), Ok(_)) => false,
}
}
fn referenced_environment_variables(config: &McpServerConfig) -> Vec<(String, Option<OsString>)> {
let mut names = match &config.transport {
McpServerTransportConfig::Stdio { env_vars, .. } => env_vars
.iter()
.filter(|env_var| !env_var.is_remote_source())
.map(|env_var| env_var.name().to_string())
.collect::<Vec<_>>(),
McpServerTransportConfig::StreamableHttp {
bearer_token_env_var,
env_http_headers,
..
} => bearer_token_env_var
.iter()
.chain(env_http_headers.iter().flat_map(|headers| headers.values()))
.cloned()
.collect(),
};
names.sort();
names.dedup();
names
.into_iter()
.map(|name| {
let value = std::env::var_os(&name);
(name, value)
})
.collect()
} }
/// Transport origin retained for metrics and diagnostics after server launch. /// Transport origin retained for metrics and diagnostics after server launch.
@@ -95,23 +257,22 @@ impl McpServerMetadata {
impl From<&EffectiveMcpServer> for McpServerMetadata { impl From<&EffectiveMcpServer> for McpServerMetadata {
fn from(server: &EffectiveMcpServer) -> Self { fn from(server: &EffectiveMcpServer) -> Self {
match server.launch() { let config = server.config();
McpServerLaunch::Configured(config) => Self { Self {
environment_id: config.environment_id.clone(), environment_id: config.environment_id.clone(),
pollutes_memory: true, pollutes_memory: true,
origin: McpServerOrigin::from_transport(&config.transport), origin: McpServerOrigin::from_transport(&config.transport),
supports_parallel_tool_calls: config.supports_parallel_tool_calls, supports_parallel_tool_calls: config.supports_parallel_tool_calls,
default_tools_approval_mode: config.default_tools_approval_mode, default_tools_approval_mode: config.default_tools_approval_mode,
tool_approval_modes: config tool_approval_modes: config
.tools .tools
.iter() .iter()
.filter_map(|(name, config)| { .filter_map(|(name, config)| {
config config
.approval_mode .approval_mode
.map(|approval_mode| (name.clone(), approval_mode)) .map(|approval_mode| (name.clone(), approval_mode))
}) })
.collect(), .collect(),
},
} }
} }
} }
+2 -2
View File
@@ -298,8 +298,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
true true
} else if tools.is_empty() { } else if tools.is_empty() {
let timeout = cfg let timeout = cfg
.configured_config() .config()
.and_then(|config| config.startup_timeout_sec) .startup_timeout_sec
.unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS); .unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS);
let ready = mcp_runtime let ready = mcp_runtime
.latest_wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) .latest_wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
+10 -3
View File
@@ -327,12 +327,19 @@ impl Session {
} }
pub(crate) fn mcp_elicitation_reviewer(self: &Arc<Self>) -> ElicitationReviewerHandle { pub(crate) fn mcp_elicitation_reviewer(self: &Arc<Self>) -> ElicitationReviewerHandle {
Arc::new(GuardianMcpElicitationReviewer::new(self)) Arc::clone(
self.mcp_elicitation_reviewer_handle
.get_or_init(|| Arc::new(GuardianMcpElicitationReviewer::new(self))),
)
} }
pub(crate) fn mcp_elicitation_lifecycle(&self) -> codex_mcp::ElicitationLifecycle { pub(crate) fn mcp_elicitation_lifecycle(&self) -> codex_mcp::ElicitationLifecycle {
let elicitations = self.services.elicitations.clone(); self.mcp_elicitation_lifecycle_handle
codex_mcp::ElicitationLifecycle::new(move || elicitations.register()) .get_or_init(|| {
let elicitations = self.services.elicitations.clone();
codex_mcp::ElicitationLifecycle::new(move || elicitations.register())
})
.clone()
} }
#[expect( #[expect(
+4
View File
@@ -41,6 +41,8 @@ pub(crate) struct Session {
pub(super) mcp_refresh_pending: std::sync::atomic::AtomicBool, pub(super) mcp_refresh_pending: std::sync::atomic::AtomicBool,
/// Serializes runtime refreshes without blocking calls that own a snapshot. /// Serializes runtime refreshes without blocking calls that own a snapshot.
pub(super) mcp_refresh_lock: Semaphore, pub(super) mcp_refresh_lock: Semaphore,
pub(super) mcp_elicitation_reviewer_handle: OnceLock<codex_mcp::ElicitationReviewerHandle>,
pub(super) mcp_elicitation_lifecycle_handle: OnceLock<codex_mcp::ElicitationLifecycle>,
pub(crate) conversation: Arc<RealtimeConversationManager>, pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>, pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(crate) input_queue: InputQueue, pub(crate) input_queue: InputQueue,
@@ -1151,6 +1153,8 @@ impl Session {
multi_agent_version, multi_agent_version,
mcp_refresh_pending: std::sync::atomic::AtomicBool::new(false), mcp_refresh_pending: std::sync::atomic::AtomicBool::new(false),
mcp_refresh_lock: Semaphore::new(/*permits*/ 1), mcp_refresh_lock: Semaphore::new(/*permits*/ 1),
mcp_elicitation_reviewer_handle: OnceLock::new(),
mcp_elicitation_lifecycle_handle: OnceLock::new(),
conversation: Arc::new(RealtimeConversationManager::new()), conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None), active_turn: Mutex::new(None),
input_queue: InputQueue::new(), input_queue: InputQueue::new(),
+16
View File
@@ -5542,6 +5542,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()),
mcp_refresh_pending: std::sync::atomic::AtomicBool::new(true), mcp_refresh_pending: std::sync::atomic::AtomicBool::new(true),
mcp_refresh_lock: Semaphore::new(/*permits*/ 1), mcp_refresh_lock: Semaphore::new(/*permits*/ 1),
mcp_elicitation_reviewer_handle: OnceLock::new(),
mcp_elicitation_lifecycle_handle: OnceLock::new(),
conversation: Arc::new(RealtimeConversationManager::new()), conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None), active_turn: Mutex::new(None),
input_queue: super::input_queue::InputQueue::new(), input_queue: super::input_queue::InputQueue::new(),
@@ -7703,6 +7705,8 @@ where
multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()), multi_agent_version: OnceLock::from(config.multi_agent_version_from_features()),
mcp_refresh_pending: std::sync::atomic::AtomicBool::new(true), mcp_refresh_pending: std::sync::atomic::AtomicBool::new(true),
mcp_refresh_lock: Semaphore::new(/*permits*/ 1), mcp_refresh_lock: Semaphore::new(/*permits*/ 1),
mcp_elicitation_reviewer_handle: OnceLock::new(),
mcp_elicitation_lifecycle_handle: OnceLock::new(),
conversation: Arc::new(RealtimeConversationManager::new()), conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None), active_turn: Mutex::new(None),
input_queue: super::input_queue::InputQueue::new(), input_queue: super::input_queue::InputQueue::new(),
@@ -7883,6 +7887,18 @@ async fn cancelled_mcp_refresh_remains_pending() {
); );
} }
#[tokio::test]
async fn mcp_elicitation_reviewer_is_reused_across_runtime_refreshes() {
let (session, _turn_context) = make_session_and_context().await;
let session = Arc::new(session);
let previous = session.mcp_elicitation_reviewer();
session.mark_mcp_runtime_dirty();
session.refresh_mcp_if_dirty().await;
assert!(Arc::ptr_eq(&previous, &session.mcp_elicitation_reviewer()));
}
struct PendingNoiseConnectProvider; struct PendingNoiseConnectProvider;
impl codex_exec_server::NoiseRendezvousConnectProvider for PendingNoiseConnectProvider { impl codex_exec_server::NoiseRendezvousConnectProvider for PendingNoiseConnectProvider {
@@ -381,6 +381,9 @@ async fn later_follow_up_uses_background_recovered_apps_after_mid_thread_startup
tokio::fs::remove_dir_all(test.codex_home_path().join("cache/codex_apps_tools")).await?; tokio::fs::remove_dir_all(test.codex_home_path().join("cache/codex_apps_tools")).await?;
startup_control.fail_next_initialize_attempts(/*attempts*/ 1); startup_control.fail_next_initialize_attempts(/*attempts*/ 1);
test.codex
.set_openai_form_elicitation_support(/*supported*/ true)
.await?;
test.codex.submit(Op::RefreshMcpServers).await?; test.codex.submit(Op::RefreshMcpServers).await?;
test.submit_turn("use Calendar after transient Apps startup failures") test.submit_turn("use Calendar after transient Apps startup failures")
.await?; .await?;
+4 -4
View File
@@ -33,8 +33,8 @@ async fn contributes_hosted_plugin_runtime_without_an_executor() -> TestResult {
let servers = manager.effective_servers(&config, Some(&auth)).await; let servers = manager.effective_servers(&config, Some(&auth)).await;
let server = servers let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME) .get(CODEX_APPS_MCP_SERVER_NAME)
.and_then(|server| server.configured_config()) .ok_or("hosted plugin runtime should be contributed as a configured server")?
.ok_or("hosted plugin runtime should be contributed as a configured server")?; .config();
let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else { let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else {
panic!("hosted plugin runtime should use streamable HTTP"); panic!("hosted plugin runtime should use streamable HTTP");
}; };
@@ -94,8 +94,8 @@ async fn default_fallback_overwrites_reserved_config_without_an_extension() -> T
let servers = manager.effective_servers(&config, Some(&auth)).await; let servers = manager.effective_servers(&config, Some(&auth)).await;
let server = servers let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME) .get(CODEX_APPS_MCP_SERVER_NAME)
.and_then(|server| server.configured_config()) .ok_or("default Apps MCP should be present")?
.ok_or("default Apps MCP should be present")?; .config();
let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else { let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else {
panic!("default Apps MCP should use streamable HTTP"); panic!("default Apps MCP should use streamable HTTP");
}; };
+1
View File
@@ -28,6 +28,7 @@ pub use oauth::StoredOAuthTokens;
pub use oauth::WrappedOAuthTokenResponse; pub use oauth::WrappedOAuthTokenResponse;
pub use oauth::delete_oauth_tokens; pub use oauth::delete_oauth_tokens;
pub use oauth::save_oauth_tokens; pub use oauth::save_oauth_tokens;
pub use oauth::stored_oauth_credentials;
pub use perform_oauth_login::OAuthProviderError; pub use perform_oauth_login::OAuthProviderError;
pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::OauthLoginHandle;
pub use perform_oauth_login::perform_oauth_login; pub use perform_oauth_login::perform_oauth_login;
+74
View File
@@ -127,6 +127,34 @@ pub(crate) fn oauth_token_status(
}) })
} }
/// Returns stored OAuth credentials without their derived expiration interval.
pub fn stored_oauth_credentials(
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<Option<StoredOAuthTokens>> {
let Some(resolved) = resolve_oauth_tokens_from_store_policy(
&DefaultKeyringStore,
server_name,
url,
store_mode,
keyring_backend_kind,
)?
else {
return Ok(None);
};
Ok(normalized_oauth_credentials(Some(&resolved.tokens)))
}
fn normalized_oauth_credentials(tokens: Option<&StoredOAuthTokens>) -> Option<StoredOAuthTokens> {
tokens.map(|tokens| {
let mut tokens = tokens.clone();
tokens.token_response.0.set_expires_in(None);
tokens
})
}
fn oauth_tokens_are_usable(tokens: &StoredOAuthTokens) -> bool { fn oauth_tokens_are_usable(tokens: &StoredOAuthTokens) -> bool {
if tokens.client_id.trim().is_empty() { if tokens.client_id.trim().is_empty() {
return false; return false;
@@ -510,6 +538,11 @@ impl OAuthPersistor {
} }
} }
pub(crate) async fn stored_credentials(&self) -> Option<StoredOAuthTokens> {
let credentials = self.inner.last_credentials.lock().await;
normalized_oauth_credentials(credentials.as_ref())
}
/// Persists RMCP-managed credential changes back to this client's resolved authority. /// Persists RMCP-managed credential changes back to this client's resolved authority.
#[expect( #[expect(
clippy::await_holding_invalid_type, clippy::await_holding_invalid_type,
@@ -837,6 +870,47 @@ mod tests {
use super::test_support::TempCodexHome; use super::test_support::TempCodexHome;
#[test]
fn stored_oauth_credentials_ignore_derived_expiration_and_track_token_changes() -> Result<()> {
let _env = TempCodexHome::new();
let mut tokens = sample_tokens();
let credentials = super::normalized_oauth_credentials(Some(&tokens));
tokens
.token_response
.0
.set_expires_in(Some(&Duration::from_secs(1)));
assert_eq!(
credentials,
super::normalized_oauth_credentials(Some(&tokens))
);
super::save_oauth_tokens_to_file(&tokens)?;
assert_eq!(
credentials,
super::stored_oauth_credentials(
&tokens.server_name,
&tokens.url,
OAuthCredentialsStoreMode::File,
AuthKeyringBackendKind::Direct,
)?
);
tokens
.token_response
.0
.set_access_token(AccessToken::new("new-access-token".to_string()));
super::save_oauth_tokens_to_file(&tokens)?;
assert_ne!(
credentials,
super::stored_oauth_credentials(
&tokens.server_name,
&tokens.url,
OAuthCredentialsStoreMode::File,
AuthKeyringBackendKind::Direct,
)?
);
Ok(())
}
#[test] #[test]
fn resolve_oauth_tokens_from_store_policy_uses_keyring_when_available() -> Result<()> { fn resolve_oauth_tokens_from_store_policy_uses_keyring_when_available() -> Result<()> {
let _env = TempCodexHome::new(); let _env = TempCodexHome::new();
@@ -5,6 +5,7 @@ use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use codex_config::types::AuthKeyringBackendKind; use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
use keyring::Error as KeyringError; use keyring::Error as KeyringError;
use oauth2::AccessToken; use oauth2::AccessToken;
use oauth2::TokenResponse; use oauth2::TokenResponse;
@@ -39,6 +40,7 @@ use crate::oauth::compute_store_key;
use crate::oauth::load_oauth_tokens_from_file; use crate::oauth::load_oauth_tokens_from_file;
use crate::oauth::refresh_lock::RefreshCredentialLock; use crate::oauth::refresh_lock::RefreshCredentialLock;
use crate::oauth::save_oauth_tokens_to_file; use crate::oauth::save_oauth_tokens_to_file;
use crate::oauth::stored_oauth_credentials;
use crate::startup_error::is_authentication_required_error; use crate::startup_error::is_authentication_required_error;
const REFRESH_LOCK_CONTENTION_EVENT_TARGET: &str = const REFRESH_LOCK_CONTENTION_EVENT_TARGET: &str =
@@ -114,6 +116,7 @@ async fn concurrent_refreshes_call_provider_once_and_carry_omitted_fields() -> R
let first = persistor_for(&initial).await?; let first = persistor_for(&initial).await?;
let second = persistor_for(&initial).await?; let second = persistor_for(&initial).await?;
let initial_credentials = first.stored_credentials().await;
let first_task = tokio::spawn({ let first_task = tokio::spawn({
let first = first.clone(); let first = first.clone();
async move { first.refresh_if_needed().await } async move { first.refresh_if_needed().await }
@@ -135,6 +138,15 @@ async fn concurrent_refreshes_call_provider_once_and_carry_omitted_fields() -> R
first.persist_if_needed().await?; first.persist_if_needed().await?;
let stored = load_oauth_tokens_from_file(&initial.server_name, &initial.url)? let stored = load_oauth_tokens_from_file(&initial.server_name, &initial.url)?
.expect("refreshed credentials should be stored"); .expect("refreshed credentials should be stored");
let live_credentials = first.stored_credentials().await;
let disk_credentials = stored_oauth_credentials(
&initial.server_name,
&initial.url,
OAuthCredentialsStoreMode::File,
AuthKeyringBackendKind::Direct,
)?;
assert_eq!(live_credentials, disk_credentials);
assert_ne!(live_credentials, initial_credentials);
let mut expected_response = initial.token_response.0.clone(); let mut expected_response = initial.token_response.0.clone();
expected_response.set_access_token(AccessToken::new("refreshed-access-token".to_string())); expected_response.set_access_token(AccessToken::new("refreshed-access-token".to_string()));
// File loads derive `expires_in` from stable `expires_at`, so it may tick down before this // File loads derive `expires_in` from stable `expires_at`, so it may tick down before this
+6
View File
@@ -733,6 +733,12 @@ impl RmcpClient {
} }
} }
/// Returns `None` when this client does not manage stored OAuth credentials.
pub async fn managed_oauth_credentials(&self) -> Option<Option<StoredOAuthTokens>> {
let persistor = self.oauth_persistor().await?;
Some(persistor.stored_credentials().await)
}
/// Stop the MCP transport and any stdio server process owned by this client. /// Stop the MCP transport and any stdio server process owned by this client.
pub async fn shutdown(&self) { pub async fn shutdown(&self) {
let previous_state = { let previous_state = {