Skip to main content

codex_mcp/
runtime.rs

1//! Runtime support for Model Context Protocol (MCP) servers.
2//!
3//! This module contains the thread-owned MCP runtime and data that describes the
4//! environment in which MCP servers execute. Transport startup lives in
5//! [`crate::rmcp_client`] and connection-set behavior lives in
6//! [`crate::connection_manager`].
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use arc_swap::ArcSwap;
13use codex_exec_server::Environment;
14use codex_exec_server::EnvironmentManager;
15use codex_exec_server::HttpClient;
16use codex_exec_server::ReqwestHttpClient;
17use codex_protocol::models::PermissionProfile;
18use codex_utils_path_uri::PathUri;
19use serde::Deserialize;
20use serde::Serialize;
21
22use crate::McpConnectionManager;
23
24/// Owns the currently published MCP connection set for one Codex thread.
25///
26/// Replacements are published atomically. Callers that already hold a snapshot
27/// keep the previous connection set alive until their work completes.
28pub struct McpRuntime {
29    connections: ArcSwap<McpConnectionManager>,
30}
31
32impl McpRuntime {
33    pub fn new(connections: Arc<McpConnectionManager>) -> Self {
34        Self {
35            connections: ArcSwap::from(connections),
36        }
37    }
38
39    pub fn snapshot(&self) -> Arc<McpConnectionManager> {
40        self.connections.load_full()
41    }
42
43    pub fn replace(&self, connections: McpConnectionManager) -> Arc<McpConnectionManager> {
44        let connections = Arc::new(connections);
45        self.connections.store(Arc::clone(&connections));
46        connections
47    }
48
49    pub async fn shutdown(&self) {
50        self.snapshot().shutdown().await;
51    }
52}
53
54#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct SandboxState {
57    pub permission_profile: PermissionProfile,
58    pub codex_linux_sandbox_exe: Option<PathBuf>,
59    pub sandbox_cwd: PathUri,
60    #[serde(default)]
61    pub use_legacy_landlock: bool,
62}
63
64/// Runtime context used when resolving per-server MCP environments.
65///
66/// `McpConfig` describes what servers exist. This value carries the canonical
67/// environment registry plus the local stdio fallback cwd used when a local
68/// stdio server omits its own working directory.
69#[derive(Clone)]
70pub struct McpRuntimeContext {
71    environment_manager: Arc<EnvironmentManager>,
72    local_stdio_fallback_cwd: PathBuf,
73}
74
75impl McpRuntimeContext {
76    pub fn new(
77        environment_manager: Arc<EnvironmentManager>,
78        local_stdio_fallback_cwd: PathBuf,
79    ) -> Self {
80        Self {
81            environment_manager,
82            local_stdio_fallback_cwd,
83        }
84    }
85
86    pub(crate) fn local_stdio_fallback_cwd(&self) -> PathBuf {
87        self.local_stdio_fallback_cwd.clone()
88    }
89
90    pub(crate) fn resolve_server_environment(
91        &self,
92        server_name: &str,
93        config: &codex_config::McpServerConfig,
94    ) -> Result<Option<Arc<Environment>>, String> {
95        // Resolve `"local"` through the shared registry when available. Local
96        // HTTP is the one current exception: it can use the ambient HTTP client
97        // even when no local Environment is configured.
98        if let Some(environment) = self
99            .environment_manager
100            .get_environment(&config.environment_id)
101        {
102            return Ok(Some(environment));
103        }
104
105        if config.is_local_environment() {
106            return match config.transport {
107                codex_config::McpServerTransportConfig::Stdio { .. } => Err(format!(
108                    "local stdio MCP server `{server_name}` requires a local environment"
109                )),
110                codex_config::McpServerTransportConfig::StreamableHttp { .. } => Ok(None),
111            };
112        }
113
114        Err(format!(
115            "MCP server `{server_name}` references unknown environment id `{}`",
116            config.environment_id
117        ))
118    }
119
120    /// Resolves the HTTP capability owned by the server's configured environment.
121    pub fn resolve_http_client(
122        &self,
123        server_name: &str,
124        config: &codex_config::McpServerConfig,
125    ) -> Result<Arc<dyn HttpClient>, String> {
126        Ok(self
127            .resolve_server_environment(server_name, config)?
128            .map_or_else(
129                || Arc::new(ReqwestHttpClient) as Arc<dyn HttpClient>,
130                |environment| environment.get_http_client(),
131            ))
132    }
133}
134
135pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) {
136    if let Some(metrics) = codex_otel::global() {
137        let _ = metrics.record_duration(metric, duration, tags);
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use std::collections::HashMap;
144
145    use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
146    use codex_config::McpServerConfig;
147    use codex_config::McpServerTransportConfig;
148    use codex_exec_server::EnvironmentManager;
149    use codex_utils_path_uri::LegacyAppPathString;
150    use pretty_assertions::assert_eq;
151    use serde_json::Value;
152
153    use super::*;
154
155    fn stdio_server(environment_id: &str) -> McpServerConfig {
156        McpServerConfig {
157            auth: Default::default(),
158            transport: McpServerTransportConfig::Stdio {
159                command: "echo".to_string(),
160                args: Vec::new(),
161                env: None,
162                env_vars: Vec::new(),
163                cwd: None,
164            },
165            environment_id: environment_id.to_string(),
166            enabled: true,
167            required: false,
168            supports_parallel_tool_calls: false,
169            disabled_reason: None,
170            startup_timeout_sec: None,
171            tool_timeout_sec: None,
172            default_tools_approval_mode: None,
173            enabled_tools: None,
174            disabled_tools: None,
175            scopes: None,
176            oauth: None,
177            oauth_resource: None,
178            tools: HashMap::new(),
179        }
180    }
181
182    fn http_server(environment_id: &str) -> McpServerConfig {
183        McpServerConfig {
184            auth: Default::default(),
185            transport: McpServerTransportConfig::StreamableHttp {
186                url: "http://127.0.0.1:1".to_string(),
187                bearer_token_env_var: None,
188                http_headers: None,
189                env_http_headers: None,
190            },
191            environment_id: environment_id.to_string(),
192            ..stdio_server(environment_id)
193        }
194    }
195
196    #[test]
197    fn sandbox_state_serializes_skip_missing_entries_as_missing_path_behavior() {
198        let sandbox_cwd = PathUri::from_host_native_path(
199            std::env::current_dir().expect("current directory should be available"),
200        )
201        .expect("current directory should convert to a URI");
202        let sandbox_state = SandboxState {
203            permission_profile: PermissionProfile::workspace_write(),
204            codex_linux_sandbox_exe: None,
205            sandbox_cwd,
206            use_legacy_landlock: false,
207        };
208
209        let serialized = serde_json::to_value(&sandbox_state).expect("serialize sandbox state");
210        let serialized_text = serde_json::to_string(&serialized).expect("serialize JSON text");
211        assert!(
212            !serialized_text.contains("generated_default_path"),
213            "MCP sandbox metadata must preserve FileSystemPath's stable wire variants"
214        );
215        assert!(
216            !serialized_text.contains("generated_default_special"),
217            "MCP sandbox metadata must preserve FileSystemPath's stable wire variants"
218        );
219
220        let entries = serialized
221            .pointer("/permissionProfile/file_system/entries")
222            .and_then(Value::as_array)
223            .expect("workspace-write profile should contain filesystem entries");
224        let skip_missing_entries = entries
225            .iter()
226            .filter(|entry| {
227                entry.get("missing_path_behavior").and_then(Value::as_str) == Some("skip")
228            })
229            .collect::<Vec<_>>();
230        assert!(
231            !skip_missing_entries.is_empty(),
232            "skip-missing entries should be represented as optional missing_path_behavior"
233        );
234        assert!(
235            skip_missing_entries.iter().all(|entry| {
236                matches!(
237                    entry.pointer("/path/type").and_then(Value::as_str),
238                    Some("path" | "special")
239                )
240            }),
241            "skip-missing entries should use the stable path/special variants"
242        );
243
244        let deserialized: SandboxState =
245            serde_json::from_value(serialized).expect("deserialize sandbox state");
246        assert_eq!(
247            deserialized.permission_profile,
248            sandbox_state.permission_profile
249        );
250    }
251
252    #[test]
253    fn local_stdio_requires_local_stdio_availability() {
254        let runtime_context = McpRuntimeContext::new(
255            Arc::new(EnvironmentManager::without_environments()),
256            PathBuf::from("/tmp"),
257        );
258
259        let error = match runtime_context
260            .resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
261        {
262            Ok(_) => panic!("local stdio MCP should require a local environment"),
263            Err(error) => error,
264        };
265        assert_eq!(
266            error,
267            "local stdio MCP server `stdio` requires a local environment"
268        );
269    }
270
271    #[test]
272    fn local_http_does_not_require_local_stdio_availability() {
273        let runtime_context = McpRuntimeContext::new(
274            Arc::new(EnvironmentManager::without_environments()),
275            PathBuf::from("/tmp"),
276        );
277
278        let resolved_runtime = match runtime_context
279            .resolve_server_environment("http", &http_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
280        {
281            Ok(resolved_runtime) => resolved_runtime,
282            Err(error) => panic!("local HTTP MCP should resolve: {error}"),
283        };
284        assert!(resolved_runtime.is_none());
285    }
286
287    #[test]
288    fn unknown_explicit_environment_is_rejected() {
289        let runtime_context = McpRuntimeContext::new(
290            Arc::new(EnvironmentManager::without_environments()),
291            PathBuf::from("/tmp"),
292        );
293
294        let error =
295            match runtime_context.resolve_server_environment("stdio", &stdio_server("remote")) {
296                Ok(_) => panic!("unknown MCP environment should fail"),
297                Err(error) => error,
298            };
299        assert_eq!(
300            error,
301            "MCP server `stdio` references unknown environment id `remote`"
302        );
303    }
304
305    #[tokio::test]
306    async fn explicit_remote_stdio_and_http_accept_named_environment() {
307        let runtime_context = McpRuntimeContext::new(
308            Arc::new(
309                EnvironmentManager::create_for_tests(
310                    Some("ws://127.0.0.1:8765".to_string()),
311                    /*local_runtime_paths*/ None,
312                )
313                .await,
314            ),
315            PathBuf::from("/tmp"),
316        );
317
318        let mut remote_stdio = stdio_server("remote");
319        let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
320            unreachable!("stdio helper should build stdio transport");
321        };
322        *cwd = Some(LegacyAppPathString::from_path(&std::env::temp_dir()));
323        for resolved_runtime in [
324            runtime_context.resolve_server_environment("stdio", &remote_stdio),
325            runtime_context.resolve_server_environment("http", &http_server("remote")),
326        ] {
327            let resolved_runtime = match resolved_runtime {
328                Ok(resolved_runtime) => resolved_runtime,
329                Err(error) => panic!("remote MCP should resolve: {error}"),
330            };
331            assert!(resolved_runtime.is_some());
332        }
333    }
334
335    #[tokio::test]
336    async fn remote_stdio_accepts_foreign_absolute_cwd() {
337        let runtime_context = McpRuntimeContext::new(
338            Arc::new(
339                EnvironmentManager::create_for_tests(
340                    Some("ws://127.0.0.1:8765".to_string()),
341                    /*local_runtime_paths*/ None,
342                )
343                .await,
344            ),
345            PathBuf::from("/tmp"),
346        );
347        let mut remote_stdio = stdio_server("remote");
348        let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
349            unreachable!("stdio helper should build stdio transport");
350        };
351        *cwd = Some(
352            PathUri::parse("file:///C:/plugins/demo")
353                .expect("foreign cwd URI")
354                .into(),
355        );
356
357        let resolved_runtime =
358            match runtime_context.resolve_server_environment("stdio", &remote_stdio) {
359                Ok(resolved_runtime) => resolved_runtime,
360                Err(error) => panic!("foreign cwd should resolve: {error}"),
361            };
362        assert!(resolved_runtime.is_some());
363    }
364
365    #[tokio::test]
366    async fn local_stdio_accepts_local_environment_when_available() {
367        let runtime_context = McpRuntimeContext::new(
368            Arc::new(EnvironmentManager::default_for_tests()),
369            PathBuf::from("/tmp"),
370        );
371
372        let resolved_runtime = match runtime_context
373            .resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
374        {
375            Ok(resolved_runtime) => resolved_runtime,
376            Err(error) => panic!("local stdio MCP should resolve: {error}"),
377        };
378        assert!(resolved_runtime.is_some());
379    }
380}