//! 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::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; 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::binding::PreparedMcpCall; use crate::connection_manager::McpConnectionSet; use crate::elicitation::ElicitationLifecycle; use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::ElicitationReviewerHandle; 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 codex_apps_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, reconnect_pending: AtomicBool, elicitation_router: ElicitationRequestRouter, resource_origins: Mutex, } struct PublishedMcpRuntime { connections: Arc, config: Option>, auth: Option, auth_token: Option, plugins_available: bool, ready_selected_capability_roots: Vec, cached_binding: Mutex>, } struct CachedMcpBinding { catalog_revision: u64, binding: Arc, } 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(), cached_binding: Mutex::new(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 connections = Arc::new( McpConnectionSet::new( previous, publication_gate, input, self.elicitation_router.clone(), ) .await, ); self.current.store(Arc::new(PublishedMcpRuntime { connections, config: Some(config), auth, auth_token, plugins_available, ready_selected_capability_roots, cached_binding: Mutex::new(None), })); let _ = publish.send(true); } /// 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_required_servers(&[]).await } /// Captures the latest runtime, waiting for servers explicitly required by this turn. pub async fn current_binding_with_required_servers( &self, required_servers: &[String], ) -> Option> { Self::binding_from_published_runtime(self.current.load_full(), required_servers).await } async fn binding_from_published_runtime( current: Arc, required_servers: &[String], ) -> Option> { let config = Arc::clone(current.config.as_ref()?); let stable_catalog_revision = current.connections.stable_catalog_revision().await; if let Some(catalog_revision) = stable_catalog_revision { let cached = current .cached_binding .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(cached) = cached.as_ref() && cached.catalog_revision == catalog_revision { return Some(Arc::clone(&cached.binding)); } } let binding = Arc::new( current .connections .capture_binding_with_metadata(config, current.plugins_available, required_servers) .await, ); if let Some(catalog_revision) = stable_catalog_revision && current.connections.stable_catalog_revision().await == Some(catalog_revision) { let mut cached = current .cached_binding .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(cached) = cached.as_ref() && cached.catalog_revision == catalog_revision { return Some(Arc::clone(&cached.binding)); } *cached = Some(CachedMcpBinding { catalog_revision, binding: Arc::clone(&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*/ &[]).await } /// Prepares an exact MCP tool call only when its server is already connected. /// /// Missing, disconnected, filtered, or uncataloged tools return `None` immediately rather /// than starting a server, waiting for startup, initiating OAuth, or reconnecting. Successful /// calls capture the live client, effective tool policy, and current catalog revision together. pub async fn prepare_call_if_connected( &self, server: &str, tool: &str, ) -> Option { let current = self.current.load_full(); let config = Arc::clone(current.config.as_ref()?); current .connections .prepare_connected_call(config, server, tool) .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() } 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() .hard_refresh_codex_apps_tools_cache() .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 } pub async fn latest_call_tool( &self, server: &str, tool: &str, arguments: Option, meta: Option, ) -> anyhow::Result { self.latest_connections() .call_tool(server, tool, arguments, meta) .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(); } pub(crate) fn latest_connections(&self) -> Arc { Arc::clone(&self.current.load().connections) } 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, 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> { let codex_config::McpServerTransportConfig::StreamableHttp { url, http_headers_helper: Some(command), .. } = &config.transport else { return Ok(client); }; if matches!( config.disabled_reason, Some(McpServerDisabledReason::Requirements { .. }) ) { return Err("the MCP server is disabled by managed requirements".to_string()); } 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, local_process_cwd, local_http_client, } } pub(crate) fn local_process_cwd(&self) -> PathBuf { self.local_process_cwd.clone() } 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 .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_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(), cached_binding: Mutex::new(None), }); let first = McpRuntime::binding_from_published_runtime( Arc::clone(&published), /*required_servers*/ &[], ) .await .expect("first binding"); let repeated = McpRuntime::binding_from_published_runtime( Arc::clone(&published), /*required_servers*/ &[], ) .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*/ &[]) .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()); } }