//! Runtime support for Model Context Protocol (MCP) servers. //! //! This module contains the thread-owned MCP runtime and data that describes the //! environment in which MCP servers execute. Transport startup lives in //! [`crate::rmcp_client`] and connection-set behavior lives in //! [`crate::connection_manager`]. 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; /// Controls when one task starts its eligible MCP servers. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum McpStartupPolicy { /// Start configured servers when their task's MCP runtime is published. Eager, /// Start servers with cached tool definitions on first use. LazyWhenCached, } /// Everything needed to materialize one exact MCP configuration. pub struct McpRuntimeInput { pub startup_policy: McpStartupPolicy, pub config: Arc, pub plugins_available: bool, pub ready_selected_capability_roots: Vec, pub mcp_servers: HashMap, pub submit_id: String, pub tx_event: Option>, pub startup_cancellation_token: CancellationToken, pub runtime_context: McpRuntimeContext, pub codex_apps_tools_cache: ConnectorRuntimeManager, pub tool_catalog_cache: McpToolCatalogCache, pub codex_apps_tools_cache_key: ConnectorRuntimeContextKey, pub client_mcp_extensions: ClientMcpExtensions, pub auth: Option, pub auth_manager: Option>, pub elicitation_reviewer: Option, pub elicitation_lifecycle: Option, } /// Owns all mutable MCP state for one Codex thread. /// /// Publication replaces the latest state atomically. Existing bindings retain /// their exact connections and configuration for as long as they are needed. pub struct McpRuntime { current: ArcSwap, event_stream_cancellation: Mutex, reconnect_pending: AtomicBool, elicitation_router: ElicitationRequestRouter, resource_origins: Mutex, } struct EventStreamCancellation { event_server_available: bool, cancel_event_streams_on_server_removal: watch::Sender<()>, retained_subscription_cancellation: Option>, } struct PublishedMcpRuntime { connections: Arc, config: Option>, auth: Option, auth_token: Option, plugins_available: bool, ready_selected_capability_roots: Vec, selected_environments: HashMap>, cached_binding: Mutex>, } 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, // Reuse a frozen binding while a model step or caller still needs it. binding: Weak, } 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>, } impl McpPublicationGate { fn pending() -> (watch::Sender, 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 { /// Creates a runtime with no configured servers. /// /// This is useful while constructing a thread that must publish a stable /// runtime handle before its full MCP inputs are available. 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(), } } /// Updates this thread's bounded resource provenance from a live or restored event. 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); } /// Captures bounded widget provenance for the next compaction checkpoint. pub fn resource_origin_checkpoint(&self) -> Option { self.resource_origins .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .checkpoint() } /// Restores widget provenance retained by a compaction 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); } /// Reads a widget through the current binding of the app tool that produced it. pub async fn read_resource_for_call( &self, thread_id: ThreadId, call_id: &str, uri: &str, ) -> anyhow::Result { 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 } /// Reconciles configured servers and publishes their immutable runtime snapshot. 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; } /// Starts fresh connections and returns their complete, refreshed Apps catalog. pub async fn replace_fresh(&self, input: McpRuntimeInput) -> anyhow::Result> { self.publish(input, /*previous*/ 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(()); } } } /// Ensures the next refresh creates fresh connections for every configured server. pub fn reconnect_on_next_refresh(&self) { self.reconnect_pending.store(true, Ordering::Release); } /// Captures the latest published configuration and live client handles. pub async fn current_binding(&self) -> Option> { self.current_binding_with_requirements(&[], &HashSet::new()) .await } /// Captures one runtime, waiting for explicitly required servers and selected plugins. /// Plugin IDs are resolved by the captured connection set, even if a refresh publishes later. pub async fn current_binding_with_requirements( &self, required_servers: &[String], required_plugins: &HashSet, ) -> Option> { Self::binding_from_published_runtime( self.current.load_full(), required_servers, required_plugins, ) .await } async fn binding_from_published_runtime( current: Arc, required_servers: &[String], required_plugins: &HashSet, ) -> Option> { 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) } /// Returns whether the published snapshot still belongs to the current credentials. 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, } } /// Detects newly saved credentials for servers whose startup failed authentication. pub async fn updated_oauth_credentials_after_auth_failure(&self) -> Vec { 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 } /// Checks the current generation before retrying servers detected outside the refresh gate. 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)) } /// Waits for the selected server without capturing an execution binding. pub async fn wait_for_server_startup(&self, server: &str) { self.current .load_full() .connections .wait_for_server_startup(server) .await; } /// Captures the current runtime after its selected server has finished startup. pub async fn current_binding_for_call(&self, server: &str) -> Option> { 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, /*required_servers*/ &[], /*required_plugins*/ &HashSet::new(), ) .await } /// Returns the latest published configuration without waiting for clients. pub fn current_config(&self) -> Option> { self.current.load().config.clone() } pub fn current_ready_selected_capability_roots(&self) -> Vec { self.current.load().ready_selected_capability_roots.clone() } /// Whether this publication uses the currently ready environment handles. pub fn current_environments_match( &self, environments: &HashMap>, ) -> 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> { self.latest_connections() .refresh_codex_apps_tools_for_discovery() .await } /// Refreshes the published Apps client and returns its exact inventory and MCP eligibility. pub async fn refresh_codex_apps_tools(&self) -> anyhow::Result { 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 } /// Lists the latest known tools for non-model discovery surfaces. /// /// Unlike [`Self::current_binding`], this may return cached tools while their /// client reconnects because callers only inspect tool metadata. pub async fn latest_list_all_tools(&self) -> Vec { 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, meta: Option, requested_timeout: Option, wait_for_server: bool, ) -> anyhow::Result { 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 { 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(); } /// Observes matching published registrations without starting or reconnecting servers. pub async fn connection_statuses( &self, config: &McpConfig, ) -> std::collections::HashMap { 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 { Arc::clone(&self.current.load().connections) } pub(crate) fn latest_host_owned_codex_apps_connections( &self, ) -> anyhow::Result> { 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, 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 { 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, pub sandbox_cwd: PathUri, #[serde(default)] pub use_legacy_landlock: bool, } /// Runtime context used when resolving per-server MCP environments. /// /// `McpConfig` describes what servers exist. This value carries the canonical /// environment registry plus the host-local cwd used by local MCP processes. #[derive(Clone)] pub struct McpRuntimeContext { environment_manager: Arc, selected_environments: HashMap>, local_process_cwd: PathBuf, local_http_client: Arc, } /// Applies the local HTTP headers helper configured for an MCP server. /// /// Callers retain ownership of selecting the underlying HTTP transport. This /// function centralizes the helper-specific policy checks and decoration used /// by both MCP runtime startup and standalone OAuth login. pub fn apply_http_headers_helper( client: Arc, config: &codex_config::McpServerConfig, local_process_cwd: PathBuf, ) -> Result, 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, 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, } } /// Pins the concrete environment handles captured for this thread or model step. pub fn with_selected_environments( mut self, selected_environments: HashMap>, ) -> 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 { Arc::clone(&self.local_http_client) } pub(crate) fn resolve_server_environment( &self, server_name: &str, config: &codex_config::McpServerConfig, ) -> Result>, String> { // Resolve `"local"` through the shared registry when available. Local // HTTP is the one current exception: it can use the ambient HTTP client // even when no local Environment is configured. 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 )) } /// Resolves local MCP's specialized HTTP capability or the selected remote capability. pub fn resolve_http_client( &self, server_name: &str, config: &codex_config::McpServerConfig, ) -> Result, 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>, ) -> Result, 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::::default().context( codex_home.path().to_path_buf(), ConnectorRuntimeContextKey::personal( /*account_id*/ None, /*chatgpt_user_id*/ None, ), ); let connections = crate::connection_manager::tests::create_test_manager_with_ready_apps_client( cache_context, "search", /*list_started*/ None, /*release_list*/ None, ) .await?; // Complete the fixture's shared startup future before testing stable reuse. 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), /*required_servers*/ &[], /*required_plugins*/ &HashSet::new(), ) .await .expect("initial binding"); let repeated = McpRuntime::binding_from_published_runtime( Arc::clone(&published), /*required_servers*/ &[], /*required_plugins*/ &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), /*required_servers*/ &[], /*required_plugins*/ &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(/*requested_timeout*/ 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), /*required_servers*/ &[], /*required_plugins*/ &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(/*prefix_mcp_tool_names*/ 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), /*required_servers*/ &[], /*required_plugins*/ &HashSet::new(), ) .await .expect("first binding"); let repeated = McpRuntime::binding_from_published_runtime( Arc::clone(&published), /*required_servers*/ &[], /*required_plugins*/ &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, /*required_servers*/ &[], /*required_plugins*/ &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::>(); 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()), /*local_runtime_paths*/ 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()), /*local_runtime_paths*/ 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()); } }