1use 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
24pub 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#[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 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 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 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 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}