| |
| |
| |
| |
| |
| |
|
|
| use std::collections::HashMap; |
| use std::collections::HashSet; |
| use std::path::PathBuf; |
| use std::sync::Arc; |
| use std::sync::Mutex; |
| use std::sync::Weak; |
| use std::sync::atomic::AtomicBool; |
| use std::sync::atomic::Ordering; |
| use std::time::Duration; |
|
|
| use arc_swap::ArcSwap; |
| use async_channel::Sender; |
| use codex_config::types::McpServerDisabledReason; |
| use codex_connectors::ConnectorRuntimeContextKey; |
| use codex_connectors::ConnectorRuntimeManager; |
| use codex_exec_server::Environment; |
| use codex_exec_server::EnvironmentManager; |
| use codex_exec_server::HttpClient; |
| use codex_exec_server::RouteAwareHttpClient; |
| use codex_login::AuthManager; |
| use codex_login::CodexAuth; |
| use codex_protocol::ThreadId; |
| use codex_protocol::capabilities::SelectedCapabilityRoot; |
| use codex_protocol::mcp::CallToolResult; |
| use codex_protocol::mcp::ClientMcpExtensions; |
| use codex_protocol::mcp::McpResourceOriginCheckpoint; |
| use codex_protocol::models::PermissionProfile; |
| use codex_protocol::protocol::Event; |
| use codex_protocol::protocol::EventMsg; |
| use codex_rmcp_client::ElicitationResponse; |
| use codex_rmcp_client::with_http_headers_helper; |
| use codex_utils_path_uri::PathUri; |
| use rmcp::model::ReadResourceRequestParams; |
| use rmcp::model::ReadResourceResult; |
| use rmcp::model::RequestId; |
| use serde::Deserialize; |
| use serde::Serialize; |
| use tokio::sync::watch; |
| use tokio_util::sync::CancellationToken; |
|
|
| use crate::McpConfig; |
| use crate::binding::McpBinding; |
| use crate::client_tool_catalog::CodexAppsToolSnapshot; |
| use crate::connection_manager::BindingCatalogRevision; |
| use crate::connection_manager::McpConnectionSet; |
| use crate::elicitation::ElicitationLifecycle; |
| use crate::elicitation::ElicitationRequestRouter; |
| use crate::elicitation::ElicitationReviewerHandle; |
| use crate::event_stream::McpEventStreamOpener; |
| use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; |
| use crate::resource_origin::ResourceOrigins; |
| use crate::server::EffectiveMcpServer; |
| use crate::tool_catalog_cache::McpToolCatalogCache; |
| use crate::tools::ToolInfo; |
|
|
| |
| #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| pub enum McpStartupPolicy { |
| |
| Eager, |
| |
| LazyWhenCached, |
| } |
|
|
| |
| pub struct McpRuntimeInput { |
| pub startup_policy: McpStartupPolicy, |
| pub config: Arc<McpConfig>, |
| pub plugins_available: bool, |
| pub ready_selected_capability_roots: Vec<SelectedCapabilityRoot>, |
| pub mcp_servers: HashMap<String, EffectiveMcpServer>, |
| pub submit_id: String, |
| pub tx_event: Option<Sender<Event>>, |
| pub startup_cancellation_token: CancellationToken, |
| pub runtime_context: McpRuntimeContext, |
| pub codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>, |
| pub tool_catalog_cache: McpToolCatalogCache, |
| pub codex_apps_tools_cache_key: ConnectorRuntimeContextKey, |
| pub client_mcp_extensions: ClientMcpExtensions, |
| pub auth: Option<CodexAuth>, |
| pub auth_manager: Option<Arc<AuthManager>>, |
| pub elicitation_reviewer: Option<ElicitationReviewerHandle>, |
| pub elicitation_lifecycle: Option<ElicitationLifecycle>, |
| } |
|
|
| |
| |
| |
| |
| pub struct McpRuntime { |
| current: ArcSwap<PublishedMcpRuntime>, |
| event_stream_cancellation: Mutex<EventStreamCancellation>, |
| reconnect_pending: AtomicBool, |
| elicitation_router: ElicitationRequestRouter, |
| resource_origins: Mutex<ResourceOrigins>, |
| } |
|
|
| struct EventStreamCancellation { |
| event_server_available: bool, |
| cancel_event_streams_on_server_removal: watch::Sender<()>, |
| retained_subscription_cancellation: Option<watch::Sender<()>>, |
| } |
|
|
| struct PublishedMcpRuntime { |
| connections: Arc<McpConnectionSet>, |
| config: Option<Arc<McpConfig>>, |
| auth: Option<CodexAuth>, |
| auth_token: Option<String>, |
| plugins_available: bool, |
| ready_selected_capability_roots: Vec<SelectedCapabilityRoot>, |
| selected_environments: HashMap<String, Arc<Environment>>, |
| cached_binding: Mutex<Option<CachedMcpBinding>>, |
| } |
|
|
| fn ensure_host_owned_apps_registration( |
| current: &PublishedMcpRuntime, |
| server: &str, |
| ) -> anyhow::Result<()> { |
| if !current |
| .config |
| .as_ref() |
| .and_then(|config| config.mcp_server_catalog.server(server)) |
| .is_some_and(|registration| { |
| registration |
| .source() |
| .is_host_owned_apps(server, registration.config()) |
| }) |
| { |
| anyhow::bail!("MCP server '{server}' is not registered by the hosted runtime"); |
| } |
| Ok(()) |
| } |
|
|
| struct CachedMcpBinding { |
| catalog_revisions: HashMap<String, BindingCatalogRevision>, |
| |
| binding: Weak<McpBinding>, |
| } |
|
|
| struct McpReconnectGuard<'a> { |
| pending: &'a AtomicBool, |
| claimed: bool, |
| } |
|
|
| impl Drop for McpReconnectGuard<'_> { |
| fn drop(&mut self) { |
| if self.claimed { |
| self.pending.store(true, Ordering::Release); |
| } |
| } |
| } |
|
|
| #[derive(Clone)] |
| pub(crate) struct McpPublicationGate { |
| published: Option<watch::Receiver<bool>>, |
| } |
|
|
| impl McpPublicationGate { |
| fn pending() -> (watch::Sender<bool>, Self) { |
| let (publish, published) = watch::channel(false); |
| ( |
| publish, |
| Self { |
| published: Some(published), |
| }, |
| ) |
| } |
|
|
| pub(crate) fn already_published() -> Self { |
| Self { published: None } |
| } |
|
|
| pub(crate) async fn wait(mut self) -> bool { |
| let Some(published) = self.published.as_mut() else { |
| return true; |
| }; |
| loop { |
| if *published.borrow() { |
| return true; |
| } |
| if published.changed().await.is_err() { |
| return false; |
| } |
| } |
| } |
| } |
|
|
| impl McpRuntime { |
| |
| |
| |
| |
| pub fn empty(prefix_mcp_tool_names: bool) -> Self { |
| Self { |
| current: ArcSwap::from_pointee(PublishedMcpRuntime { |
| connections: Arc::new(McpConnectionSet::empty(prefix_mcp_tool_names)), |
| config: None, |
| auth: None, |
| auth_token: None, |
| plugins_available: false, |
| ready_selected_capability_roots: Vec::new(), |
| selected_environments: HashMap::new(), |
| cached_binding: Mutex::new(None), |
| }), |
| event_stream_cancellation: Mutex::new(EventStreamCancellation { |
| event_server_available: false, |
| cancel_event_streams_on_server_removal: watch::channel(()).0, |
| retained_subscription_cancellation: None, |
| }), |
| reconnect_pending: AtomicBool::new(false), |
| elicitation_router: ElicitationRequestRouter::default(), |
| resource_origins: Mutex::default(), |
| } |
| } |
|
|
| |
| pub fn observe_event(&self, event: &EventMsg) { |
| if !matches!( |
| event, |
| EventMsg::TurnStarted(_) |
| | EventMsg::ItemCompleted(_) |
| | EventMsg::McpToolCallEnd(_) |
| | EventMsg::ThreadRolledBack(_) |
| ) { |
| return; |
| } |
| self.resource_origins |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| .observe(event); |
| } |
|
|
| |
| pub fn resource_origin_checkpoint(&self) -> Option<McpResourceOriginCheckpoint> { |
| self.resource_origins |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| .checkpoint() |
| } |
|
|
| |
| pub fn restore_resource_origin_checkpoint(&self, checkpoint: &McpResourceOriginCheckpoint) { |
| self.resource_origins |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| .restore_checkpoint(checkpoint); |
| } |
|
|
| |
| pub async fn read_resource_for_call( |
| &self, |
| thread_id: ThreadId, |
| call_id: &str, |
| uri: &str, |
| ) -> anyhow::Result<ReadResourceResult> { |
| let origin = self |
| .resource_origins |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner) |
| .find(call_id)?; |
| let binding = self |
| .current_binding_for_call(crate::CODEX_APPS_MCP_SERVER_NAME) |
| .await |
| .ok_or_else(|| anyhow::anyhow!("codex_apps MCP server is unavailable"))?; |
|
|
| origin.read(&binding, thread_id, uri).await |
| } |
|
|
| pub async fn new(input: McpRuntimeInput) -> Self { |
| let runtime = Self::empty(input.config.prefix_mcp_tool_names); |
| runtime.replace(input).await; |
| runtime |
| } |
|
|
| |
| pub async fn replace(&self, input: McpRuntimeInput) { |
| let current = self.current.load_full(); |
| let mut reconnect = McpReconnectGuard { |
| pending: &self.reconnect_pending, |
| claimed: self.reconnect_pending.swap(false, Ordering::AcqRel), |
| }; |
| self.publish( |
| input, |
| (!reconnect.claimed).then_some(current.connections.as_ref()), |
| ) |
| .await; |
| reconnect.claimed = false; |
| } |
|
|
| |
| pub async fn replace_fresh(&self, input: McpRuntimeInput) -> anyhow::Result<Vec<ToolInfo>> { |
| self.publish(input, None).await; |
| self.latest_hard_refresh_codex_apps_tools_cache().await |
| } |
|
|
| async fn publish(&self, input: McpRuntimeInput, previous: Option<&McpConnectionSet>) { |
| let (publish, publication_gate) = McpPublicationGate::pending(); |
| let config = Arc::clone(&input.config); |
| let auth = input.auth.clone(); |
| let auth_token = auth.as_ref().and_then(|auth| auth.get_token().ok()); |
| let plugins_available = input.plugins_available; |
| let ready_selected_capability_roots = input.ready_selected_capability_roots.clone(); |
| let selected_environments = input.runtime_context.selected_environments.clone(); |
| let connections = Arc::new( |
| McpConnectionSet::new( |
| previous, |
| publication_gate, |
| input, |
| self.elicitation_router.clone(), |
| ) |
| .await, |
| ); |
| let hosted_event_server_retained = connections.contains_server(CODEX_APPS_MCP_SERVER_NAME) |
| && config |
| .mcp_server_catalog |
| .server(CODEX_APPS_MCP_SERVER_NAME) |
| .is_some_and(|registration| { |
| registration |
| .source() |
| .is_host_owned_apps(CODEX_APPS_MCP_SERVER_NAME, registration.config()) |
| }); |
| let mut cancellation = self |
| .event_stream_cancellation |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner); |
| self.current.store(Arc::new(PublishedMcpRuntime { |
| connections, |
| config: Some(config), |
| auth, |
| auth_token, |
| plugins_available, |
| ready_selected_capability_roots, |
| selected_environments, |
| cached_binding: Mutex::new(None), |
| })); |
| let _ = publish.send(true); |
| cancellation.event_server_available = hosted_event_server_retained; |
| if !hosted_event_server_retained { |
| cancellation |
| .cancel_event_streams_on_server_removal |
| .send_replace(()); |
| if let Some(retained) = &cancellation.retained_subscription_cancellation { |
| retained.send_replace(()); |
| } |
| } |
| } |
|
|
| |
| pub fn reconnect_on_next_refresh(&self) { |
| self.reconnect_pending.store(true, Ordering::Release); |
| } |
|
|
| |
| pub async fn current_binding(&self) -> Option<Arc<McpBinding>> { |
| self.current_binding_with_requirements(&[], &HashSet::new()) |
| .await |
| } |
|
|
| |
| |
| pub async fn current_binding_with_requirements( |
| &self, |
| required_servers: &[String], |
| required_plugins: &HashSet<String>, |
| ) -> Option<Arc<McpBinding>> { |
| Self::binding_from_published_runtime( |
| self.current.load_full(), |
| required_servers, |
| required_plugins, |
| ) |
| .await |
| } |
|
|
| async fn binding_from_published_runtime( |
| current: Arc<PublishedMcpRuntime>, |
| required_servers: &[String], |
| required_plugins: &HashSet<String>, |
| ) -> Option<Arc<McpBinding>> { |
| let config = Arc::clone(current.config.as_ref()?); |
| let stable_catalog_revisions = current |
| .connections |
| .stable_catalog_revisions(required_servers, required_plugins) |
| .await; |
| if let Some(catalog_revisions) = &stable_catalog_revisions { |
| let cached = current |
| .cached_binding |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner); |
| if let Some(cached) = cached.as_ref() |
| && &cached.catalog_revisions == catalog_revisions |
| && let Some(binding) = cached.binding.upgrade() |
| { |
| return Some(binding); |
| } |
| } |
|
|
| let binding = Arc::new( |
| current |
| .connections |
| .capture_binding_with_metadata( |
| config, |
| current.plugins_available, |
| required_servers, |
| required_plugins, |
| ) |
| .await, |
| ); |
| if let Some(catalog_revisions) = stable_catalog_revisions |
| && current |
| .connections |
| .stable_catalog_revisions(required_servers, required_plugins) |
| .await |
| .as_ref() |
| == Some(&catalog_revisions) |
| { |
| let mut cached = current |
| .cached_binding |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner); |
| if let Some(cached) = cached.as_ref() |
| && cached.catalog_revisions == catalog_revisions |
| && let Some(binding) = cached.binding.upgrade() |
| { |
| return Some(binding); |
| } |
| *cached = Some(CachedMcpBinding { |
| catalog_revisions, |
| binding: Arc::downgrade(&binding), |
| }); |
| } |
| Some(binding) |
| } |
|
|
| |
| pub fn current_auth_matches(&self, auth: Option<&CodexAuth>) -> bool { |
| let current = self.current.load(); |
| match (current.auth.as_ref(), auth) { |
| (Some(previous), Some(latest)) => { |
| previous == latest |
| && previous.get_account_id() == latest.get_account_id() |
| && previous.get_chatgpt_user_id() == latest.get_chatgpt_user_id() |
| && previous.is_fedramp_account() == latest.is_fedramp_account() |
| && current.auth_token == latest.get_token().ok() |
| } |
| (None, None) => true, |
| (Some(_), None) | (None, Some(_)) => false, |
| } |
| } |
|
|
| |
| pub async fn updated_oauth_credentials_after_auth_failure(&self) -> Vec<String> { |
| let current = self.current.load_full(); |
| let Some(config) = current.config.as_ref() else { |
| return Vec::new(); |
| }; |
| current |
| .connections |
| .updated_oauth_credentials_after_auth_failure(config) |
| .await |
| } |
|
|
| |
| pub async fn has_authentication_failed_servers(&self, server_names: &[String]) -> bool { |
| self.current |
| .load_full() |
| .connections |
| .authentication_failed_servers() |
| .await |
| .into_iter() |
| .any(|server_name| server_names.contains(&server_name)) |
| } |
|
|
| |
| pub async fn wait_for_server_startup(&self, server: &str) { |
| self.current |
| .load_full() |
| .connections |
| .wait_for_server_startup(server) |
| .await; |
| } |
|
|
| |
| pub async fn current_binding_for_call(&self, server: &str) -> Option<Arc<McpBinding>> { |
| let current = self.current.load_full(); |
| current.config.as_ref()?; |
| if !current.connections.wait_for_server_startup(server).await { |
| return None; |
| } |
| Self::binding_from_published_runtime( |
| current, |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| } |
|
|
| |
| pub fn current_config(&self) -> Option<Arc<McpConfig>> { |
| self.current.load().config.clone() |
| } |
|
|
| pub fn current_ready_selected_capability_roots(&self) -> Vec<SelectedCapabilityRoot> { |
| self.current.load().ready_selected_capability_roots.clone() |
| } |
|
|
| |
| pub fn current_environments_match( |
| &self, |
| environments: &HashMap<String, Arc<Environment>>, |
| ) -> bool { |
| let current = self.current.load(); |
| current.config.is_some() |
| && current.selected_environments.len() == environments.len() |
| && environments.iter().all(|(id, environment)| { |
| current |
| .selected_environments |
| .get(id) |
| .is_some_and(|published| Arc::ptr_eq(published, environment)) |
| }) |
| } |
|
|
| pub fn elicitations_auto_deny(&self) -> bool { |
| self.elicitation_router.auto_deny() |
| } |
|
|
| pub fn set_elicitations_auto_deny(&self, auto_deny: bool) { |
| self.elicitation_router.set_auto_deny(auto_deny); |
| } |
|
|
| pub fn enable_full_access_form_input(&self) { |
| self.elicitation_router.enable_full_access_form_input(); |
| } |
|
|
| pub async fn resolve_elicitation( |
| &self, |
| server_name: String, |
| id: RequestId, |
| response: ElicitationResponse, |
| ) -> anyhow::Result<()> { |
| self.elicitation_router |
| .resolve(server_name, id, response) |
| .await |
| } |
|
|
| pub async fn latest_hard_refresh_codex_apps_tools_cache( |
| &self, |
| ) -> anyhow::Result<Vec<ToolInfo>> { |
| self.latest_connections() |
| .refresh_codex_apps_tools_for_discovery() |
| .await |
| } |
|
|
| |
| pub async fn refresh_codex_apps_tools(&self) -> anyhow::Result<CodexAppsToolSnapshot> { |
| let current = self.current.load_full(); |
| let config = current |
| .config |
| .as_deref() |
| .ok_or_else(|| anyhow::anyhow!("MCP runtime is not configured"))?; |
| current |
| .connections |
| .refresh_codex_apps_client_catalog(config) |
| .await |
| } |
|
|
| |
| |
| |
| |
| pub async fn latest_list_all_tools(&self) -> Vec<ToolInfo> { |
| self.latest_connections().list_all_tools().await |
| } |
|
|
| #[allow(clippy::too_many_arguments)] |
| pub async fn latest_call_tool( |
| &self, |
| server: &str, |
| tool: &str, |
| environment_id: Option<&str>, |
| arguments: Option<serde_json::Value>, |
| meta: Option<serde_json::Value>, |
| requested_timeout: Option<Duration>, |
| wait_for_server: bool, |
| ) -> anyhow::Result<CallToolResult> { |
| self.latest_connections() |
| .call_tool( |
| server, |
| tool, |
| environment_id, |
| arguments, |
| meta, |
| requested_timeout, |
| wait_for_server, |
| ) |
| .await |
| } |
|
|
| pub async fn latest_read_resource( |
| &self, |
| server: &str, |
| params: ReadResourceRequestParams, |
| ) -> anyhow::Result<ReadResourceResult> { |
| self.latest_connections() |
| .read_resource(server, params) |
| .await |
| } |
|
|
| pub async fn latest_wait_for_server_ready(&self, server: &str, timeout: Duration) -> bool { |
| self.latest_connections() |
| .wait_for_server_ready(server, timeout) |
| .await |
| } |
|
|
| pub async fn validate_required_servers(&self) -> anyhow::Result<()> { |
| self.latest_connections().validate_required_servers().await |
| } |
|
|
| pub fn cancel_startup(&self) { |
| self.current.load().connections.cancel_startup(); |
| } |
|
|
| |
| pub async fn connection_statuses( |
| &self, |
| config: &McpConfig, |
| ) -> std::collections::HashMap<String, codex_protocol::mcp::McpServerConnectionStatus> { |
| let current = self.current.load_full(); |
| let Some(published_config) = current.config.as_ref() else { |
| return HashMap::new(); |
| }; |
| let mut statuses = current.connections.connection_statuses().await; |
| statuses.retain(|name, _| { |
| published_config |
| .mcp_server_catalog |
| .server(name) |
| .is_some_and(|server| config.mcp_server_catalog.server(name) == Some(server)) |
| }); |
| statuses |
| } |
|
|
| pub(crate) fn latest_connections(&self) -> Arc<McpConnectionSet> { |
| Arc::clone(&self.current.load().connections) |
| } |
|
|
| pub(crate) fn latest_host_owned_codex_apps_connections( |
| &self, |
| ) -> anyhow::Result<Arc<McpConnectionSet>> { |
| let current = self.current.load(); |
| ensure_host_owned_apps_registration(¤t, CODEX_APPS_MCP_SERVER_NAME)?; |
| Ok(Arc::clone(¤t.connections)) |
| } |
|
|
| pub(crate) fn latest_connections_for_event_server( |
| &self, |
| server: &str, |
| ) -> anyhow::Result<(Arc<McpConnectionSet>, watch::Receiver<()>)> { |
| let cancellation = self |
| .event_stream_cancellation |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner); |
| let cancel_event_streams_on_server_removal = cancellation |
| .cancel_event_streams_on_server_removal |
| .subscribe(); |
| let current = self.current.load(); |
| if server == CODEX_APPS_MCP_SERVER_NAME { |
| ensure_host_owned_apps_registration(¤t, server)?; |
| } |
| Ok(( |
| Arc::clone(¤t.connections), |
| cancel_event_streams_on_server_removal, |
| )) |
| } |
|
|
| pub(crate) fn event_stream_opener(&self) -> anyhow::Result<McpEventStreamOpener> { |
| let cancellation = self |
| .event_stream_cancellation |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner); |
| let connection = self |
| .current |
| .load() |
| .connections |
| .event_stream_connection |
| .clone() |
| .ok_or_else(|| anyhow::anyhow!("Event subscriptions are unavailable for this task"))?; |
| let cancel_event_streams_on_server_removal = cancellation |
| .retained_subscription_cancellation |
| .as_ref() |
| .unwrap_or(&cancellation.cancel_event_streams_on_server_removal) |
| .clone(); |
| Ok(McpEventStreamOpener { |
| connection, |
| cancellation_receiver: cancel_event_streams_on_server_removal.subscribe(), |
| cancel_event_streams_on_server_removal, |
| }) |
| } |
|
|
| pub(crate) fn forward_event_server_removals_to(&self, retained: watch::Sender<()>) { |
| let mut cancellation = self |
| .event_stream_cancellation |
| .lock() |
| .unwrap_or_else(std::sync::PoisonError::into_inner); |
| if !cancellation.event_server_available { |
| retained.send_replace(()); |
| } |
| cancellation.retained_subscription_cancellation = Some(retained); |
| } |
|
|
| pub async fn shutdown(&self) { |
| self.latest_connections().shutdown().await; |
| } |
| } |
|
|
| #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] |
| #[serde(rename_all = "camelCase")] |
| pub struct SandboxState { |
| pub permission_profile: PermissionProfile, |
| pub codex_linux_sandbox_exe: Option<PathBuf>, |
| pub sandbox_cwd: PathUri, |
| #[serde(default)] |
| pub use_legacy_landlock: bool, |
| } |
|
|
| |
| |
| |
| |
| #[derive(Clone)] |
| pub struct McpRuntimeContext { |
| environment_manager: Arc<EnvironmentManager>, |
| selected_environments: HashMap<String, Arc<Environment>>, |
| local_process_cwd: PathBuf, |
| local_http_client: Arc<dyn HttpClient>, |
| } |
|
|
| |
| |
| |
| |
| |
| pub fn apply_http_headers_helper( |
| client: Arc<dyn HttpClient>, |
| config: &codex_config::McpServerConfig, |
| local_process_cwd: PathBuf, |
| ) -> Result<Arc<dyn HttpClient>, String> { |
| if matches!( |
| config.disabled_reason, |
| Some(McpServerDisabledReason::Requirements { .. }) |
| ) { |
| return Err("the MCP server is disabled by managed requirements".to_string()); |
| } |
| let codex_config::McpServerTransportConfig::StreamableHttp { |
| url, |
| http_headers_helper: Some(command), |
| .. |
| } = &config.transport |
| else { |
| return Ok(client); |
| }; |
| if !config.is_local_environment() { |
| return Err("HTTP headers helpers can only run in the local environment".to_string()); |
| } |
| with_http_headers_helper(client, url, command, local_process_cwd) |
| .map_err(|error| error.to_string()) |
| } |
|
|
| impl McpRuntimeContext { |
| pub fn new(environment_manager: Arc<EnvironmentManager>, local_process_cwd: PathBuf) -> Self { |
| let local_http_client = Arc::new( |
| RouteAwareHttpClient::new(environment_manager.http_client_factory().clone()) |
| .with_tls_backend_fallback(), |
| ); |
| Self { |
| environment_manager, |
| selected_environments: HashMap::new(), |
| local_process_cwd, |
| local_http_client, |
| } |
| } |
|
|
| |
| pub fn with_selected_environments( |
| mut self, |
| selected_environments: HashMap<String, Arc<Environment>>, |
| ) -> Self { |
| self.selected_environments = selected_environments; |
| self |
| } |
|
|
| pub(crate) fn local_process_cwd(&self) -> PathBuf { |
| self.local_process_cwd.clone() |
| } |
|
|
| pub(crate) fn local_http_client(&self) -> Arc<dyn HttpClient> { |
| Arc::clone(&self.local_http_client) |
| } |
|
|
| pub(crate) fn resolve_server_environment( |
| &self, |
| server_name: &str, |
| config: &codex_config::McpServerConfig, |
| ) -> Result<Option<Arc<Environment>>, String> { |
| |
| |
| |
| if let Some(environment) = self |
| .selected_environments |
| .get(&config.environment_id) |
| .cloned() |
| .or_else(|| { |
| self.environment_manager |
| .get_environment(&config.environment_id) |
| }) |
| { |
| return Ok(Some(environment)); |
| } |
|
|
| if config.is_local_environment() { |
| return match config.transport { |
| codex_config::McpServerTransportConfig::Stdio { .. } => Err(format!( |
| "local stdio MCP server `{server_name}` requires a local environment" |
| )), |
| codex_config::McpServerTransportConfig::StreamableHttp { .. } => Ok(None), |
| }; |
| } |
|
|
| Err(format!( |
| "MCP server `{server_name}` references unknown environment id `{}`", |
| config.environment_id |
| )) |
| } |
|
|
| |
| pub fn resolve_http_client( |
| &self, |
| server_name: &str, |
| config: &codex_config::McpServerConfig, |
| ) -> Result<Arc<dyn HttpClient>, String> { |
| let environment = self.resolve_server_environment(server_name, config)?; |
| self.http_client_for_server(config, environment.as_ref()) |
| } |
|
|
| pub(crate) fn http_client_for_server( |
| &self, |
| config: &codex_config::McpServerConfig, |
| environment: Option<&Arc<Environment>>, |
| ) -> Result<Arc<dyn HttpClient>, String> { |
| let client = match environment { |
| Some(environment) if environment.is_remote() => environment.get_http_client(), |
| Some(_) | None => self.local_http_client(), |
| }; |
| apply_http_headers_helper(client, config, self.local_process_cwd()) |
| } |
| } |
|
|
| pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { |
| if let Some(metrics) = codex_otel::global() { |
| let _ = metrics.record_duration(metric, duration, tags); |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::collections::HashMap; |
|
|
| use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; |
| use codex_config::McpServerConfig; |
| use codex_config::McpServerTransportConfig; |
| use codex_exec_server::EnvironmentManager; |
| use codex_exec_server_test_support::environment_manager_without_environments; |
| use codex_utils_path_uri::LegacyAppPathString; |
| use pretty_assertions::assert_eq; |
| use serde_json::Value; |
|
|
| use super::*; |
|
|
| fn stdio_server(environment_id: &str) -> McpServerConfig { |
| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::Stdio { |
| command: "echo".to_string(), |
| args: Vec::new(), |
| env: None, |
| env_vars: Vec::new(), |
| cwd: None, |
| }, |
| environment_id: environment_id.to_string(), |
| enabled: true, |
| required: false, |
| supports_parallel_tool_calls: false, |
| omit_tools_from: None, |
| disabled_reason: None, |
| startup_timeout_sec: None, |
| tool_timeout_sec: None, |
| default_tools_approval_mode: None, |
| enabled_tools: None, |
| disabled_tools: None, |
| scopes: None, |
| oauth: None, |
| oauth_resource: None, |
| tools: HashMap::new(), |
| } |
| } |
|
|
| #[tokio::test] |
| async fn publication_gate_opens_only_for_the_winning_candidate() { |
| let (publish, gate) = McpPublicationGate::pending(); |
| let wait = tokio::spawn(gate.wait()); |
| tokio::task::yield_now().await; |
| assert!(!wait.is_finished()); |
|
|
| publish.send(true).expect("publish candidate"); |
| assert!(wait.await.expect("gate task")); |
|
|
| let (publish, gate) = McpPublicationGate::pending(); |
| drop(publish); |
| assert!(!gate.wait().await); |
| } |
|
|
| #[tokio::test] |
| async fn cached_bindings_follow_the_clients_catalog_revision() -> anyhow::Result<()> { |
| let codex_home = tempfile::tempdir()?; |
| let cache_context = ConnectorRuntimeManager::<ToolInfo>::default().context( |
| codex_home.path().to_path_buf(), |
| ConnectorRuntimeContextKey::personal( |
| None, None, |
| ), |
| ); |
| let connections = |
| crate::connection_manager::tests::create_test_manager_with_ready_apps_client( |
| cache_context, |
| "search", |
| None, |
| None, |
| ) |
| .await?; |
| |
| connections.list_all_tools().await; |
| let mut config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf()); |
| config.server_permission_profiles.insert( |
| CODEX_APPS_MCP_SERVER_NAME.to_string(), |
| PermissionProfile::default(), |
| ); |
| let published = Arc::new(PublishedMcpRuntime { |
| connections: Arc::clone(&connections), |
| config: Some(Arc::new(config)), |
| auth: None, |
| auth_token: None, |
| plugins_available: false, |
| ready_selected_capability_roots: Vec::new(), |
| selected_environments: HashMap::new(), |
| cached_binding: Mutex::new(None), |
| }); |
| let before = McpRuntime::binding_from_published_runtime( |
| Arc::clone(&published), |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| .expect("initial binding"); |
| let repeated = McpRuntime::binding_from_published_runtime( |
| Arc::clone(&published), |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| .expect("cached initial binding"); |
| assert!(Arc::ptr_eq(&before, &repeated)); |
|
|
| connections.refresh_codex_apps_tools_for_discovery().await?; |
|
|
| let refreshed = McpRuntime::binding_from_published_runtime( |
| Arc::clone(&published), |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| .expect("refreshed binding"); |
| assert!(!Arc::ptr_eq(&before, &refreshed)); |
| let call = refreshed |
| .prepare_call(CODEX_APPS_MCP_SERVER_NAME, "search") |
| .expect("refreshed call"); |
| let error = call |
| .call_with_preparation( None, || async { |
| Err(anyhow::anyhow!("reached refreshed call preparation")) |
| }) |
| .await |
| .expect_err("stop before dispatch"); |
| assert!( |
| error |
| .to_string() |
| .contains("reached refreshed call preparation") |
| ); |
| let repeated = McpRuntime::binding_from_published_runtime( |
| Arc::clone(&published), |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| .expect("cached refreshed binding"); |
| assert!(Arc::ptr_eq(&refreshed, &repeated)); |
| let released = Arc::downgrade(&refreshed); |
| drop(refreshed); |
| drop(repeated); |
| assert!( |
| released.upgrade().is_none(), |
| "the runtime cache must not pin an unused binding" |
| ); |
| Ok(()) |
| } |
|
|
| #[tokio::test] |
| async fn cached_bindings_are_scoped_to_the_published_runtime() { |
| let published = Arc::new(PublishedMcpRuntime { |
| connections: Arc::new(McpConnectionSet::empty( true)), |
| config: Some(Arc::new(crate::mcp::tests::test_mcp_config( |
| std::env::temp_dir(), |
| ))), |
| auth: None, |
| auth_token: None, |
| plugins_available: false, |
| ready_selected_capability_roots: Vec::new(), |
| selected_environments: HashMap::new(), |
| cached_binding: Mutex::new(None), |
| }); |
| let first = McpRuntime::binding_from_published_runtime( |
| Arc::clone(&published), |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| .expect("first binding"); |
| let repeated = McpRuntime::binding_from_published_runtime( |
| Arc::clone(&published), |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| .expect("repeated binding"); |
| assert!(Arc::ptr_eq(&first, &repeated)); |
|
|
| let previous = Arc::into_inner(published).expect("published runtime has no other owners"); |
| let republished = Arc::new(PublishedMcpRuntime { |
| cached_binding: Mutex::new(None), |
| ..previous |
| }); |
| let refreshed = McpRuntime::binding_from_published_runtime( |
| republished, |
| &[], |
| &HashSet::new(), |
| ) |
| .await |
| .expect("republished binding"); |
| assert!(!Arc::ptr_eq(&first, &refreshed)); |
| } |
|
|
| fn http_server(environment_id: &str) -> McpServerConfig { |
| McpServerConfig { |
| auth: Default::default(), |
| transport: McpServerTransportConfig::StreamableHttp { |
| url: "http://127.0.0.1:1".to_string(), |
| bearer_token_env_var: None, |
| http_headers: None, |
| env_http_headers: None, |
| http_headers_helper: None, |
| }, |
| environment_id: environment_id.to_string(), |
| ..stdio_server(environment_id) |
| } |
| } |
|
|
| #[test] |
| fn sandbox_state_serializes_skip_missing_entries_as_missing_path_behavior() { |
| let sandbox_cwd = PathUri::from_host_native_path( |
| std::env::current_dir().expect("current directory should be available"), |
| ) |
| .expect("current directory should convert to a URI"); |
| let sandbox_state = SandboxState { |
| permission_profile: PermissionProfile::workspace_write(), |
| codex_linux_sandbox_exe: None, |
| sandbox_cwd, |
| use_legacy_landlock: false, |
| }; |
|
|
| let serialized = serde_json::to_value(&sandbox_state).expect("serialize sandbox state"); |
| let serialized_text = serde_json::to_string(&serialized).expect("serialize JSON text"); |
| assert!( |
| !serialized_text.contains("generated_default_path"), |
| "MCP sandbox metadata must preserve FileSystemPath's stable wire variants" |
| ); |
| assert!( |
| !serialized_text.contains("generated_default_special"), |
| "MCP sandbox metadata must preserve FileSystemPath's stable wire variants" |
| ); |
|
|
| let entries = serialized |
| .pointer("/permissionProfile/file_system/entries") |
| .and_then(Value::as_array) |
| .expect("workspace-write profile should contain filesystem entries"); |
| let skip_missing_entries = entries |
| .iter() |
| .filter(|entry| { |
| entry.get("missing_path_behavior").and_then(Value::as_str) == Some("skip") |
| }) |
| .collect::<Vec<_>>(); |
| assert!( |
| !skip_missing_entries.is_empty(), |
| "skip-missing entries should be represented as optional missing_path_behavior" |
| ); |
| assert!( |
| skip_missing_entries.iter().all(|entry| { |
| matches!( |
| entry.pointer("/path/type").and_then(Value::as_str), |
| Some("path" | "special") |
| ) |
| }), |
| "skip-missing entries should use the stable path/special variants" |
| ); |
|
|
| let deserialized: SandboxState = |
| serde_json::from_value(serialized).expect("deserialize sandbox state"); |
| assert_eq!( |
| deserialized.permission_profile, |
| sandbox_state.permission_profile |
| ); |
| } |
|
|
| #[test] |
| fn local_stdio_requires_local_stdio_availability() { |
| let runtime_context = McpRuntimeContext::new( |
| Arc::new(environment_manager_without_environments()), |
| PathBuf::from("/tmp"), |
| ); |
|
|
| let error = match runtime_context |
| .resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID)) |
| { |
| Ok(_) => panic!("local stdio MCP should require a local environment"), |
| Err(error) => error, |
| }; |
| assert_eq!( |
| error, |
| "local stdio MCP server `stdio` requires a local environment" |
| ); |
| } |
|
|
| #[test] |
| fn local_http_does_not_require_local_stdio_availability() { |
| let runtime_context = McpRuntimeContext::new( |
| Arc::new(environment_manager_without_environments()), |
| PathBuf::from("/tmp"), |
| ); |
|
|
| let resolved_runtime = match runtime_context |
| .resolve_server_environment("http", &http_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID)) |
| { |
| Ok(resolved_runtime) => resolved_runtime, |
| Err(error) => panic!("local HTTP MCP should resolve: {error}"), |
| }; |
| assert!(resolved_runtime.is_none()); |
| } |
|
|
| #[tokio::test] |
| async fn local_http_client_is_shared_across_resolution_and_context_clones() { |
| for environment_manager in [ |
| EnvironmentManager::default_for_tests(), |
| environment_manager_without_environments(), |
| ] { |
| let runtime_context = |
| McpRuntimeContext::new(Arc::new(environment_manager), PathBuf::from("/tmp")); |
| let config = http_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID); |
| let first_client = runtime_context |
| .resolve_http_client("http", &config) |
| .expect("first local HTTP capability should resolve"); |
| let repeated_client = runtime_context |
| .resolve_http_client("http", &config) |
| .expect("repeated local HTTP capability should resolve"); |
| let resolved_environment = runtime_context |
| .resolve_server_environment("http", &config) |
| .expect("local HTTP environment should resolve"); |
| let startup_client = runtime_context |
| .http_client_for_server(&config, resolved_environment.as_ref()) |
| .expect("startup local HTTP capability should resolve"); |
| let cloned_client = runtime_context |
| .clone() |
| .resolve_http_client("http", &config) |
| .expect("cloned local HTTP capability should resolve"); |
|
|
| assert!(Arc::ptr_eq(&first_client, &repeated_client)); |
| assert!(Arc::ptr_eq(&first_client, &startup_client)); |
| assert!(Arc::ptr_eq(&first_client, &cloned_client)); |
| } |
| } |
|
|
| #[test] |
| fn unknown_explicit_environment_is_rejected() { |
| let runtime_context = McpRuntimeContext::new( |
| Arc::new(environment_manager_without_environments()), |
| PathBuf::from("/tmp"), |
| ); |
|
|
| let error = |
| match runtime_context.resolve_server_environment("stdio", &stdio_server("remote")) { |
| Ok(_) => panic!("unknown MCP environment should fail"), |
| Err(error) => error, |
| }; |
| assert_eq!( |
| error, |
| "MCP server `stdio` references unknown environment id `remote`" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn explicit_remote_stdio_and_http_accept_named_environment() { |
| let runtime_context = McpRuntimeContext::new( |
| Arc::new( |
| EnvironmentManager::create_for_tests( |
| Some("ws://127.0.0.1:8765".to_string()), |
| None, |
| ) |
| .await, |
| ), |
| PathBuf::from("/tmp"), |
| ); |
|
|
| let mut remote_stdio = stdio_server("remote"); |
| let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { |
| unreachable!("stdio helper should build stdio transport"); |
| }; |
| *cwd = Some(LegacyAppPathString::from_path(&std::env::temp_dir())); |
| for resolved_runtime in [ |
| runtime_context.resolve_server_environment("stdio", &remote_stdio), |
| runtime_context.resolve_server_environment("http", &http_server("remote")), |
| ] { |
| let resolved_runtime = match resolved_runtime { |
| Ok(resolved_runtime) => resolved_runtime, |
| Err(error) => panic!("remote MCP should resolve: {error}"), |
| }; |
| assert!(resolved_runtime.is_some()); |
| } |
|
|
| let mut remote_http_with_helper = http_server("remote"); |
| let McpServerTransportConfig::StreamableHttp { |
| http_headers_helper, |
| .. |
| } = &mut remote_http_with_helper.transport |
| else { |
| unreachable!("HTTP helper should build streamable HTTP transport"); |
| }; |
| *http_headers_helper = Some("helper-that-must-not-run".to_string()); |
| let error = match runtime_context.resolve_http_client("http", &remote_http_with_helper) { |
| Ok(_) => panic!("remote HTTP helper should be rejected"), |
| Err(error) => error, |
| }; |
| assert_eq!( |
| error, |
| "HTTP headers helpers can only run in the local environment" |
| ); |
|
|
| let remote_http = http_server("remote"); |
| let remote_environment = runtime_context |
| .resolve_server_environment("http", &remote_http) |
| .expect("remote HTTP MCP should resolve") |
| .expect("remote HTTP MCP should have an environment"); |
| let remote_client = runtime_context |
| .resolve_http_client("http", &remote_http) |
| .expect("remote HTTP capability should resolve"); |
| assert!(Arc::ptr_eq( |
| &remote_client, |
| &remote_environment.get_http_client() |
| )); |
| } |
|
|
| #[tokio::test] |
| async fn remote_stdio_accepts_foreign_absolute_cwd() { |
| let runtime_context = McpRuntimeContext::new( |
| Arc::new( |
| EnvironmentManager::create_for_tests( |
| Some("ws://127.0.0.1:8765".to_string()), |
| None, |
| ) |
| .await, |
| ), |
| PathBuf::from("/tmp"), |
| ); |
| let mut remote_stdio = stdio_server("remote"); |
| let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { |
| unreachable!("stdio helper should build stdio transport"); |
| }; |
| *cwd = Some( |
| PathUri::parse("file:///C:/plugins/demo") |
| .expect("foreign cwd URI") |
| .into(), |
| ); |
|
|
| let resolved_runtime = |
| match runtime_context.resolve_server_environment("stdio", &remote_stdio) { |
| Ok(resolved_runtime) => resolved_runtime, |
| Err(error) => panic!("foreign cwd should resolve: {error}"), |
| }; |
| assert!(resolved_runtime.is_some()); |
| } |
|
|
| #[tokio::test] |
| async fn local_stdio_accepts_local_environment_when_available() { |
| let runtime_context = McpRuntimeContext::new( |
| Arc::new(EnvironmentManager::default_for_tests()), |
| PathBuf::from("/tmp"), |
| ); |
|
|
| let resolved_runtime = match runtime_context |
| .resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID)) |
| { |
| Ok(resolved_runtime) => resolved_runtime, |
| Err(error) => panic!("local stdio MCP should resolve: {error}"), |
| }; |
| assert!(resolved_runtime.is_some()); |
| } |
| } |
|
|