返回 CodeWhale
tests.rs
根目录 / crates / tui / src / mcp / tests.rs
1 use super::headers::{MCP_HTTP_ACCEPT, is_safe_custom_header, with_default_mcp_http_headers};
2 use super::http::{HttpTransport, McpHttpAuth};
3 use super::streamable_http::StreamableHttpTransport;
4 use super::wire::{
5 find_sse_event_separator, find_sse_event_separator_bytes, is_mcp_stale_session_error,
6 parse_sse_message_data,
7 };
8 use super::*;
9 use reqwest::header::{ACCEPT, CONTENT_TYPE};
10 use std::collections::VecDeque;
11 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering};
12 use std::sync::{Arc, Mutex, OnceLock};
13 #[cfg(unix)]
14 use tokio::io::AsyncBufReadExt;
15
16 fn test_http_client() -> reqwest::Client {
17 let _ = rustls::crypto::ring::default_provider().install_default();
18 crate::tls::reqwest_client()
19 }
20
21 async fn lock_mcp_loopback_tests() -> tokio::sync::MutexGuard<'static, ()> {
22 static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
23 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
24 .lock()
25 .await
26 }
27
28 struct WorkspaceTrustConfigGuard {
29 config_path: PathBuf,
30 _codewhale_config_path: crate::test_support::EnvVarGuard,
31 _deepseek_config_path: crate::test_support::EnvVarGuard,
32 _env_lock: crate::test_support::TestEnvLock,
33 }
34
35 fn workspace_trust_config_guard(workspace: &Path) -> WorkspaceTrustConfigGuard {
36 let env_lock = crate::test_support::lock_test_env();
37 let config_path = workspace
38 .parent()
39 .unwrap_or(workspace)
40 .join("user-config")
41 .join("config.toml");
42 if let Some(parent) = config_path.parent() {
43 fs::create_dir_all(parent).unwrap();
44 }
45 let codewhale_config_path =
46 crate::test_support::EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
47 let deepseek_config_path = crate::test_support::EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
48
49 WorkspaceTrustConfigGuard {
50 config_path,
51 _codewhale_config_path: codewhale_config_path,
52 _deepseek_config_path: deepseek_config_path,
53 _env_lock: env_lock,
54 }
55 }
56
57 fn write_workspace_trust_config(config_path: &Path, workspace: &Path) {
58 let workspace = workspace
59 .canonicalize()
60 .unwrap_or_else(|_| workspace.to_path_buf());
61 let key = workspace
62 .to_string_lossy()
63 .replace('\\', "\\\\")
64 .replace('"', "\\\"");
65 fs::write(
66 config_path,
67 format!("[projects.\"{key}\"]\ntrust_level = \"trusted\"\n"),
68 )
69 .unwrap();
70 }
71
72 fn mark_workspace_trusted(workspace: &Path) -> WorkspaceTrustConfigGuard {
73 let guard = workspace_trust_config_guard(workspace);
74 write_workspace_trust_config(&guard.config_path, workspace);
75 guard
76 }
77
78 #[test]
79 fn test_mcp_config_defaults() {
80 let config = McpConfig::default();
81 assert_eq!(config.timeouts.connect_timeout, 10);
82 assert_eq!(config.timeouts.execute_timeout, 60);
83 assert_eq!(config.timeouts.read_timeout, 120);
84 assert!(config.servers.is_empty());
85 }
86
87 #[test]
88 fn reviewed_remote_endpoint_identity_normalizes_case_idna_and_default_ports() {
89 let canonical = reviewed_remote_endpoint_identity("https://example.com/mcp").unwrap();
90 assert_eq!(
91 reviewed_remote_endpoint_identity("https://EXAMPLE.COM:443/mcp").unwrap(),
92 canonical
93 );
94 assert_eq!(
95 reviewed_remote_endpoint_identity("https://BÜCHER.example:443/mcp").unwrap(),
96 reviewed_remote_endpoint_identity("https://xn--bcher-kva.example/mcp").unwrap()
97 );
98 assert_ne!(
99 reviewed_remote_endpoint_identity("https://example.com:444/mcp").unwrap(),
100 canonical
101 );
102 assert!(reviewed_remote_endpoint_identity("http://localhost:8080/mcp").is_ok());
103 assert!(reviewed_remote_endpoint_identity("http://127.0.0.1/mcp").is_ok());
104 assert!(reviewed_remote_endpoint_identity("http://[::1]/mcp").is_ok());
105 }
106
107 #[test]
108 fn reviewed_remote_endpoint_identity_rejects_ambiguous_or_secret_bearing_urls() {
109 for endpoint in [
110 "http://example.com/mcp",
111 "ftp://example.com/mcp",
112 "https://user@example.com/mcp",
113 "https://user:secret@example.com/mcp",
114 "https://example.com/mcp?token=secret",
115 "https://example.com/mcp#fragment",
116 ] {
117 let error = reviewed_remote_endpoint_identity(endpoint)
118 .expect_err("unsafe reviewed endpoint must fail closed")
119 .to_string();
120 assert!(
121 !error.contains("secret"),
122 "endpoint error leaked URL material"
123 );
124 }
125 }
126
127 #[test]
128 fn reviewed_plugin_redirects_are_exact_normalized_origin_only() {
129 let approved = reviewed_remote_endpoint_identity("https://BÜCHER.example:443/mcp")
130 .unwrap()
131 .1;
132 let accepted = [
133 "https://xn--bcher-kva.example/next",
134 "https://BÜCHER.example:443/next?cursor=opaque",
135 ];
136 for endpoint in accepted {
137 assert!(reviewed_redirect_matches_origin(
138 &reqwest::Url::parse(endpoint).unwrap(),
139 &approved
140 ));
141 }
142
143 let rejected = [
144 "http://xn--bcher-kva.example/next",
145 "https://user@xn--bcher-kva.example/next",
146 "https://xn--bcher-kva.example:444/next",
147 "https://other.example/next",
148 ];
149 for endpoint in rejected {
150 assert!(!reviewed_redirect_matches_origin(
151 &reqwest::Url::parse(endpoint).unwrap(),
152 &approved
153 ));
154 }
155 }
156
157 #[test]
158 fn reviewed_plugin_remote_proxy_policy_never_reads_ambient_environment() {
159 let reads = std::cell::Cell::new(0_u32);
160 let proxy = configured_mcp_proxy(
161 &reqwest::Url::parse("https://example.com/mcp").unwrap(),
162 true,
163 |_| {
164 reads.set(reads.get() + 1);
165 Ok("http://127.0.0.1:9999".to_string())
166 },
167 );
168
169 assert_eq!(
170 reads.get(),
171 0,
172 "reviewed remotes must not read proxy values"
173 );
174 assert!(proxy.unwrap().is_none());
175 }
176
177 #[test]
178 fn user_authored_mcp_proxy_policy_keeps_environment_support() {
179 let requested = std::cell::RefCell::new(Vec::new());
180 let proxy = configured_mcp_proxy(
181 &reqwest::Url::parse("https://example.com/mcp").unwrap(),
182 false,
183 |name| {
184 requested.borrow_mut().push(name.to_string());
185 match name {
186 "HTTPS_PROXY" => Ok("http://127.0.0.1:8080".to_string()),
187 _ => Err(std::env::VarError::NotPresent),
188 }
189 },
190 );
191
192 assert_eq!(
193 requested.into_inner(),
194 vec![
195 "HTTPS_PROXY".to_string(),
196 "NO_PROXY".to_string(),
197 "no_proxy".to_string(),
198 ]
199 );
200 assert!(proxy.unwrap().is_some());
201 }
202
203 #[test]
204 fn test_mcp_config_parse() {
205 let json = r#"{
206 "timeouts": {
207 "connect_timeout": 15,
208 "execute_timeout": 90
209 },
210 "servers": {
211 "test": {
212 "command": "node",
213 "args": ["server.js"],
214 "env": {"FOO": "bar"}
215 }
216 }
217 }"#;
218
219 let config: McpConfig = serde_json::from_str(json).unwrap();
220 assert_eq!(config.timeouts.connect_timeout, 15);
221 assert_eq!(config.timeouts.execute_timeout, 90);
222 assert_eq!(config.timeouts.read_timeout, 120); // default
223 assert!(config.servers.contains_key("test"));
224
225 let server = config.servers.get("test").unwrap();
226 assert_eq!(server.command, Some("node".to_string()));
227 assert_eq!(server.args, vec!["server.js"]);
228 assert_eq!(server.env.get("FOO"), Some(&"bar".to_string()));
229 }
230
231 #[test]
232 fn mcp_pool_parse_prefixed_name_rejects_ambiguous_configured_server_prefixes() {
233 let config: McpConfig = serde_json::from_str(
234 r#"{
235 "servers": {
236 "my": {"command": "node"},
237 "my_db": {"command": "node"}
238 }
239 }"#,
240 )
241 .unwrap();
242 let pool = McpPool::new(config);
243
244 let error = pool
245 .parse_prefixed_name("mcp_my_db_execute_sql")
246 .expect_err("configured server-prefix collisions must fail closed");
247 assert!(error.to_string().contains("Unknown MCP tool name"));
248 }
249
250 #[test]
251 fn mcp_server_config_parses_custom_headers() {
252 let json = r#"{
253 "servers": {
254 "hf": {
255 "url": "https://example.invalid/mcp",
256 "headers": {
257 "Authorization": "Bearer tok",
258 "X-Org": "anthropic"
259 }
260 }
261 }
262 }"#;
263 let cfg: McpConfig = serde_json::from_str(json).unwrap();
264 let hf = cfg.servers.get("hf").expect("server present");
265 assert_eq!(
266 hf.headers.get("Authorization"),
267 Some(&"Bearer tok".to_string())
268 );
269 assert_eq!(hf.headers.get("X-Org"), Some(&"anthropic".to_string()));
270 }
271
272 #[test]
273 fn mcp_server_config_parses_remote_auth_fields() {
274 let json = r#"{
275 "servers": {
276 "remote": {
277 "url": "https://example.invalid/mcp",
278 "env_http_headers": {
279 "X-Api-Key": "REMOTE_MCP_KEY"
280 },
281 "bearer_token_env_var": "REMOTE_MCP_TOKEN",
282 "scopes": ["tools/read", "tools/write"],
283 "oauth": {
284 "client_id": "client-123"
285 },
286 "oauth_resource": "https://example.invalid"
287 }
288 }
289 }"#;
290 let cfg: McpConfig = serde_json::from_str(json).unwrap();
291 let remote = cfg.servers.get("remote").expect("server present");
292 assert_eq!(
293 remote.env_headers.get("X-Api-Key"),
294 Some(&"REMOTE_MCP_KEY".to_string())
295 );
296 assert_eq!(
297 remote.bearer_token_env_var.as_deref(),
298 Some("REMOTE_MCP_TOKEN")
299 );
300 assert_eq!(remote.scopes, vec!["tools/read", "tools/write"]);
301 assert_eq!(remote.oauth_client_id(), Some("client-123"));
302 assert_eq!(
303 remote.oauth_resource.as_deref(),
304 Some("https://example.invalid")
305 );
306 }
307
308 #[test]
309 fn mcp_server_config_omits_headers_when_empty() {
310 // Empty headers map should not appear in the serialized output —
311 // older mcp.json files written before v0.8.31 must round-trip
312 // unchanged so a `mcp save` from a fresh install doesn't add
313 // dead keys.
314 let cfg = McpServerConfig {
315 command: Some("node".into()),
316 args: vec!["server.js".into()],
317 env: HashMap::new(),
318 cwd: None,
319 url: None,
320 transport: None,
321 connect_timeout: None,
322 execute_timeout: None,
323 read_timeout: None,
324 disabled: false,
325 enabled: true,
326 required: false,
327 enabled_tools: Vec::new(),
328 disabled_tools: Vec::new(),
329 headers: HashMap::new(),
330 env_headers: HashMap::new(),
331 bearer_token_env_var: None,
332 scopes: Vec::new(),
333 oauth: None,
334 oauth_resource: None,
335 reviewed_plugin: None,
336 runtime_added: false,
337 allow_private_network: false,
338 };
339 let serialized = serde_json::to_string(&cfg).unwrap();
340 assert!(
341 !serialized.contains("\"headers\""),
342 "empty headers must be omitted: {serialized}"
343 );
344 assert!(
345 !serialized.contains("\"env_headers\""),
346 "empty env_headers must be omitted: {serialized}"
347 );
348 assert!(
349 !serialized.contains("\"scopes\""),
350 "empty scopes must be omitted: {serialized}"
351 );
352 assert!(
353 !serialized.contains("\"oauth\""),
354 "empty oauth config must be omitted: {serialized}"
355 );
356 }
357
358 #[test]
359 fn expand_env_placeholders_expands_value_from_environment() {
360 let _lock = crate::test_support::lock_test_env();
361 let _secret =
362 crate::test_support::EnvVarGuard::set("MCP_TEST_SECRET_TOKEN", "test-secret-123456");
363 let mut env = HashMap::new();
364 env.insert(
365 "API_TOKEN".to_string(),
366 "${MCP_TEST_SECRET_TOKEN}".to_string(),
367 );
368
369 let expanded = expand_env_placeholders_map(&env, "env").unwrap();
370
371 assert_eq!(
372 expanded.get("API_TOKEN").map(String::as_str),
373 Some("test-secret-123456")
374 );
375 }
376
377 #[test]
378 fn expand_env_placeholders_reports_missing_variable_without_secret_value() {
379 let _lock = crate::test_support::lock_test_env();
380 let _missing = crate::test_support::EnvVarGuard::remove("MCP_TEST_MISSING_SECRET");
381
382 let err = expand_env_placeholders("Bearer ${MCP_TEST_MISSING_SECRET}")
383 .expect_err("missing env should fail")
384 .to_string();
385
386 // The error must name the variable but must not leak the surrounding
387 // value (which in practice carries the secret).
388 assert!(err.contains("MCP_TEST_MISSING_SECRET"));
389 assert!(!err.contains("Bearer "));
390 }
391
392 #[test]
393 fn reviewed_plugin_environment_uses_only_the_pre_dotenv_snapshot() {
394 let _lock = crate::test_support::lock_test_env();
395 let dir = tempfile::tempdir().unwrap();
396 let plugin_base = dir.path().join("plugins/env-snapshot");
397 fs::create_dir_all(&plugin_base).unwrap();
398 fs::write(
399 plugin_base.join("plugin.toml"),
400 "schema_version = 1\n[plugin]\nname = \"env-snapshot\"\nversion = \"1.0.0\"\n",
401 )
402 .unwrap();
403 let (_, authority) = active_plugin_fixture(&plugin_base);
404 let snapshot = crate::plugins::HostEnvironment::from_entries([(
405 OsString::from("PLUGIN_SNAPSHOT_TOKEN"),
406 OsString::from("captured-before-dotenv"),
407 )]);
408 let mut server = test_server_config();
409 server
410 .env
411 .insert("TOKEN".to_string(), "${PLUGIN_SNAPSHOT_TOKEN}".to_string());
412 server.reviewed_plugin =
413 Some(ReviewedPluginMcpSource::from_authority(authority, None, Arc::new(snapshot)).unwrap());
414 let _late_dotenv = crate::test_support::EnvVarGuard::set(
415 "PLUGIN_SNAPSHOT_TOKEN",
416 "workspace-dotenv-must-not-win",
417 );
418
419 let expanded = expanded_mcp_stdio_env(&server).unwrap();
420 assert_eq!(expanded["TOKEN"], "captured-before-dotenv");
421
422 server.reviewed_plugin.as_mut().unwrap().host_environment =
423 Arc::new(crate::plugins::HostEnvironment::from_entries([]));
424 let error = expanded_mcp_stdio_env(&server)
425 .expect_err("a value present only after dotenv must fail closed");
426 assert!(
427 format!("{error:#}").contains("PLUGIN_SNAPSHOT_TOKEN"),
428 "unexpected missing-snapshot error: {error:#}"
429 );
430 assert!(!format!("{error:#}").contains("workspace-dotenv-must-not-win"));
431 }
432
433 fn write_path_only_test_command(dir: &Path) -> String {
434 let command = "codewhale-mcp-path-only-test";
435 #[cfg(windows)]
436 let file_name = format!("{command}.exe");
437 #[cfg(not(windows))]
438 let file_name = command.to_string();
439 let path = dir.join(file_name);
440 fs::write(&path, b"test executable").expect("write path-only test command");
441 #[cfg(unix)]
442 {
443 use std::os::unix::fs::PermissionsExt;
444
445 let mut permissions = fs::metadata(&path)
446 .expect("path-only command metadata")
447 .permissions();
448 permissions.set_mode(0o755);
449 fs::set_permissions(&path, permissions).expect("make path-only test command executable");
450 }
451 command.to_string()
452 }
453
454 #[test]
455 fn static_mcp_command_uses_expanded_sanitized_stdio_path() {
456 let _lock = crate::test_support::lock_test_env();
457 let temp = tempfile::tempdir().expect("tempdir");
458 let command = write_path_only_test_command(temp.path());
459 let _path = crate::test_support::EnvVarGuard::set(
460 "CODEWHALE_MCP_PATH_ONLY_DIR",
461 temp.path().as_os_str(),
462 );
463 let _secret = crate::test_support::EnvVarGuard::set(
464 "CODEWHALE_MCP_STATIC_TEST_SECRET",
465 "must-not-reach-child",
466 );
467 let mut server = test_server_config();
468 server.command = Some(command);
469 server.env.insert(
470 "PATH".to_string(),
471 "${CODEWHALE_MCP_PATH_ONLY_DIR}".to_string(),
472 );
473
474 assert_eq!(
475 static_mcp_command_availability(&server).expect("static command check"),
476 McpCommandAvailability::Available
477 );
478
479 let child_env = mcp_stdio_child_env(&server).expect("stdio child env");
480 assert_eq!(
481 env_value(&child_env, "PATH"),
482 Some(temp.path().as_os_str()),
483 "expanded server PATH must override the inherited PATH"
484 );
485 assert!(
486 child_env
487 .iter()
488 .all(|(key, _)| key != "CODEWHALE_MCP_STATIC_TEST_SECRET"),
489 "static lookup must use the same sanitized parent environment as spawn"
490 );
491
492 let expanded_env = expand_env_placeholders_map(&server.env, "env").expect("expanded env");
493 let mut old_spawn_command = tokio::process::Command::new("unused-test-command");
494 crate::child_env::apply_to_tokio_command_mcp(
495 &mut old_spawn_command,
496 crate::child_env::string_map_env(&expanded_env),
497 );
498 let old_spawn_env = old_spawn_command
499 .as_std()
500 .get_envs()
501 .map(|(key, value)| {
502 (
503 key.to_os_string(),
504 value.expect("spawn env value").to_os_string(),
505 )
506 })
507 .collect::<HashMap<_, _>>();
508 let static_env = child_env.into_iter().collect::<HashMap<_, _>>();
509 assert_eq!(
510 static_env, old_spawn_env,
511 "static lookup and the pre-fix spawn helper must receive identical environments"
512 );
513 }
514
515 #[cfg(not(windows))]
516 #[test]
517 fn static_mcp_command_reports_missing_with_server_path_override() {
518 let temp = tempfile::tempdir().expect("tempdir");
519 let mut server = test_server_config();
520 server.command = Some("codewhale-mcp-command-that-does-not-exist".to_string());
521 server.env.insert(
522 "PATH".to_string(),
523 temp.path().to_string_lossy().into_owned(),
524 );
525
526 assert_eq!(
527 static_mcp_command_availability(&server).expect("static command check"),
528 McpCommandAvailability::Missing
529 );
530 }
531
532 #[test]
533 fn static_mcp_command_reports_invalid_path_expansion() {
534 let _lock = crate::test_support::lock_test_env();
535 let _missing = crate::test_support::EnvVarGuard::remove("CODEWHALE_MCP_MISSING_PATH_DIR");
536 let mut server = test_server_config();
537 server.command = Some("codewhale-mcp-command".to_string());
538 server.env.insert(
539 "PATH".to_string(),
540 "do-not-leak-${CODEWHALE_MCP_MISSING_PATH_DIR}-also-secret".to_string(),
541 );
542
543 let error = static_mcp_command_availability(&server)
544 .expect_err("missing PATH placeholder must fail static validation");
545 let error = format!("{error:#}");
546 assert!(error.contains("CODEWHALE_MCP_MISSING_PATH_DIR"));
547 assert!(!error.contains("codewhale-mcp-command"));
548 assert!(!error.contains("do-not-leak"));
549 assert!(!error.contains("also-secret"));
550 }
551
552 #[cfg(unix)]
553 fn write_unix_test_command(path: &Path, mode: u32) {
554 use std::os::unix::fs::PermissionsExt;
555
556 fs::write(path, b"#!/bin/sh\nexit 0\n").expect("write Unix test command");
557 let mut permissions = fs::metadata(path)
558 .expect("Unix test command metadata")
559 .permissions();
560 permissions.set_mode(mode);
561 fs::set_permissions(path, permissions).expect("set Unix test command mode");
562 }
563
564 #[cfg(unix)]
565 #[test]
566 fn static_mcp_command_anchors_relative_and_empty_path_to_server_cwd() {
567 let temp = tempfile::tempdir().expect("tempdir");
568 let cwd = temp.path().join("server-cwd");
569 let bin = cwd.join("relative-bin");
570 fs::create_dir_all(&bin).expect("relative bin dir");
571 let relative_command = "codewhale-mcp-relative-path-test";
572 write_unix_test_command(&bin.join(relative_command), 0o755);
573
574 let mut server = test_server_config();
575 server.command = Some(relative_command.to_string());
576 server.cwd = Some(cwd.clone());
577 server
578 .env
579 .insert("PATH".to_string(), "relative-bin".to_string());
580 assert_eq!(
581 static_mcp_command_availability(&server).expect("relative PATH check"),
582 McpCommandAvailability::Available
583 );
584
585 let empty_path_command = "codewhale-mcp-empty-path-test";
586 write_unix_test_command(&cwd.join(empty_path_command), 0o755);
587 server.command = Some(empty_path_command.to_string());
588 server.env.insert("PATH".to_string(), String::new());
589 assert_eq!(
590 static_mcp_command_availability(&server).expect("empty PATH check"),
591 McpCommandAvailability::Available,
592 "an empty Unix PATH entry resolves from the child's cwd"
593 );
594 }
595
596 #[cfg(unix)]
597 #[test]
598 fn static_mcp_command_preserves_literal_name_and_requires_execute_bits() {
599 let temp = tempfile::tempdir().expect("tempdir");
600 let literal_command = " codewhale-mcp-literal-command-test ";
601 write_unix_test_command(&temp.path().join(literal_command), 0o755);
602
603 let mut server = test_server_config();
604 server.command = Some(literal_command.to_string());
605 server.env.insert(
606 "PATH".to_string(),
607 temp.path().to_string_lossy().into_owned(),
608 );
609 assert_eq!(
610 static_mcp_command_availability(&server).expect("literal command check"),
611 McpCommandAvailability::Available,
612 "static validation must not trim the command passed to Command::new"
613 );
614
615 let non_executable = temp.path().join("codewhale-mcp-non-executable-test");
616 write_unix_test_command(&non_executable, 0o644);
617 server.command = Some("codewhale-mcp-non-executable-test".to_string());
618 assert_eq!(
619 static_mcp_command_availability(&server).expect("PATH execute-bit check"),
620 McpCommandAvailability::Missing
621 );
622 server.command = Some(non_executable.to_string_lossy().into_owned());
623 assert_eq!(
624 static_mcp_command_availability(&server).expect("absolute execute-bit check"),
625 McpCommandAvailability::Missing
626 );
627 }
628
629 #[cfg(windows)]
630 #[test]
631 fn static_mcp_command_matches_windows_path_and_extension_rules() {
632 let temp = tempfile::tempdir().expect("tempdir");
633 let command = write_path_only_test_command(temp.path());
634 let mut server = test_server_config();
635 server.command = Some(command);
636 server.env.insert(
637 "Path".to_string(),
638 temp.path().to_string_lossy().into_owned(),
639 );
640
641 assert_eq!(
642 static_mcp_command_availability(&server).expect("case-insensitive PATH check"),
643 McpCommandAvailability::Available
644 );
645
646 server.command = Some(
647 temp.path()
648 .join("codewhale-mcp-path-only-test")
649 .to_string_lossy()
650 .into_owned(),
651 );
652 assert_eq!(
653 static_mcp_command_availability(&server).expect("absolute omitted .exe check"),
654 McpCommandAvailability::Available
655 );
656
657 let pathext_command = "codewhale-mcp-pathext-only-test";
658 fs::write(
659 temp.path().join(format!("{pathext_command}.cmd")),
660 b"@exit /b 0\r\n",
661 )
662 .expect("write PATHEXT-only command");
663 server.command = Some(pathext_command.to_string());
664 server.env.insert("PATHEXT".to_string(), ".CMD".to_string());
665 assert_eq!(
666 static_mcp_command_availability(&server).expect("PATHEXT command check"),
667 McpCommandAvailability::NotChecked,
668 "a child-PATH miss is conservative because Windows still searches implicit fallbacks"
669 );
670 server.command = Some(format!("{pathext_command}.cmd"));
671 assert_eq!(
672 static_mcp_command_availability(&server).expect("explicit .cmd command check"),
673 McpCommandAvailability::Available,
674 "Rust requires non-.exe extensions to be explicit"
675 );
676 }
677
678 #[tokio::test]
679 async fn mcp_http_auth_prefers_static_authorization_over_bearer_env() {
680 let mut headers = HashMap::new();
681 headers.insert("Authorization".to_string(), "Bearer static".to_string());
682 let auth = McpHttpAuth {
683 headers,
684 bearer_token_env_var: Some("PATH".to_string()),
685 ..Default::default()
686 };
687
688 let resolved = auth.resolved_headers().await.unwrap();
689 assert_eq!(
690 resolved.get("Authorization"),
691 Some(&"Bearer static".to_string())
692 );
693 }
694
695 #[tokio::test]
696 async fn mcp_http_auth_uses_bearer_env_when_no_authorization_header() {
697 let auth = McpHttpAuth {
698 bearer_token_env_var: Some("PATH".to_string()),
699 ..Default::default()
700 };
701
702 let resolved = auth.resolved_headers().await.unwrap();
703 assert!(
704 resolved
705 .get("Authorization")
706 .is_some_and(|value| value.starts_with("Bearer ") && value.len() > "Bearer ".len()),
707 "expected PATH-backed bearer header, got {resolved:?}"
708 );
709 }
710
711 #[test]
712 fn is_safe_custom_header_accepts_normal_auth_pairs() {
713 assert!(is_safe_custom_header("Authorization", "Bearer tok"));
714 assert!(is_safe_custom_header("X-Api-Key", "deadbeef"));
715 assert!(is_safe_custom_header("x-org", "anthropic"));
716 }
717
718 #[test]
719 fn is_safe_custom_header_rejects_empty_or_whitespace_key() {
720 assert!(!is_safe_custom_header("", "value"));
721 assert!(!is_safe_custom_header(" ", "value"));
722 }
723
724 #[test]
725 fn is_safe_custom_header_rejects_response_splitting_values() {
726 assert!(
727 !is_safe_custom_header("X-Foo", "abc\r\nSet-Cookie: evil=1"),
728 "CRLF in value must reject — response-splitting defense"
729 );
730 assert!(
731 !is_safe_custom_header("X-Foo", "abc\nbar"),
732 "bare LF in value must reject"
733 );
734 assert!(
735 !is_safe_custom_header("X-Foo", "abc\rbar"),
736 "bare CR in value must reject"
737 );
738 }
739
740 #[test]
741 fn is_safe_custom_header_rejects_protocol_framing_overrides() {
742 // The MCP Streamable HTTP transport relies on its own
743 // Accept / Content-Type values for protocol negotiation;
744 // a stray user override would silently break tool discovery.
745 assert!(!is_safe_custom_header("Accept", "text/plain"));
746 assert!(!is_safe_custom_header("accept", "text/plain"));
747 assert!(!is_safe_custom_header("Content-Type", "text/plain"));
748 assert!(!is_safe_custom_header("CONTENT-TYPE", "x/y"));
749 }
750
751 #[test]
752 fn default_mcp_http_get_accepts_json_and_event_stream() {
753 let client = test_http_client();
754 let request = with_default_mcp_http_headers(client.get("https://example.invalid/mcp"), false)
755 .build()
756 .unwrap();
757 assert_eq!(
758 request.headers().get(ACCEPT).and_then(|v| v.to_str().ok()),
759 Some(MCP_HTTP_ACCEPT)
760 );
761 assert!(
762 request.headers().get(CONTENT_TYPE).is_none(),
763 "SSE GET requests should not advertise a JSON request body"
764 );
765 }
766
767 #[test]
768 fn default_mcp_http_post_accepts_json_and_event_stream() {
769 let client = test_http_client();
770 let request = with_default_mcp_http_headers(client.post("https://example.invalid/mcp"), true)
771 .build()
772 .unwrap();
773 assert_eq!(
774 request.headers().get(ACCEPT).and_then(|v| v.to_str().ok()),
775 Some(MCP_HTTP_ACCEPT)
776 );
777 assert_eq!(
778 request
779 .headers()
780 .get(CONTENT_TYPE)
781 .and_then(|v| v.to_str().ok()),
782 Some("application/json")
783 );
784 }
785
786 #[test]
787 fn streamable_http_transport_stores_headers() {
788 let mut headers = HashMap::new();
789 headers.insert("Authorization".to_string(), "Bearer xyz".to_string());
790 let transport = StreamableHttpTransport::new(
791 test_mcp_http_client("https://example.invalid/mcp"),
792 "https://example.invalid/mcp".to_string(),
793 McpHttpAuth {
794 headers: headers.clone(),
795 ..Default::default()
796 },
797 );
798 assert_eq!(transport.auth.headers, headers);
799 }
800
801 #[test]
802 fn mcp_auth_required_error_item_is_model_visible() {
803 let pool = McpPool::new(McpConfig::default());
804 let item = pool.mcp_auth_required_error_item("nordic-mcp");
805 assert_eq!(item["error"], "authentication_required");
806 assert_eq!(item["server"], "nordic-mcp");
807 assert!(
808 item.get("authenticate_tool").is_none(),
809 "an OAuth-servable synthetic tool is only named for a configured needs-auth server: {item}"
810 );
811 assert!(
812 item["message"]
813 .as_str()
814 .expect("message")
815 .contains("codewhale mcp login nordic-mcp")
816 );
817 }
818
819 #[test]
820 fn test_mcp_config_parse_mcp_servers_alias_and_snapshot() {
821 let dir = tempfile::tempdir().unwrap();
822 let path = dir.path().join("mcp.json");
823 fs::write(
824 &path,
825 r#"{
826 "mcpServers": {
827 "disabled": {
828 "command": "node",
829 "args": ["server.js"],
830 "disabled": true
831 }
832 }
833 }"#,
834 )
835 .unwrap();
836
837 let cfg = load_config(&path).unwrap();
838 assert!(cfg.servers.contains_key("disabled"));
839 let snapshot = manager_snapshot_from_config(&path, true).unwrap();
840 assert!(snapshot.reload_required);
841 assert_eq!(snapshot.servers[0].name, "disabled");
842 assert!(!snapshot.servers[0].enabled);
843 assert_eq!(snapshot.servers[0].error.as_deref(), Some("disabled"));
844 assert_eq!(
845 snapshot.servers[0].capability_metadata,
846 McpServerCapabilityMetadata::NotObserved
847 );
848 }
849
850 #[test]
851 fn malformed_mcp_config_error_omits_secret_contents_and_keys() {
852 let dir = tempfile::tempdir().unwrap();
853 let path = dir.path().join("mcp.json");
854 let secret = "cw-secret-mcp-config-4507";
855 fs::write(
856 &path,
857 format!(
858 r#"{{"servers":{{"private":{{"headers":{{"Authorization":"{secret}"}} trailing-junk}}}}}}"#
859 ),
860 )
861 .unwrap();
862
863 let error = load_config(&path).expect_err("malformed MCP config must fail");
864 let diagnostic = format!("{error:#}");
865 assert!(!diagnostic.contains(secret), "{diagnostic}");
866 assert!(!diagnostic.contains("Authorization"), "{diagnostic}");
867 assert!(
868 diagnostic.contains("file contents were omitted"),
869 "{diagnostic}"
870 );
871 }
872
873 #[test]
874 fn workspace_mcp_config_merges_with_project_overrides() {
875 let dir = tempfile::tempdir().unwrap();
876 let global_path = dir.path().join("global-mcp.json");
877 let workspace = dir.path().join("workspace");
878 let project_dir = workspace.join(".codewhale");
879 fs::create_dir_all(&project_dir).unwrap();
880 let _trust = mark_workspace_trusted(&workspace);
881 fs::write(
882 &global_path,
883 r#"{
884 "servers": {
885 "global": {"command": "node", "args": ["global.js"]},
886 "shared": {"command": "node", "args": ["global-shared.js"]}
887 }
888 }"#,
889 )
890 .unwrap();
891 fs::write(
892 project_dir.join("mcp.json"),
893 r#"{
894 "servers": {
895 "project": {"command": "php", "args": ["artisan", "boost:mcp"]},
896 "shared": {"command": "php", "args": ["artisan", "shared:mcp"]}
897 }
898 }"#,
899 )
900 .unwrap();
901
902 let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
903 let workspace = workspace.canonicalize().unwrap();
904
905 assert!(cfg.servers.contains_key("global"));
906 let project = cfg.servers.get("project").unwrap();
907 assert_eq!(project.command.as_deref(), Some("php"));
908 assert_eq!(project.cwd.as_deref(), Some(workspace.as_path()));
909 let shared = cfg.servers.get("shared").unwrap();
910 assert_eq!(shared.args, vec!["artisan", "shared:mcp"]);
911 assert_eq!(shared.cwd.as_deref(), Some(workspace.as_path()));
912 }
913
914 #[test]
915 fn workspace_manager_snapshot_counts_global_and_project_servers() {
916 let dir = tempfile::tempdir().unwrap();
917 let global_path = dir.path().join("global-mcp.json");
918 let workspace = dir.path().join("workspace");
919 let project_dir = workspace.join(".codewhale");
920 fs::create_dir_all(&project_dir).unwrap();
921 let _trust = mark_workspace_trusted(&workspace);
922 fs::write(
923 &global_path,
924 r#"{
925 "servers": {
926 "chrome-devtools": {"command": "npx", "args": ["-y", "chrome-devtools-mcp@latest"]},
927 "context7": {"command": "npx", "args": ["-y", "@upstash/context7-mcp@latest"]}
928 }
929 }"#,
930 )
931 .unwrap();
932 fs::write(
933 project_dir.join("mcp.json"),
934 r#"{
935 "servers": {
936 "laravel-boost": {"command": "php", "args": ["artisan", "boost:mcp"]}
937 }
938 }"#,
939 )
940 .unwrap();
941
942 let plain = manager_snapshot_from_config(&global_path, false).unwrap();
943 let merged =
944 manager_snapshot_from_config_with_workspace(&global_path, &workspace, false).unwrap();
945
946 assert_eq!(plain.servers.len(), 2);
947 assert_eq!(merged.servers.len(), 3);
948 assert!(
949 merged
950 .servers
951 .iter()
952 .any(|server| server.name == "laravel-boost"),
953 "workspace-aware snapshots must include trusted project MCP servers"
954 );
955 }
956
957 #[test]
958 fn plugin_mcp_servers_are_qualified_and_resolve_relative_cwd() {
959 let dir = tempfile::tempdir().unwrap();
960 let plugin_base = dir.path().join("plugins").join("fleet");
961 fs::create_dir_all(plugin_base.join("servers/local")).unwrap();
962 fs::write(plugin_base.join("servers/local/server.js"), "// server\n").unwrap();
963
964 fs::write(
965 plugin_base.join("plugin.toml"),
966 r#"
967 schema_version = 1
968 [plugin]
969 name = "fleet"
970 version = "1.0.0"
971
972 [mcp_servers.local]
973 command = "node"
974 args = ["server.js"]
975 cwd = "servers/local"
976
977 [mcp_servers.remote]
978 url = "https://example.invalid/mcp"
979
980 [capabilities]
981 network_hosts = ["example.invalid"]
982 "#,
983 )
984 .unwrap();
985 let (plugin, authority) = active_plugin_fixture(&plugin_base);
986 let plugin_for_collision = plugin.clone();
987 let authority_for_collision = authority.clone();
988 let mut config = McpConfig::default();
989 config.servers.insert(
990 "global".to_string(),
991 serde_json::from_str(r#"{"command":"node","args":["global.js"]}"#).unwrap(),
992 );
993
994 let cfg = merge_plugin_mcp_servers_from_plugins(
995 config,
996 vec![("fleet".to_string(), plugin, authority)],
997 )
998 .unwrap();
999
1000 assert!(cfg.servers.contains_key("global"));
1001
1002 let local = cfg.servers.get("plugin-5-fleet-local").unwrap();
1003 assert_eq!(local.command.as_deref(), Some("node"));
1004 let staged_root = plugin_for_collision.staged_root.as_deref().unwrap();
1005 assert_eq!(
1006 local.args,
1007 vec![
1008 staged_root
1009 .join("servers/local/server.js")
1010 .display()
1011 .to_string()
1012 ]
1013 );
1014 assert_eq!(
1015 local.cwd.as_deref(),
1016 Some(staged_root.join("servers/local").as_path())
1017 );
1018
1019 let remote = cfg.servers.get("plugin-5-fleet-remote").unwrap();
1020 assert_eq!(remote.url.as_deref(), Some("https://example.invalid/mcp"));
1021 assert!(remote.cwd.is_none());
1022
1023 let mut explicit = McpConfig::default();
1024 explicit.servers.insert(
1025 "plugin-5-fleet-local".to_string(),
1026 serde_json::from_str(r#"{"command":"node","args":["explicit.js"]}"#).unwrap(),
1027 );
1028 let collision_safe = merge_plugin_mcp_servers_from_plugins(
1029 explicit,
1030 vec![(
1031 "fleet".to_string(),
1032 plugin_for_collision,
1033 authority_for_collision,
1034 )],
1035 )
1036 .unwrap();
1037 assert_eq!(
1038 collision_safe.servers["plugin-5-fleet-local"].args,
1039 vec!["explicit.js"],
1040 "explicit MCP config must outrank a colliding plugin server"
1041 );
1042 }
1043
1044 #[cfg(target_os = "macos")]
1045 #[tokio::test]
1046 async fn reviewed_node_mjs_plugin_connects_through_inherited_descriptor() {
1047 if std::process::Command::new("node")
1048 .arg("--version")
1049 .output()
1050 .is_err()
1051 {
1052 eprintln!("skipping reviewed Node ESM launch test because node is unavailable");
1053 return;
1054 }
1055
1056 let dir = tempfile::tempdir().unwrap();
1057 let plugins_root = dir.path().join("plugins");
1058 let plugin_base = plugins_root.join("node-esm");
1059 fs::create_dir_all(&plugin_base).unwrap();
1060 fs::write(
1061 plugin_base.join("server.mjs"),
1062 r#"import readline from 'node:readline';
1063 const lines = readline.createInterface({ input: process.stdin });
1064 lines.on('line', (line) => {
1065 const request = JSON.parse(line);
1066 if (request.id === undefined) return;
1067 let result;
1068 if (request.method === 'initialize') {
1069 result = {
1070 protocolVersion: '2025-06-18',
1071 capabilities: { tools: {} },
1072 serverInfo: { name: 'node-esm', version: '1.0.0' }
1073 };
1074 } else if (request.method === 'tools/list') {
1075 result = {
1076 tools: [{ name: 'ready', description: 'ready', inputSchema: { type: 'object' } }]
1077 };
1078 } else {
1079 result = {};
1080 }
1081 process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
1082 });
1083 "#,
1084 )
1085 .unwrap();
1086 fs::write(
1087 plugin_base.join("plugin.toml"),
1088 r#"
1089 schema_version = 1
1090 [plugin]
1091 name = "node-esm"
1092 version = "1.0.0"
1093
1094 [mcp_servers.local]
1095 command = "node"
1096 args = ["server.mjs"]
1097 connect_timeout = 2
1098 "#,
1099 )
1100 .unwrap();
1101
1102 let discovery = crate::plugins::discovery::DiscoveryConfig {
1103 workspace: dir.path().join("project"),
1104 user_plugins_dir: plugins_root,
1105 workspace_plugins_dir: dir.path().join("workspace-plugins-unused"),
1106 builtin_plugin_dirs: Vec::new(),
1107 state_path: dir.path().join("plugin-state/state.json"),
1108 };
1109 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
1110 registry.trust("node-esm").unwrap();
1111 registry.enable("node-esm").unwrap();
1112 let active = registry.active_plugins()[0].clone();
1113 let authority = registry.authority_for("node-esm").unwrap();
1114 let merged = merge_plugin_mcp_servers_from_plugins(
1115 McpConfig::default(),
1116 vec![("node-esm".to_string(), active, authority)],
1117 )
1118 .unwrap();
1119 let mut pool = McpPool::new(merged);
1120
1121 let connection = pool
1122 .get_or_connect("plugin-8-node-esm-local")
1123 .await
1124 .unwrap();
1125 assert_eq!(connection.tools().len(), 1);
1126 assert_eq!(connection.tools()[0].name, "ready");
1127 }
1128
1129 #[cfg(target_os = "macos")]
1130 #[tokio::test]
1131 async fn reviewed_node_plugins_preserve_module_path_context() {
1132 if std::process::Command::new("node")
1133 .arg("--version")
1134 .output()
1135 .is_err()
1136 {
1137 eprintln!("skipping reviewed multi-file Node ESM launch test because node is unavailable");
1138 return;
1139 }
1140
1141 for (extension, package_type) in [
1142 ("mjs", "module"),
1143 ("js", "module"),
1144 ("js", "commonjs"),
1145 ("cjs", "module"),
1146 ] {
1147 let esm = extension != "cjs" && package_type == "module";
1148 // The entry imports a sibling module, exactly like the computer-use
1149 // bundle (#5916). Launched by descriptor, Node would resolve `./lib/...`
1150 // against `/dev/` and the child would die before the handshake.
1151 let dir = tempfile::tempdir().unwrap();
1152 let plugins_root = dir.path().join("plugins");
1153 let plugin_base = plugins_root.join("node-esm-multi");
1154 fs::create_dir_all(plugin_base.join("mcp")).unwrap();
1155 fs::create_dir_all(plugin_base.join("lib")).unwrap();
1156 fs::write(
1157 plugin_base.join("package.json"),
1158 format!(r#"{{"type":"{package_type}"}}"#),
1159 )
1160 .unwrap();
1161 fs::write(plugin_base.join("mcp/reply.json"), r#"{"answer":42}"#).unwrap();
1162 fs::write(
1163 plugin_base.join("lib").join(format!("reply.{extension}")),
1164 if esm {
1165 r#"import path from 'node:path';
1166 import url from 'node:url';
1167 export const TOOL = 'ready-from-sibling';
1168 export const ENTRY_DIR = path.basename(path.dirname(url.fileURLToPath(import.meta.url)));
1169 "#
1170 } else {
1171 r#"const path = require('node:path');
1172 exports.TOOL = 'ready-from-sibling';
1173 exports.ENTRY_DIR = path.basename(__dirname);
1174 "#
1175 },
1176 )
1177 .unwrap();
1178 let imports = if esm {
1179 format!(
1180 "import readline from 'node:readline';\nimport fs from 'node:fs';\nimport {{ TOOL, ENTRY_DIR }} from '../lib/reply.{extension}';\n"
1181 )
1182 } else {
1183 format!(
1184 "const readline = require('node:readline');\nconst fs = require('node:fs');\nconst {{ TOOL, ENTRY_DIR }} = require('../lib/reply.{extension}');\n"
1185 )
1186 };
1187 fs::write(
1188 plugin_base.join("mcp").join(format!("server.{extension}")),
1189 imports
1190 + r#"const lines = readline.createInterface({ input: process.stdin });
1191 lines.on('line', (line) => {
1192 const request = JSON.parse(line);
1193 if (request.id === undefined) return;
1194 let result;
1195 if (request.method === 'initialize') {
1196 result = {
1197 protocolVersion: '2025-06-18',
1198 capabilities: { tools: {} },
1199 serverInfo: { name: 'node-esm-multi', version: '1.0.0' }
1200 };
1201 } else if (request.method === 'tools/list') {
1202 result = {
1203 tools: [{ name: TOOL, description: ENTRY_DIR, inputSchema: { type: 'object' } }]
1204 };
1205 } else if (request.method === 'tools/call') {
1206 result = { content: [{ type: 'text', text: JSON.stringify({
1207 answer: JSON.parse(fs.readFileSync('reply.json', 'utf8')).answer,
1208 args: process.argv.slice(2)
1209 }) }] };
1210 } else {
1211 result = {};
1212 }
1213 process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
1214 });
1215 "#,
1216 )
1217 .unwrap();
1218 fs::write(
1219 plugin_base.join("plugin.toml"),
1220 format!(
1221 r#"
1222 schema_version = 1
1223 [plugin]
1224 name = "node-esm-multi"
1225 version = "1.0.0"
1226
1227 [mcp_servers.local]
1228 command = "node"
1229 args = ["--no-warnings", "server.{extension}", "fixture-argument"]
1230 cwd = "mcp"
1231 connect_timeout = 2
1232 "#
1233 ),
1234 )
1235 .unwrap();
1236
1237 let discovery = crate::plugins::discovery::DiscoveryConfig {
1238 workspace: dir.path().join("project"),
1239 user_plugins_dir: plugins_root,
1240 workspace_plugins_dir: dir.path().join("workspace-plugins-unused"),
1241 builtin_plugin_dirs: Vec::new(),
1242 state_path: dir.path().join("plugin-state/state.json"),
1243 };
1244 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
1245 registry.trust("node-esm-multi").unwrap();
1246 registry.enable("node-esm-multi").unwrap();
1247 let active = registry.active_plugins()[0].clone();
1248 let authority = registry.authority_for("node-esm-multi").unwrap();
1249 let merged = merge_plugin_mcp_servers_from_plugins(
1250 McpConfig::default(),
1251 vec![("node-esm-multi".to_string(), active, authority)],
1252 )
1253 .unwrap();
1254 let mut pool = McpPool::new(merged);
1255
1256 let connection = pool
1257 .get_or_connect("plugin-14-node-esm-multi-local")
1258 .await
1259 .unwrap();
1260 assert_eq!(connection.tools().len(), 1);
1261 assert_eq!(connection.tools()[0].name, "ready-from-sibling");
1262 // The sibling resolved from the staged tree, not from `/dev/`.
1263 assert_eq!(connection.tools()[0].description.as_deref(), Some("lib"));
1264 let result = pool
1265 .call_tool(
1266 "mcp_plugin-14-node-esm-multi-local_ready-from-sibling",
1267 serde_json::json!({}),
1268 )
1269 .await
1270 .unwrap();
1271 assert_eq!(
1272 result["content"][0]["text"], r#"{"answer":42,"args":["fixture-argument"]}"#,
1273 "{extension}/{package_type} must preserve staged cwd resources and script arguments"
1274 );
1275 registry.disable("node-esm-multi").unwrap();
1276 assert!(pool.all_tools().is_empty());
1277 assert!(
1278 pool.call_tool(
1279 "mcp_plugin-14-node-esm-multi-local_ready-from-sibling",
1280 serde_json::json!({})
1281 )
1282 .await
1283 .is_err(),
1284 "disabled Node tools must not remain callable"
1285 );
1286 }
1287 }
1288
1289 #[cfg(target_os = "macos")]
1290 #[test]
1291 fn node_entry_preserves_package_type_and_sibling_context() {
1292 use std::collections::BTreeMap;
1293 let staged_root = Path::new("/stage/plugin");
1294 let entry = staged_root.join("mcp/server.mjs");
1295 let hash = |paths: &[&str]| {
1296 paths
1297 .iter()
1298 .map(|path| (PathBuf::from(path), "h".to_string()))
1299 .collect::<BTreeMap<_, _>>()
1300 };
1301 // Even a single .js/.cjs depends on filename/package-type semantics.
1302 for extension in ["js", "cjs"] {
1303 let relative = format!("mcp/server.{extension}");
1304 assert!(node_entry_needs_staged_path(
1305 staged_root,
1306 &staged_root.join(&relative),
1307 &hash(&[&relative, "package.json"]),
1308 ));
1309 }
1310 // Manifests, docs, and data files are not modules.
1311 assert!(!node_entry_needs_staged_path(
1312 staged_root,
1313 &entry,
1314 &hash(&[
1315 "mcp/server.mjs",
1316 "plugin.json",
1317 "mcp.json",
1318 "README.md",
1319 "skills/a/SKILL.md"
1320 ]),
1321 ));
1322 for sibling in [
1323 "src/tools.mjs",
1324 "lib/x.js",
1325 "lib/x.cjs",
1326 "native/x.node",
1327 "wasm/x.wasm",
1328 ] {
1329 assert!(
1330 node_entry_needs_staged_path(
1331 staged_root,
1332 &entry,
1333 &hash(&["mcp/server.mjs", "plugin.json", sibling]),
1334 ),
1335 "{sibling} must force a path launch"
1336 );
1337 }
1338 // An entry outside the stage never qualifies.
1339 assert!(!node_entry_needs_staged_path(
1340 Path::new("/elsewhere"),
1341 &entry,
1342 &hash(&["mcp/server.mjs", "src/tools.mjs"]),
1343 ));
1344 }
1345
1346 #[cfg(target_os = "macos")]
1347 #[test]
1348 fn node_esm_descriptor_launch_keeps_options_argv_shape_and_script_arguments() {
1349 use std::ffi::OsString;
1350 let os = |value: &str| OsString::from(value);
1351
1352 // Options before the entrypoint stay in front; the entry is imported by
1353 // descriptor once, an empty main is supplied, and the descriptor path is
1354 // echoed after `--` so `process.argv[1]` keeps the file-mode shape.
1355 let args = vec![
1356 os("--max-old-space-size=256"),
1357 os("/dev/fd/7"),
1358 os("--port"),
1359 os("0"),
1360 ];
1361 assert_eq!(
1362 super::node_esm_descriptor_args(&args, 1),
1363 vec![
1364 os("--max-old-space-size=256"),
1365 os("--import"),
1366 os("/dev/fd/7"),
1367 os("-e"),
1368 os(""),
1369 os("--"),
1370 os("/dev/fd/7"),
1371 os("--port"),
1372 os("0"),
1373 ]
1374 );
1375
1376 // A `.mjs` that is not the first positional argument is not the
1377 // entrypoint; the launch is left untouched.
1378 let args = vec![os("other.js"), os("/dev/fd/7")];
1379 assert_eq!(super::node_esm_descriptor_args(&args, 1), args);
1380
1381 // The original option terminator cannot precede the injected --import.
1382 let args = vec![
1383 os("--no-warnings"),
1384 os("--"),
1385 os("/dev/fd/7"),
1386 os("argument"),
1387 ];
1388 assert_eq!(
1389 super::node_esm_descriptor_args(&args, 2),
1390 vec![
1391 os("--no-warnings"),
1392 os("--import"),
1393 os("/dev/fd/7"),
1394 os("-e"),
1395 os(""),
1396 os("--"),
1397 os("/dev/fd/7"),
1398 os("argument")
1399 ]
1400 );
1401 let args = vec![os("--conditions"), os("fixture"), os("/dev/fd/7")];
1402 let rewritten = super::node_esm_descriptor_args(&args, 2);
1403 assert_eq!(
1404 &rewritten[..4],
1405 &[
1406 os("--conditions"),
1407 os("fixture"),
1408 os("--import"),
1409 os("/dev/fd/7")
1410 ]
1411 );
1412 }
1413
1414 #[cfg(target_os = "macos")]
1415 #[test]
1416 fn reviewed_node_launch_identifies_only_the_script_operand() {
1417 for (args, expected) in [
1418 (vec!["/stage/server.js", "/stage/later.mjs"], Some(0)),
1419 (vec!["other.js", "/stage/later.mjs"], Some(0)),
1420 (
1421 vec!["--require", "/stage/preload.cjs", "/stage/server.js"],
1422 Some(2),
1423 ),
1424 (
1425 vec!["--import", "/stage/preload.mjs", "/stage/server.mjs"],
1426 Some(2),
1427 ),
1428 (vec!["--require"], None),
1429 (vec!["--", "/stage/server.cjs"], Some(1)),
1430 (vec!["--"], None),
1431 (
1432 vec!["--max-old-space-size=256", "/stage/server.mjs"],
1433 Some(1),
1434 ),
1435 (
1436 vec!["--max-old-space-size", "256", "/stage/server.mjs"],
1437 Some(2),
1438 ),
1439 (
1440 vec![
1441 "--abort-on-uncaught-exception",
1442 "--expose-gc",
1443 "--jitless",
1444 "/stage/server.mjs",
1445 ],
1446 Some(3),
1447 ),
1448 (vec!["-e", "console.log('x')", "/stage/later.mjs"], None),
1449 (vec!["--eval=console.log('x')", "/stage/later.mjs"], None),
1450 (vec!["--run=task", "--", "/stage/later.js"], None),
1451 (vec!["--input-type=module", "/stage/server.mjs"], None),
1452 (vec!["--input-type", "module", "/stage/server.mjs"], None),
1453 (vec!["--unknown-option", "/stage/value.js"], None),
1454 (vec!["-", "/stage/later.mjs"], None),
1455 ] {
1456 assert_eq!(node_script_entry_index(&args), expected, "{args:?}");
1457 }
1458 }
1459
1460 #[test]
1461 fn plugin_server_ids_are_unambiguous_across_hyphenated_plugin_and_server_names() {
1462 let left = qualified_plugin_server_name("foo-bar", "baz");
1463 let right = qualified_plugin_server_name("foo", "bar-baz");
1464
1465 assert_eq!(left, "plugin-7-foo-bar-baz");
1466 assert_eq!(right, "plugin-3-foo-bar-baz");
1467 assert_ne!(left, right);
1468 }
1469
1470 #[test]
1471 fn mixed_components_contribute_only_mcp_servers_to_the_mcp_catalog() {
1472 let dir = tempfile::tempdir().unwrap();
1473 let plugin_base = dir.path().join("plugin");
1474 fs::create_dir_all(plugin_base.join("commands")).unwrap();
1475 fs::create_dir_all(plugin_base.join("hooks")).unwrap();
1476 fs::write(plugin_base.join("server.js"), "// reviewed entrypoint\n").unwrap();
1477 fs::write(
1478 plugin_base.join("plugin.toml"),
1479 r#"
1480 schema_version = 1
1481 [plugin]
1482 name = "fleet"
1483 version = "1.0.0"
1484
1485 [mcp_servers.local]
1486 command = "node"
1487 args = ["server.js"]
1488
1489 [mcp_servers.remote]
1490 url = "https://example.invalid/mcp"
1491
1492 [capabilities]
1493 network_hosts = ["example.invalid"]
1494
1495 [commands]
1496 path = "commands"
1497
1498 [hooks]
1499 path = "hooks"
1500 "#,
1501 )
1502 .unwrap();
1503 let (plugin, authority) = active_plugin_fixture(&plugin_base);
1504 assert!(plugin.active());
1505
1506 let cfg = merge_plugin_mcp_servers_from_plugins(
1507 McpConfig::default(),
1508 vec![("fleet".to_string(), plugin.clone(), authority)],
1509 )
1510 .unwrap();
1511 assert!(cfg.servers.contains_key("plugin-5-fleet-local"));
1512 assert!(cfg.servers.contains_key("plugin-5-fleet-remote"));
1513 assert_eq!(
1514 cfg.servers.len(),
1515 2,
1516 "declarative components must not become MCP servers: {:?}",
1517 cfg.servers.keys().collect::<Vec<_>>()
1518 );
1519 assert_eq!(
1520 cfg.servers["plugin-5-fleet-local"].command.as_deref(),
1521 Some("node")
1522 );
1523 assert_eq!(
1524 cfg.servers["plugin-5-fleet-remote"].url.as_deref(),
1525 Some("https://example.invalid/mcp")
1526 );
1527 }
1528
1529 #[test]
1530 fn plugin_mcp_adapter_denies_disabled_and_untrusted_bundles() {
1531 let dir = tempfile::tempdir().unwrap();
1532 let plugin_base = dir.path().join("plugin");
1533 fs::create_dir_all(&plugin_base).unwrap();
1534 fs::write(
1535 plugin_base.join("plugin.toml"),
1536 r#"
1537 schema_version = 1
1538 [plugin]
1539 name = "denied"
1540 version = "1.0.0"
1541
1542 [mcp_servers.local]
1543 command = "node"
1544 "#,
1545 )
1546 .unwrap();
1547 let (mut disabled, authority) = active_plugin_fixture(&plugin_base);
1548 disabled.enabled = false;
1549 let mut untrusted = disabled.clone();
1550 untrusted.enabled = true;
1551 untrusted.trust_status = crate::plugins::types::PluginTrustStatus::NeverReviewed;
1552
1553 for plugin in [disabled, untrusted] {
1554 let config = merge_plugin_mcp_servers_from_plugins(
1555 McpConfig::default(),
1556 vec![("denied".to_string(), plugin, authority.clone())],
1557 )
1558 .unwrap();
1559 assert!(
1560 config.servers.is_empty(),
1561 "headless MCP adapter admitted an inactive bundle"
1562 );
1563 }
1564 }
1565
1566 #[test]
1567 fn plugin_mcp_adapter_denies_content_changed_after_snapshot() {
1568 let dir = tempfile::tempdir().unwrap();
1569 let plugin_base = dir.path().join("plugin");
1570 fs::create_dir_all(&plugin_base).unwrap();
1571 let manifest_path = plugin_base.join("plugin.toml");
1572 fs::write(
1573 &manifest_path,
1574 r#"
1575 schema_version = 1
1576 [plugin]
1577 name = "changed"
1578 version = "1.0.0"
1579
1580 [mcp_servers.local]
1581 command = "node"
1582 "#,
1583 )
1584 .unwrap();
1585 let (plugin, authority) = active_plugin_fixture(&plugin_base);
1586 fs::write(plugin_base.join("late-change.txt"), "changed after review").unwrap();
1587
1588 let config = merge_plugin_mcp_servers_from_plugins(
1589 McpConfig::default(),
1590 vec![("changed".to_string(), plugin, authority)],
1591 )
1592 .unwrap();
1593 assert!(config.servers.is_empty());
1594 }
1595
1596 fn registry_with_local_mcp(
1597 name: &str,
1598 base_path: PathBuf,
1599 workspace: &Path,
1600 ) -> crate::plugins::PluginRegistry {
1601 fs::write(
1602 base_path.join("plugin.toml"),
1603 format!(
1604 r#"
1605 schema_version = 1
1606 [plugin]
1607 name = "{name}"
1608 version = "1.0.0"
1609
1610 [mcp_servers.local]
1611 command = "node"
1612 args = ["server.js"]
1613 "#,
1614 ),
1615 )
1616 .unwrap();
1617 let plugins_root = base_path.parent().expect("plugin parent").to_path_buf();
1618 let discovery = crate::plugins::discovery::DiscoveryConfig {
1619 workspace: workspace.to_path_buf(),
1620 user_plugins_dir: plugins_root,
1621 workspace_plugins_dir: workspace.join(".codewhale/plugins-unused"),
1622 builtin_plugin_dirs: Vec::new(),
1623 state_path: workspace
1624 .join("plugin-state")
1625 .join(format!("plugin-state-{name}.json")),
1626 };
1627 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
1628 registry.trust(name).unwrap();
1629 registry.enable(name).unwrap();
1630 registry
1631 }
1632
1633 #[test]
1634 fn plugin_mcp_servers_merge_without_project_config() {
1635 let dir = tempfile::tempdir().unwrap();
1636 let global_path = dir.path().join("global-mcp.json");
1637 let workspace = dir.path().join("workspace");
1638 let plugin_base = dir.path().join("plugins").join("fixture");
1639 fs::create_dir_all(&workspace).unwrap();
1640 fs::create_dir_all(&plugin_base).unwrap();
1641 fs::write(
1642 &global_path,
1643 r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
1644 )
1645 .unwrap();
1646
1647 let plugins = registry_with_local_mcp("fixture", plugin_base.clone(), &workspace);
1648 let staged_root = plugins
1649 .get("fixture")
1650 .and_then(|plugin| plugin.staged_root.clone())
1651 .expect("trusted plugin should have an immutable runtime snapshot");
1652 let cfg = load_config_with_workspace_and_plugins(&global_path, &workspace, &plugins).unwrap();
1653
1654 assert!(cfg.servers.contains_key("global"));
1655 let qualified_name = qualified_plugin_server_name("fixture", "local");
1656 let local = cfg
1657 .servers
1658 .get(&qualified_name)
1659 .expect("plugin MCP should merge without a project MCP config");
1660 assert_eq!(local.command.as_deref(), Some("node"));
1661 assert_eq!(local.cwd.as_deref(), Some(staged_root.as_path()));
1662 }
1663
1664 #[cfg(unix)]
1665 #[tokio::test]
1666 async fn plugin_mcp_lazy_spawn_denies_component_changed_after_pool_construction() {
1667 use std::os::unix::fs::PermissionsExt;
1668
1669 let dir = tempfile::tempdir().unwrap();
1670 let plugins_root = dir.path().join("plugins");
1671 let plugin_base = plugins_root.join("guarded");
1672 fs::create_dir_all(&plugin_base).unwrap();
1673 let server_path = plugin_base.join("server.sh");
1674 fs::write(&server_path, "#!/bin/sh\nexit 0\n").unwrap();
1675 let mut permissions = fs::metadata(&server_path).unwrap().permissions();
1676 permissions.set_mode(0o700);
1677 fs::set_permissions(&server_path, permissions).unwrap();
1678 fs::write(
1679 plugin_base.join("plugin.toml"),
1680 r#"
1681 schema_version = 1
1682 [plugin]
1683 name = "guarded"
1684 version = "1.0.0"
1685
1686 [mcp_servers.local]
1687 command = "sh"
1688 args = ["server.sh"]
1689 connect_timeout = 1
1690 "#,
1691 )
1692 .unwrap();
1693
1694 let discovery = crate::plugins::discovery::DiscoveryConfig {
1695 workspace: dir.path().join("project"),
1696 user_plugins_dir: plugins_root,
1697 workspace_plugins_dir: dir.path().join("workspace-plugins"),
1698 builtin_plugin_dirs: Vec::new(),
1699 state_path: dir.path().join("plugin-state/state.json"),
1700 };
1701 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
1702 registry.trust("guarded").unwrap();
1703 registry.enable("guarded").unwrap();
1704 let active = registry.active_plugins()[0].clone();
1705 let authority = registry.authority_for("guarded").unwrap();
1706 let merged = merge_plugin_mcp_servers_from_plugins(
1707 McpConfig::default(),
1708 vec![("guarded".to_string(), active, authority)],
1709 )
1710 .unwrap();
1711 assert!(
1712 merged.servers["plugin-7-guarded-local"]
1713 .reviewed_plugin
1714 .is_some(),
1715 "plugin provenance must survive through MCP pool construction"
1716 );
1717 let mut pool = McpPool::new(merged);
1718
1719 // Adversarial mutation after trust, enablement, merge, and pool
1720 // construction. If the lazy child executes, it creates this marker before
1721 // closing stdio, so the regression proves denial happened pre-spawn.
1722 let executed_marker = plugin_base.join("executed.marker");
1723 fs::write(&server_path, "#!/bin/sh\n: > executed.marker\nexit 0\n").unwrap();
1724
1725 let error = pool
1726 .get_or_connect("plugin-7-guarded-local")
1727 .await
1728 .err()
1729 .expect("changed reviewed component must be denied before spawn");
1730 let message = format!("{error:#}");
1731 assert!(
1732 message.contains("Refusing to use MCP server 'plugin-7-guarded-local'"),
1733 "unexpected pre-spawn denial: {message}"
1734 );
1735 assert!(message.contains("changed after review"));
1736 assert!(message.contains("/plugin reload"));
1737 assert!(
1738 !executed_marker.exists(),
1739 "mutated MCP component executed despite pre-spawn hash denial"
1740 );
1741 }
1742
1743 #[cfg(unix)]
1744 #[tokio::test]
1745 async fn plugin_mcp_inflight_call_is_cancelled_after_cross_process_revocation() {
1746 let _env_lock = crate::test_support::lock_test_env();
1747 let dir = tempfile::tempdir().unwrap();
1748 let call_marker = dir.path().join("call.marker");
1749 let _call_marker_env = crate::test_support::EnvVarGuard::set(
1750 "CODEWHALE_TEST_PLUGIN_CALL_MARKER",
1751 call_marker.as_os_str(),
1752 );
1753 let plugins_root = dir.path().join("plugins");
1754 let plugin_base = plugins_root.join("revoked");
1755 fs::create_dir_all(&plugin_base).unwrap();
1756 fs::create_dir_all(dir.path().join("project")).unwrap();
1757 fs::write(
1758 plugin_base.join("server.sh"),
1759 r#"#!/bin/sh
1760 trap 'exit 0' TERM INT
1761 while IFS= read -r line; do
1762 case "$line" in
1763 *'"method":"notifications/initialized"'*)
1764 ;;
1765 *'"method":"initialize"'*)
1766 printf '%s\n' '{"jsonrpc":"2.0","id":"1","result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"revocation-test","version":"1.0.0"},"capabilities":{"tools":{}}}}'
1767 ;;
1768 *'"method":"tools/list"'*)
1769 printf '%s\n' '{"jsonrpc":"2.0","id":"2","result":{"tools":[{"name":"wait","description":"Wait until revoked","inputSchema":{"type":"object"}}]}}'
1770 ;;
1771 *'"method":"tools/call"'*)
1772 : > "$CALL_MARKER"
1773 while :; do sleep 1; done
1774 ;;
1775 esac
1776 done
1777 "#,
1778 )
1779 .unwrap();
1780 fs::write(
1781 plugin_base.join("plugin.toml"),
1782 r#"
1783 schema_version = 1
1784 [plugin]
1785 name = "revoked"
1786 version = "1.0.0"
1787
1788 [mcp_servers.local]
1789 command = "sh"
1790 args = ["server.sh"]
1791 connect_timeout = 2
1792 execute_timeout = 30
1793 read_timeout = 30
1794
1795 [mcp_servers.local.env]
1796 CALL_MARKER = "${CODEWHALE_TEST_PLUGIN_CALL_MARKER}"
1797 "#,
1798 )
1799 .unwrap();
1800
1801 let discovery = crate::plugins::discovery::DiscoveryConfig {
1802 workspace: dir.path().join("project"),
1803 user_plugins_dir: plugins_root,
1804 workspace_plugins_dir: dir.path().join("workspace-plugins-unused"),
1805 builtin_plugin_dirs: Vec::new(),
1806 state_path: dir.path().join("plugin-state/state.json"),
1807 };
1808 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
1809 registry.trust("revoked").unwrap();
1810 registry.enable("revoked").unwrap();
1811 let active = registry.active_plugins()[0].clone();
1812 let authority = registry.authority_for("revoked").unwrap();
1813 let merged = merge_plugin_mcp_servers_from_plugins(
1814 McpConfig::default(),
1815 vec![("revoked".to_string(), active, authority)],
1816 )
1817 .unwrap();
1818 let mut pool = McpPool::new(merged);
1819 pool.get_or_connect("plugin-7-revoked-local").await.unwrap();
1820
1821 let call = tokio::spawn(async move {
1822 pool.call_tool("mcp_plugin-7-revoked-local_wait", serde_json::json!({}))
1823 .await
1824 });
1825 for _ in 0..100 {
1826 if call_marker.exists() {
1827 break;
1828 }
1829 if call.is_finished() {
1830 let early = call
1831 .await
1832 .expect("in-flight tool task panicked before reaching the server");
1833 panic!("in-flight tool call ended before reaching the server: {early:?}");
1834 }
1835 tokio::time::sleep(Duration::from_millis(20)).await;
1836 }
1837 assert!(
1838 call_marker.exists(),
1839 "test server never observed the in-flight tool call"
1840 );
1841
1842 let mut external = crate::plugins::discovery::discover_with_config(&discovery);
1843 external.revoke_trust("revoked").unwrap();
1844 let result = tokio::time::timeout(Duration::from_secs(5), call)
1845 .await
1846 .expect("revocation watcher did not cancel the in-flight call")
1847 .unwrap();
1848 let error = result
1849 .expect_err("revoked in-flight call must not complete")
1850 .to_string();
1851 assert!(error.contains("cancelled after authority changed"));
1852 assert!(error.contains("disabled, revoked, or no longer matches"));
1853 }
1854
1855 #[cfg(unix)]
1856 #[tokio::test]
1857 async fn plugin_stdio_authority_cancellation_terminates_an_idle_child() {
1858 let dir = tempfile::tempdir().unwrap();
1859 let plugin_base = dir.path().join("plugins/idle-child");
1860 fs::create_dir_all(&plugin_base).unwrap();
1861 fs::write(
1862 plugin_base.join("plugin.toml"),
1863 "schema_version = 1\n[plugin]\nname = \"idle-child\"\nversion = \"1.0.0\"\n",
1864 )
1865 .unwrap();
1866 let (_, authority) = active_plugin_fixture(&plugin_base);
1867 let mut config = test_server_config();
1868 config.command = Some("sh".to_string());
1869 config.args = vec![
1870 "-c".to_string(),
1871 "trap 'exit 0' TERM INT; while :; do sleep 1; done".to_string(),
1872 ];
1873 config.reviewed_plugin = Some(
1874 ReviewedPluginMcpSource::from_authority(
1875 authority,
1876 None,
1877 Arc::new(crate::plugins::HostEnvironment::capture()),
1878 )
1879 .unwrap(),
1880 );
1881 let cancellation = tokio_util::sync::CancellationToken::new();
1882 let transport = StdioTransport::spawn(
1883 "idle-child",
1884 config.command.as_deref().unwrap(),
1885 &config,
1886 cancellation.clone(),
1887 )
1888 .unwrap();
1889 assert!(transport.child.lock().await.try_wait().unwrap().is_none());
1890
1891 cancellation.cancel();
1892 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
1893 loop {
1894 if transport.child.lock().await.try_wait().unwrap().is_some() {
1895 break;
1896 }
1897 assert!(
1898 tokio::time::Instant::now() < deadline,
1899 "authority cancellation left the plugin stdio child alive"
1900 );
1901 tokio::time::sleep(Duration::from_millis(20)).await;
1902 }
1903 }
1904
1905 #[cfg(unix)]
1906 #[tokio::test]
1907 async fn plugin_stdio_does_not_surface_reviewed_child_stderr() {
1908 let dir = tempfile::tempdir().unwrap();
1909 let plugin_base = dir.path().join("plugins/stderr-secret");
1910 fs::create_dir_all(&plugin_base).unwrap();
1911 fs::write(
1912 plugin_base.join("plugin.toml"),
1913 "schema_version = 1\n[plugin]\nname = \"stderr-secret\"\nversion = \"1.0.0\"\n",
1914 )
1915 .unwrap();
1916 let (_, authority) = active_plugin_fixture(&plugin_base);
1917 let mut config = test_server_config();
1918 config.command = Some("sh".to_string());
1919 config.args = vec![
1920 "-c".to_string(),
1921 "echo 'ARBITRARY_PLUGIN_CREDENTIAL' 1>&2; exit 1".to_string(),
1922 ];
1923 config.reviewed_plugin = Some(
1924 ReviewedPluginMcpSource::from_authority(
1925 authority,
1926 None,
1927 Arc::new(crate::plugins::HostEnvironment::capture()),
1928 )
1929 .unwrap(),
1930 );
1931 let mut transport = StdioTransport::spawn(
1932 "stderr-secret",
1933 config.command.as_deref().unwrap(),
1934 &config,
1935 tokio_util::sync::CancellationToken::new(),
1936 )
1937 .unwrap();
1938
1939 tokio::time::sleep(Duration::from_millis(100)).await;
1940 let error = transport
1941 .recv()
1942 .await
1943 .expect_err("reviewed child should have closed its transport")
1944 .to_string();
1945 assert!(error.contains("Stdio transport closed"));
1946 assert!(!error.contains("ARBITRARY_PLUGIN_CREDENTIAL"));
1947 }
1948
1949 /// #6187: a crashed stdio child must stop reading as "ready" before any
1950 /// call is in flight — `is_ready` probes the child, so the pool rebuilds
1951 /// the connection on the next use instead of handing the dead transport
1952 /// back.
1953 #[cfg(unix)]
1954 #[tokio::test]
1955 async fn dead_stdio_child_stops_reading_ready_without_a_call_in_flight() {
1956 let mut config = test_server_config();
1957 config.command = Some("sh".to_string());
1958 config.args = vec!["-c".to_string(), "while :; do sleep 1; done".to_string()];
1959 let transport = StdioTransport::spawn(
1960 "idle",
1961 "sh",
1962 &config,
1963 tokio_util::sync::CancellationToken::new(),
1964 )
1965 .unwrap();
1966 let child = Arc::clone(&transport.child);
1967 let connection = test_connection(Box::new(transport));
1968
1969 // Alive child: the Ready state flag is the whole answer.
1970 assert!(
1971 connection.is_ready(),
1972 "a live stdio child must not be probed dead"
1973 );
1974
1975 child.lock().await.start_kill().unwrap();
1976 let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
1977 loop {
1978 if child.lock().await.try_wait().unwrap().is_some() {
1979 break;
1980 }
1981 assert!(
1982 tokio::time::Instant::now() < deadline,
1983 "killed stdio child was never reaped"
1984 );
1985 tokio::time::sleep(Duration::from_millis(10)).await;
1986 }
1987
1988 assert!(
1989 !connection.is_ready(),
1990 "a reaped stdio child must fail is_ready without a call in flight"
1991 );
1992 }
1993
1994 #[tokio::test]
1995 async fn revoked_plugin_mcp_denies_catalog_tool_resource_and_prompt_operations() {
1996 let dir = tempfile::tempdir().unwrap();
1997 let plugins_root = dir.path().join("plugins");
1998 let plugin_base = plugins_root.join("catalog-guard");
1999 fs::create_dir_all(&plugin_base).unwrap();
2000 fs::create_dir_all(dir.path().join("project")).unwrap();
2001 fs::write(
2002 plugin_base.join("plugin.toml"),
2003 "schema_version = 1\n[plugin]\nname = \"catalog-guard\"\nversion = \"1.0.0\"\n",
2004 )
2005 .unwrap();
2006 let discovery = crate::plugins::discovery::DiscoveryConfig {
2007 workspace: dir.path().join("project"),
2008 user_plugins_dir: plugins_root,
2009 workspace_plugins_dir: dir.path().join("workspace-plugins-unused"),
2010 builtin_plugin_dirs: Vec::new(),
2011 state_path: dir.path().join("plugin-state/state.json"),
2012 };
2013 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
2014 registry.trust("catalog-guard").unwrap();
2015 registry.enable("catalog-guard").unwrap();
2016 let authority = registry.authority_for("catalog-guard").unwrap();
2017
2018 let sent = Arc::new(Mutex::new(Vec::new()));
2019 let mut connection = test_connection(Box::new(ScriptedValueTransport {
2020 sent: Arc::clone(&sent),
2021 responses: VecDeque::new(),
2022 }));
2023 let source = ReviewedPluginMcpSource::from_authority(
2024 authority,
2025 None,
2026 Arc::new(crate::plugins::HostEnvironment::capture()),
2027 )
2028 .unwrap();
2029 connection.config.reviewed_plugin = Some(source.clone());
2030 connection.tools.push(McpTool {
2031 name: "echo".to_string(),
2032 description: None,
2033 input_schema: serde_json::json!({}),
2034 });
2035 connection.resources.push(McpResource {
2036 uri: "memory://one".to_string(),
2037 name: "one".to_string(),
2038 description: None,
2039 mime_type: None,
2040 });
2041 connection.resource_templates.push(McpResourceTemplate {
2042 uri_template: "memory://{id}".to_string(),
2043 name: "memory".to_string(),
2044 description: None,
2045 mime_type: None,
2046 });
2047 connection.prompts.push(McpPrompt {
2048 name: "review".to_string(),
2049 description: None,
2050 arguments: Vec::new(),
2051 });
2052 let mut config = McpConfig::default();
2053 let mut server = test_server_config();
2054 server.reviewed_plugin = Some(source);
2055 config.servers.insert("guarded".to_string(), server);
2056 let mut pool = McpPool::new(config);
2057 pool.connections.insert("guarded".to_string(), connection);
2058 assert_eq!(pool.all_tools().len(), 1);
2059 assert_eq!(pool.all_resources().len(), 1);
2060 assert_eq!(pool.all_resource_templates().len(), 1);
2061 assert_eq!(pool.all_prompts().len(), 1);
2062
2063 let mut external = crate::plugins::discovery::discover_with_config(&discovery);
2064 external.revoke_trust("catalog-guard").unwrap();
2065 assert!(pool.all_tools().is_empty());
2066 assert!(pool.all_resources().is_empty());
2067 assert!(pool.all_resource_templates().is_empty());
2068 assert!(pool.all_prompts().is_empty());
2069
2070 let tool = pool
2071 .call_tool("mcp_guarded_echo", serde_json::json!({}))
2072 .await;
2073 let resource = pool.read_resource("guarded", "memory://one").await;
2074 let prompt = pool
2075 .get_prompt("guarded", "review", serde_json::json!({}))
2076 .await;
2077 let resource_catalog = pool
2078 .call_tool(
2079 "list_mcp_resources",
2080 serde_json::json!({"server": "guarded"}),
2081 )
2082 .await;
2083 let template_catalog = pool
2084 .call_tool(
2085 "list_mcp_resource_templates",
2086 serde_json::json!({"server": "guarded"}),
2087 )
2088 .await;
2089 for result in [tool, resource, prompt, resource_catalog, template_catalog] {
2090 let error = result
2091 .expect_err("revoked plugin MCP operation must fail closed")
2092 .to_string();
2093 assert!(error.contains("Refusing to use MCP server 'guarded'"));
2094 }
2095 assert!(
2096 sent.lock().unwrap().is_empty(),
2097 "revoked plugin MCP operation reached the transport"
2098 );
2099 }
2100
2101 fn cached_reviewed_plugin_catalog_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, McpPool) {
2102 let dir = tempfile::tempdir().unwrap();
2103 let plugins_root = dir.path().join("plugins");
2104 let plugin_base = plugins_root.join("catalog-drift");
2105 fs::create_dir_all(&plugin_base).unwrap();
2106 fs::create_dir_all(dir.path().join("project")).unwrap();
2107 fs::write(
2108 plugin_base.join("plugin.toml"),
2109 "schema_version = 1\n[plugin]\nname = \"catalog-drift\"\nversion = \"1.0.0\"\n",
2110 )
2111 .unwrap();
2112 let discovery = crate::plugins::discovery::DiscoveryConfig {
2113 workspace: dir.path().join("project"),
2114 user_plugins_dir: plugins_root,
2115 workspace_plugins_dir: dir.path().join("workspace-plugins-unused"),
2116 builtin_plugin_dirs: Vec::new(),
2117 state_path: dir.path().join("plugin-state/state.json"),
2118 };
2119 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
2120 registry.trust("catalog-drift").unwrap();
2121 registry.enable("catalog-drift").unwrap();
2122 let authority = registry.authority_for("catalog-drift").unwrap();
2123 let staged_manifest = authority.staged_manifest.clone();
2124
2125 let mut connection = test_connection(Box::new(ScriptedValueTransport {
2126 sent: Arc::new(Mutex::new(Vec::new())),
2127 responses: VecDeque::new(),
2128 }));
2129 let source = ReviewedPluginMcpSource::from_authority(
2130 authority,
2131 None,
2132 Arc::new(crate::plugins::HostEnvironment::capture()),
2133 )
2134 .unwrap();
2135 connection.config.reviewed_plugin = Some(source.clone());
2136 connection.tools.push(McpTool {
2137 name: "echo".to_string(),
2138 description: None,
2139 input_schema: serde_json::json!({}),
2140 });
2141 connection.resources.push(McpResource {
2142 uri: "memory://one".to_string(),
2143 name: "one".to_string(),
2144 description: None,
2145 mime_type: None,
2146 });
2147 connection.resource_templates.push(McpResourceTemplate {
2148 uri_template: "memory://{id}".to_string(),
2149 name: "memory".to_string(),
2150 description: None,
2151 mime_type: None,
2152 });
2153 connection.prompts.push(McpPrompt {
2154 name: "review".to_string(),
2155 description: None,
2156 arguments: Vec::new(),
2157 });
2158 let mut config = McpConfig::default();
2159 let mut server = test_server_config();
2160 server.reviewed_plugin = Some(source);
2161 config.servers.insert("guarded".to_string(), server);
2162 let mut pool = McpPool::new(config);
2163 pool.connections.insert("guarded".to_string(), connection);
2164 assert_eq!(pool.all_tools().len(), 1);
2165 assert_eq!(pool.all_resources().len(), 1);
2166 assert_eq!(pool.all_resource_templates().len(), 1);
2167 assert_eq!(pool.all_prompts().len(), 1);
2168
2169 (dir, plugin_base, staged_manifest, pool)
2170 }
2171
2172 fn assert_reviewed_plugin_catalog_hidden(pool: &McpPool, boundary: &str) {
2173 assert!(pool.all_tools().is_empty());
2174 assert!(pool.all_resources().is_empty());
2175 assert!(pool.all_resource_templates().is_empty());
2176 assert!(pool.all_prompts().is_empty());
2177 assert!(
2178 pool.to_api_tools()
2179 .iter()
2180 .all(|tool| tool.name != "mcp_guarded_echo"),
2181 "{boundary} drift must remove cached reviewed tools from the model API catalog"
2182 );
2183 assert!(pool.parse_prefixed_name("mcp_guarded_echo").is_err());
2184 }
2185
2186 #[test]
2187 fn reviewed_plugin_source_drift_hides_every_cached_catalog_surface() {
2188 let (_dir, plugin_base, _staged_manifest, pool) = cached_reviewed_plugin_catalog_fixture();
2189
2190 fs::write(plugin_base.join("unreviewed-companion.txt"), b"drift").unwrap();
2191
2192 assert_reviewed_plugin_catalog_hidden(&pool, "source");
2193 }
2194
2195 #[cfg(unix)]
2196 #[test]
2197 fn reviewed_plugin_stage_drift_hides_every_cached_catalog_surface() {
2198 use std::io::Write as _;
2199 use std::os::unix::fs::PermissionsExt as _;
2200
2201 let (_dir, _plugin_base, staged_manifest, pool) = cached_reviewed_plugin_catalog_fixture();
2202 std::fs::set_permissions(&staged_manifest, std::fs::Permissions::from_mode(0o600)).unwrap();
2203 std::fs::OpenOptions::new()
2204 .append(true)
2205 .open(&staged_manifest)
2206 .unwrap()
2207 .write_all(b"\n# test-only staged drift\n")
2208 .unwrap();
2209
2210 assert_reviewed_plugin_catalog_hidden(&pool, "staged-tree");
2211 }
2212
2213 #[tokio::test]
2214 async fn reviewed_plugin_oauth_is_disabled_without_network_or_token_mutation() {
2215 let dir = tempfile::tempdir().unwrap();
2216 let plugin_base = dir.path().join("plugins/oauth-disabled");
2217 fs::create_dir_all(&plugin_base).unwrap();
2218 fs::write(
2219 plugin_base.join("plugin.toml"),
2220 "schema_version = 1\n[plugin]\nname = \"oauth-disabled\"\nversion = \"1.0.0\"\n",
2221 )
2222 .unwrap();
2223 let (_, authority) = active_plugin_fixture(&plugin_base);
2224
2225 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2226 let endpoint = format!("http://{}/mcp", listener.local_addr().unwrap());
2227 let mut server = test_server_config();
2228 server.command = None;
2229 server.url = Some(endpoint.clone());
2230 server.reviewed_plugin = Some(
2231 ReviewedPluginMcpSource::from_authority(
2232 authority,
2233 Some(&endpoint),
2234 Arc::new(crate::plugins::HostEnvironment::default()),
2235 )
2236 .unwrap(),
2237 );
2238
2239 assert_eq!(
2240 oauth::auth_status_for_server("plugin-oauth", &server, None).await,
2241 oauth::McpAuthStatus::Unsupported
2242 );
2243 assert!(
2244 oauth::oauth_login_support(&server, None)
2245 .await
2246 .unwrap()
2247 .is_none()
2248 );
2249 assert!(
2250 oauth::McpOAuthRuntime::from_server_config(
2251 "plugin-oauth",
2252 &server,
2253 reqwest::header::HeaderMap::new(),
2254 )
2255 .await
2256 .unwrap()
2257 .is_none()
2258 );
2259 let login_error =
2260 oauth::perform_oauth_login_for_server("plugin-oauth", &server, None, None, None, None)
2261 .await
2262 .expect_err("plugin OAuth login must be disabled")
2263 .to_string();
2264 assert!(login_error.contains("disabled for plugin-contributed MCP servers"));
2265 let logout_error = oauth::delete_oauth_tokens_for_server("plugin-oauth", &server)
2266 .expect_err("plugin OAuth logout must not touch token storage")
2267 .to_string();
2268 assert!(logout_error.contains("storage is disabled"));
2269
2270 assert!(
2271 tokio::time::timeout(Duration::from_millis(50), listener.accept())
2272 .await
2273 .is_err(),
2274 "plugin OAuth disabled paths must not probe the network"
2275 );
2276 }
2277
2278 fn active_plugin_fixture(
2279 plugin_base: &Path,
2280 ) -> (
2281 crate::plugins::types::LoadedPlugin,
2282 crate::plugins::types::PluginAuthority,
2283 ) {
2284 let plugins_root = plugin_base.parent().expect("plugin parent").to_path_buf();
2285 // Callers use both `<temp>/plugins/<name>` and `<temp>/<name>` layouts.
2286 // Only peel the conventional `plugins` directory; otherwise using the
2287 // parent would place multiple parallel fixtures in the shared system temp
2288 // root and make their durable state files collide on Windows.
2289 let root = if plugins_root.file_name().and_then(|name| name.to_str()) == Some("plugins") {
2290 plugins_root.parent().unwrap_or(&plugins_root).to_path_buf()
2291 } else {
2292 plugins_root.clone()
2293 };
2294 let discovery = crate::plugins::discovery::DiscoveryConfig {
2295 workspace: root.join("project"),
2296 user_plugins_dir: plugins_root,
2297 workspace_plugins_dir: root.join("workspace-plugins-unused"),
2298 builtin_plugin_dirs: Vec::new(),
2299 state_path: root.join("plugin-state").join(format!(
2300 "plugin-state-{}.json",
2301 plugin_base.file_name().unwrap().to_string_lossy()
2302 )),
2303 };
2304 let mut registry = crate::plugins::discovery::discover_with_config(&discovery);
2305 let name = registry
2306 .list()
2307 .first()
2308 .expect("discovered plugin")
2309 .name()
2310 .to_string();
2311 registry.trust(&name).unwrap();
2312 registry.enable(&name).unwrap();
2313 (
2314 registry.get(&name).unwrap().clone(),
2315 registry.authority_for(&name).unwrap(),
2316 )
2317 }
2318
2319 #[test]
2320 fn workspace_mcp_config_ignores_project_file_until_workspace_trusted() {
2321 let dir = tempfile::tempdir().unwrap();
2322 let global_path = dir.path().join("global-mcp.json");
2323 let workspace = dir.path().join("workspace");
2324 let project_dir = workspace.join(".codewhale");
2325 let plugin_base = dir.path().join("plugins").join("fixture");
2326 fs::create_dir_all(&project_dir).unwrap();
2327 fs::create_dir_all(&plugin_base).unwrap();
2328 fs::write(
2329 &global_path,
2330 r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
2331 )
2332 .unwrap();
2333 fs::write(
2334 project_dir.join("mcp.json"),
2335 r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
2336 )
2337 .unwrap();
2338
2339 let plugins = registry_with_local_mcp("fixture", plugin_base, &workspace);
2340 let cfg = load_config_with_workspace_and_plugins(&global_path, &workspace, &plugins).unwrap();
2341
2342 assert!(cfg.servers.contains_key("global"));
2343 assert!(!cfg.servers.contains_key("project"));
2344 assert!(
2345 cfg.servers
2346 .contains_key(&qualified_plugin_server_name("fixture", "local")),
2347 "user plugin MCP should not be gated by project workspace trust"
2348 );
2349 }
2350
2351 #[test]
2352 fn workspace_mcp_config_ignores_project_local_legacy_trust_marker() {
2353 let dir = tempfile::tempdir().unwrap();
2354 let global_path = dir.path().join("global-mcp.json");
2355 let workspace = dir.path().join("workspace");
2356 let project_dir = workspace.join(".codewhale");
2357 fs::create_dir_all(&project_dir).unwrap();
2358 fs::create_dir_all(workspace.join(".deepseek")).unwrap();
2359 fs::write(workspace.join(".deepseek").join("trusted"), "").unwrap();
2360 fs::write(
2361 &global_path,
2362 r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
2363 )
2364 .unwrap();
2365 fs::write(
2366 project_dir.join("mcp.json"),
2367 r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
2368 )
2369 .unwrap();
2370
2371 let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
2372
2373 assert!(cfg.servers.contains_key("global"));
2374 assert!(!cfg.servers.contains_key("project"));
2375 }
2376
2377 #[test]
2378 fn workspace_mcp_config_ignores_invalid_untrusted_project_file() {
2379 let dir = tempfile::tempdir().unwrap();
2380 let global_path = dir.path().join("global-mcp.json");
2381 let workspace = dir.path().join("workspace");
2382 let project_dir = workspace.join(".codewhale");
2383 fs::create_dir_all(&project_dir).unwrap();
2384 fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
2385 fs::write(project_dir.join("mcp.json"), "{ not json").unwrap();
2386
2387 let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
2388
2389 assert!(cfg.servers.is_empty());
2390 }
2391
2392 #[test]
2393 fn workspace_mcp_config_rejects_parent_components() {
2394 let dir = tempfile::tempdir().unwrap();
2395 let global_path = dir.path().join("global-mcp.json");
2396 let workspace = dir.path().join("workspace");
2397 let project_dir = workspace.join(".codewhale");
2398 fs::create_dir_all(&project_dir).unwrap();
2399 let _trust = mark_workspace_trusted(&workspace);
2400 fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
2401 fs::write(
2402 project_dir.join("mcp.json"),
2403 r#"{"servers": {"project": {"command": "node", "args": ["server.js"]}}}"#,
2404 )
2405 .unwrap();
2406
2407 let workspace_with_parent = workspace.join("..").join("workspace");
2408 let err = load_config_with_workspace(&global_path, &workspace_with_parent)
2409 .expect_err("parent components in workspace should fail closed");
2410
2411 assert!(
2412 format!("{err:#}").contains("workspace path cannot contain '..'"),
2413 "unexpected error: {err:#}"
2414 );
2415 }
2416
2417 #[test]
2418 fn workspace_mcp_config_resolves_relative_cwd_from_workspace() {
2419 let dir = tempfile::tempdir().unwrap();
2420 let global_path = dir.path().join("global-mcp.json");
2421 let workspace = dir.path().join("workspace");
2422 let project_dir = workspace.join(".codewhale");
2423 fs::create_dir_all(&project_dir).unwrap();
2424 let _trust = mark_workspace_trusted(&workspace);
2425 fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
2426 fs::write(
2427 project_dir.join("mcp.json"),
2428 r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools/mcp"}}}"#,
2429 )
2430 .unwrap();
2431
2432 let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
2433 let workspace = workspace.canonicalize().unwrap();
2434
2435 let project = cfg.servers.get("project").unwrap();
2436 assert_eq!(
2437 project.cwd.as_deref(),
2438 Some(workspace.join("tools/mcp").as_path())
2439 );
2440 }
2441
2442 #[test]
2443 fn workspace_mcp_config_rejects_project_cwd_escape() {
2444 let dir = tempfile::tempdir().unwrap();
2445 let global_path = dir.path().join("global-mcp.json");
2446 let workspace = dir.path().join("workspace");
2447 let project_dir = workspace.join(".codewhale");
2448 fs::create_dir_all(&project_dir).unwrap();
2449 let _trust = mark_workspace_trusted(&workspace);
2450 fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
2451 fs::write(
2452 project_dir.join("mcp.json"),
2453 r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "../outside"}}}"#,
2454 )
2455 .unwrap();
2456
2457 let err = load_config_with_workspace(&global_path, &workspace)
2458 .expect_err("project MCP cwd escape must be rejected");
2459
2460 assert!(
2461 err.to_string()
2462 .contains("Project MCP server cwd must stay within workspace"),
2463 "unexpected error: {err}"
2464 );
2465 }
2466
2467 #[cfg(unix)]
2468 #[test]
2469 fn workspace_mcp_config_rejects_symlinked_project_cwd_escape() {
2470 let dir = tempfile::tempdir().unwrap();
2471 let global_path = dir.path().join("global-mcp.json");
2472 let workspace = dir.path().join("workspace");
2473 let project_dir = workspace.join(".codewhale");
2474 let outside = dir.path().join("outside");
2475 fs::create_dir_all(&project_dir).unwrap();
2476 fs::create_dir_all(&outside).unwrap();
2477 std::os::unix::fs::symlink(&outside, workspace.join("tools")).unwrap();
2478 let _trust = mark_workspace_trusted(&workspace);
2479 fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
2480 fs::write(
2481 project_dir.join("mcp.json"),
2482 r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools"}}}"#,
2483 )
2484 .unwrap();
2485
2486 let err = load_config_with_workspace(&global_path, &workspace)
2487 .expect_err("project MCP symlink cwd escape must be rejected");
2488
2489 assert!(
2490 err.to_string()
2491 .contains("Project MCP server cwd must stay within workspace"),
2492 "unexpected error: {err}"
2493 );
2494 }
2495
2496 #[test]
2497 fn workspace_mcp_config_rejects_workspace_traversal() {
2498 let dir = tempfile::tempdir().unwrap();
2499 let global_path = dir.path().join("global-mcp.json");
2500 let workspace = dir.path().join("workspace");
2501 let bad_workspace = workspace.join("..").join("outside");
2502 fs::create_dir_all(&workspace).unwrap();
2503 fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
2504
2505 let err = load_config_with_workspace(&global_path, &bad_workspace)
2506 .expect_err("workspace traversal should fail");
2507 assert!(
2508 format!("{err:#}").contains("workspace path cannot contain '..'"),
2509 "unexpected error: {err:#}"
2510 );
2511 }
2512
2513 #[tokio::test]
2514 async fn workspace_mcp_pool_reload_picks_up_project_config_creation() {
2515 let dir = tempfile::tempdir().unwrap();
2516 let global_path = dir.path().join("global-mcp.json");
2517 let workspace = dir.path().join("workspace");
2518 let project_dir = workspace.join(".codewhale");
2519 fs::create_dir_all(&workspace).unwrap();
2520 let _trust = mark_workspace_trusted(&workspace);
2521 fs::write(
2522 &global_path,
2523 r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
2524 )
2525 .unwrap();
2526
2527 let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
2528 assert_eq!(pool.server_names(), vec!["global".to_string()]);
2529
2530 fs::create_dir_all(&project_dir).unwrap();
2531 fs::write(
2532 project_dir.join("mcp.json"),
2533 r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
2534 )
2535 .unwrap();
2536
2537 assert!(pool.reload_if_config_changed().await.unwrap());
2538 let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
2539 let expected: std::collections::BTreeSet<String> =
2540 ["global".to_string(), "project".to_string()]
2541 .into_iter()
2542 .collect();
2543 assert_eq!(names, expected);
2544 }
2545
2546 #[tokio::test]
2547 async fn workspace_mcp_pool_reload_picks_up_project_config_after_workspace_trust() {
2548 let dir = tempfile::tempdir().unwrap();
2549 let global_path = dir.path().join("global-mcp.json");
2550 let workspace = dir.path().join("workspace");
2551 let project_dir = workspace.join(".codewhale");
2552 fs::create_dir_all(&project_dir).unwrap();
2553 let trust_env = workspace_trust_config_guard(&workspace);
2554 fs::write(
2555 &global_path,
2556 r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
2557 )
2558 .unwrap();
2559 fs::write(
2560 project_dir.join("mcp.json"),
2561 r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
2562 )
2563 .unwrap();
2564
2565 let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
2566 assert_eq!(pool.server_names(), vec!["global".to_string()]);
2567
2568 write_workspace_trust_config(&trust_env.config_path, &workspace);
2569
2570 assert!(pool.reload_if_config_changed().await.unwrap());
2571 let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
2572 let expected: std::collections::BTreeSet<String> =
2573 ["global".to_string(), "project".to_string()]
2574 .into_iter()
2575 .collect();
2576 assert_eq!(names, expected);
2577 }
2578
2579 #[tokio::test]
2580 async fn workspace_mcp_pool_reload_drops_project_config_after_workspace_trust_removed() {
2581 let dir = tempfile::tempdir().unwrap();
2582 let global_path = dir.path().join("global-mcp.json");
2583 let workspace = dir.path().join("workspace");
2584 let project_dir = workspace.join(".codewhale");
2585 fs::create_dir_all(&project_dir).unwrap();
2586 let trust = mark_workspace_trusted(&workspace);
2587 fs::write(
2588 &global_path,
2589 r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
2590 )
2591 .unwrap();
2592 fs::write(
2593 project_dir.join("mcp.json"),
2594 r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
2595 )
2596 .unwrap();
2597
2598 let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
2599 let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
2600 let expected: std::collections::BTreeSet<String> =
2601 ["global".to_string(), "project".to_string()]
2602 .into_iter()
2603 .collect();
2604 assert_eq!(names, expected);
2605
2606 fs::remove_file(&trust.config_path).unwrap();
2607
2608 assert!(pool.reload_if_config_changed().await.unwrap());
2609 assert_eq!(pool.server_names(), vec!["global".to_string()]);
2610 }
2611
2612 #[tokio::test]
2613 async fn workspace_mcp_pool_reload_drops_project_config_after_deletion() {
2614 let dir = tempfile::tempdir().unwrap();
2615 let global_path = dir.path().join("global-mcp.json");
2616 let workspace = dir.path().join("workspace");
2617 let project_dir = workspace.join(".codewhale");
2618 fs::create_dir_all(&project_dir).unwrap();
2619 let _trust = mark_workspace_trusted(&workspace);
2620 fs::write(
2621 &global_path,
2622 r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
2623 )
2624 .unwrap();
2625 let project_path = project_dir.join("mcp.json");
2626 fs::write(
2627 &project_path,
2628 r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
2629 )
2630 .unwrap();
2631
2632 let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace).unwrap();
2633 let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
2634 let expected: std::collections::BTreeSet<String> =
2635 ["global".to_string(), "project".to_string()]
2636 .into_iter()
2637 .collect();
2638 assert_eq!(names, expected);
2639
2640 fs::remove_file(project_path).unwrap();
2641
2642 assert!(pool.reload_if_config_changed().await.unwrap());
2643 assert_eq!(pool.server_names(), vec!["global".to_string()]);
2644 }
2645
2646 #[test]
2647 fn test_mcp_config_rejects_traversal_path() {
2648 let err = load_config(Path::new("../mcp.json")).expect_err("traversal path should fail");
2649 assert!(
2650 format!("{err:#}").contains("cannot contain '..'"),
2651 "got: {err:#}"
2652 );
2653 }
2654
2655 #[cfg(unix)]
2656 #[test]
2657 fn mcp_config_rejects_symlinked_config_file() {
2658 let dir = tempfile::tempdir().unwrap();
2659 let target = dir.path().join("target-mcp.json");
2660 let link = dir.path().join("mcp.json");
2661 fs::write(&target, r#"{"servers": {}}"#).expect("write target config");
2662 std::os::unix::fs::symlink(&target, &link).expect("symlink mcp config");
2663
2664 let err = load_config(&link).expect_err("symlinked MCP config should fail");
2665
2666 assert!(format!("{err:#}").contains("regular file"), "got: {err:#}");
2667 }
2668
2669 #[test]
2670 fn init_mcp_config_rejects_traversal_before_parent_creation() {
2671 let dir = tempfile::tempdir().unwrap();
2672 let outside_dir = dir.path().join("outside");
2673 let path = dir
2674 .path()
2675 .join("allowed")
2676 .join("..")
2677 .join("outside")
2678 .join("mcp.json");
2679
2680 let err = init_config(&path, false).expect_err("traversal path should fail");
2681
2682 assert!(
2683 format!("{err:#}").contains("cannot contain '..'"),
2684 "got: {err:#}"
2685 );
2686 assert!(
2687 !outside_dir.exists(),
2688 "init_config must validate before creating parent directories"
2689 );
2690 }
2691
2692 #[test]
2693 fn test_mcp_config_manager_actions_round_trip() {
2694 let dir = tempfile::tempdir().unwrap();
2695 let path = dir.path().join("mcp.json");
2696
2697 assert_eq!(init_config(&path, false).unwrap(), McpWriteStatus::Created);
2698 assert_eq!(
2699 init_config(&path, false).unwrap(),
2700 McpWriteStatus::SkippedExists
2701 );
2702
2703 add_server_config(
2704 &path,
2705 "local".to_string(),
2706 Some("node".to_string()),
2707 None,
2708 vec!["server.js".to_string()],
2709 None,
2710 )
2711 .unwrap();
2712 set_server_enabled(&path, "local", false).unwrap();
2713 let disabled = manager_snapshot_from_config(&path, true).unwrap();
2714 let local = disabled
2715 .servers
2716 .iter()
2717 .find(|server| server.name == "local")
2718 .unwrap();
2719 assert!(!local.enabled);
2720 assert_eq!(local.transport, "stdio");
2721
2722 remove_server_config(&path, "local").unwrap();
2723 let removed = manager_snapshot_from_config(&path, true).unwrap();
2724 assert!(removed.servers.iter().all(|server| server.name != "local"));
2725 }
2726
2727 #[test]
2728 fn test_mcp_config_adds_explicit_sse_transport() {
2729 let dir = tempfile::tempdir().unwrap();
2730 let path = dir.path().join("mcp.json");
2731
2732 add_server_config(
2733 &path,
2734 "legacy".to_string(),
2735 None,
2736 Some("https://example.com/v1/mcp/sse".to_string()),
2737 Vec::new(),
2738 Some("sse".to_string()),
2739 )
2740 .unwrap();
2741
2742 let cfg = load_config(&path).unwrap();
2743 assert_eq!(
2744 cfg.servers
2745 .get("legacy")
2746 .and_then(|server| server.transport.as_deref()),
2747 Some("sse")
2748 );
2749
2750 let snapshot = manager_snapshot_from_config(&path, false).unwrap();
2751 assert_eq!(snapshot.servers[0].transport, "sse");
2752 }
2753
2754 #[test]
2755 fn test_mcp_config_rejects_unknown_transport() {
2756 let dir = tempfile::tempdir().unwrap();
2757 let path = dir.path().join("mcp.json");
2758
2759 let err = add_server_config(
2760 &path,
2761 "bad".to_string(),
2762 None,
2763 Some("https://example.com/mcp".to_string()),
2764 Vec::new(),
2765 Some("streamable".to_string()),
2766 )
2767 .expect_err("unknown transport should fail");
2768
2769 assert!(
2770 format!("{err:#}").contains("Unsupported MCP transport"),
2771 "got: {err:#}"
2772 );
2773 }
2774
2775 #[test]
2776 fn test_server_effective_timeouts() {
2777 let global = McpTimeouts::default();
2778
2779 let server_with_override = McpServerConfig {
2780 command: Some("test".to_string()),
2781 args: vec![],
2782 env: HashMap::new(),
2783 cwd: None,
2784 url: None,
2785 transport: None,
2786 connect_timeout: Some(20),
2787 execute_timeout: None,
2788 read_timeout: Some(180),
2789 disabled: false,
2790 enabled: true,
2791 required: false,
2792 enabled_tools: Vec::new(),
2793 disabled_tools: Vec::new(),
2794 headers: HashMap::new(),
2795 env_headers: HashMap::new(),
2796 bearer_token_env_var: None,
2797 scopes: Vec::new(),
2798 oauth: None,
2799 oauth_resource: None,
2800 reviewed_plugin: None,
2801 runtime_added: false,
2802 allow_private_network: false,
2803 };
2804
2805 assert_eq!(server_with_override.effective_connect_timeout(&global), 20);
2806 assert_eq!(server_with_override.effective_execute_timeout(&global), 60); // global default
2807 assert_eq!(server_with_override.effective_read_timeout(&global), 180);
2808 }
2809
2810 #[test]
2811 fn test_mcp_pool_is_mcp_tool() {
2812 assert!(McpPool::is_mcp_tool("mcp_filesystem_read"));
2813 assert!(McpPool::is_mcp_tool("mcp_git_status"));
2814 assert!(McpPool::is_mcp_tool("list_mcp_resources"));
2815 assert!(McpPool::is_mcp_tool("list_mcp_resource_templates"));
2816 assert!(McpPool::is_mcp_tool("read_mcp_resource"));
2817 assert!(!McpPool::is_mcp_tool("read_file"));
2818 assert!(!McpPool::is_mcp_tool("exec_shell"));
2819 }
2820
2821 struct ScriptedValueTransport {
2822 sent: Arc<Mutex<Vec<serde_json::Value>>>,
2823 responses: VecDeque<Vec<u8>>,
2824 }
2825
2826 #[async_trait::async_trait]
2827 impl McpTransport for ScriptedValueTransport {
2828 async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
2829 self.sent
2830 .lock()
2831 .unwrap()
2832 .push(serde_json::from_slice(&msg)?);
2833 Ok(())
2834 }
2835
2836 async fn recv(&mut self) -> Result<Vec<u8>> {
2837 self.responses
2838 .pop_front()
2839 .context("scripted transport exhausted")
2840 }
2841 }
2842
2843 struct HangingValueTransport {
2844 sent: Arc<Mutex<Vec<serde_json::Value>>>,
2845 }
2846
2847 #[async_trait::async_trait]
2848 impl McpTransport for HangingValueTransport {
2849 async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
2850 self.sent
2851 .lock()
2852 .unwrap()
2853 .push(serde_json::from_slice(&msg)?);
2854 Ok(())
2855 }
2856
2857 async fn recv(&mut self) -> Result<Vec<u8>> {
2858 std::future::pending().await
2859 }
2860 }
2861
2862 struct ScriptedThenHangingTransport {
2863 sent: Arc<Mutex<Vec<serde_json::Value>>>,
2864 responses: VecDeque<Vec<u8>>,
2865 }
2866
2867 #[async_trait::async_trait]
2868 impl McpTransport for ScriptedThenHangingTransport {
2869 async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
2870 self.sent
2871 .lock()
2872 .unwrap()
2873 .push(serde_json::from_slice(&msg)?);
2874 Ok(())
2875 }
2876
2877 async fn recv(&mut self) -> Result<Vec<u8>> {
2878 match self.responses.pop_front() {
2879 Some(response) => Ok(response),
2880 None => std::future::pending().await,
2881 }
2882 }
2883 }
2884
2885 /// A transport whose write side is gone — the shape a crashed or exited
2886 /// stdio MCP child leaves behind (EPIPE on the next `write_all`).
2887 struct FailingSendTransport;
2888
2889 #[async_trait::async_trait]
2890 impl McpTransport for FailingSendTransport {
2891 async fn send(&mut self, _msg: Vec<u8>) -> Result<()> {
2892 anyhow::bail!("Broken pipe (os error 32)")
2893 }
2894
2895 async fn recv(&mut self) -> Result<Vec<u8>> {
2896 std::future::pending().await
2897 }
2898 }
2899
2900 struct DropCountingTransport {
2901 drops: Arc<AtomicUsize>,
2902 }
2903
2904 #[async_trait::async_trait]
2905 impl McpTransport for DropCountingTransport {
2906 async fn send(&mut self, _msg: Vec<u8>) -> Result<()> {
2907 Ok(())
2908 }
2909
2910 async fn recv(&mut self) -> Result<Vec<u8>> {
2911 std::future::pending().await
2912 }
2913 }
2914
2915 impl Drop for DropCountingTransport {
2916 fn drop(&mut self) {
2917 self.drops.fetch_add(1, AtomicOrdering::SeqCst);
2918 }
2919 }
2920
2921 fn test_server_config() -> McpServerConfig {
2922 McpServerConfig {
2923 command: Some("mock".to_string()),
2924 args: Vec::new(),
2925 env: HashMap::new(),
2926 cwd: None,
2927 url: None,
2928 transport: None,
2929 connect_timeout: None,
2930 execute_timeout: None,
2931 read_timeout: None,
2932 disabled: false,
2933 enabled: true,
2934 required: false,
2935 enabled_tools: Vec::new(),
2936 disabled_tools: Vec::new(),
2937 headers: HashMap::new(),
2938 env_headers: HashMap::new(),
2939 bearer_token_env_var: None,
2940 scopes: Vec::new(),
2941 oauth: None,
2942 oauth_resource: None,
2943 reviewed_plugin: None,
2944 runtime_added: false,
2945 allow_private_network: false,
2946 }
2947 }
2948
2949 fn test_connection(transport: Box<dyn McpTransport>) -> McpConnection {
2950 McpConnection {
2951 name: "mock".to_string(),
2952 transport,
2953 tools: Vec::new(),
2954 resources: Vec::new(),
2955 resource_templates: Vec::new(),
2956 prompts: Vec::new(),
2957 request_id: AtomicU64::new(1),
2958 state: ConnectionState::Ready,
2959 config: test_server_config(),
2960 server_capabilities: None,
2961 discovery_timeout: Duration::from_secs(default_connect_timeout()),
2962 read_timeout_secs: default_read_timeout(),
2963 cancel_token: tokio_util::sync::CancellationToken::new(),
2964 authority_revocation_reason: Arc::new(std::sync::Mutex::new(None)),
2965 authority_watch: None,
2966 catalog_generation: 0,
2967 }
2968 }
2969
2970 #[cfg(unix)]
2971 #[tokio::test]
2972 async fn execute_timeout_after_partial_stdio_response_does_not_corrupt_next_call() -> Result<()> {
2973 use serde_json::{Value, json};
2974 struct SharedStdio(Arc<tokio::sync::Mutex<StdioTransport>>);
2975 #[async_trait::async_trait]
2976 impl McpTransport for SharedStdio {
2977 async fn send(&mut self, bytes: Vec<u8>) -> Result<()> {
2978 self.0.lock().await.send(bytes).await
2979 }
2980 async fn recv(&mut self) -> Result<Vec<u8>> {
2981 self.0.lock().await.recv().await
2982 }
2983 }
2984 let dir = tempfile::tempdir()?;
2985 let requests = dir.path().join("requests.jsonl");
2986 let ready = dir.path().join("ready");
2987 let script = r#"
2988 printf '%s' '{"jsonrpc":"2.0","id":"1","result":'
2989 : > "$2"
2990 IFS= read -r first
2991 printf '%s\n' "$first" >> "$1"
2992 IFS= read -r second
2993 printf '%s\n' "$second" >> "$1"
2994 printf '%s\n' 'null}' '{"jsonrpc":"2.0","id":"2","result":{"ok":true}}'
2995 IFS= read -r keep_open
2996 "#;
2997 let mut config = test_server_config();
2998 config.args = vec![
2999 "-c".into(),
3000 script.into(),
3001 "cw-partial-frame-fixture".into(),
3002 requests.display().to_string(),
3003 ready.display().to_string(),
3004 ];
3005 let transport = Arc::new(tokio::sync::Mutex::new(StdioTransport::spawn(
3006 "partial-frame",
3007 "sh",
3008 &config,
3009 tokio_util::sync::CancellationToken::new(),
3010 )?));
3011 tokio::time::timeout(Duration::from_secs(10), async {
3012 while !ready.exists() {
3013 tokio::time::sleep(Duration::from_millis(5)).await;
3014 }
3015 })
3016 .await?;
3017 let mut connection = test_connection(Box::new(SharedStdio(Arc::clone(&transport))));
3018 let error = connection
3019 .call_tool("first", json!({}), 1)
3020 .await
3021 .unwrap_err();
3022 assert!(error.to_string().contains("timed out"), "{error:#}");
3023 assert_eq!(
3024 transport.lock().await.pending_line,
3025 br#"{"jsonrpc":"2.0","id":"1","result":"#
3026 );
3027 assert!(connection.is_ready());
3028 assert_eq!(
3029 connection.call_tool("second", json!({}), 5).await?,
3030 json!({"ok": true})
3031 );
3032 let sent = fs::read_to_string(requests)?;
3033 let sent = sent
3034 .lines()
3035 .map(serde_json::from_str::<Value>)
3036 .collect::<std::result::Result<Vec<_>, _>>()?;
3037 assert_eq!(sent.len(), 2, "neither request may be replayed");
3038 assert_eq!(sent[0]["id"], "1");
3039 assert_eq!(sent[1]["id"], "2");
3040 transport.lock().await.shutdown().await;
3041 Ok::<_, anyhow::Error>(())
3042 }
3043
3044 fn json_frame(value: serde_json::Value) -> Vec<u8> {
3045 serde_json::to_vec(&value).unwrap()
3046 }
3047
3048 #[tokio::test]
3049 async fn call_method_skips_notifications_and_unmatched_responses() {
3050 let sent = Arc::new(Mutex::new(Vec::new()));
3051 let transport = ScriptedValueTransport {
3052 sent: Arc::clone(&sent),
3053 responses: VecDeque::from([
3054 json_frame(serde_json::json!({
3055 "jsonrpc": "2.0",
3056 "method": "notifications/progress",
3057 "params": {"progress": 0.5}
3058 })),
3059 json_frame(serde_json::json!({
3060 "jsonrpc": "2.0",
3061 "id": 99,
3062 "result": {"ignored": true}
3063 })),
3064 json_frame(serde_json::json!({
3065 "jsonrpc": "2.0",
3066 "id": 1,
3067 "result": {"ok": true}
3068 })),
3069 ]),
3070 };
3071 let mut conn = test_connection(Box::new(transport));
3072
3073 let result = conn
3074 .call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
3075 .await
3076 .unwrap();
3077
3078 assert_eq!(result, serde_json::json!({"ok": true}));
3079 let sent = sent.lock().unwrap();
3080 assert_eq!(sent.len(), 1);
3081 assert_eq!(sent[0]["jsonrpc"], "2.0");
3082 assert_eq!(sent[0]["id"], "1");
3083 assert_eq!(sent[0]["method"], "tools/call");
3084 }
3085
3086 #[tokio::test]
3087 async fn call_method_invalid_json_includes_server_output_preview() {
3088 let sent = Arc::new(Mutex::new(Vec::new()));
3089 let transport = ScriptedValueTransport {
3090 sent: Arc::clone(&sent),
3091 responses: VecDeque::from([b"Allow Burp MCP connection? [y/N]".to_vec()]),
3092 };
3093 let mut conn = test_connection(Box::new(transport));
3094
3095 let err = conn
3096 .call_method("tools/call", serde_json::json!({"name": "burp"}), 1)
3097 .await
3098 .expect_err("non-json MCP stdout should fail");
3099 let msg = err.to_string();
3100
3101 assert!(msg.contains("Invalid MCP JSON-RPC message from server 'mock'"));
3102 assert!(msg.contains("Allow Burp MCP connection"));
3103 assert_eq!(conn.state(), ConnectionState::Disconnected);
3104 }
3105
3106 #[tokio::test]
3107 async fn recv_times_out_waiting_for_mcp_response_and_disconnects() {
3108 let sent = Arc::new(Mutex::new(Vec::new()));
3109 let mut conn = test_connection(Box::new(HangingValueTransport {
3110 sent: Arc::clone(&sent),
3111 }));
3112 conn.read_timeout_secs = 0;
3113
3114 let err = conn
3115 .recv("1".to_string())
3116 .await
3117 .expect_err("hung transport should time out inside recv");
3118
3119 assert!(
3120 err.to_string()
3121 .contains("Timed out waiting for MCP JSON-RPC response from server 'mock' after 0s"),
3122 "unexpected error: {err:#}"
3123 );
3124 assert_eq!(conn.state(), ConnectionState::Disconnected);
3125 }
3126
3127 #[tokio::test]
3128 async fn call_method_times_out_while_waiting_for_response() {
3129 let sent = Arc::new(Mutex::new(Vec::new()));
3130 let mut conn = test_connection(Box::new(HangingValueTransport {
3131 sent: Arc::clone(&sent),
3132 }));
3133
3134 let err = conn
3135 .call_method("tools/call", serde_json::json!({"name": "echo"}), 0)
3136 .await
3137 .expect_err("hung receive should time out");
3138
3139 assert!(
3140 err.to_string()
3141 .contains("MCP method 'tools/call' on server 'mock' timed out after 0s"),
3142 "unexpected error: {err:#}"
3143 );
3144 assert_eq!(sent.lock().unwrap().len(), 1);
3145 }
3146
3147 /// JSON-RPC requires exactly one of `result` / `error` on a response. A
3148 /// response carrying neither is a broken server, and reporting it as a
3149 /// successful call with a `null` payload is a fake success: the tool result
3150 /// reaches the model as `ToolResult::success("null")`, indistinguishable from
3151 /// a tool that genuinely did nothing.
3152 #[tokio::test]
3153 async fn call_method_rejects_a_response_with_neither_result_nor_error() {
3154 let sent = Arc::new(Mutex::new(Vec::new()));
3155 let transport = ScriptedValueTransport {
3156 sent: Arc::clone(&sent),
3157 responses: VecDeque::from([json_frame(serde_json::json!({
3158 "jsonrpc": "2.0",
3159 "id": 1
3160 }))]),
3161 };
3162 let mut conn = test_connection(Box::new(transport));
3163
3164 let err = conn
3165 .call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
3166 .await
3167 .expect_err("a result-less, error-less response is not a successful call");
3168 let rendered = format!("{err:#}");
3169 assert!(
3170 rendered.contains("neither a result nor an error"),
3171 "unexpected error: {rendered}"
3172 );
3173 assert!(
3174 rendered.contains("tools/call"),
3175 "unexpected error: {rendered}"
3176 );
3177 }
3178
3179 /// …while an *explicit* `"result": null` is a well-formed empty success and
3180 /// must keep flowing through unchanged.
3181 #[tokio::test]
3182 async fn call_method_preserves_an_explicit_null_result() {
3183 let sent = Arc::new(Mutex::new(Vec::new()));
3184 let transport = ScriptedValueTransport {
3185 sent: Arc::clone(&sent),
3186 responses: VecDeque::from([json_frame(serde_json::json!({
3187 "jsonrpc": "2.0",
3188 "id": 1,
3189 "result": null
3190 }))]),
3191 };
3192 let mut conn = test_connection(Box::new(transport));
3193
3194 let result = conn
3195 .call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
3196 .await
3197 .expect("an explicit null result is a valid response");
3198 assert_eq!(result, serde_json::Value::Null);
3199 }
3200
3201 /// A failed *write* has to disconnect the connection, exactly like a failed
3202 /// read does. `McpPool::get_or_connect` reuses any connection whose
3203 /// `is_ready()` is true, so a connection left in `Ready` after its transport
3204 /// write side died is never rebuilt — the pool hands the same dead child back
3205 /// on every later tool call and the server stays broken for the rest of the
3206 /// session even though a reconnect would fix it.
3207 #[tokio::test]
3208 async fn call_method_disconnects_when_the_transport_write_side_is_gone() {
3209 let mut conn = test_connection(Box::new(FailingSendTransport));
3210
3211 let err = conn
3212 .call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
3213 .await
3214 .expect_err("a dead write side must fail the call");
3215 assert!(
3216 format!("{err:#}").contains("Broken pipe"),
3217 "unexpected error: {err:#}"
3218 );
3219 assert_eq!(conn.state(), ConnectionState::Disconnected);
3220 assert!(
3221 !conn.is_ready(),
3222 "a connection whose write side died must not be reused"
3223 );
3224 }
3225
3226 /// The pool-level consequence of the same defect: `/mcp` (and every
3227 /// `connected_servers` caller) reported a server with a dead write side as
3228 /// still connected, and `get_or_connect` handed the dead connection back
3229 /// instead of rebuilding it.
3230 #[tokio::test]
3231 async fn pool_stops_advertising_a_server_whose_write_side_died() {
3232 let dir = tempfile::tempdir().unwrap();
3233 let path = dir.path().join("mcp.json");
3234 fs::write(
3235 &path,
3236 r#"{
3237 "mcpServers": {
3238 "mock": {
3239 "command": "codewhale-tui-test-this-binary-does-not-exist-9f8e7d6c5b4a",
3240 "args": []
3241 }
3242 }
3243 }"#,
3244 )
3245 .unwrap();
3246 let mut pool = McpPool::from_config_path(&path).unwrap();
3247 let mut conn = test_connection(Box::new(FailingSendTransport));
3248 conn.tools.push(McpTool {
3249 name: "echo".to_string(),
3250 description: None,
3251 input_schema: serde_json::json!({"type": "object"}),
3252 });
3253 pool.connections.insert("mock".to_string(), conn);
3254 assert_eq!(pool.connected_servers(), vec!["mock"]);
3255
3256 let err = pool
3257 .call_tool("mcp_mock_echo", serde_json::json!({}))
3258 .await
3259 .expect_err("a dead write side must fail the call");
3260 assert!(
3261 format!("{err:#}").contains("Broken pipe"),
3262 "unexpected error: {err:#}"
3263 );
3264
3265 assert!(
3266 pool.connected_servers().is_empty(),
3267 "a server whose write side died must not report as connected"
3268 );
3269 let reconnect = match pool.get_or_connect("mock").await {
3270 Ok(_) => panic!("the pool must rebuild rather than reuse the dead connection"),
3271 Err(error) => error,
3272 };
3273 assert!(
3274 format!("{reconnect:#}").contains("spawn failed"),
3275 "expected a fresh spawn attempt, got: {reconnect:#}"
3276 );
3277 }
3278
3279 /// #6187: a failed reconnect must not erase the previous connection — the
3280 /// last-good tool catalog stays registered (model-visible, since catalog
3281 /// aggregation filters on authority, not liveness) for the whole outage,
3282 /// while the restored connection stays non-ready so `get_or_connect`
3283 /// keeps retrying per the backoff.
3284 #[tokio::test]
3285 async fn failed_reconnect_restores_last_good_catalog() {
3286 let dir = tempfile::tempdir().unwrap();
3287 let path = dir.path().join("mcp.json");
3288 fs::write(
3289 &path,
3290 r#"{
3291 "mcpServers": {
3292 "mock": {
3293 "command": "codewhale-tui-test-this-binary-does-not-exist-9f8e7d6c5b4a",
3294 "args": []
3295 }
3296 }
3297 }"#,
3298 )
3299 .unwrap();
3300 let mut pool = McpPool::from_config_path(&path).unwrap();
3301 let mut conn = test_connection(Box::new(HangingValueTransport {
3302 sent: Arc::new(Mutex::new(Vec::new())),
3303 }));
3304 conn.name = "mock".to_string();
3305 conn.config = pool.config.servers.get("mock").unwrap().clone();
3306 conn.catalog_generation = pool.current_catalog_generation();
3307 // The shape a crashed server leaves behind: not ready, but its
3308 // last-good catalog is still discovered on the connection.
3309 conn.state = ConnectionState::Disconnected;
3310 conn.tools.push(McpTool {
3311 name: "echo".to_string(),
3312 description: None,
3313 input_schema: serde_json::json!({"type": "object"}),
3314 });
3315 pool.connections.insert("mock".to_string(), conn);
3316
3317 // `&mut McpConnection` is not `Debug`, so mirror the sibling test's
3318 // match instead of `expect_err`.
3319 let error = match pool.get_or_connect("mock").await {
3320 Ok(_) => panic!("reconnect against a missing binary must fail"),
3321 Err(error) => error,
3322 };
3323 assert!(
3324 format!("{error:#}").contains("spawn failed"),
3325 "unexpected error: {error:#}"
3326 );
3327
3328 let restored = pool
3329 .connections
3330 .get("mock")
3331 .expect("failed reconnect must restore the previous connection");
3332 assert!(
3333 !restored.is_ready(),
3334 "the restored connection must stay non-ready so the pool keeps retrying"
3335 );
3336 assert!(
3337 pool.all_tools()
3338 .iter()
3339 .any(|(name, _)| name == "mcp_mock_echo"),
3340 "the model-visible tool surface must survive the failed reconnect"
3341 );
3342 assert_eq!(
3343 restored.tools.len(),
3344 1,
3345 "the restored connection must keep its last-good catalog"
3346 );
3347 }
3348
3349 #[tokio::test]
3350 async fn test_mcp_pool_empty_config() {
3351 let pool = McpPool::new(McpConfig::default());
3352 assert!(pool.server_names().is_empty());
3353 assert!(pool.all_tools().is_empty());
3354 }
3355
3356 /// #1267 part 2: a pool built without a source path has no file to watch,
3357 /// so `reload_if_config_changed` must short-circuit instead of trying
3358 /// to stat `/`.
3359 #[tokio::test]
3360 async fn reload_if_config_changed_is_noop_without_source_path() {
3361 let mut pool = McpPool::new(McpConfig::default());
3362 let reloaded = pool.reload_if_config_changed().await.unwrap();
3363 assert!(!reloaded, "no source path → no reload");
3364 }
3365
3366 /// #1267 part 2: when the on-disk config is byte-unchanged, the lazy
3367 /// reload must not drop connections — every call to `get_or_connect`
3368 /// would otherwise pay a full reconnect cycle on networked filesystems
3369 /// where mtime granularity is coarse.
3370 #[tokio::test]
3371 async fn reload_if_config_changed_skips_when_content_unchanged() {
3372 let dir = tempfile::tempdir().unwrap();
3373 let path = dir.path().join("mcp.json");
3374 std::fs::write(&path, r#"{"servers":{}}"#).unwrap();
3375 let mut pool = McpPool::from_config_path(&path).unwrap();
3376 // Force the mtime to advance without changing content.
3377 std::thread::sleep(std::time::Duration::from_millis(10));
3378 std::fs::write(&path, r#"{"servers":{}}"#).unwrap();
3379 let reloaded = pool.reload_if_config_changed().await.unwrap();
3380 assert!(
3381 !reloaded,
3382 "content-unchanged config must not trigger a reload"
3383 );
3384 }
3385
3386 /// #1267 part 2: when the on-disk config changes content, the next
3387 /// `reload_if_config_changed` call must swap in the new config and
3388 /// (would) drop all live connections. We can't stand up a real
3389 /// `McpConnection` in a unit test, so we observe the swap via the
3390 /// publicly-readable side: server names go from empty to non-empty.
3391 #[tokio::test]
3392 async fn reload_if_config_changed_swaps_config_on_content_change() {
3393 let dir = tempfile::tempdir().unwrap();
3394 let path = dir.path().join("mcp.json");
3395 std::fs::write(&path, r#"{"servers":{}}"#).unwrap();
3396 let mut pool = McpPool::from_config_path(&path).unwrap();
3397 assert!(pool.server_names().is_empty());
3398 // Mutate the file so both the mtime and the hash change.
3399 std::thread::sleep(std::time::Duration::from_millis(10));
3400 std::fs::write(
3401 &path,
3402 r#"{"servers":{"new":{"command":"echo","args":["hi"]}}}"#,
3403 )
3404 .unwrap();
3405 let reloaded = pool.reload_if_config_changed().await.unwrap();
3406 assert!(reloaded, "content-changed config must trigger reload");
3407 let names = pool.server_names();
3408 assert!(
3409 names.contains(&"new".to_string()),
3410 "expected new server in pool after reload, got {names:?}"
3411 );
3412 }
3413
3414 #[tokio::test]
3415 async fn stale_handshake_cannot_be_restamped_after_config_reload() {
3416 let dir = tempfile::tempdir().unwrap();
3417 let path = dir.path().join("mcp.json");
3418 std::fs::write(&path, r#"{"servers":{"local":{"command":"node"}}}"#).unwrap();
3419 let mut pool = McpPool::from_config_path(&path).unwrap();
3420 let drops = Arc::new(AtomicUsize::new(0));
3421 let mut connection = test_connection(Box::new(DropCountingTransport {
3422 drops: drops.clone(),
3423 }));
3424 connection.catalog_generation = pool.current_catalog_generation();
3425 std::fs::write(&path, r#"{"servers":{}}"#).unwrap();
3426 pool.reload_from_config_sources(true).unwrap();
3427 let error = pool
3428 .store_ready_connection("local".to_string(), connection)
3429 .unwrap_err();
3430 assert!(error.to_string().contains("configuration changed"));
3431 assert!(!pool.connections.contains_key("local"));
3432 assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
3433 }
3434
3435 #[tokio::test]
3436 async fn reload_if_config_changed_drops_live_connections() {
3437 let dir = tempfile::tempdir().unwrap();
3438 let path = dir.path().join("mcp.json");
3439 std::fs::write(
3440 &path,
3441 r#"{"servers":{"local":{"command":"node","args":["server.js"]}}}"#,
3442 )
3443 .unwrap();
3444 let mut pool = McpPool::from_config_path(&path).unwrap();
3445 let drops = Arc::new(AtomicUsize::new(0));
3446 let mut conn = test_connection(Box::new(DropCountingTransport {
3447 drops: Arc::clone(&drops),
3448 }));
3449 conn.name = "local".to_string();
3450 conn.config = pool.config.servers.get("local").unwrap().clone();
3451 pool.connections.insert("local".to_string(), conn);
3452
3453 std::thread::sleep(std::time::Duration::from_millis(10));
3454 std::fs::write(
3455 &path,
3456 r#"{"servers":{"local":{"command":"node","args":["server-v2.js"]}}}"#,
3457 )
3458 .unwrap();
3459
3460 let reloaded = pool.reload_if_config_changed().await.unwrap();
3461 assert!(reloaded, "content-changed config must trigger reload");
3462 assert_eq!(
3463 drops.load(AtomicOrdering::SeqCst),
3464 1,
3465 "reload must drop the stale live transport"
3466 );
3467 assert!(
3468 !pool.connections.contains_key("local"),
3469 "stale connection must not survive config reload"
3470 );
3471 assert_eq!(
3472 pool.config.servers.get("local").unwrap().args,
3473 vec!["server-v2.js".to_string()]
3474 );
3475 }
3476
3477 #[tokio::test]
3478 async fn connect_all_reloads_before_snapshotting_new_server_names() {
3479 let dir = tempfile::tempdir().unwrap();
3480 let path = dir.path().join("mcp.json");
3481 std::fs::write(&path, r#"{"servers":{}}"#).unwrap();
3482 let mut pool = McpPool::from_config_path(&path).unwrap();
3483
3484 std::fs::write(
3485 &path,
3486 r#"{"servers":{"late":{"command":"codewhale-test-command-that-does-not-exist"}}}"#,
3487 )
3488 .unwrap();
3489 // Make the test independent of filesystem mtime granularity.
3490 pool.last_mtimes = vec![None];
3491
3492 let errors = pool.connect_all().await;
3493 assert!(
3494 pool.server_names().contains(&"late".to_string()),
3495 "the first connect_all call must install the changed config"
3496 );
3497 assert!(
3498 errors.iter().any(|(name, _)| name == "late"),
3499 "the newly-added server must be attempted on the same call"
3500 );
3501 }
3502
3503 #[tokio::test]
3504 async fn explicit_reload_reconnects_unchanged_config_and_preserves_dynamic_servers() {
3505 let dir = tempfile::tempdir().unwrap();
3506 let path = dir.path().join("mcp.json");
3507 std::fs::write(
3508 &path,
3509 r#"{"servers":{"local":{"command":"node","disabled":true}}}"#,
3510 )
3511 .unwrap();
3512 let mut pool = McpPool::from_config_path(&path).unwrap();
3513 let drops = Arc::new(AtomicUsize::new(0));
3514 let mut conn = test_connection(Box::new(DropCountingTransport {
3515 drops: Arc::clone(&drops),
3516 }));
3517 conn.name = "local".to_string();
3518 conn.config = pool.config.servers.get("local").unwrap().clone();
3519 pool.connections.insert("local".to_string(), conn);
3520 let mut runtime_config = test_server_config();
3521 runtime_config.command = Some("runtime-server".to_string());
3522 pool.add_runtime_server_config("runtime".to_string(), runtime_config)
3523 .unwrap();
3524 let generation_before = pool.catalog_generation.load(AtomicOrdering::SeqCst);
3525
3526 pool.force_reload_config_sources().unwrap();
3527 let errors = pool.connect_all().await;
3528
3529 assert!(
3530 errors.is_empty(),
3531 "disabled config should not connect: {errors:?}"
3532 );
3533 assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
3534 assert!(!pool.connections.contains_key("local"));
3535 assert!(pool.server_names().contains(&"runtime".to_string()));
3536 assert_eq!(
3537 pool.catalog_generation.load(AtomicOrdering::SeqCst),
3538 generation_before + 1,
3539 "explicit reload must invalidate every previously advertised route"
3540 );
3541 }
3542
3543 #[tokio::test]
3544 async fn config_source_switch_preserves_dynamic_servers_in_the_shared_pool() {
3545 let dir = tempfile::tempdir().unwrap();
3546 let workspace = dir.path().join("workspace");
3547 std::fs::create_dir_all(&workspace).unwrap();
3548 let initial_path = dir.path().join("initial.json");
3549 let invalid_path = dir.path().join("invalid.json");
3550 let replacement_path = dir.path().join("replacement.json");
3551 std::fs::write(
3552 &initial_path,
3553 r#"{"servers":{"local":{"command":"node","disabled":true}}}"#,
3554 )
3555 .unwrap();
3556 std::fs::write(&invalid_path, r#"{"servers":{"broken": trailing}}"#).unwrap();
3557 std::fs::write(&replacement_path, r#"{"servers":{}}"#).unwrap();
3558 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(&workspace));
3559 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
3560 &initial_path,
3561 &workspace,
3562 Arc::clone(&plugins),
3563 )
3564 .unwrap();
3565 let mut runtime_config = test_server_config();
3566 runtime_config.command = Some("runtime-server".to_string());
3567 pool.add_runtime_server_config("runtime".to_string(), runtime_config)
3568 .unwrap();
3569 let drops = Arc::new(AtomicUsize::new(0));
3570 let mut conn = test_connection(Box::new(DropCountingTransport {
3571 drops: Arc::clone(&drops),
3572 }));
3573 conn.name = "local".to_string();
3574 conn.config = pool.config.servers.get("local").unwrap().clone();
3575 pool.connections.insert("local".to_string(), conn);
3576 let generation_before = pool.catalog_generation.load(AtomicOrdering::SeqCst);
3577
3578 pool.switch_workspace_config_source(&invalid_path, &workspace, Arc::clone(&plugins))
3579 .expect_err("malformed replacement must fail closed");
3580 assert_eq!(pool.config_sources.first(), Some(&initial_path));
3581 assert!(pool.connections.contains_key("local"));
3582 assert_eq!(drops.load(AtomicOrdering::SeqCst), 0);
3583 assert_eq!(
3584 pool.catalog_generation.load(AtomicOrdering::SeqCst),
3585 generation_before
3586 );
3587
3588 pool.switch_workspace_config_source(&replacement_path, &workspace, plugins)
3589 .unwrap();
3590 let errors = pool.connect_all().await;
3591
3592 assert!(errors.is_empty());
3593 assert!(pool.server_names().contains(&"runtime".to_string()));
3594 assert_eq!(pool.config_sources.first(), Some(&replacement_path));
3595 assert_eq!(drops.load(AtomicOrdering::SeqCst), 1);
3596 }
3597
3598 /// #1267 part 2: hash-based comparison must be stable for byte-identical
3599 /// configs and distinct for differing configs.
3600 #[test]
3601 fn hash_mcp_config_is_stable_and_change_sensitive() {
3602 let a = McpConfig::default();
3603 let b = McpConfig::default();
3604 assert_eq!(hash_mcp_config(&a), hash_mcp_config(&b));
3605 let mut c = McpConfig::default();
3606 c.servers.insert(
3607 "x".into(),
3608 McpServerConfig {
3609 command: Some("/bin/echo".into()),
3610 args: vec!["hi".into()],
3611 env: Default::default(),
3612 cwd: None,
3613 url: None,
3614 transport: None,
3615 connect_timeout: None,
3616 execute_timeout: None,
3617 read_timeout: None,
3618 disabled: false,
3619 enabled: true,
3620 required: false,
3621 enabled_tools: Vec::new(),
3622 disabled_tools: Vec::new(),
3623 headers: HashMap::new(),
3624 env_headers: HashMap::new(),
3625 bearer_token_env_var: None,
3626 scopes: Vec::new(),
3627 oauth: None,
3628 oauth_resource: None,
3629 reviewed_plugin: None,
3630 runtime_added: false,
3631 allow_private_network: false,
3632 },
3633 );
3634 assert_ne!(
3635 hash_mcp_config(&a),
3636 hash_mcp_config(&c),
3637 "hash must change when servers map changes"
3638 );
3639 }
3640
3641 /// #1267 part 2: `hash_mcp_config` is the *only* thing standing between a
3642 /// touched-but-unchanged config file and a full teardown of every live MCP
3643 /// connection (stdio children included). `McpConfig::servers`, `env`,
3644 /// `headers`, and `env_headers` are all `HashMap`s, and two `HashMap`s built
3645 /// separately in one process iterate in different orders — so hashing the
3646 /// config's `serde_json` bytes straight from the struct is not
3647 /// content-addressed once a map holds more than one entry. Parse the same
3648 /// bytes repeatedly and require one hash.
3649 #[test]
3650 fn hash_mcp_config_is_order_independent_across_identical_parses() {
3651 let raw = r#"{
3652 "servers": {
3653 "alpha": { "command": "a", "env": { "A": "1", "B": "2", "C": "3", "D": "4" } },
3654 "bravo": { "command": "b" },
3655 "charlie": { "command": "c" },
3656 "delta": { "command": "d" },
3657 "echo": { "command": "e" },
3658 "foxtrot": { "command": "f" },
3659 "golf": { "command": "g" },
3660 "hotel": { "command": "h" },
3661 "india": { "command": "i" },
3662 "juliett": { "command": "j" }
3663 }
3664 }"#;
3665 let hashes: HashSet<u64> = (0..16)
3666 .map(|_| {
3667 let parsed: McpConfig = serde_json::from_str(raw).expect("fixture parses");
3668 hash_mcp_config(&parsed)
3669 })
3670 .collect();
3671 assert_eq!(
3672 hashes.len(),
3673 1,
3674 "byte-identical MCP config must hash identically; got {} distinct hashes",
3675 hashes.len()
3676 );
3677 }
3678
3679 /// The same invariant for the nested per-server maps: an unchanged server
3680 /// whose `env` / `headers` hold several entries must not look changed.
3681 #[test]
3682 fn hash_mcp_config_is_order_independent_for_nested_server_maps() {
3683 let raw = r#"{
3684 "servers": {
3685 "only": {
3686 "url": "https://example.invalid/mcp",
3687 "headers": { "H1": "1", "H2": "2", "H3": "3", "H4": "4", "H5": "5", "H6": "6" },
3688 "env_headers": { "E1": "V1", "E2": "V2", "E3": "V3", "E4": "V4" }
3689 }
3690 }
3691 }"#;
3692 let hashes: HashSet<u64> = (0..16)
3693 .map(|_| {
3694 let parsed: McpConfig = serde_json::from_str(raw).expect("fixture parses");
3695 hash_mcp_config(&parsed)
3696 })
3697 .collect();
3698 assert_eq!(
3699 hashes.len(),
3700 1,
3701 "byte-identical MCP config must hash identically; got {} distinct hashes",
3702 hashes.len()
3703 );
3704 }
3705
3706 /// #1319: discovered tools must be sorted by name so the prompt prefix
3707 /// is stable across runs (cache-hit stability), even when the server
3708 /// returns them in arbitrary or paginated order.
3709 #[tokio::test]
3710 async fn discover_tools_sorts_by_name_for_cache_stability() {
3711 let sent = Arc::new(Mutex::new(Vec::new()));
3712 let transport = ScriptedValueTransport {
3713 sent: Arc::clone(&sent),
3714 responses: VecDeque::from([
3715 json_frame(serde_json::json!({
3716 "jsonrpc": "2.0",
3717 "id": 1,
3718 "result": {
3719 "tools": [
3720 { "name": "zeta", "inputSchema": {} },
3721 { "name": "alpha", "inputSchema": {} }
3722 ],
3723 "nextCursor": "page-2"
3724 }
3725 })),
3726 json_frame(serde_json::json!({
3727 "jsonrpc": "2.0",
3728 "id": 2,
3729 "result": {
3730 "tools": [
3731 { "name": "mu", "inputSchema": {} },
3732 { "name": "beta", "inputSchema": {} }
3733 ]
3734 }
3735 })),
3736 ]),
3737 };
3738 let mut conn = test_connection(Box::new(transport));
3739 conn.discover_tools().await.expect("discover");
3740
3741 let names: Vec<&str> = conn.tools.iter().map(|t| t.name.as_str()).collect();
3742 assert_eq!(
3743 names,
3744 vec!["alpha", "beta", "mu", "zeta"],
3745 "tools must be sorted by name regardless of server order or pagination"
3746 );
3747 }
3748
3749 #[tokio::test]
3750 async fn discover_tools_rejects_a_repeated_pagination_cursor_without_publishing_partials() {
3751 let transport = ScriptedValueTransport {
3752 sent: Arc::new(Mutex::new(Vec::new())),
3753 responses: VecDeque::from([
3754 json_frame(serde_json::json!({
3755 "jsonrpc": "2.0",
3756 "id": 1,
3757 "result": {
3758 "tools": [{ "name": "first", "inputSchema": {} }],
3759 "nextCursor": "same"
3760 }
3761 })),
3762 json_frame(serde_json::json!({
3763 "jsonrpc": "2.0",
3764 "id": 2,
3765 "result": {
3766 "tools": [{ "name": "second", "inputSchema": {} }],
3767 "nextCursor": "same"
3768 }
3769 })),
3770 ]),
3771 };
3772 let mut conn = test_connection(Box::new(transport));
3773
3774 let error = conn
3775 .discover_tools()
3776 .await
3777 .expect_err("repeated cursor must abort discovery");
3778 assert!(error.to_string().contains("repeated pagination cursor"));
3779 assert!(
3780 conn.tools.is_empty(),
3781 "an aborted catalogue must not publish attacker-controlled partial entries"
3782 );
3783 }
3784
3785 #[test]
3786 fn mcp_tool_description_formatter_is_one_line_and_unicode_safe() {
3787 let long_cjk = format!("{}\n这行不应显示", "鲸".repeat(81));
3788 assert_eq!(
3789 format_mcp_tool_description(Some(&long_cjk)),
3790 format!(": {}...", "鲸".repeat(80))
3791 );
3792 assert_eq!(
3793 format_mcp_tool_description(Some("第一行\r\n第二行")),
3794 ": 第一行"
3795 );
3796 assert_eq!(format_mcp_tool_description(Some(" \nignored")), "");
3797 assert_eq!(format_mcp_tool_description(None), "");
3798 }
3799
3800 #[tokio::test]
3801 async fn discover_all_honors_tools_only_server_capabilities() {
3802 let sent = Arc::new(Mutex::new(Vec::new()));
3803 let transport = ScriptedValueTransport {
3804 sent: Arc::clone(&sent),
3805 responses: VecDeque::from([
3806 json_frame(serde_json::json!({
3807 "jsonrpc": "2.0",
3808 "id": 1,
3809 "result": {
3810 "protocolVersion": "2024-11-05",
3811 "serverInfo": {"name": "tools-only", "version": "1.0.0"},
3812 "capabilities": {"tools": {}}
3813 }
3814 })),
3815 json_frame(serde_json::json!({
3816 "jsonrpc": "2.0",
3817 "id": 2,
3818 "result": {
3819 "tools": [{"name": "idea_search", "inputSchema": {}}]
3820 }
3821 })),
3822 ]),
3823 };
3824 let mut conn = test_connection(Box::new(transport));
3825
3826 conn.initialize().await.expect("initialize");
3827 conn.discover_all().await.expect("discover tools");
3828
3829 assert_eq!(
3830 conn.server_capabilities,
3831 Some(McpServerCapabilities {
3832 tools: true,
3833 resources: false,
3834 prompts: false,
3835 })
3836 );
3837 assert_eq!(conn.tools.len(), 1);
3838 assert!(conn.resources.is_empty());
3839 assert!(conn.resource_templates.is_empty());
3840 assert!(conn.prompts.is_empty());
3841 let methods: Vec<_> = sent
3842 .lock()
3843 .unwrap()
3844 .iter()
3845 .filter_map(|message| message.get("method").and_then(|method| method.as_str()))
3846 .map(str::to_string)
3847 .collect();
3848 assert_eq!(
3849 methods,
3850 ["initialize", "notifications/initialized", "tools/list"]
3851 );
3852 }
3853
3854 #[tokio::test]
3855 async fn discover_all_populates_every_advertised_capability() {
3856 let sent = Arc::new(Mutex::new(Vec::new()));
3857 let transport = ScriptedValueTransport {
3858 sent: Arc::clone(&sent),
3859 responses: VecDeque::from([
3860 json_frame(serde_json::json!({
3861 "jsonrpc": "2.0",
3862 "id": 1,
3863 "result": {
3864 "protocolVersion": "2024-11-05",
3865 "serverInfo": {"name": "full", "version": "1.0.0"},
3866 "capabilities": {"tools": {}, "resources": {}, "prompts": {}}
3867 }
3868 })),
3869 json_frame(serde_json::json!({
3870 "jsonrpc": "2.0",
3871 "id": 2,
3872 "result": {"tools": [{"name": "search", "inputSchema": {}}]}
3873 })),
3874 json_frame(serde_json::json!({
3875 "jsonrpc": "2.0",
3876 "id": 3,
3877 "result": {"resources": [{"uri": "file:///readme", "name": "readme"}]}
3878 })),
3879 json_frame(serde_json::json!({
3880 "jsonrpc": "2.0",
3881 "id": 4,
3882 "result": {
3883 "resourceTemplates": [{"uriTemplate": "file:///{path}", "name": "file"}]
3884 }
3885 })),
3886 json_frame(serde_json::json!({
3887 "jsonrpc": "2.0",
3888 "id": 5,
3889 "result": {"prompts": [{"name": "review"}]}
3890 })),
3891 ]),
3892 };
3893 let mut conn = test_connection(Box::new(transport));
3894
3895 conn.initialize().await.expect("initialize");
3896 conn.discover_all().await.expect("discover all");
3897
3898 assert_eq!(
3899 conn.server_capabilities,
3900 Some(McpServerCapabilities {
3901 tools: true,
3902 resources: true,
3903 prompts: true,
3904 })
3905 );
3906 assert_eq!(conn.tools.len(), 1);
3907 assert_eq!(conn.resources.len(), 1);
3908 assert_eq!(conn.resource_templates.len(), 1);
3909 assert_eq!(conn.prompts.len(), 1);
3910 let methods: Vec<_> = sent
3911 .lock()
3912 .unwrap()
3913 .iter()
3914 .filter_map(|message| message.get("method").and_then(|method| method.as_str()))
3915 .map(str::to_string)
3916 .collect();
3917 assert_eq!(
3918 methods,
3919 [
3920 "initialize",
3921 "notifications/initialized",
3922 "tools/list",
3923 "resources/list",
3924 "resources/templates/list",
3925 "prompts/list",
3926 ]
3927 );
3928 }
3929
3930 #[tokio::test]
3931 async fn legacy_optional_discovery_hangs_are_bounded_and_fail_soft() {
3932 let sent = Arc::new(Mutex::new(Vec::new()));
3933 let transport = ScriptedThenHangingTransport {
3934 sent: Arc::clone(&sent),
3935 responses: VecDeque::from([json_frame(serde_json::json!({
3936 "jsonrpc": "2.0",
3937 "id": 1,
3938 "result": {"tools": [{"name": "search", "inputSchema": {}}]}
3939 }))]),
3940 };
3941 let mut conn = test_connection(Box::new(transport));
3942 conn.discovery_timeout = Duration::from_millis(60);
3943
3944 let started = tokio::time::Instant::now();
3945 conn.discover_all()
3946 .await
3947 .expect("hung optional methods must not fail discovery");
3948
3949 assert_eq!(conn.server_capabilities, None);
3950 assert_eq!(conn.tools.len(), 1);
3951 assert!(
3952 started.elapsed() < Duration::from_secs(1),
3953 "optional discovery exceeded its bounded budget: {:?}",
3954 started.elapsed()
3955 );
3956 let methods: Vec<_> = sent
3957 .lock()
3958 .unwrap()
3959 .iter()
3960 .filter_map(|message| message.get("method").and_then(|method| method.as_str()))
3961 .map(str::to_string)
3962 .collect();
3963 assert_eq!(
3964 methods,
3965 [
3966 "tools/list",
3967 "resources/list",
3968 "resources/templates/list",
3969 "prompts/list",
3970 ]
3971 );
3972 }
3973
3974 #[test]
3975 fn manager_snapshot_preserves_advertised_and_legacy_capability_provenance() {
3976 let advertised_config = test_server_config();
3977 let legacy_config = test_server_config();
3978 let config = McpConfig {
3979 servers: HashMap::from([
3980 ("advertised".to_string(), advertised_config.clone()),
3981 ("legacy".to_string(), legacy_config.clone()),
3982 ]),
3983 ..McpConfig::default()
3984 };
3985 let mut pool = McpPool::new(config.clone());
3986 let drops = Arc::new(AtomicUsize::new(0));
3987
3988 let mut advertised = test_connection(Box::new(DropCountingTransport {
3989 drops: Arc::clone(&drops),
3990 }));
3991 advertised.name = "advertised".to_string();
3992 advertised.config = advertised_config;
3993 advertised.server_capabilities = Some(McpServerCapabilities {
3994 tools: true,
3995 resources: false,
3996 prompts: true,
3997 });
3998 pool.connections
3999 .insert("advertised".to_string(), advertised);
4000
4001 let mut legacy = test_connection(Box::new(DropCountingTransport { drops }));
4002 legacy.name = "legacy".to_string();
4003 legacy.config = legacy_config;
4004 pool.connections.insert("legacy".to_string(), legacy);
4005
4006 let errors = HashMap::new();
4007 let snapshot = snapshot_from_config(
4008 Path::new("mcp.json"),
4009 true,
4010 false,
4011 &config,
4012 Some((&pool, &errors)),
4013 );
4014
4015 assert_eq!(
4016 snapshot.servers[0].capability_metadata,
4017 McpServerCapabilityMetadata::Advertised(McpServerCapabilities {
4018 tools: true,
4019 resources: false,
4020 prompts: true,
4021 })
4022 );
4023 assert_eq!(
4024 snapshot.servers[1].capability_metadata,
4025 McpServerCapabilityMetadata::LegacyFallback
4026 );
4027 }
4028
4029 #[tokio::test]
4030 async fn mcp_pool_call_tool_preserves_tool_names_with_dashes() {
4031 let sent = Arc::new(Mutex::new(Vec::new()));
4032 let transport = ScriptedValueTransport {
4033 sent: Arc::clone(&sent),
4034 responses: VecDeque::from([json_frame(serde_json::json!({
4035 "jsonrpc": "2.0",
4036 "id": 1,
4037 "result": {"ok": true}
4038 }))]),
4039 };
4040 let mut conn = test_connection(Box::new(transport));
4041 conn.name = "dephy".to_string();
4042 conn.tools = vec![McpTool {
4043 name: "company--search".to_string(),
4044 description: None,
4045 input_schema: serde_json::json!({}),
4046 }];
4047
4048 let mut pool = McpPool::new(McpConfig {
4049 timeouts: McpTimeouts::default(),
4050 servers: HashMap::new(),
4051 });
4052 pool.connections.insert("dephy".to_string(), conn);
4053
4054 let result = pool
4055 .call_tool(
4056 "mcp_dephy_company--search",
4057 serde_json::json!({"query": "dephy"}),
4058 )
4059 .await
4060 .unwrap();
4061
4062 assert_eq!(result, serde_json::json!({"ok": true}));
4063 let sent = sent.lock().unwrap();
4064 assert_eq!(sent[0]["method"], "tools/call");
4065 assert_eq!(sent[0]["params"]["name"], "company--search");
4066 assert_eq!(
4067 sent[0]["params"]["arguments"],
4068 serde_json::json!({"query": "dephy"})
4069 );
4070 }
4071
4072 #[tokio::test]
4073 async fn mcp_pool_rejects_unadvertised_tool_without_sending_tools_call() {
4074 let sent = Arc::new(Mutex::new(Vec::new()));
4075 let transport = ScriptedValueTransport {
4076 sent: Arc::clone(&sent),
4077 // A malicious server could implement this hidden method, but local
4078 // catalog authorization must prevent the transport from seeing it.
4079 responses: VecDeque::from([json_frame(serde_json::json!({
4080 "jsonrpc": "2.0", "id": 1, "result": {"deleted": true}
4081 }))]),
4082 };
4083 let mut conn = test_connection(Box::new(transport));
4084 conn.name = "spy".to_string();
4085 conn.tools = vec![McpTool {
4086 name: "read".to_string(),
4087 description: None,
4088 input_schema: serde_json::json!({}),
4089 }];
4090 let mut pool = McpPool::new(McpConfig::default());
4091 pool.connections.insert("spy".to_string(), conn);
4092
4093 let error = pool
4094 .call_tool("mcp_spy_delete", serde_json::json!({}))
4095 .await
4096 .expect_err("unadvertised hidden tool must fail locally");
4097 assert!(error.to_string().contains("Unknown MCP tool name"));
4098 assert!(sent.lock().unwrap().is_empty(), "zero tools/call requests");
4099 }
4100
4101 #[tokio::test]
4102 async fn mcp_pool_binds_prompts_and_resources_to_advertised_catalog() {
4103 let sent = Arc::new(Mutex::new(Vec::new()));
4104 let transport = ScriptedValueTransport {
4105 sent: Arc::clone(&sent),
4106 responses: VecDeque::from([json_frame(serde_json::json!({
4107 "jsonrpc": "2.0", "id": 1, "result": {"contents": []}
4108 }))]),
4109 };
4110 let mut conn = test_connection(Box::new(transport));
4111 conn.name = "catalog".to_string();
4112 conn.prompts = vec![McpPrompt {
4113 name: "review".to_string(),
4114 description: None,
4115 arguments: Vec::new(),
4116 }];
4117 conn.resources = vec![McpResource {
4118 uri: "file:///readme".to_string(),
4119 name: "readme".to_string(),
4120 description: None,
4121 mime_type: None,
4122 }];
4123 conn.resource_templates = vec![McpResourceTemplate {
4124 uri_template: "repo://item/{id}".to_string(),
4125 name: "item".to_string(),
4126 description: None,
4127 mime_type: None,
4128 }];
4129 let mut pool = McpPool::new(McpConfig::default());
4130 pool.connections.insert("catalog".to_string(), conn);
4131
4132 pool.get_prompt("catalog", "hidden", serde_json::json!({}))
4133 .await
4134 .expect_err("hidden prompt must fail locally");
4135 pool.read_resource("catalog", "file:///hidden")
4136 .await
4137 .expect_err("hidden literal resource must fail locally");
4138 assert!(sent.lock().unwrap().is_empty());
4139
4140 let result = pool
4141 .read_resource("catalog", "repo://item/42")
4142 .await
4143 .expect("exact advertised template expansion is callable");
4144 assert_eq!(result, serde_json::json!({"contents": []}));
4145 let sent = sent.lock().unwrap();
4146 assert_eq!(sent.len(), 1);
4147 assert_eq!(sent[0]["method"], "resources/read");
4148 assert_eq!(sent[0]["params"]["uri"], "repo://item/42");
4149 }
4150
4151 #[tokio::test]
4152 async fn mcp_pool_call_tool_preserves_server_names_with_underscores() {
4153 let sent = Arc::new(Mutex::new(Vec::new()));
4154 let transport = ScriptedValueTransport {
4155 sent: Arc::clone(&sent),
4156 responses: VecDeque::from([json_frame(serde_json::json!({
4157 "jsonrpc": "2.0",
4158 "id": 1,
4159 "result": {"ok": true}
4160 }))]),
4161 };
4162 let mut conn = test_connection(Box::new(transport));
4163 conn.name = "my_db".to_string();
4164 conn.tools = vec![McpTool {
4165 name: "execute_sql".to_string(),
4166 description: None,
4167 input_schema: serde_json::json!({}),
4168 }];
4169
4170 let mut pool = McpPool::new(McpConfig {
4171 timeouts: McpTimeouts::default(),
4172 servers: HashMap::new(),
4173 });
4174 pool.connections.insert("my_db".to_string(), conn);
4175
4176 let result = pool
4177 .call_tool(
4178 "mcp_my_db_execute_sql",
4179 serde_json::json!({"query": "select 1"}),
4180 )
4181 .await
4182 .unwrap();
4183
4184 assert_eq!(result, serde_json::json!({"ok": true}));
4185 let sent = sent.lock().unwrap();
4186 assert_eq!(sent[0]["method"], "tools/call");
4187 assert_eq!(sent[0]["params"]["name"], "execute_sql");
4188 assert_eq!(
4189 sent[0]["params"]["arguments"],
4190 serde_json::json!({"query": "select 1"})
4191 );
4192 }
4193
4194 #[tokio::test]
4195 async fn mcp_pool_hides_and_rejects_ambiguous_model_tool_names() {
4196 let sent_short = Arc::new(Mutex::new(Vec::new()));
4197 let short_transport = ScriptedValueTransport {
4198 sent: Arc::clone(&sent_short),
4199 responses: VecDeque::from([json_frame(serde_json::json!({
4200 "jsonrpc": "2.0",
4201 "id": 1,
4202 "result": {"short": true}
4203 }))]),
4204 };
4205 let mut short_conn = test_connection(Box::new(short_transport));
4206 short_conn.name = "my".to_string();
4207 short_conn.tools = vec![McpTool {
4208 name: "db_execute_sql".to_string(),
4209 description: None,
4210 input_schema: serde_json::json!({}),
4211 }];
4212
4213 let sent_long = Arc::new(Mutex::new(Vec::new()));
4214 let long_transport = ScriptedValueTransport {
4215 sent: Arc::clone(&sent_long),
4216 responses: VecDeque::from([json_frame(serde_json::json!({
4217 "jsonrpc": "2.0",
4218 "id": 1,
4219 "result": {"long": true}
4220 }))]),
4221 };
4222 let mut long_conn = test_connection(Box::new(long_transport));
4223 long_conn.name = "my_db".to_string();
4224 long_conn.tools = vec![McpTool {
4225 name: "execute_sql".to_string(),
4226 description: None,
4227 input_schema: serde_json::json!({}),
4228 }];
4229
4230 let mut pool = McpPool::new(McpConfig {
4231 timeouts: McpTimeouts::default(),
4232 servers: HashMap::new(),
4233 });
4234 pool.connections.insert("my".to_string(), short_conn);
4235 pool.connections.insert("my_db".to_string(), long_conn);
4236
4237 assert!(
4238 pool.all_tools().is_empty(),
4239 "ambiguous names must never be advertised to the model"
4240 );
4241 let error = pool
4242 .call_tool(
4243 "mcp_my_db_execute_sql",
4244 serde_json::json!({"query": "select 1"}),
4245 )
4246 .await
4247 .expect_err("ambiguous tool route must fail closed");
4248
4249 assert!(error.to_string().contains("Ambiguous MCP tool name"));
4250 assert!(
4251 sent_short.lock().unwrap().is_empty(),
4252 "neither authority may receive an ambiguous tool call"
4253 );
4254 assert!(
4255 sent_long.lock().unwrap().is_empty(),
4256 "neither authority may receive an ambiguous tool call"
4257 );
4258 }
4259
4260 #[tokio::test]
4261 async fn json_rpc_session_error_is_marked_stale() {
4262 let sent = Arc::new(Mutex::new(Vec::new()));
4263 let transport = ScriptedValueTransport {
4264 sent: Arc::clone(&sent),
4265 responses: VecDeque::from([json_frame(serde_json::json!({
4266 "jsonrpc": "2.0",
4267 "id": 1,
4268 "error": {
4269 "code": -32001,
4270 "message": "MCP session expired"
4271 }
4272 }))]),
4273 };
4274 let mut conn = test_connection(Box::new(transport));
4275
4276 let err = conn
4277 .call_tool("search", serde_json::json!({"query": "dephy"}), 1)
4278 .await
4279 .expect_err("session error should fail");
4280
4281 assert!(
4282 is_mcp_stale_session_error(&err),
4283 "JSON-RPC session error should be retryable, got: {err:#}"
4284 );
4285 }
4286
4287 #[test]
4288 fn sse_transport_closed_is_retryable() {
4289 let err = anyhow::anyhow!("SSE transport closed");
4290 assert!(
4291 is_mcp_stale_session_error(&err),
4292 "closed SSE stream should force reconnect before retry"
4293 );
4294 }
4295
4296 #[test]
4297 fn stdio_transport_closed_is_retryable() {
4298 let err = anyhow::anyhow!("Stdio transport closed (exit status: 1)");
4299 assert!(
4300 is_mcp_stale_session_error(&err),
4301 "dead stdio child should force reconnect before retry"
4302 );
4303 }
4304
4305 #[test]
4306 fn legacy_sse_post_disconnect_is_retryable() {
4307 let err = anyhow::anyhow!(
4308 "MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): connection closed before message completed"
4309 );
4310 assert!(
4311 is_mcp_stale_session_error(&err),
4312 "closed legacy SSE POST should force reconnect before retry"
4313 );
4314
4315 let err = anyhow::anyhow!(
4316 "MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): connection reset by peer"
4317 );
4318 assert!(
4319 is_mcp_stale_session_error(&err),
4320 "reset legacy SSE POST should force reconnect before retry"
4321 );
4322
4323 let err = anyhow::anyhow!(
4324 "MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): An existing connection was forcibly closed by the remote host."
4325 );
4326 assert!(
4327 is_mcp_stale_session_error(&err),
4328 "Windows reset wording should force reconnect before retry"
4329 );
4330 }
4331
4332 #[tokio::test]
4333 async fn discover_all_ignores_unsupported_optional_capabilities() {
4334 let sent = Arc::new(Mutex::new(Vec::new()));
4335 let transport = ScriptedValueTransport {
4336 sent: Arc::clone(&sent),
4337 responses: VecDeque::from([
4338 json_frame(serde_json::json!({
4339 "jsonrpc": "2.0",
4340 "id": 1,
4341 "result": {
4342 "tools": [
4343 { "name": "search", "inputSchema": {} }
4344 ]
4345 }
4346 })),
4347 json_frame(serde_json::json!({
4348 "jsonrpc": "2.0",
4349 "id": 2,
4350 "error": {
4351 "code": -32601,
4352 "message": "resources not supported"
4353 }
4354 })),
4355 json_frame(serde_json::json!({
4356 "jsonrpc": "2.0",
4357 "id": 3,
4358 "error": {
4359 "code": -32601,
4360 "message": "resource templates not supported"
4361 }
4362 })),
4363 json_frame(serde_json::json!({
4364 "jsonrpc": "2.0",
4365 "id": 4,
4366 "error": {
4367 "code": -32601,
4368 "message": "prompts not supported"
4369 }
4370 })),
4371 ]),
4372 };
4373 let mut conn = test_connection(Box::new(transport));
4374 conn.server_capabilities = Some(McpServerCapabilities {
4375 tools: true,
4376 resources: true,
4377 prompts: true,
4378 });
4379
4380 conn.discover_all().await.expect("discover");
4381
4382 assert_eq!(conn.tools.len(), 1);
4383 assert_eq!(conn.tools[0].name, "search");
4384 assert!(conn.resources.is_empty());
4385 assert!(conn.resource_templates.is_empty());
4386 assert!(conn.prompts.is_empty());
4387 let methods: Vec<_> = sent
4388 .lock()
4389 .unwrap()
4390 .iter()
4391 .filter_map(|message| message.get("method").and_then(|method| method.as_str()))
4392 .map(str::to_string)
4393 .collect();
4394 assert_eq!(
4395 methods,
4396 [
4397 "tools/list",
4398 "resources/list",
4399 "resources/templates/list",
4400 "prompts/list",
4401 ]
4402 );
4403 }
4404
4405 #[tokio::test]
4406 async fn discover_all_keeps_advertised_tool_discovery_required() {
4407 let sent = Arc::new(Mutex::new(Vec::new()));
4408 let transport = ScriptedValueTransport {
4409 sent,
4410 responses: VecDeque::from([json_frame(serde_json::json!({
4411 "jsonrpc": "2.0",
4412 "id": 1,
4413 "error": {"code": -32601, "message": "tools not supported"}
4414 }))]),
4415 };
4416 let mut conn = test_connection(Box::new(transport));
4417 conn.server_capabilities = Some(McpServerCapabilities {
4418 tools: true,
4419 resources: false,
4420 prompts: false,
4421 });
4422
4423 let error = conn
4424 .discover_all()
4425 .await
4426 .expect_err("advertised tools/list failure must fail discovery");
4427
4428 assert!(
4429 error.to_string().contains("MCP error in 'tools/list'"),
4430 "unexpected error: {error:#}"
4431 );
4432 }
4433
4434 /// #1244: when an MCP stdio server fails to spawn, the underlying OS
4435 /// error (e.g. ENOENT for a missing binary) must reach the user via the
4436 /// snapshot.error string. Regression test for `err.to_string()` dropping
4437 /// the anyhow chain — without `{err:#}` the user sees only the opaque
4438 /// wrapper "MCP stdio spawn failed (...)" and has nothing to act on.
4439 #[tokio::test]
4440 async fn discover_snapshot_includes_underlying_spawn_error_in_chain() {
4441 let dir = tempfile::tempdir().unwrap();
4442 let path = dir.path().join("mcp.json");
4443 fs::write(
4444 &path,
4445 r#"{
4446 "mcpServers": {
4447 "broken": {
4448 "command": "codewhale-tui-test-this-binary-does-not-exist-9f8e7d6c5b4a",
4449 "args": []
4450 }
4451 }
4452 }"#,
4453 )
4454 .unwrap();
4455
4456 let snapshot = discover_manager_snapshot(&path, None, false).await.unwrap();
4457 let server = snapshot
4458 .servers
4459 .iter()
4460 .find(|s| s.name == "broken")
4461 .expect("broken server should appear in snapshot");
4462 let err = server
4463 .error
4464 .as_deref()
4465 .expect("broken server should have an error");
4466 let lowered = err.to_lowercase();
4467 assert!(
4468 lowered.contains("os error")
4469 || lowered.contains("not found")
4470 || lowered.contains("no such"),
4471 "expected underlying spawn error in chain, got: {err}"
4472 );
4473 }
4474
4475 #[tokio::test]
4476 async fn discover_snapshot_explains_a_missing_node_runtime() {
4477 let dir = tempfile::tempdir().unwrap();
4478 let config_path = dir.path().join("mcp.json");
4479 let missing_node = dir
4480 .path()
4481 .join(if cfg!(windows) { "node.exe" } else { "node" });
4482 fs::write(
4483 &config_path,
4484 serde_json::to_vec(&serde_json::json!({
4485 "mcpServers": { "computer": { "command": missing_node, "args": [] } }
4486 }))
4487 .unwrap(),
4488 )
4489 .unwrap();
4490 let snapshot = discover_manager_snapshot(&config_path, None, false)
4491 .await
4492 .unwrap();
4493 let error = snapshot
4494 .servers
4495 .iter()
4496 .find(|server| server.name == "computer")
4497 .unwrap()
4498 .error
4499 .as_ref()
4500 .unwrap();
4501 assert!(error.contains("Node.js 20 or newer"), "{error}");
4502 assert!(error.contains("https://nodejs.org/"), "{error}");
4503 }
4504
4505 /// The same guarantee for a server the user marked `required`. `connect_all`
4506 /// appends a generic "required MCP server failed to initialize" entry after
4507 /// the real per-server connect error, and every snapshot path folds the
4508 /// returned pairs into a `HashMap<name, message>` — so the later, contentless
4509 /// entry overwrites the diagnosis. Marking a server required must not blind
4510 /// the user to *why* it did not start.
4511 #[tokio::test]
4512 async fn required_server_snapshot_keeps_the_real_spawn_error() {
4513 let dir = tempfile::tempdir().unwrap();
4514 let path = dir.path().join("mcp.json");
4515 fs::write(
4516 &path,
4517 r#"{
4518 "mcpServers": {
4519 "broken": {
4520 "command": "codewhale-tui-test-this-binary-does-not-exist-9f8e7d6c5b4a",
4521 "args": [],
4522 "required": true
4523 }
4524 }
4525 }"#,
4526 )
4527 .unwrap();
4528
4529 let snapshot = discover_manager_snapshot(&path, None, false).await.unwrap();
4530 let server = snapshot
4531 .servers
4532 .iter()
4533 .find(|s| s.name == "broken")
4534 .expect("broken server should appear in snapshot");
4535 let err = server
4536 .error
4537 .as_deref()
4538 .expect("broken server should have an error");
4539 let lowered = err.to_lowercase();
4540 assert!(
4541 lowered.contains("os error")
4542 || lowered.contains("not found")
4543 || lowered.contains("no such"),
4544 "required server must still report why it failed, got: {err}"
4545 );
4546 }
4547
4548 /// `connect_all` must report one error per failed server. A `required`
4549 /// server that already failed to connect got a second, contentless entry
4550 /// appended for the same name.
4551 #[tokio::test]
4552 async fn connect_all_reports_one_error_per_failed_required_server() {
4553 let dir = tempfile::tempdir().unwrap();
4554 let path = dir.path().join("mcp.json");
4555 fs::write(
4556 &path,
4557 r#"{
4558 "mcpServers": {
4559 "broken": {
4560 "command": "codewhale-tui-test-this-binary-does-not-exist-9f8e7d6c5b4a",
4561 "args": [],
4562 "required": true
4563 }
4564 }
4565 }"#,
4566 )
4567 .unwrap();
4568
4569 let mut pool = McpPool::from_config_path(&path).unwrap();
4570 let errors = pool.connect_all().await;
4571 let for_broken: Vec<_> = errors.iter().filter(|(name, _)| name == "broken").collect();
4572 assert_eq!(
4573 for_broken.len(),
4574 1,
4575 "expected exactly one error for 'broken', got: {:?}",
4576 errors
4577 .iter()
4578 .map(|(name, err)| format!("{name}: {err:#}"))
4579 .collect::<Vec<_>>()
4580 );
4581 let rendered = format!("{:#}", for_broken[0].1).to_lowercase();
4582 assert!(
4583 rendered.contains("spawn failed"),
4584 "the single error must be the real cause, got: {rendered}"
4585 );
4586 }
4587
4588 /// A dead server must not be re-dialed on every turn.
4589 ///
4590 /// The turn loop rebuilds the tool catalog on each user message, and that
4591 /// path calls `connect_all`. Before the cooldown, a wall of unreachable
4592 /// servers meant a full round of connect timeouts before every first token —
4593 /// the "MCP is always reloading and slowing things down" report. The second
4594 /// pass must produce the same diagnosis without dialing anything.
4595 #[tokio::test]
4596 async fn a_failed_server_waits_out_a_cooldown_instead_of_redialing_every_turn() {
4597 let dir = tempfile::tempdir().unwrap();
4598 let path = dir.path().join("mcp.json");
4599 fs::write(
4600 &path,
4601 r#"{
4602 "mcpServers": {
4603 "broken": {
4604 "command": "codewhale-tui-test-this-binary-does-not-exist-9f8e7d6c5b4a",
4605 "args": []
4606 }
4607 }
4608 }"#,
4609 )
4610 .unwrap();
4611
4612 let mut pool = McpPool::from_config_path(&path).unwrap();
4613 let first = pool.connect_all().await;
4614 assert_eq!(first.len(), 1, "first pass should dial and fail once");
4615
4616 // Second pass: still reported as failing, but nothing is queued to dial.
4617 let (pending, errors) = pool.collect_pending_connects(None);
4618 assert!(
4619 pending.is_empty(),
4620 "a server inside its cooldown must not be re-dialed: {:?}",
4621 pending.iter().map(|(name, _)| name).collect::<Vec<_>>()
4622 );
4623 assert_eq!(errors.len(), 1, "the failure must still be reported");
4624 assert_eq!(errors[0].0, "broken");
4625 assert!(
4626 format!("{:#}", errors[0].1)
4627 .to_lowercase()
4628 .contains("spawn"),
4629 "the replayed diagnosis must be the real one: {:#}",
4630 errors[0].1
4631 );
4632
4633 // Asking for that server by name is explicit intent and lifts the wait.
4634 assert!(pool.retry_connection("broken").await.is_err());
4635 let (pending, _) = pool.collect_pending_connects(None);
4636 assert!(
4637 pending.is_empty(),
4638 "the failed retry restarts the ladder rather than clearing it"
4639 );
4640 }
4641
4642 /// Lazy boot (#6033): the scoped collect starts only the eager set —
4643 /// `required` servers plus ones an explicit tool selection covers — and the
4644 /// pool tracks exactly those names as in-flight, so "connecting" never has
4645 /// to be inferred from "enabled but unconnected".
4646 #[test]
4647 fn lazy_boot_scopes_pending_connects_and_tracks_in_flight() {
4648 let mut required_cfg = test_server_config();
4649 required_cfg.required = true;
4650 let mut pool = McpPool::new(McpConfig {
4651 timeouts: McpTimeouts::default(),
4652 servers: HashMap::from([
4653 ("needed".to_string(), required_cfg),
4654 ("selected".to_string(), test_server_config()),
4655 ("lazy".to_string(), test_server_config()),
4656 ]),
4657 });
4658 let requested = vec!["mcp_selected_read".to_string()];
4659
4660 let eager = pool.eager_boot_server_names(&requested);
4661 assert_eq!(
4662 eager,
4663 HashSet::from(["needed".to_string(), "selected".to_string()]),
4664 "the eager set is required servers plus selection-covered ones"
4665 );
4666
4667 let (pending, errors) = pool.collect_pending_connects(Some(&eager));
4668 assert!(errors.is_empty());
4669 let pending_names: HashSet<String> = pending.iter().map(|(name, _)| name.clone()).collect();
4670 assert_eq!(pending_names, eager);
4671 assert_eq!(
4672 pool.connecting_servers()
4673 .into_iter()
4674 .collect::<HashSet<_>>(),
4675 pending_names,
4676 "in-flight marks must name exactly the spawned connects"
4677 );
4678
4679 // A lazy server is neither spawned nor reported connecting.
4680 let (pending, errors) = pool.collect_pending_connects(Some(&eager));
4681 assert!(
4682 pending.is_empty() && errors.is_empty(),
4683 "an in-flight name is not re-queued by a second scoped pass"
4684 );
4685
4686 // Explicit selection is intent: the lazy server starts on demand and is
4687 // marked in-flight while it does.
4688 let (pending, errors) = pool.take_pending_connects_for(&["lazy".to_string()]);
4689 assert!(errors.is_empty());
4690 assert_eq!(pending.len(), 1);
4691 assert!(pool.connecting_servers().contains(&"lazy".to_string()));
4692
4693 // Aborting clears the marks without touching connection state.
4694 pool.cancel_connecting(&HashSet::from([
4695 "needed".to_string(),
4696 "selected".to_string(),
4697 "lazy".to_string(),
4698 ]));
4699 assert!(pool.connecting_servers().is_empty());
4700 }
4701
4702 /// Selection coverage shared by lazy boot and the per-turn wait: exact
4703 /// `mcp_<server>_<tool>` names and `mcp_<prefix>*` globs both count.
4704 #[test]
4705 fn tool_selection_covers_exact_names_and_globs() {
4706 let selected = vec![
4707 "mcp_fs_read".to_string(),
4708 "mcp_git_*".to_string(),
4709 "shell".to_string(),
4710 ];
4711 assert!(tool_selection_covers_server(&selected, "fs"));
4712 assert!(tool_selection_covers_server(&selected, "git_status"));
4713 assert!(!tool_selection_covers_server(&selected, "slack"));
4714 // A prefix glob reaches every server whose `mcp_<server>_` namespace
4715 // starts with it: `mcp_gi*` covers `git` and `gitea` alike.
4716 let glob = vec!["mcp_gi*".to_string()];
4717 assert!(tool_selection_covers_server(&glob, "git"));
4718 assert!(tool_selection_covers_server(&glob, "gitea"));
4719 assert!(!tool_selection_covers_server(&glob, "fs"));
4720 }
4721
4722 #[test]
4723 fn connect_backoff_doubles_then_holds_at_the_ceiling() {
4724 use std::time::Duration;
4725 assert_eq!(connect_backoff_delay(1), Duration::from_secs(30));
4726 assert_eq!(connect_backoff_delay(2), Duration::from_secs(60));
4727 assert_eq!(connect_backoff_delay(3), Duration::from_secs(120));
4728 assert_eq!(connect_backoff_delay(6), Duration::from_secs(600));
4729 assert_eq!(
4730 connect_backoff_delay(50),
4731 Duration::from_secs(600),
4732 "the ceiling holds; a long-dead server is retried every ten minutes"
4733 );
4734 }
4735
4736 #[test]
4737 fn parse_sse_message_data_extracts_message_events() {
4738 let body = "event: message\r\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\r\n\r\n";
4739 let messages = parse_sse_message_data(body);
4740 assert_eq!(messages.len(), 1);
4741 let value: serde_json::Value = serde_json::from_slice(&messages[0]).unwrap();
4742 assert_eq!(value["id"], 1);
4743 assert!(value.get("result").is_some());
4744 }
4745
4746 #[test]
4747 fn response_id_matches_string_and_numeric_echoes() {
4748 assert!(response_id_matches(Some(&serde_json::json!("1")), "1"));
4749 assert!(response_id_matches(Some(&serde_json::json!(1)), "1"));
4750 assert!(!response_id_matches(Some(&serde_json::json!("2")), "1"));
4751 }
4752
4753 #[test]
4754 fn legacy_sse_transport_requires_explicit_config() {
4755 let mut server = test_server_config();
4756 server.url = Some("https://example.com/mcp/abc/sse".to_string());
4757
4758 assert!(
4759 !is_legacy_sse_transport(&server),
4760 "/sse paths must not force legacy SSE without an explicit transport override"
4761 );
4762
4763 server.transport = Some("sse".to_string());
4764 assert!(is_legacy_sse_transport(&server));
4765
4766 server.transport = Some("SSE".to_string());
4767 assert!(is_legacy_sse_transport(&server));
4768
4769 server.transport = Some("http".to_string());
4770 assert!(!is_legacy_sse_transport(&server));
4771 }
4772
4773 #[test]
4774 fn find_sse_event_separator_accepts_lf_and_crlf() {
4775 assert_eq!(
4776 find_sse_event_separator("event: endpoint\n\n"),
4777 Some((15, 2))
4778 );
4779 assert_eq!(
4780 find_sse_event_separator("event: endpoint\r\n\r\n"),
4781 Some((15, 4))
4782 );
4783 }
4784
4785 #[test]
4786 fn find_sse_event_separator_bytes_matches_str_and_survives_multibyte() {
4787 // Same offsets as the str version.
4788 assert_eq!(
4789 find_sse_event_separator_bytes(b"event: endpoint\n\n"),
4790 Some((15, 2))
4791 );
4792 assert_eq!(
4793 find_sse_event_separator_bytes(b"event: endpoint\r\n\r\n"),
4794 Some((15, 4))
4795 );
4796 // A frame whose data holds a multi-byte char, accumulated byte-wise and
4797 // split mid-char across two reads, decodes intact (no U+FFFD).
4798 let frame = "data: 你好\n\n";
4799 let bytes = frame.as_bytes();
4800 let split = bytes.len() - 3; // inside "好" / before the separator
4801 let mut buffer: Vec<u8> = Vec::new();
4802 buffer.extend_from_slice(&bytes[..split]);
4803 assert_eq!(find_sse_event_separator_bytes(&buffer), None);
4804 buffer.extend_from_slice(&bytes[split..]);
4805 let (pos, sep) = find_sse_event_separator_bytes(&buffer).expect("separator");
4806 let block = String::from_utf8_lossy(&buffer[..pos]).into_owned();
4807 assert_eq!(block, "data: 你好");
4808 assert!(!block.contains('\u{FFFD}'), "multibyte corrupted");
4809 assert_eq!(sep, 2);
4810 }
4811
4812 #[tokio::test]
4813 #[ignore = "flaky: requires a live TCP listener and is sensitive to port allocation races"]
4814 async fn mcp_connection_supports_streamable_http_event_stream_responses() {
4815 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4816 use tokio::net::{TcpListener, TcpStream};
4817
4818 async fn read_http_request(socket: &mut TcpStream) -> String {
4819 let mut request = Vec::new();
4820 let mut buf = [0; 1024];
4821 let header_end = loop {
4822 let n = socket.read(&mut buf).await.unwrap();
4823 assert!(n > 0, "client closed before headers completed");
4824 request.extend_from_slice(&buf[..n]);
4825 if let Some(pos) = request.windows(4).position(|window| window == b"\r\n\r\n") {
4826 break pos + 4;
4827 }
4828 };
4829
4830 let headers = String::from_utf8_lossy(&request[..header_end]);
4831 let content_length = headers
4832 .lines()
4833 .find_map(|line| {
4834 let (name, value) = line.split_once(':')?;
4835 name.eq_ignore_ascii_case("content-length")
4836 .then(|| value.trim().parse::<usize>().ok())
4837 .flatten()
4838 })
4839 .unwrap_or(0);
4840 let total_len = header_end + content_length;
4841 while request.len() < total_len {
4842 let n = socket.read(&mut buf).await.unwrap();
4843 assert!(n > 0, "client closed before body completed");
4844 request.extend_from_slice(&buf[..n]);
4845 }
4846
4847 String::from_utf8(request).unwrap()
4848 }
4849
4850 async fn write_json_sse(socket: &mut TcpStream, response: serde_json::Value) {
4851 let body = format!("event: message\ndata: {response}\n\n");
4852 let response = format!(
4853 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n{}",
4854 body.len(),
4855 body
4856 );
4857 socket.write_all(response.as_bytes()).await.unwrap();
4858 }
4859
4860 let _lock = lock_mcp_loopback_tests().await;
4861 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4862 let addr = listener.local_addr().unwrap();
4863 let server = tokio::spawn(async move {
4864 loop {
4865 let Ok((mut socket, _)) = listener.accept().await else {
4866 break;
4867 };
4868 tokio::spawn(async move {
4869 let request = read_http_request(&mut socket).await;
4870 assert!(request.starts_with("POST /mcp "));
4871 assert!(
4872 request.contains("Accept: application/json, text/event-stream")
4873 || request.contains("accept: application/json, text/event-stream")
4874 );
4875 let body = request.split("\r\n\r\n").nth(1).unwrap_or("");
4876 let value: serde_json::Value = serde_json::from_str(body).unwrap();
4877 let method = value["method"].as_str().unwrap();
4878
4879 if method == "notifications/initialized" {
4880 socket
4881 .write_all(b"HTTP/1.1 202 Accepted\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
4882 .await
4883 .unwrap();
4884 return;
4885 }
4886
4887 let id = value["id"].clone();
4888 let result = match method {
4889 "initialize" => serde_json::json!({
4890 "protocolVersion": "2024-11-05",
4891 "serverInfo": {"name": "mock-streamable", "version": "1.0.0"},
4892 "capabilities": {"tools": {}, "resources": {}, "prompts": {}}
4893 }),
4894 "tools/list" => serde_json::json!({
4895 "tools": [{
4896 "name": "read_wiki_structure",
4897 "description": "Read wiki structure",
4898 "inputSchema": {"type": "object"}
4899 }]
4900 }),
4901 "resources/list" => serde_json::json!({"resources": []}),
4902 "resources/templates/list" => {
4903 serde_json::json!({"resourceTemplates": []})
4904 }
4905 "prompts/list" => serde_json::json!({"prompts": []}),
4906 other => panic!("unexpected method: {other}"),
4907 };
4908 write_json_sse(
4909 &mut socket,
4910 serde_json::json!({
4911 "jsonrpc": "2.0",
4912 "id": id,
4913 "result": result
4914 }),
4915 )
4916 .await;
4917 });
4918 }
4919 });
4920
4921 let config = McpServerConfig {
4922 command: None,
4923 args: vec![],
4924 env: HashMap::new(),
4925 cwd: None,
4926 url: Some(format!("http://{addr}/mcp")),
4927 transport: None,
4928 connect_timeout: Some(2),
4929 execute_timeout: None,
4930 read_timeout: None,
4931 disabled: false,
4932 enabled: true,
4933 required: false,
4934 enabled_tools: Vec::new(),
4935 disabled_tools: Vec::new(),
4936 headers: HashMap::new(),
4937 env_headers: HashMap::new(),
4938 bearer_token_env_var: None,
4939 scopes: Vec::new(),
4940 oauth: None,
4941 oauth_resource: None,
4942 reviewed_plugin: None,
4943 runtime_added: false,
4944 allow_private_network: false,
4945 };
4946
4947 let conn = McpConnection::connect_with_policy(
4948 "deepwiki".to_string(),
4949 config,
4950 &McpTimeouts::default(),
4951 None,
4952 )
4953 .await
4954 .unwrap();
4955
4956 assert_eq!(conn.state(), ConnectionState::Ready);
4957 assert_eq!(conn.tools().len(), 1);
4958 assert_eq!(conn.tools()[0].name, "read_wiki_structure");
4959
4960 server.abort();
4961 }
4962
4963 #[test]
4964 fn mask_url_secrets_strips_userinfo() {
4965 let masked = mask_url_secrets("https://user:s3cret@host.example/api?foo=bar");
4966 assert!(masked.contains("***"), "expected masked userinfo: {masked}");
4967 assert!(!masked.contains("s3cret"), "secret leaked: {masked}");
4968 assert!(masked.contains("host.example"), "host preserved: {masked}");
4969 }
4970
4971 #[test]
4972 fn mask_url_secrets_passes_through_clean_url() {
4973 assert_eq!(
4974 mask_url_secrets("https://api.example.com/mcp"),
4975 "https://api.example.com/mcp"
4976 );
4977 }
4978
4979 #[test]
4980 fn redact_body_preview_masks_bearer_token() {
4981 let redacted = redact_body_preview(
4982 "Authorization: Bearer abc.def.ghi end; authorization: bearer second-token end",
4983 );
4984 assert_eq!(
4985 redacted.matches("Bearer ***").count() + redacted.matches("bearer ***").count(),
4986 2,
4987 "redacted: {redacted}"
4988 );
4989 assert!(
4990 !redacted.contains("abc.def.ghi") && !redacted.contains("second-token"),
4991 "leaked: {redacted}"
4992 );
4993 }
4994
4995 #[test]
4996 fn redact_proxy_userinfo_strips_password() {
4997 // Corporate-style proxy URL with embedded creds — the
4998 // password must never reach the on-disk log file. URL strings
4999 // are assembled from placeholder constants via `format!` so the
5000 // literal source never contains a scheme-prefixed username +
5001 // password pair (colon-separated, `@`-terminated) that
5002 // GitGuardian's "Basic Auth String" detector would flag as a
5003 // committed credential.
5004 let (placeholder_user, placeholder_pass) = ("PLACEHOLDER_USER", "PLACEHOLDER_PASS");
5005 let with_creds = format!("http://{placeholder_user}:{placeholder_pass}@proxy.example/");
5006 let redacted = redact_proxy_userinfo(&with_creds);
5007 assert_eq!(redacted, "http://***@proxy.example/");
5008 assert!(!redacted.contains(placeholder_pass));
5009 assert!(!redacted.contains(placeholder_user));
5010
5011 // User only (no password) — still redacted.
5012 let with_user_only = format!("https://{placeholder_user}@proxy.example:8080");
5013 let redacted = redact_proxy_userinfo(&with_user_only);
5014 assert_eq!(redacted, "https://***@proxy.example:8080");
5015
5016 // No userinfo segment — pass through.
5017 let redacted = redact_proxy_userinfo("http://proxy.example:3128/");
5018 assert_eq!(redacted, "http://proxy.example:3128/");
5019
5020 // `@` appears only in the path, not as userinfo separator —
5021 // must not be mistaken for credentials.
5022 let redacted = redact_proxy_userinfo("http://proxy.example/path@thing");
5023 assert_eq!(redacted, "http://proxy.example/path@thing");
5024
5025 // Garbage input (no `://`) returned unchanged — the
5026 // surrounding warning log is the only caller and is already
5027 // handling the malformed-URL case.
5028 assert_eq!(redact_proxy_userinfo("not-a-url"), "not-a-url");
5029 }
5030
5031 #[test]
5032 fn redact_body_preview_masks_api_key_param() {
5033 let redacted = redact_body_preview("error api_key=sk-12345&other=val then TOKEN=second-secret");
5034 assert!(redacted.contains("api_key=***"), "redacted: {redacted}");
5035 assert!(redacted.contains("TOKEN=***"), "redacted: {redacted}");
5036 assert!(
5037 !redacted.contains("sk-12345") && !redacted.contains("second-secret"),
5038 "leaked: {redacted}"
5039 );
5040 assert!(
5041 redacted.contains("other=val"),
5042 "non-secret preserved: {redacted}"
5043 );
5044 }
5045
5046 #[test]
5047 fn reviewed_plugin_server_errors_suppress_arbitrary_details() {
5048 let auth = McpHttpAuth {
5049 suppress_server_error_details: true,
5050 ..Default::default()
5051 };
5052 assert_eq!(
5053 auth.server_error_preview("arbitrary credential value"),
5054 "<server details suppressed for reviewed plugin>"
5055 );
5056
5057 let response = serde_json::json!({
5058 "error": { "message": "arbitrary credential value" }
5059 });
5060 let error = response_result(&response, "tools/call", true)
5061 .expect_err("reviewed plugin JSON-RPC error must be generic")
5062 .to_string();
5063 assert!(!error.contains("arbitrary credential value"));
5064 assert!(error.contains("details suppressed"));
5065 }
5066
5067 #[test]
5068 fn invalid_json_preview_collapses_lines_and_redacts_secrets() {
5069 let preview = invalid_json_preview(
5070 b"Authorization: Bearer PLACEHOLDER_TOKEN\nAllow connection? api_key=PLACEHOLDER_KEY",
5071 );
5072
5073 assert!(
5074 preview.contains("Authorization: Bearer *** Allow connection? api_key=***"),
5075 "preview: {preview}"
5076 );
5077 assert!(
5078 !preview.contains('\n'),
5079 "preview should be single-line: {preview}"
5080 );
5081 assert!(
5082 !preview.contains("PLACEHOLDER_TOKEN") && !preview.contains("PLACEHOLDER_KEY"),
5083 "secret leaked: {preview}"
5084 );
5085 }
5086
5087 /// #420: `StdioTransport::shutdown` reaps the child process by sending
5088 /// SIGTERM and giving it a brief grace period before drop fires SIGKILL.
5089 /// The test spawns `cat` (which exits immediately on stdin EOF / SIGTERM)
5090 /// and verifies the transport tears down cleanly. Unix-only because
5091 /// SIGTERM doesn't exist on Windows; on Windows the test would just
5092 /// duplicate the kill_on_drop path.
5093 #[cfg(unix)]
5094 #[tokio::test]
5095 async fn stdio_transport_shutdown_terminates_child() {
5096 use tokio::process::Command as TokioCommand;
5097 let mut cmd = TokioCommand::new("cat");
5098 cmd.stdin(std::process::Stdio::piped())
5099 .stdout(std::process::Stdio::piped())
5100 .stderr(std::process::Stdio::null())
5101 .kill_on_drop(true);
5102 let mut child = cmd.spawn().expect("spawn cat");
5103 let pid = child.id().expect("child pid");
5104 let stdin = child.stdin.take().expect("child stdin");
5105 let stdout = child.stdout.take().expect("child stdout");
5106 let mut transport = StdioTransport {
5107 child: Arc::new(tokio::sync::Mutex::new(child)),
5108 stdin,
5109 reader: tokio::io::BufReader::new(stdout),
5110 pending_line: Vec::new(),
5111 stderr_tail: StderrTail::new(),
5112 authority_cancel_watch: None,
5113 _reviewed_launch: None,
5114 };
5115
5116 // shutdown() should send SIGTERM and complete within the grace window.
5117 let start = std::time::Instant::now();
5118 transport.shutdown().await;
5119 let elapsed = start.elapsed();
5120 assert!(
5121 elapsed < STDIO_SHUTDOWN_GRACE + Duration::from_millis(500),
5122 "shutdown blocked beyond grace window: {elapsed:?}"
5123 );
5124
5125 // The child should be reaped — kill(pid, 0) returning ESRCH means
5126 // the pid is gone. If it's still alive, kill(0) returns 0, which
5127 // means our shutdown didn't terminate it.
5128 // SAFETY: pid was just collected from a tokio Child we spawned.
5129 // libc::kill with signal 0 only checks pid existence and is
5130 // async-signal-safe.
5131 let still_alive = unsafe { libc::kill(pid as i32, 0) } == 0;
5132 assert!(
5133 !still_alive,
5134 "child {pid} survived StdioTransport::shutdown — SIGTERM not delivered"
5135 );
5136 }
5137
5138 #[cfg(unix)]
5139 #[tokio::test]
5140 async fn stdio_transport_drop_allows_child_cleanup() {
5141 let directory = tempfile::tempdir().expect("temporary cleanup receipt");
5142 let receipt = directory.path().join("cleaned");
5143 let config: McpServerConfig = serde_json::from_value(serde_json::json!({
5144 "args": [
5145 "-c",
5146 "trap 'sleep 0.1; printf cleaned > \"$1.tmp\"; mv \"$1.tmp\" \"$1\"; exit 0' TERM; printf 'ready\\n'; while :; do sleep 0.05; done",
5147 "cleanup-test",
5148 receipt.display().to_string(),
5149 ],
5150 }))
5151 .unwrap();
5152 let mut transport = StdioTransport::spawn(
5153 "drop-cleanup-test",
5154 "/bin/sh",
5155 &config,
5156 tokio_util::sync::CancellationToken::new(),
5157 )
5158 .expect("spawn cleanup fixture");
5159 assert_eq!(transport.recv().await.unwrap(), b"ready");
5160 // Retaining a strong Child reference would mask immediate kill_on_drop.
5161 drop(transport);
5162 tokio::time::timeout(STDIO_SHUTDOWN_GRACE + Duration::from_secs(1), async {
5163 while !receipt.exists() {
5164 tokio::time::sleep(Duration::from_millis(20)).await;
5165 }
5166 })
5167 .await
5168 .expect("dropped transport lets SIGTERM cleanup finish");
5169 assert_eq!(std::fs::read_to_string(receipt).unwrap(), "cleaned");
5170 }
5171
5172 #[cfg(unix)]
5173 #[tokio::test]
5174 async fn stdio_transport_drop_kills_child_that_ignores_cleanup() {
5175 let config: McpServerConfig = serde_json::from_value(serde_json::json!({
5176 "args": [
5177 "-c",
5178 "trap '' TERM; printf 'ready\\n'; while :; do sleep 0.05; done",
5179 ],
5180 }))
5181 .unwrap();
5182 let mut transport = StdioTransport::spawn(
5183 "drop-hung-test",
5184 "/bin/sh",
5185 &config,
5186 tokio_util::sync::CancellationToken::new(),
5187 )
5188 .expect("spawn unresponsive fixture");
5189 assert_eq!(transport.recv().await.unwrap(), b"ready");
5190 let pid = transport.child.lock().await.id().expect("live child");
5191 drop(transport);
5192 tokio::time::timeout(STDIO_SHUTDOWN_GRACE + Duration::from_secs(1), async {
5193 // Signal zero only observes the process; the owned Child sends kills.
5194 while unsafe { libc::kill(pid as i32, 0) } == 0 {
5195 tokio::time::sleep(Duration::from_millis(20)).await;
5196 }
5197 })
5198 .await
5199 .expect("dropped transport force-kills and reaps hung child");
5200 }
5201
5202 /// Mid-run MCP server crash: the v0.8.x spawn path used `Stdio::null` for
5203 /// stderr, so a server that died with a useful stderr message left the
5204 /// caller with only "Stdio transport closed". Now stderr is piped into a
5205 /// bounded ring buffer and surfaced when the read side fails.
5206 #[cfg(unix)]
5207 #[tokio::test]
5208 async fn stdio_transport_recv_error_includes_stderr_tail() {
5209 use tokio::process::Command as TokioCommand;
5210
5211 let mut cmd = TokioCommand::new("sh");
5212 cmd.arg("-c")
5213 .arg("echo 'mcp-server: failed to load plugin' 1>&2; exit 1")
5214 .stdin(std::process::Stdio::piped())
5215 .stdout(std::process::Stdio::piped())
5216 .stderr(std::process::Stdio::piped())
5217 .kill_on_drop(true);
5218
5219 let mut child = cmd.spawn().expect("spawn sh");
5220 let stdin = child.stdin.take().expect("stdin");
5221 let stdout = child.stdout.take().expect("stdout");
5222 let stderr = child.stderr.take().expect("stderr");
5223
5224 let stderr_tail = StderrTail::new();
5225 {
5226 let tail = Arc::clone(&stderr_tail);
5227 tokio::spawn(async move {
5228 let mut lines = tokio::io::BufReader::new(stderr).lines();
5229 while let Ok(Some(line)) = lines.next_line().await {
5230 tail.push(line).await;
5231 }
5232 });
5233 }
5234
5235 let mut transport = StdioTransport {
5236 child: Arc::new(tokio::sync::Mutex::new(child)),
5237 stdin,
5238 reader: tokio::io::BufReader::new(stdout),
5239 pending_line: Vec::new(),
5240 stderr_tail,
5241 authority_cancel_watch: None,
5242 _reviewed_launch: None,
5243 };
5244
5245 // Give the subprocess time to write its stderr line and exit.
5246 tokio::time::sleep(Duration::from_millis(300)).await;
5247
5248 let err = transport
5249 .recv()
5250 .await
5251 .expect_err("expected transport closed error");
5252 let err_str = format!("{err}");
5253 assert!(
5254 err_str.contains("Stdio transport closed"),
5255 "missing closed marker in: {err_str}"
5256 );
5257 assert!(
5258 err_str.contains("mcp-server: failed to load plugin"),
5259 "stderr context missing from error: {err_str}"
5260 );
5261 }
5262
5263 #[tokio::test]
5264 async fn sse_connect_waits_for_endpoint_before_first_send() {
5265 use std::sync::{
5266 Arc,
5267 atomic::{AtomicBool, Ordering as AtomicOrdering},
5268 };
5269 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5270 use tokio::net::TcpListener;
5271
5272 let _lock = lock_mcp_loopback_tests().await;
5273 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5274 let addr = listener.local_addr().unwrap();
5275 let post_seen = Arc::new(AtomicBool::new(false));
5276 let server_post_seen = Arc::clone(&post_seen);
5277 let cancel_token = tokio_util::sync::CancellationToken::new();
5278 let server_cancel = cancel_token.clone();
5279
5280 let server = tokio::spawn(async move {
5281 loop {
5282 let Ok((mut socket, _)) = listener.accept().await else {
5283 break;
5284 };
5285 let post_seen = Arc::clone(&server_post_seen);
5286 let server_cancel = server_cancel.clone();
5287 tokio::spawn(async move {
5288 let mut request = Vec::new();
5289 let mut buf = [0; 1024];
5290 loop {
5291 let n = socket.read(&mut buf).await.unwrap();
5292 if n == 0 {
5293 return;
5294 }
5295 request.extend_from_slice(&buf[..n]);
5296 if request.windows(4).any(|window| window == b"\r\n\r\n") {
5297 break;
5298 }
5299 }
5300 let request = String::from_utf8_lossy(&request);
5301 if request.starts_with("GET /sse ") {
5302 socket
5303 .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n")
5304 .await
5305 .unwrap();
5306 tokio::time::sleep(Duration::from_millis(150)).await;
5307 socket
5308 .write_all(b"event: endpoint\ndata: /messages\n\n")
5309 .await
5310 .unwrap();
5311 server_cancel.cancelled().await;
5312 } else if request.starts_with("POST /messages ") {
5313 post_seen.store(true, AtomicOrdering::SeqCst);
5314 socket
5315 .write_all(
5316 b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
5317 )
5318 .await
5319 .unwrap();
5320 }
5321 });
5322 }
5323 });
5324
5325 let url = format!("http://{addr}/sse");
5326 let client = test_mcp_http_client(&url);
5327 let mut transport = SseTransport::connect(
5328 client,
5329 url,
5330 McpHttpAuth::default(),
5331 cancel_token.clone(),
5332 Duration::from_secs(2),
5333 )
5334 .await
5335 .unwrap();
5336
5337 transport
5338 .send(json_frame(serde_json::json!({
5339 "jsonrpc": "2.0",
5340 "id": 1,
5341 "method": "initialize"
5342 })))
5343 .await
5344 .unwrap();
5345
5346 assert!(
5347 post_seen.load(AtomicOrdering::SeqCst),
5348 "first SSE send should POST to the discovered endpoint"
5349 );
5350
5351 cancel_token.cancel();
5352 server.abort();
5353 }
5354
5355 #[tokio::test]
5356 async fn sse_connect_accepts_crlf_endpoint_events() {
5357 use std::sync::{
5358 Arc,
5359 atomic::{AtomicBool, Ordering as AtomicOrdering},
5360 };
5361 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5362 use tokio::net::TcpListener;
5363
5364 let _lock = lock_mcp_loopback_tests().await;
5365 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5366 let addr = listener.local_addr().unwrap();
5367 let post_seen = Arc::new(AtomicBool::new(false));
5368 let server_post_seen = Arc::clone(&post_seen);
5369 let cancel_token = tokio_util::sync::CancellationToken::new();
5370 let server_cancel = cancel_token.clone();
5371
5372 let server = tokio::spawn(async move {
5373 loop {
5374 let Ok((mut socket, _)) = listener.accept().await else {
5375 break;
5376 };
5377 let post_seen = Arc::clone(&server_post_seen);
5378 let server_cancel = server_cancel.clone();
5379 tokio::spawn(async move {
5380 let mut request = Vec::new();
5381 let mut buf = [0; 1024];
5382 loop {
5383 let n = socket.read(&mut buf).await.unwrap();
5384 if n == 0 {
5385 return;
5386 }
5387 request.extend_from_slice(&buf[..n]);
5388 if request.windows(4).any(|window| window == b"\r\n\r\n") {
5389 break;
5390 }
5391 }
5392 let request = String::from_utf8_lossy(&request);
5393 if request.starts_with("GET /sse ") {
5394 socket
5395 .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n")
5396 .await
5397 .unwrap();
5398 socket
5399 .write_all(b"event: endpoint\r\ndata: /messages\r\n\r\n")
5400 .await
5401 .unwrap();
5402 server_cancel.cancelled().await;
5403 } else if request.starts_with("POST /messages ") {
5404 post_seen.store(true, AtomicOrdering::SeqCst);
5405 socket
5406 .write_all(
5407 b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
5408 )
5409 .await
5410 .unwrap();
5411 }
5412 });
5413 }
5414 });
5415
5416 let url = format!("http://{addr}/sse");
5417 let client = test_mcp_http_client(&url);
5418 let mut transport = SseTransport::connect(
5419 client,
5420 url,
5421 McpHttpAuth::default(),
5422 cancel_token.clone(),
5423 Duration::from_secs(2),
5424 )
5425 .await
5426 .unwrap();
5427
5428 transport
5429 .send(json_frame(serde_json::json!({
5430 "jsonrpc": "2.0",
5431 "id": 1,
5432 "method": "initialize"
5433 })))
5434 .await
5435 .unwrap();
5436
5437 assert!(
5438 post_seen.load(AtomicOrdering::SeqCst),
5439 "first SSE send should POST to the CRLF-discovered endpoint"
5440 );
5441
5442 cancel_token.cancel();
5443 server.abort();
5444 }
5445
5446 #[tokio::test]
5447 async fn sse_transport_applies_custom_headers_to_get_and_post() {
5448 use std::sync::{
5449 Arc,
5450 atomic::{AtomicBool, Ordering as AtomicOrdering},
5451 };
5452 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5453 use tokio::net::TcpListener;
5454
5455 let _lock = lock_mcp_loopback_tests().await;
5456 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5457 let addr = listener.local_addr().unwrap();
5458 let get_header_seen = Arc::new(AtomicBool::new(false));
5459 let post_header_seen = Arc::new(AtomicBool::new(false));
5460 let server_get_header_seen = Arc::clone(&get_header_seen);
5461 let server_post_header_seen = Arc::clone(&post_header_seen);
5462 let cancel_token = tokio_util::sync::CancellationToken::new();
5463 let server_cancel = cancel_token.clone();
5464
5465 let server = tokio::spawn(async move {
5466 loop {
5467 let Ok((mut socket, _)) = listener.accept().await else {
5468 break;
5469 };
5470 let get_header_seen = Arc::clone(&server_get_header_seen);
5471 let post_header_seen = Arc::clone(&server_post_header_seen);
5472 let server_cancel = server_cancel.clone();
5473 tokio::spawn(async move {
5474 let mut request = Vec::new();
5475 let mut buf = [0; 1024];
5476 loop {
5477 let n = socket.read(&mut buf).await.unwrap();
5478 if n == 0 {
5479 return;
5480 }
5481 request.extend_from_slice(&buf[..n]);
5482 if request.windows(4).any(|window| window == b"\r\n\r\n") {
5483 break;
5484 }
5485 }
5486 let request = String::from_utf8_lossy(&request);
5487 let request_lower = request.to_lowercase();
5488 if request.starts_with("GET /sse ") {
5489 if request_lower.contains("x-custom-auth: my-test-token") {
5490 get_header_seen.store(true, AtomicOrdering::SeqCst);
5491 }
5492 socket
5493 .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n")
5494 .await
5495 .unwrap();
5496 socket
5497 .write_all(b"event: endpoint\ndata: /messages\n\n")
5498 .await
5499 .unwrap();
5500 server_cancel.cancelled().await;
5501 } else if request.starts_with("POST /messages ") {
5502 if request_lower.contains("x-custom-auth: my-test-token") {
5503 post_header_seen.store(true, AtomicOrdering::SeqCst);
5504 }
5505 socket
5506 .write_all(
5507 b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
5508 )
5509 .await
5510 .unwrap();
5511 }
5512 });
5513 }
5514 });
5515
5516 let url = format!("http://{addr}/sse");
5517 let client = test_mcp_http_client(&url);
5518 let mut headers = HashMap::new();
5519 headers.insert("X-Custom-Auth".to_string(), "my-test-token".to_string());
5520 let mut transport = SseTransport::connect(
5521 client,
5522 url,
5523 McpHttpAuth {
5524 headers,
5525 ..Default::default()
5526 },
5527 cancel_token.clone(),
5528 Duration::from_secs(2),
5529 )
5530 .await
5531 .unwrap();
5532
5533 transport
5534 .send(json_frame(serde_json::json!({
5535 "jsonrpc": "2.0",
5536 "id": 1,
5537 "method": "initialize"
5538 })))
5539 .await
5540 .unwrap();
5541
5542 assert!(
5543 get_header_seen.load(AtomicOrdering::SeqCst),
5544 "legacy SSE GET must include user-configured custom headers"
5545 );
5546 assert!(
5547 post_header_seen.load(AtomicOrdering::SeqCst),
5548 "legacy SSE POST must include user-configured custom headers"
5549 );
5550
5551 cancel_token.cancel();
5552 server.abort();
5553 }
5554
5555 #[tokio::test]
5556 async fn sse_post_error_includes_response_body_excerpt() {
5557 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5558 use tokio::net::TcpListener;
5559
5560 let _lock = lock_mcp_loopback_tests().await;
5561 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5562 let addr = listener.local_addr().unwrap();
5563 let cancel_token = tokio_util::sync::CancellationToken::new();
5564 let server_cancel = cancel_token.clone();
5565
5566 let server = tokio::spawn(async move {
5567 loop {
5568 let Ok((mut socket, _)) = listener.accept().await else {
5569 break;
5570 };
5571 let server_cancel = server_cancel.clone();
5572 tokio::spawn(async move {
5573 let mut request = Vec::new();
5574 let mut buf = [0; 1024];
5575 loop {
5576 let n = socket.read(&mut buf).await.unwrap();
5577 if n == 0 {
5578 return;
5579 }
5580 request.extend_from_slice(&buf[..n]);
5581 if request.windows(4).any(|window| window == b"\r\n\r\n") {
5582 break;
5583 }
5584 }
5585 let request = String::from_utf8_lossy(&request);
5586 if request.starts_with("GET /sse ") {
5587 socket
5588 .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n")
5589 .await
5590 .unwrap();
5591 socket
5592 .write_all(b"event: endpoint\ndata: /messages\n\n")
5593 .await
5594 .unwrap();
5595 server_cancel.cancelled().await;
5596 } else if request.starts_with("POST /messages ") {
5597 socket
5598 .write_all(
5599 b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 25\r\n\r\n{\"error\":\"missing query\"}",
5600 )
5601 .await
5602 .unwrap();
5603 }
5604 });
5605 }
5606 });
5607
5608 let url = format!("http://{addr}/sse");
5609 let client = test_mcp_http_client(&url);
5610 let mut transport = SseTransport::connect(
5611 client,
5612 url,
5613 McpHttpAuth::default(),
5614 cancel_token.clone(),
5615 Duration::from_secs(2),
5616 )
5617 .await
5618 .unwrap();
5619
5620 let err = transport
5621 .send(json_frame(serde_json::json!({
5622 "jsonrpc": "2.0",
5623 "id": 1,
5624 "method": "initialize"
5625 })))
5626 .await
5627 .expect_err("POST rejection should be returned");
5628 let err = format!("{err:#}");
5629 assert!(
5630 err.contains("400 Bad Request") && err.contains("missing query"),
5631 "SSE POST error should include status and body, got: {err}"
5632 );
5633
5634 cancel_token.cancel();
5635 server.abort();
5636 }
5637
5638 #[tokio::test]
5639 async fn streamable_http_caps_chunked_bodies_without_content_length() {
5640 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5641 use tokio::net::TcpListener;
5642
5643 let _lock = lock_mcp_loopback_tests().await;
5644 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5645 let addr = listener.local_addr().unwrap();
5646
5647 // Serve chunked responses (no Content-Length) of the requested size:
5648 // GET /over streams past the cap, GET /under stays below it.
5649 let server = tokio::spawn(async move {
5650 loop {
5651 let Ok((mut socket, _)) = listener.accept().await else {
5652 break;
5653 };
5654 tokio::spawn(async move {
5655 let mut request = Vec::new();
5656 let mut buf = [0; 1024];
5657 loop {
5658 let n = socket.read(&mut buf).await.unwrap();
5659 if n == 0 {
5660 return;
5661 }
5662 request.extend_from_slice(&buf[..n]);
5663 if request.windows(4).any(|window| window == b"\r\n\r\n") {
5664 break;
5665 }
5666 }
5667 let request = String::from_utf8_lossy(&request);
5668 let total: usize = if request.starts_with("GET /over ") {
5669 256
5670 } else {
5671 16
5672 };
5673 socket
5674 .write_all(
5675 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n",
5676 )
5677 .await
5678 .unwrap();
5679 let chunk = [b'x'; 32];
5680 let mut sent = 0;
5681 while sent < total {
5682 let n = chunk.len().min(total - sent);
5683 let frame = format!("{n:x}\r\n");
5684 socket.write_all(frame.as_bytes()).await.unwrap();
5685 socket.write_all(&chunk[..n]).await.unwrap();
5686 socket.write_all(b"\r\n").await.unwrap();
5687 sent += n;
5688 }
5689 socket.write_all(b"0\r\n\r\n").await.unwrap();
5690 socket.flush().await.unwrap();
5691 });
5692 }
5693 });
5694
5695 let client = test_http_client();
5696 let cap = 64;
5697
5698 let over = client
5699 .get(format!("http://{addr}/over"))
5700 .send()
5701 .await
5702 .unwrap();
5703 assert_eq!(
5704 over.content_length(),
5705 None,
5706 "chunked response must not declare a length for this test to be meaningful"
5707 );
5708 let err = streamable_http::read_body_capped(over, cap)
5709 .await
5710 .expect_err("a chunked body past the cap must fail, not OOM");
5711 assert!(
5712 err.to_string().contains("exceeds"),
5713 "unexpected error: {err}"
5714 );
5715
5716 let under = client
5717 .get(format!("http://{addr}/under"))
5718 .send()
5719 .await
5720 .unwrap();
5721 let body = streamable_http::read_body_capped(under, cap)
5722 .await
5723 .expect("a chunked body under the cap reads fine");
5724 assert_eq!(body, "x".repeat(16));
5725
5726 server.abort();
5727 }
5728
5729 #[tokio::test]
5730 async fn error_body_excerpt_stops_at_cap_without_waiting_for_eof() {
5731 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5732 use tokio::net::TcpListener;
5733
5734 let _lock = lock_mcp_loopback_tests().await;
5735 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5736 let addr = listener.local_addr().unwrap();
5737 let server_cancel = tokio_util::sync::CancellationToken::new();
5738 let task_cancel = server_cancel.clone();
5739
5740 // Deliberately omit the terminating zero-sized chunk and keep the socket
5741 // open. A `.text()`-based diagnostic would wait for EOF; the bounded
5742 // reader must return as soon as the first chunk reaches the cap.
5743 let server = tokio::spawn(async move {
5744 let (mut socket, _) = listener.accept().await.unwrap();
5745 let mut request = Vec::new();
5746 let mut buf = [0; 1024];
5747 loop {
5748 let n = socket.read(&mut buf).await.unwrap();
5749 if n == 0 {
5750 return;
5751 }
5752 request.extend_from_slice(&buf[..n]);
5753 if request.windows(4).any(|window| window == b"\r\n\r\n") {
5754 break;
5755 }
5756 }
5757 socket
5758 .write_all(
5759 b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n100\r\n",
5760 )
5761 .await
5762 .unwrap();
5763 socket.write_all(&[b'x'; 256]).await.unwrap();
5764 socket.write_all(b"\r\n").await.unwrap();
5765 socket.flush().await.unwrap();
5766 task_cancel.cancelled().await;
5767 });
5768
5769 let response = test_http_client()
5770 .get(format!("http://{addr}/preview"))
5771 .send()
5772 .await
5773 .unwrap();
5774 let preview = tokio::time::timeout(Duration::from_secs(1), bounded_body_excerpt(response, 64))
5775 .await
5776 .expect("bounded excerpt must not wait for an attacker-controlled EOF");
5777 assert_eq!(preview, format!("{}…", "x".repeat(64)));
5778
5779 server_cancel.cancel();
5780 server.abort();
5781 }
5782
5783 #[tokio::test]
5784 async fn streamable_http_stale_session_reconnects_and_retries_tool_call() {
5785 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
5786 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5787 use tokio::net::TcpListener;
5788
5789 async fn write_response(socket: &mut tokio::net::TcpStream, response: &[u8]) {
5790 socket.write_all(response).await.unwrap();
5791 socket.flush().await.unwrap();
5792 socket.shutdown().await.unwrap();
5793 }
5794
5795 let _lock = lock_mcp_loopback_tests().await;
5796 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5797 let addr = listener.local_addr().unwrap();
5798 let get_count = Arc::new(AtomicUsize::new(0));
5799 let stale_seen = Arc::new(AtomicBool::new(false));
5800 let success_seen = Arc::new(AtomicBool::new(false));
5801 let server_get_count = Arc::clone(&get_count);
5802 let server_stale_seen = Arc::clone(&stale_seen);
5803 let server_success_seen = Arc::clone(&success_seen);
5804
5805 let server = tokio::spawn(async move {
5806 loop {
5807 let Ok((mut socket, _)) = listener.accept().await else {
5808 break;
5809 };
5810 let get_count = Arc::clone(&server_get_count);
5811 let stale_seen = Arc::clone(&server_stale_seen);
5812 let success_seen = Arc::clone(&server_success_seen);
5813 tokio::spawn(async move {
5814 let mut request = Vec::new();
5815 let mut buf = [0; 4096];
5816 let header_end = loop {
5817 let n = socket.read(&mut buf).await.unwrap();
5818 if n == 0 {
5819 return;
5820 }
5821 request.extend_from_slice(&buf[..n]);
5822 if let Some(pos) = request.windows(4).position(|w| w == b"\r\n\r\n") {
5823 break pos + 4;
5824 }
5825 };
5826 let headers = String::from_utf8_lossy(&request[..header_end]).to_string();
5827 let content_length = headers
5828 .lines()
5829 .find_map(|line| {
5830 let (name, value) = line.split_once(':')?;
5831 name.eq_ignore_ascii_case("content-length")
5832 .then(|| value.trim().parse::<usize>().ok())
5833 .flatten()
5834 })
5835 .unwrap_or(0);
5836 while request.len() < header_end + content_length {
5837 let n = socket.read(&mut buf).await.unwrap();
5838 if n == 0 {
5839 return;
5840 }
5841 request.extend_from_slice(&buf[..n]);
5842 }
5843 let body = &request[header_end..header_end + content_length];
5844 let session_header = headers.lines().find_map(|line| {
5845 let (name, value) = line.split_once(':')?;
5846 name.eq_ignore_ascii_case("mcp-session-id")
5847 .then(|| value.trim().to_string())
5848 });
5849
5850 if headers.starts_with("GET /mcp ") {
5851 let count = get_count.fetch_add(1, AtomicOrdering::SeqCst);
5852 let session = if count == 0 { "sess-old" } else { "sess-new" };
5853 let response = format!(
5854 "HTTP/1.1 200 OK\r\nConnection: close\r\nMcp-Session-Id: {session}\r\nContent-Length: 0\r\n\r\n"
5855 );
5856 write_response(&mut socket, response.as_bytes()).await;
5857 return;
5858 }
5859
5860 let request_json: serde_json::Value = serde_json::from_slice(body).unwrap();
5861 let method = request_json
5862 .get("method")
5863 .and_then(serde_json::Value::as_str)
5864 .unwrap_or("");
5865 let id = request_json
5866 .get("id")
5867 .cloned()
5868 .unwrap_or_else(|| serde_json::json!("0"));
5869
5870 if method == "tools/call" && session_header.as_deref() == Some("sess-old") {
5871 stale_seen.store(true, AtomicOrdering::SeqCst);
5872 write_response(
5873 &mut socket,
5874 b"HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 27\r\n\r\n{\"error\":\"session expired\"}",
5875 )
5876 .await;
5877 return;
5878 }
5879
5880 let result = match method {
5881 "initialize" => serde_json::json!({
5882 "protocolVersion": "2024-11-05",
5883 "capabilities": {"tools": {}, "resources": {}, "prompts": {}}
5884 }),
5885 "tools/list" => serde_json::json!({
5886 "tools": [
5887 { "name": "search", "inputSchema": {} }
5888 ]
5889 }),
5890 "resources/list" => serde_json::json!({ "resources": [] }),
5891 "resources/templates/list" => {
5892 serde_json::json!({ "resourceTemplates": [] })
5893 }
5894 "prompts/list" => serde_json::json!({ "prompts": [] }),
5895 "tools/call" => {
5896 assert_eq!(session_header.as_deref(), Some("sess-new"));
5897 success_seen.store(true, AtomicOrdering::SeqCst);
5898 serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] })
5899 }
5900 _ => {
5901 write_response(
5902 &mut socket,
5903 b"HTTP/1.1 202 Accepted\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
5904 )
5905 .await;
5906 return;
5907 }
5908 };
5909 let response_body = serde_json::json!({
5910 "jsonrpc": "2.0",
5911 "id": id,
5912 "result": result
5913 })
5914 .to_string();
5915 let response = format!(
5916 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
5917 response_body.len(),
5918 response_body
5919 );
5920 write_response(&mut socket, response.as_bytes()).await;
5921 });
5922 }
5923 });
5924
5925 let mut cfg = McpConfig::default();
5926 cfg.servers.insert(
5927 "dephy".to_string(),
5928 McpServerConfig {
5929 command: None,
5930 args: Vec::new(),
5931 env: HashMap::new(),
5932 cwd: None,
5933 url: Some(format!("http://{addr}/mcp")),
5934 transport: None,
5935 connect_timeout: Some(10),
5936 execute_timeout: Some(10),
5937 read_timeout: None,
5938 disabled: false,
5939 enabled: true,
5940 required: false,
5941 enabled_tools: Vec::new(),
5942 disabled_tools: Vec::new(),
5943 headers: HashMap::new(),
5944 env_headers: HashMap::new(),
5945 bearer_token_env_var: None,
5946 scopes: Vec::new(),
5947 oauth: None,
5948 oauth_resource: None,
5949 reviewed_plugin: None,
5950 runtime_added: false,
5951 allow_private_network: false,
5952 },
5953 );
5954 let mut pool = McpPool::new(cfg);
5955
5956 let result = pool
5957 .call_tool("mcp_dephy_search", serde_json::json!({ "query": "dephy" }))
5958 .await
5959 .unwrap();
5960
5961 assert_eq!(
5962 result,
5963 serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] })
5964 );
5965 assert!(stale_seen.load(AtomicOrdering::SeqCst));
5966 assert!(success_seen.load(AtomicOrdering::SeqCst));
5967 assert_eq!(get_count.load(AtomicOrdering::SeqCst), 2);
5968
5969 server.abort();
5970 }
5971
5972 #[tokio::test]
5973 async fn legacy_sse_session_expiry_is_marked_stale() {
5974 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5975 use tokio::net::TcpListener;
5976 use tokio::sync::mpsc;
5977
5978 let _lock = lock_mcp_loopback_tests().await;
5979 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5980 let addr = listener.local_addr().unwrap();
5981
5982 let server = tokio::spawn(async move {
5983 let (mut socket, _) = listener.accept().await.unwrap();
5984 let mut request = Vec::new();
5985 let mut buf = [0; 4096];
5986 let header_end = loop {
5987 let n = socket.read(&mut buf).await.unwrap();
5988 if n == 0 {
5989 return;
5990 }
5991 request.extend_from_slice(&buf[..n]);
5992 if let Some(pos) = request.windows(4).position(|w| w == b"\r\n\r\n") {
5993 break pos + 4;
5994 }
5995 };
5996 let headers = String::from_utf8_lossy(&request[..header_end]);
5997 assert!(headers.starts_with("POST /messages "));
5998 socket
5999 .write_all(
6000 b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 27\r\n\r\n{\"error\":\"session expired\"}",
6001 )
6002 .await
6003 .unwrap();
6004 });
6005
6006 let (_sender, receiver) = mpsc::channel(1);
6007 let sse_task = tokio::spawn(async {});
6008 let mut transport = SseTransport {
6009 client: test_mcp_http_client(&format!("http://{addr}/sse")),
6010 base_url: format!("http://{addr}/sse"),
6011 auth: McpHttpAuth::default(),
6012 endpoint_url: Some(format!("http://{addr}/messages")),
6013 receiver,
6014 sse_task,
6015 };
6016
6017 let err = transport
6018 .send(br#"{"jsonrpc":"2.0","id":1,"method":"tools/call"}"#.to_vec())
6019 .await
6020 .expect_err("expired SSE session should fail");
6021
6022 assert!(
6023 is_mcp_stale_session_error(&err),
6024 "SSE session expiry should be retryable, got: {err:#}"
6025 );
6026
6027 server.abort();
6028 }
6029
6030 #[tokio::test]
6031 async fn legacy_sse_closed_stream_reconnects_and_retries_tool_call() {
6032 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
6033 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6034 use tokio::net::{TcpListener, TcpStream};
6035 use tokio::sync::mpsc;
6036
6037 async fn read_http_request(socket: &mut TcpStream) -> (String, serde_json::Value) {
6038 let mut request = Vec::new();
6039 let mut buf = [0; 4096];
6040 let header_end = loop {
6041 let n = socket.read(&mut buf).await.unwrap();
6042 if n == 0 {
6043 return (String::new(), serde_json::Value::Null);
6044 }
6045 request.extend_from_slice(&buf[..n]);
6046 if let Some(pos) = request.windows(4).position(|w| w == b"\r\n\r\n") {
6047 break pos + 4;
6048 }
6049 };
6050 let headers = String::from_utf8_lossy(&request[..header_end]).to_string();
6051 let content_length = headers
6052 .lines()
6053 .find_map(|line| {
6054 let (name, value) = line.split_once(':')?;
6055 name.eq_ignore_ascii_case("content-length")
6056 .then(|| value.trim().parse::<usize>().ok())
6057 .flatten()
6058 })
6059 .unwrap_or(0);
6060 while request.len() < header_end + content_length {
6061 let n = socket.read(&mut buf).await.unwrap();
6062 if n == 0 {
6063 return (headers, serde_json::Value::Null);
6064 }
6065 request.extend_from_slice(&buf[..n]);
6066 }
6067 let body = &request[header_end..header_end + content_length];
6068 let json = if body.is_empty() {
6069 serde_json::Value::Null
6070 } else {
6071 serde_json::from_slice(body).unwrap()
6072 };
6073 (headers, json)
6074 }
6075
6076 let _lock = lock_mcp_loopback_tests().await;
6077 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6078 let addr = listener.local_addr().unwrap();
6079 let active_sse = Arc::new(Mutex::new(None::<mpsc::UnboundedSender<Option<String>>>));
6080 let get_count = Arc::new(AtomicUsize::new(0));
6081 let tool_call_count = Arc::new(AtomicUsize::new(0));
6082 let success_seen = Arc::new(AtomicBool::new(false));
6083 let server_active_sse = Arc::clone(&active_sse);
6084 let server_get_count = Arc::clone(&get_count);
6085 let server_tool_call_count = Arc::clone(&tool_call_count);
6086 let server_success_seen = Arc::clone(&success_seen);
6087
6088 let server = tokio::spawn(async move {
6089 loop {
6090 let Ok((mut socket, _)) = listener.accept().await else {
6091 break;
6092 };
6093 let active_sse = Arc::clone(&server_active_sse);
6094 let get_count = Arc::clone(&server_get_count);
6095 let tool_call_count = Arc::clone(&server_tool_call_count);
6096 let success_seen = Arc::clone(&server_success_seen);
6097 tokio::spawn(async move {
6098 let (headers, request_json) = read_http_request(&mut socket).await;
6099 if headers.starts_with("GET /sse ") {
6100 get_count.fetch_add(1, AtomicOrdering::SeqCst);
6101 let (tx, mut rx) = mpsc::unbounded_channel::<Option<String>>();
6102 *active_sse.lock().unwrap() = Some(tx);
6103 socket
6104 .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n")
6105 .await
6106 .unwrap();
6107 socket
6108 .write_all(b"event: endpoint\ndata: /messages\n\n")
6109 .await
6110 .unwrap();
6111 while let Some(message) = rx.recv().await {
6112 let Some(message) = message else {
6113 return;
6114 };
6115 let event = format!("event: message\ndata: {message}\n\n");
6116 socket.write_all(event.as_bytes()).await.unwrap();
6117 }
6118 return;
6119 }
6120
6121 if !headers.starts_with("POST /messages ") {
6122 return;
6123 }
6124
6125 socket
6126 .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
6127 .await
6128 .unwrap();
6129
6130 let method = request_json
6131 .get("method")
6132 .and_then(serde_json::Value::as_str)
6133 .unwrap_or("");
6134 if method == "notifications/initialized" {
6135 return;
6136 }
6137
6138 let id = request_json
6139 .get("id")
6140 .cloned()
6141 .unwrap_or_else(|| serde_json::json!("0"));
6142
6143 if method == "tools/call" {
6144 let count = tool_call_count.fetch_add(1, AtomicOrdering::SeqCst);
6145 if count == 0 {
6146 if let Some(tx) = active_sse.lock().unwrap().take() {
6147 let _ = tx.send(None);
6148 }
6149 return;
6150 }
6151 }
6152
6153 let result = match method {
6154 "initialize" => serde_json::json!({
6155 "protocolVersion": "2024-11-05",
6156 "capabilities": {"tools": {}, "resources": {}, "prompts": {}}
6157 }),
6158 "tools/list" => serde_json::json!({
6159 "tools": [
6160 { "name": "search", "inputSchema": {} }
6161 ]
6162 }),
6163 "resources/list" => serde_json::json!({ "resources": [] }),
6164 "resources/templates/list" => {
6165 serde_json::json!({ "resourceTemplates": [] })
6166 }
6167 "prompts/list" => serde_json::json!({ "prompts": [] }),
6168 "tools/call" => {
6169 success_seen.store(true, AtomicOrdering::SeqCst);
6170 serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] })
6171 }
6172 other => panic!("unexpected method: {other}"),
6173 };
6174 let response = serde_json::json!({
6175 "jsonrpc": "2.0",
6176 "id": id,
6177 "result": result
6178 })
6179 .to_string();
6180 // Deliver the response over the *current* SSE channel. The
6181 // retry tool call can race ahead of the reconnecting GET
6182 // /sse that re-stores the sender; under parallel load those
6183 // two server tasks are scheduled in either order, so wait
6184 // briefly for the channel instead of dropping the response
6185 // (which left the client hanging until timeout) (#2597).
6186 let send_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
6187 let tx = loop {
6188 if let Some(tx) = active_sse.lock().unwrap().as_ref().cloned() {
6189 break Some(tx);
6190 }
6191 if std::time::Instant::now() >= send_deadline {
6192 break None;
6193 }
6194 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
6195 };
6196 if let Some(tx) = tx {
6197 let _ = tx.send(Some(response));
6198 }
6199 });
6200 }
6201 });
6202
6203 let mut cfg = McpConfig::default();
6204 cfg.servers.insert(
6205 "dephy".to_string(),
6206 McpServerConfig {
6207 command: None,
6208 args: Vec::new(),
6209 env: HashMap::new(),
6210 cwd: None,
6211 url: Some(format!("http://{addr}/sse")),
6212 transport: Some("sse".to_string()),
6213 connect_timeout: Some(10),
6214 execute_timeout: Some(10),
6215 read_timeout: None,
6216 disabled: false,
6217 enabled: true,
6218 required: false,
6219 enabled_tools: Vec::new(),
6220 disabled_tools: Vec::new(),
6221 headers: HashMap::new(),
6222 env_headers: HashMap::new(),
6223 bearer_token_env_var: None,
6224 scopes: Vec::new(),
6225 oauth: None,
6226 oauth_resource: None,
6227 reviewed_plugin: None,
6228 runtime_added: false,
6229 allow_private_network: false,
6230 },
6231 );
6232 let mut pool = McpPool::new(cfg);
6233
6234 let result = pool
6235 .call_tool("mcp_dephy_search", serde_json::json!({ "query": "dephy" }))
6236 .await
6237 .unwrap();
6238
6239 assert_eq!(
6240 result,
6241 serde_json::json!({ "content": [{ "type": "text", "text": "ok" }] })
6242 );
6243 assert_eq!(tool_call_count.load(AtomicOrdering::SeqCst), 2);
6244 assert_eq!(get_count.load(AtomicOrdering::SeqCst), 2);
6245 assert!(success_seen.load(AtomicOrdering::SeqCst));
6246
6247 server.abort();
6248 }
6249
6250 #[test]
6251 fn session_id_starts_none() {
6252 let transport = StreamableHttpTransport::new(
6253 test_mcp_http_client("https://example.invalid/mcp"),
6254 "https://example.invalid/mcp".to_string(),
6255 McpHttpAuth::default(),
6256 );
6257 assert!(transport.session_id.is_none());
6258 }
6259
6260 /// Session ID captured from a POST response is replayed on the next POST.
6261 #[tokio::test]
6262 async fn session_id_captured_from_post_response_and_replayed() {
6263 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6264 use tokio::net::TcpListener;
6265
6266 let _lock = lock_mcp_loopback_tests().await;
6267 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6268 let addr = listener.local_addr().unwrap();
6269 let server = tokio::spawn(async move {
6270 let (mut socket, _) = listener.accept().await.unwrap();
6271 let mut buf = [0u8; 4096];
6272 let n = socket.read(&mut buf).await.unwrap();
6273 let req = String::from_utf8_lossy(&buf[..n]);
6274 assert!(req.starts_with("POST "), "expected POST, got: {req}");
6275
6276 // First POST: return a session ID so the transport captures it.
6277 socket
6278 .write_all(
6279 b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nMcp-Session-Id: sess-abc-123\r\nContent-Length: 2\r\n\r\n{}",
6280 )
6281 .await
6282 .unwrap();
6283 socket.flush().await.unwrap();
6284
6285 // Read the second POST — should contain the session ID.
6286 let mut buf2 = [0u8; 4096];
6287 let n2 = socket.read(&mut buf2).await.unwrap();
6288 let req2 = String::from_utf8_lossy(&buf2[..n2]);
6289 // reqwest lower-cases header names.
6290 let req2_lower = req2.to_lowercase();
6291 assert!(
6292 req2_lower.contains("mcp-session-id: sess-abc-123"),
6293 "second POST must replay captured session ID, got:\n{req2}"
6294 );
6295
6296 socket
6297 .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
6298 .await
6299 .unwrap();
6300 });
6301
6302 let url = format!("http://{addr}/mcp");
6303 let mut transport =
6304 StreamableHttpTransport::new(test_mcp_http_client(&url), url, McpHttpAuth::default());
6305
6306 // First send: server returns Mcp-Session-Id.
6307 transport
6308 .send(json_frame(serde_json::json!({
6309 "jsonrpc": "2.0", "id": 1,
6310 "method": "initialize",
6311 "params": {}
6312 })))
6313 .await
6314 .unwrap();
6315 assert_eq!(
6316 transport.session_id.as_deref(),
6317 Some("sess-abc-123"),
6318 "session ID should be captured from response"
6319 );
6320
6321 // Second send: should replay the session ID.
6322 transport
6323 .send(json_frame(serde_json::json!({
6324 "jsonrpc": "2.0", "id": 2,
6325 "method": "tools/list",
6326 "params": {}
6327 })))
6328 .await
6329 .unwrap();
6330
6331 server.abort();
6332 }
6333
6334 /// Custom headers configured in McpServerConfig are applied to the GET
6335 /// preflight so servers that require auth on session-establishment GET
6336 /// (e.g. Hindsight, #1629) can authenticate it.
6337 #[tokio::test]
6338 async fn custom_headers_applied_to_get_preflight() {
6339 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6340 use tokio::net::TcpListener;
6341
6342 // Lock order is env first, then loopback — the OAuth pool tests take
6343 // them in that order, and inverting them deadlocks the suite.
6344 let _env = crate::test_support::lock_test_env();
6345 let _lock = lock_mcp_loopback_tests().await;
6346 // The fixture client honors an operator-configured proxy; pin loopback
6347 // out of any ambient proxy so a concurrent proxy-configuring test can
6348 // never route this GET away from the fixture server.
6349 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
6350 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6351 let addr = listener.local_addr().unwrap();
6352 // The test signals success by writing to this flag — the GET handler
6353 // sets it when it sees the expected header.
6354 let header_seen = Arc::new(AtomicBool::new(false));
6355 let header_seen_srv = Arc::clone(&header_seen);
6356
6357 let server = tokio::spawn(async move {
6358 let (mut socket, _) = listener.accept().await.unwrap();
6359 let mut buf = [0u8; 4096];
6360 let n = socket.read(&mut buf).await.unwrap();
6361 let req = String::from_utf8_lossy(&buf[..n]);
6362
6363 // reqwest lower-cases header names.
6364 if req.starts_with("GET ") && req.to_lowercase().contains("x-custom-auth: my-test-token") {
6365 header_seen_srv.store(true, AtomicOrdering::SeqCst);
6366 }
6367
6368 socket
6369 .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
6370 .await
6371 .unwrap();
6372 });
6373
6374 let url = format!("http://{addr}/mcp");
6375 let mut headers = HashMap::new();
6376 headers.insert("X-Custom-Auth".to_string(), "my-test-token".to_string());
6377
6378 let mut transport = HttpTransport::new(
6379 test_mcp_http_client(&url),
6380 url,
6381 McpHttpAuth {
6382 headers,
6383 ..Default::default()
6384 },
6385 tokio_util::sync::CancellationToken::new(),
6386 Duration::from_secs(10),
6387 );
6388
6389 transport.try_establish_session().await.unwrap();
6390
6391 server.abort();
6392
6393 assert!(
6394 header_seen.load(AtomicOrdering::SeqCst),
6395 "GET preflight must include user-configured custom headers"
6396 );
6397 }
6398
6399 // === add_runtime_server_config conflict tests ===
6400
6401 #[test]
6402 fn add_runtime_server_config_rejects_static_conflict() {
6403 let config: McpConfig = serde_json::from_str(
6404 r#"{
6405 "servers": {
6406 "existing": {"command": "node server.js"}
6407 }
6408 }"#,
6409 )
6410 .unwrap();
6411 let pool = McpPool::new(config);
6412
6413 let err = pool
6414 .add_runtime_server_config(
6415 "existing".to_string(),
6416 serde_json::from_str(r#"{"command": "npx other"}"#).unwrap(),
6417 )
6418 .unwrap_err();
6419 assert!(err.contains("already exists in the config file"));
6420 }
6421
6422 #[test]
6423 fn add_runtime_server_config_rejects_dynamic_duplicate() {
6424 let pool = McpPool::new(McpConfig::default());
6425
6426 pool.add_runtime_server_config(
6427 "my_server".to_string(),
6428 serde_json::from_str(r#"{"command": "node a.js"}"#).unwrap(),
6429 )
6430 .unwrap();
6431
6432 let err = pool
6433 .add_runtime_server_config(
6434 "my_server".to_string(),
6435 serde_json::from_str(r#"{"command": "node b.js"}"#).unwrap(),
6436 )
6437 .unwrap_err();
6438 assert!(err.contains("already started earlier"));
6439 }
6440
6441 #[test]
6442 fn add_runtime_server_config_accepts_new_name() {
6443 let pool = McpPool::new(McpConfig::default());
6444
6445 pool.add_runtime_server_config(
6446 "brand_new".to_string(),
6447 serde_json::from_str(r#"{"command": "node x.js"}"#).unwrap(),
6448 )
6449 .unwrap();
6450 }
6451
6452 /// Server attribution and the model-facing tool name must come from one
6453 /// definition. If they ever drift, a human reading tool provenance would be
6454 /// told which server owns a name the model never saw.
6455 #[test]
6456 fn mcp_model_tool_names_and_server_attribution_share_one_definition() {
6457 assert_eq!(
6458 McpPool::mcp_model_tool_name("files", "read"),
6459 "mcp_files_read"
6460 );
6461 // A server name containing `_` is exactly why the reverse split is a guess.
6462 assert_eq!(
6463 McpPool::mcp_model_tool_name("my_server", "read_file"),
6464 "mcp_my_server_read_file"
6465 );
6466
6467 let resolved =
6468 McpPool::resolve_tool_server_map([("files", "read"), ("git", "status")].into_iter());
6469 assert_eq!(
6470 resolved.get("mcp_files_read").map(String::as_str),
6471 Some("files")
6472 );
6473 assert_eq!(
6474 resolved.get("mcp_git_status").map(String::as_str),
6475 Some("git")
6476 );
6477
6478 // Ambiguity: two servers collapse onto the same model name. Neither wins,
6479 // so the name resolves to no server and callers report it as unknown —
6480 // the same rule `all_tools` applies when it hides the ambiguous tool.
6481 let ambiguous = McpPool::resolve_tool_server_map(
6482 [("a_b", "c"), ("a", "b_c"), ("solo", "tool")].into_iter(),
6483 );
6484 assert!(
6485 !ambiguous.contains_key("mcp_a_b_c"),
6486 "an ambiguous model name must resolve to no server"
6487 );
6488 assert_eq!(
6489 ambiguous.get("mcp_solo_tool").map(String::as_str),
6490 Some("solo")
6491 );
6492 }
6493
6494 #[test]
6495 fn removed_runtime_server_config_can_be_retried_with_same_name() {
6496 let mut pool = McpPool::new(McpConfig::default());
6497 let config: McpServerConfig = serde_json::from_str(r#"{"command": "node a.js"}"#).unwrap();
6498
6499 pool.add_runtime_server_config("retryable".to_string(), config.clone())
6500 .unwrap();
6501 pool.remove_runtime_server_config("retryable");
6502 pool.add_runtime_server_config("retryable".to_string(), config)
6503 .expect("rollback must release the deterministic runtime name");
6504 }
6505
6506 #[test]
6507 fn mcp_recovery_kind_names_real_login_and_reload_commands() {
6508 assert_eq!(
6509 mcp_recovery_kind(false, true, false, None, false),
6510 Some(McpRecoveryKind::Enable)
6511 );
6512 assert_eq!(
6513 mcp_recovery_kind(true, false, false, None, false),
6514 Some(McpRecoveryKind::Connect)
6515 );
6516 assert_eq!(
6517 mcp_recovery_kind(true, true, false, Some("connection refused"), false),
6518 Some(McpRecoveryKind::Diagnose)
6519 );
6520 assert_eq!(
6521 mcp_recovery_kind(true, true, false, Some("connection refused"), true),
6522 Some(McpRecoveryKind::Diagnose)
6523 );
6524 assert_eq!(
6525 mcp_recovery_kind(true, true, false, Some("401 Unauthorized"), true),
6526 Some(McpRecoveryKind::Reauth)
6527 );
6528 assert_eq!(
6529 mcp_recovery_kind(true, true, false, None, true),
6530 Some(McpRecoveryKind::Reauth)
6531 );
6532 assert_eq!(
6533 mcp_recovery_kind(true, true, false, None, false),
6534 Some(McpRecoveryKind::Reconnect)
6535 );
6536 // Enabled, inspected, connected, no error: nothing to recover.
6537 assert_eq!(mcp_recovery_kind(true, true, true, None, false), None);
6538
6539 assert_eq!(
6540 McpRecoveryKind::Reauth.slash_command("github"),
6541 "/mcp login github"
6542 );
6543 // One row, one server. A `[reconnect] github` row that reloads every
6544 // configured server is not the action it named.
6545 assert_eq!(
6546 McpRecoveryKind::Connect.slash_command("github"),
6547 "/mcp retry github"
6548 );
6549 assert_eq!(
6550 McpRecoveryKind::Reconnect.slash_command("github"),
6551 "/mcp retry github"
6552 );
6553 // A name the command line cannot carry safely falls back to the blunt
6554 // reload rather than emitting an argument that would not survive parsing.
6555 assert_eq!(
6556 McpRecoveryKind::Reconnect.slash_command("name with spaces"),
6557 "/mcp reload"
6558 );
6559 assert_eq!(
6560 McpRecoveryKind::Diagnose.slash_command("github"),
6561 "/mcp validate github"
6562 );
6563 assert_eq!(
6564 McpRecoveryKind::Diagnose.slash_command("name with spaces"),
6565 "/mcp validate"
6566 );
6567 assert!(
6568 !McpRecoveryKind::Reauth
6569 .slash_command("github")
6570 .contains("/mcp auth")
6571 );
6572 assert!(mcp_name_is_command_safe("github"));
6573 assert!(!mcp_name_is_command_safe("github mcp"));
6574 }
6575
6576 // === Synthetic self-serve OAuth authenticate tool (agent self-serve auth) ===
6577 //
6578 // One loopback origin serving both the MCP endpoint and the OAuth
6579 // authorization-server APIs the login/refresh flows need:
6580 // `/.well-known/oauth-authorization-server*` metadata, RFC 7591 `/register`,
6581 // and a `/token` endpoint that rejects the `rt-stale` refresh token with
6582 // `invalid_grant` and accepts everything else. The MCP endpoint 401s until
6583 // the request carries `Authorization: Bearer cw-test-access`.
6584
6585 struct OAuthMcpMock {
6586 addr: std::net::SocketAddr,
6587 token_requests: Arc<AtomicUsize>,
6588 /// When set, the provider has revoked every grant: `/mcp` 401s even with
6589 /// the previously accepted bearer and `/token` rejects every refresh
6590 /// with `invalid_grant` — a mid-session revocation.
6591 revoked: Arc<std::sync::atomic::AtomicBool>,
6592 task: tokio::task::JoinHandle<()>,
6593 }
6594
6595 impl OAuthMcpMock {
6596 fn url(&self) -> String {
6597 format!("http://{}/mcp", self.addr)
6598 }
6599
6600 fn revoke_all_grants(&self) {
6601 self.revoked.store(true, AtomicOrdering::SeqCst);
6602 }
6603
6604 async fn spawn() -> Self {
6605 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6606 use tokio::net::{TcpListener, TcpStream};
6607
6608 async fn read_request(socket: &mut TcpStream) -> String {
6609 let mut request = Vec::new();
6610 let mut buf = [0; 2048];
6611 let header_end = loop {
6612 let n = socket.read(&mut buf).await.unwrap();
6613 assert!(n > 0, "client closed before headers completed");
6614 request.extend_from_slice(&buf[..n]);
6615 if let Some(pos) = request.windows(4).position(|window| window == b"\r\n\r\n") {
6616 break pos + 4;
6617 }
6618 };
6619 let headers = String::from_utf8_lossy(&request[..header_end]);
6620 let content_length = headers
6621 .lines()
6622 .find_map(|line| {
6623 let (name, value) = line.split_once(':')?;
6624 name.eq_ignore_ascii_case("content-length")
6625 .then(|| value.trim().parse::<usize>().ok())
6626 .flatten()
6627 })
6628 .unwrap_or(0);
6629 let total_len = header_end + content_length;
6630 while request.len() < total_len {
6631 let n = socket.read(&mut buf).await.unwrap();
6632 assert!(n > 0, "client closed before body completed");
6633 request.extend_from_slice(&buf[..n]);
6634 }
6635 String::from_utf8(request).unwrap()
6636 }
6637
6638 async fn write_json(socket: &mut TcpStream, status: &str, body: &str) {
6639 let response = format!(
6640 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
6641 body.len()
6642 );
6643 socket.write_all(response.as_bytes()).await.unwrap();
6644 }
6645
6646 async fn write_empty(socket: &mut TcpStream, status: &str) {
6647 socket
6648 .write_all(
6649 format!("HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n")
6650 .as_bytes(),
6651 )
6652 .await
6653 .unwrap();
6654 }
6655
6656 async fn write_mcp_sse(
6657 socket: &mut TcpStream,
6658 id: serde_json::Value,
6659 result: serde_json::Value,
6660 ) {
6661 let payload = serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result});
6662 let body = format!("event: message\ndata: {payload}\n\n");
6663 let response = format!(
6664 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
6665 body.len()
6666 );
6667 socket.write_all(response.as_bytes()).await.unwrap();
6668 }
6669
6670 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6671 let addr = listener.local_addr().unwrap();
6672 let token_requests = Arc::new(AtomicUsize::new(0));
6673 let server_token_requests = Arc::clone(&token_requests);
6674 let revoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
6675 let server_revoked = Arc::clone(&revoked);
6676 let task = tokio::spawn(async move {
6677 loop {
6678 let Ok((mut socket, _)) = listener.accept().await else {
6679 break;
6680 };
6681 let token_requests = Arc::clone(&server_token_requests);
6682 let revoked = Arc::clone(&server_revoked);
6683 tokio::spawn(async move {
6684 let request = read_request(&mut socket).await;
6685 let first_line = request.lines().next().unwrap_or("").to_string();
6686 let mut parts = first_line.split_whitespace();
6687 let method = parts.next().unwrap_or("").to_string();
6688 let path = parts.next().unwrap_or("").to_string();
6689 let path_only = path.split('?').next().unwrap_or("").to_string();
6690 let body = request.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
6691 let revoked = revoked.load(AtomicOrdering::SeqCst);
6692 let authorized = !revoked
6693 && request
6694 .to_ascii_lowercase()
6695 .contains("authorization: bearer cw-test-access");
6696
6697 // RFC 8414: the authorization server lives at the origin
6698 // root, so it publishes its metadata only at the canonical
6699 // `/.well-known/oauth-authorization-server` and its
6700 // `issuer` is the root origin. The path-insertion
6701 // candidates rmcp probes first (`.../oauth-authorization-server/mcp`)
6702 // belong to a *different* issuer and must 404 here: rmcp
6703 // 3.2 validates the discovered `issuer` against the
6704 // discovery URL and rejects metadata served at the wrong
6705 // one.
6706 if method == "GET" && path_only == "/.well-known/oauth-authorization-server" {
6707 let metadata = serde_json::json!({
6708 "issuer": format!("http://{addr}"),
6709 "authorization_endpoint": format!("http://{addr}/authorize"),
6710 "token_endpoint": format!("http://{addr}/token"),
6711 "registration_endpoint": format!("http://{addr}/register"),
6712 "response_types_supported": ["code"],
6713 });
6714 write_json(&mut socket, "200 OK", &metadata.to_string()).await;
6715 } else if method == "GET" && path_only.starts_with("/.well-known/") {
6716 write_empty(&mut socket, "404 Not Found").await;
6717 } else if method == "POST" && path_only == "/register" {
6718 write_json(
6719 &mut socket,
6720 "200 OK",
6721 r#"{"client_id":"cw-test-client","redirect_uris":[]}"#,
6722 )
6723 .await;
6724 } else if method == "POST" && path_only == "/token" {
6725 token_requests.fetch_add(1, AtomicOrdering::SeqCst);
6726 if revoked || body.contains("refresh_token=rt-stale") {
6727 write_json(
6728 &mut socket,
6729 "400 Bad Request",
6730 r#"{"error":"invalid_grant","error_description":"stale grant"}"#,
6731 )
6732 .await;
6733 } else if body.contains("refresh_token=rt-rotated") {
6734 write_json(
6735 &mut socket,
6736 "200 OK",
6737 r#"{"access_token":"cw-rotated-access-2","token_type":"Bearer","expires_in":3600,"refresh_token":"rt-rotated-2"}"#,
6738 )
6739 .await;
6740 } else {
6741 write_json(
6742 &mut socket,
6743 "200 OK",
6744 r#"{"access_token":"cw-test-access","token_type":"Bearer","expires_in":3600,"refresh_token":"rt-fresh"}"#,
6745 )
6746 .await;
6747 }
6748 } else if path_only == "/mcp" {
6749 // The Streamable HTTP session preflight is a bodyless
6750 // GET; the real protocol starts at POST. 405 is the
6751 // spec-shaped refusal and never reaches the parser.
6752 if method == "GET" {
6753 write_empty(&mut socket, "405 Method Not Allowed").await;
6754 return;
6755 }
6756 if !authorized {
6757 write_empty(&mut socket, "401 Unauthorized").await;
6758 return;
6759 }
6760 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
6761 let rpc_method = value["method"].as_str().unwrap_or("");
6762 if rpc_method == "notifications/initialized" {
6763 write_empty(&mut socket, "202 Accepted").await;
6764 return;
6765 }
6766 let id = value["id"].clone();
6767 let result = match rpc_method {
6768 "initialize" => serde_json::json!({
6769 "protocolVersion": "2024-11-05",
6770 "serverInfo": {"name": "mock-oauth", "version": "1.0.0"},
6771 "capabilities": {"tools": {}}
6772 }),
6773 "tools/list" => serde_json::json!({
6774 "tools": [{
6775 "name": "wiki_lookup",
6776 "description": "Look up a wiki page",
6777 "inputSchema": {"type": "object"}
6778 }]
6779 }),
6780 _ => serde_json::json!({}),
6781 };
6782 write_mcp_sse(&mut socket, id, result).await;
6783 } else {
6784 write_empty(&mut socket, "404 Not Found").await;
6785 }
6786 });
6787 }
6788 });
6789 OAuthMcpMock {
6790 addr,
6791 token_requests,
6792 revoked,
6793 task,
6794 }
6795 }
6796 }
6797
6798 fn mock_oauth_server_config(addr: std::net::SocketAddr) -> McpServerConfig {
6799 let mut config = test_server_config();
6800 config.command = None;
6801 config.url = Some(format!("http://{addr}/mcp"));
6802 config
6803 }
6804
6805 fn seed_oauth_tokens(
6806 server_name: &str,
6807 url: &str,
6808 access_token: &str,
6809 refresh_token: &str,
6810 expires_at: Option<u64>,
6811 ) {
6812 let tokens: oauth::StoredMcpOAuthTokens = serde_json::from_value(serde_json::json!({
6813 "server_name": server_name,
6814 "url": url,
6815 "client_id": "cw-test-client",
6816 "token_response": {
6817 "access_token": access_token,
6818 "token_type": "Bearer",
6819 "refresh_token": refresh_token,
6820 "expires_in": 3600
6821 },
6822 "expires_at": expires_at
6823 }))
6824 .unwrap();
6825 oauth::save_oauth_tokens(&tokens).unwrap();
6826 }
6827
6828 fn millis_from_now(offset_ms: u64) -> u64 {
6829 (std::time::SystemTime::now() + Duration::from_millis(offset_ms))
6830 .duration_since(std::time::UNIX_EPOCH)
6831 .unwrap()
6832 .as_millis() as u64
6833 }
6834
6835 #[tokio::test]
6836 async fn model_reconnect_reuses_configured_credentials_without_restarting_siblings() {
6837 use crate::tools::runtime_mcp::StartRuntimeMcpServer;
6838 use crate::tools::spec::{ToolContext, ToolSpec};
6839
6840 let _env = crate::test_support::lock_test_env();
6841 let dir = tempfile::tempdir().unwrap();
6842 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
6843 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
6844 let _loopback = lock_mcp_loopback_tests().await;
6845 let mock = OAuthMcpMock::spawn().await;
6846 let name = "existing_server";
6847 let mut config = McpConfig::default();
6848 config
6849 .servers
6850 .insert(name.into(), mock_oauth_server_config(mock.addr));
6851 config
6852 .servers
6853 .insert("healthy".into(), mock_oauth_server_config(mock.addr));
6854 seed_oauth_tokens(
6855 "healthy",
6856 &mock.url(),
6857 "cw-test-access",
6858 "rt-fresh",
6859 Some(millis_from_now(3_600_000)),
6860 );
6861 let mut pool = McpPool::new(config);
6862 pool.get_or_connect("healthy").await.unwrap();
6863 let sibling_cancel = pool.connections["healthy"].cancel_token.clone();
6864 assert!(
6865 pool.get_or_connect(name).await.is_err(),
6866 "boot before login must require auth"
6867 );
6868 let pool = Arc::new(tokio::sync::Mutex::new(pool));
6869 let tool = StartRuntimeMcpServer::new(Arc::clone(&pool));
6870 let mut context = ToolContext::new(dir.path());
6871
6872 // Credentials arrive from the separate login process under the original key.
6873 seed_oauth_tokens(
6874 name,
6875 &mock.url(),
6876 "cw-test-access",
6877 "rt-fresh",
6878 Some(millis_from_now(3_600_000)),
6879 );
6880 context.disallowed_tools = vec![format!("mcp_{name}_*")];
6881 assert!(
6882 tool.execute(serde_json::json!({"name": name}), &context)
6883 .await
6884 .is_err()
6885 );
6886 assert!(!pool.lock().await.connected_servers().contains(&name));
6887 context.disallowed_tools.clear();
6888 let result = tool
6889 .execute(serde_json::json!({"name": name}), &context)
6890 .await
6891 .unwrap();
6892 assert_eq!(
6893 result.metadata,
6894 Some(serde_json::json!({"mcp_catalog_changed": true}))
6895 );
6896 let mut lock = pool.lock().await;
6897 assert!(lock.connected_servers().contains(&name));
6898 assert!(
6899 lock.all_tools()
6900 .iter()
6901 .any(|(tool, _)| tool == "mcp_existing_server_wiki_lookup")
6902 );
6903 assert!(
6904 lock.dynamic_servers.read().is_empty(),
6905 "reconnect cannot add an alias"
6906 );
6907 assert!(
6908 !sibling_cancel.is_cancelled(),
6909 "healthy sibling was restarted"
6910 );
6911 lock.call_tool("mcp_existing_server_wiki_lookup", serde_json::json!({}))
6912 .await
6913 .unwrap();
6914 drop(lock);
6915 assert!(
6916 tool.execute(serde_json::json!({"name": "absent"}), &context)
6917 .await
6918 .is_err()
6919 );
6920 assert!(pool.lock().await.dynamic_servers.read().is_empty());
6921 assert!(
6922 oauth::load_oauth_tokens("existing-server", &mock.url())
6923 .unwrap()
6924 .is_none(),
6925 "name must not be sanitized into another credential key"
6926 );
6927 mock.task.abort();
6928 }
6929
6930 #[tokio::test]
6931 async fn needs_auth_server_advertises_synthetic_authenticate_tool() {
6932 let _env = crate::test_support::lock_test_env();
6933 let dir = tempfile::tempdir().unwrap();
6934 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
6935 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
6936 let _loopback = lock_mcp_loopback_tests().await;
6937 let mock = OAuthMcpMock::spawn().await;
6938
6939 let mut mcp_config = McpConfig::default();
6940 mcp_config.servers.insert(
6941 "wikiserver".to_string(),
6942 mock_oauth_server_config(mock.addr),
6943 );
6944 // A server carrying manual bearer configuration that also 401s must not
6945 // get the OAuth tool: the flow is gated to servers OAuth can serve.
6946 let mut bearer_config = mock_oauth_server_config(mock.addr);
6947 bearer_config.bearer_token_env_var = Some("CW_TEST_UNSET_BEARER".to_string());
6948 mcp_config
6949 .servers
6950 .insert("beareronly".to_string(), bearer_config);
6951 let mut pool = McpPool::new(mcp_config);
6952
6953 let errors = pool.connect_all().await;
6954 assert_eq!(errors.len(), 2, "{errors:?}");
6955 for (name, err) in &errors {
6956 assert!(
6957 oauth::error_looks_auth_required(err),
6958 "{name} should fail auth-required: {err:#}"
6959 );
6960 }
6961 assert!(
6962 pool.all_tools().is_empty(),
6963 "needs-auth servers advertise no real tools"
6964 );
6965
6966 let tools = pool.to_api_tools();
6967 let auth_tool = tools
6968 .iter()
6969 .find(|tool| tool.name == "mcp_wikiserver_authenticate")
6970 .expect("needs-auth server must expose the synthetic authenticate tool");
6971 // The coaching contract: show the URL verbatim, the call blocks, real
6972 // tools replace the synthetic one on success.
6973 assert!(
6974 auth_tool
6975 .description
6976 .contains("shown to the user in the session status while this call waits"),
6977 "{}",
6978 auth_tool.description
6979 );
6980 assert!(
6981 auth_tool.description.contains("blocks (up to 5 minutes)"),
6982 "{}",
6983 auth_tool.description
6984 );
6985 assert!(
6986 auth_tool
6987 .description
6988 .contains("replace this synthetic authenticate tool"),
6989 "{}",
6990 auth_tool.description
6991 );
6992 assert!(
6993 auth_tool.description.contains("/mcp login wikiserver"),
6994 "{}",
6995 auth_tool.description
6996 );
6997 assert!(
6998 tools
6999 .iter()
7000 .all(|tool| !tool.name.starts_with("mcp_wikiserver_")
7001 || tool.name == "mcp_wikiserver_authenticate"),
7002 "no real wikiserver tools while needs-auth: {tools:?}"
7003 );
7004 assert!(
7005 tools
7006 .iter()
7007 .all(|tool| !tool.name.starts_with("mcp_beareronly_")),
7008 "a bearer-configured server must not get the OAuth tool: {tools:?}"
7009 );
7010
7011 // The execution predicate the engine consults: only the needs-auth
7012 // server's exact synthetic name resolves.
7013 assert_eq!(
7014 pool.authenticate_tool_target("mcp_wikiserver_authenticate")
7015 .as_deref(),
7016 Some("wikiserver")
7017 );
7018 assert_eq!(
7019 pool.authenticate_tool_target("mcp_beareronly_authenticate"),
7020 None,
7021 "manual bearer auth is not OAuth-servable"
7022 );
7023 assert_eq!(
7024 pool.authenticate_tool_target("mcp_wikiserver_wiki_lookup"),
7025 None
7026 );
7027 assert_eq!(
7028 pool.authenticate_tool_target("mcp_unknown_authenticate"),
7029 None
7030 );
7031
7032 // The typed `◆ auth required` state the TUI surfaces derive from the
7033 // same pool state: both 401 servers carry it, and the OAuth-servable one
7034 // routes to `/mcp login`.
7035 assert!(pool.server_needs_auth("wikiserver"));
7036 assert!(pool.server_needs_auth("beareronly"));
7037 let error_map: HashMap<String, String> = errors
7038 .iter()
7039 .map(|(name, err)| (name.clone(), format_mcp_error_for_display(err)))
7040 .collect();
7041 let snapshot = pool.manager_snapshot(&dir.path().join("mcp.json"), false, &error_map);
7042 let wiki = snapshot
7043 .servers
7044 .iter()
7045 .find(|server| server.name == "wikiserver")
7046 .expect("wikiserver in snapshot");
7047 assert!(wiki.auth_required, "{wiki:?}");
7048 assert!(!wiki.connected);
7049 let recovery = wiki
7050 .recovery_kind(false)
7051 .expect("a server needing auth has a recovery");
7052 assert_eq!(recovery, McpRecoveryKind::Reauth);
7053 assert_eq!(
7054 recovery.slash_command("wikiserver"),
7055 "/mcp login wikiserver"
7056 );
7057
7058 // A real tool name from a catalog built before the login lapsed must
7059 // not dead-end: the error names the synthetic tool that recovers it.
7060 let err = pool
7061 .call_tool("mcp_wikiserver_wiki_lookup", serde_json::json!({}))
7062 .await
7063 .expect_err("a needs-auth server cannot serve real tools");
7064 let text = format!("{err:#}");
7065 assert!(text.contains("mcp_wikiserver_authenticate"), "{text}");
7066 assert!(text.contains("◆ auth required"), "{text}");
7067 assert!(text.contains("/mcp login wikiserver"), "{text}");
7068 // Still needs-auth after the failed real call (no state churn).
7069 assert!(pool.server_needs_auth("wikiserver"));
7070
7071 mock.task.abort();
7072 }
7073
7074 #[tokio::test]
7075 async fn dead_refresh_grant_flips_server_to_auth_required_and_offers_login_tool() {
7076 use oauth2::TokenResponse as _;
7077
7078 let _env = crate::test_support::lock_test_env();
7079 let dir = tempfile::tempdir().unwrap();
7080 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7081 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7082 let _loopback = lock_mcp_loopback_tests().await;
7083
7084 let mock = OAuthMcpMock::spawn().await;
7085 let url = mock.url();
7086 let config = mock_oauth_server_config(mock.addr);
7087
7088 // A stored credential whose refresh grant the provider now rejects
7089 // (`invalid_grant`, nobody rotated it): the login has definitively
7090 // lapsed. Before this slice the server showed as a plain failure and
7091 // every reconnect replayed the same rejected refresh.
7092 seed_oauth_tokens("wikiserver", &url, "cw-stale-access", "rt-stale", Some(1));
7093 let stored = oauth::load_oauth_tokens("wikiserver", &url)
7094 .unwrap()
7095 .expect("seeded tokens");
7096 assert_eq!(
7097 stored.token_response.0.access_token().secret(),
7098 "cw-stale-access"
7099 );
7100
7101 let mut mcp_config = McpConfig::default();
7102 mcp_config.servers.insert("wikiserver".to_string(), config);
7103 let mut pool = McpPool::new(mcp_config);
7104 let errors = pool.connect_all().await;
7105 let (_, err) = errors
7106 .iter()
7107 .find(|(name, _)| name == "wikiserver")
7108 .expect("connect fails");
7109 assert!(
7110 oauth::error_looks_auth_required(err),
7111 "a dead grant classifies auth-required: {err:#}"
7112 );
7113 assert!(format!("{err:#}").contains("invalid_grant"), "{err:#}");
7114
7115 // The dead credential is invalidated so auth status no longer claims
7116 // "logged in", the typed state flips, and the model gets the login tool.
7117 assert!(
7118 oauth::load_oauth_tokens("wikiserver", &url)
7119 .unwrap()
7120 .is_none(),
7121 "a definitively rejected grant is removed from the store"
7122 );
7123 assert!(pool.server_needs_auth("wikiserver"));
7124 assert_eq!(
7125 pool.authenticate_tool_target("mcp_wikiserver_authenticate")
7126 .as_deref(),
7127 Some("wikiserver")
7128 );
7129 assert!(
7130 pool.to_api_tools()
7131 .iter()
7132 .any(|tool| tool.name == "mcp_wikiserver_authenticate"),
7133 "dead grant must offer the self-serve login tool"
7134 );
7135 assert_eq!(
7136 mock.token_requests.load(AtomicOrdering::SeqCst),
7137 1,
7138 "one rejected refresh; no retry against an unchanged store"
7139 );
7140
7141 mock.task.abort();
7142 }
7143
7144 #[tokio::test]
7145 async fn selfserve_auth_flow_persists_tokens_and_swaps_real_tools_back() {
7146 use oauth2::TokenResponse as _;
7147
7148 let _env = crate::test_support::lock_test_env();
7149 let dir = tempfile::tempdir().unwrap();
7150 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7151 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7152 let _loopback = lock_mcp_loopback_tests().await;
7153
7154 let mock = OAuthMcpMock::spawn().await;
7155 let url = mock.url();
7156 let config = mock_oauth_server_config(mock.addr);
7157
7158 // Before any login, the pool's connect fails auth-required and the model
7159 // catalog exposes the synthetic tool in place of the unavailable real
7160 // tools.
7161 let mut mcp_config = McpConfig::default();
7162 mcp_config
7163 .servers
7164 .insert("wikiserver".to_string(), config.clone());
7165 let mut pool = McpPool::new(mcp_config);
7166 let errors = pool.connect_all().await;
7167 assert!(
7168 errors
7169 .iter()
7170 .any(|(name, err)| name == "wikiserver" && oauth::error_looks_auth_required(err)),
7171 "{errors:?}"
7172 );
7173 assert!(pool.all_tools().is_empty());
7174 assert!(
7175 pool.to_api_tools()
7176 .iter()
7177 .any(|tool| tool.name == "mcp_wikiserver_authenticate")
7178 );
7179
7180 // A declined browser flow must surface a truthful error the model can
7181 // relay, and leave the server in needs-auth.
7182 let login =
7183 oauth::begin_oauth_login_for_server_tool("wikiserver", &config, None, None, None, None)
7184 .await
7185 .unwrap();
7186 let auth_url = reqwest::Url::parse(login.authorization_url()).unwrap();
7187 let redirect_uri = auth_url
7188 .query_pairs()
7189 .find(|(key, _)| key == "redirect_uri")
7190 .map(|(_, value)| value.into_owned())
7191 .expect("authorization URL carries redirect_uri");
7192 let flow = tokio::spawn(login.finish());
7193 test_http_client()
7194 .get(format!(
7195 "{redirect_uri}?error=access_denied&error_description=not-today"
7196 ))
7197 .send()
7198 .await
7199 .unwrap();
7200 let err = flow
7201 .await
7202 .unwrap()
7203 .expect_err("a declined flow must error, not silently succeed");
7204 assert!(format!("{err:#}").contains("access_denied"), "{err:#}");
7205 assert!(
7206 oauth::load_oauth_tokens("wikiserver", &url)
7207 .unwrap()
7208 .is_none(),
7209 "a declined flow persists no tokens"
7210 );
7211
7212 // The approved flow: drive the loopback callback in-test.
7213 let login =
7214 oauth::begin_oauth_login_for_server_tool("wikiserver", &config, None, None, None, None)
7215 .await
7216 .unwrap();
7217 let auth_url = reqwest::Url::parse(login.authorization_url()).unwrap();
7218 let state = auth_url
7219 .query_pairs()
7220 .find(|(key, _)| key == "state")
7221 .map(|(_, value)| value.into_owned())
7222 .expect("authorization URL carries state");
7223 let redirect_uri = auth_url
7224 .query_pairs()
7225 .find(|(key, _)| key == "redirect_uri")
7226 .map(|(_, value)| value.into_owned())
7227 .expect("authorization URL carries redirect_uri");
7228 let flow = tokio::spawn(login.finish());
7229 test_http_client()
7230 .get(format!("{redirect_uri}?code=cw-test-code&state={state}"))
7231 .send()
7232 .await
7233 .unwrap();
7234 flow.await.unwrap().expect("approved flow completes");
7235
7236 let stored = oauth::load_oauth_tokens("wikiserver", &url)
7237 .unwrap()
7238 .expect("successful flow persists tokens");
7239 assert_eq!(
7240 stored.token_response.0.access_token().secret(),
7241 "cw-test-access"
7242 );
7243
7244 // The synthetic tool now adopts the completed login and swaps the real
7245 // tools back through call_tool (the already-authorized branch, since the
7246 // flow above persisted tokens to the shared store).
7247 let result = pool
7248 .call_tool("mcp_wikiserver_authenticate", serde_json::json!({}))
7249 .await
7250 .unwrap();
7251 assert_eq!(result["status"], "already_authorized", "{result}");
7252 assert!(
7253 result["tools"]
7254 .as_array()
7255 .unwrap()
7256 .contains(&serde_json::json!("mcp_wikiserver_wiki_lookup")),
7257 "{result}"
7258 );
7259
7260 let real_names: Vec<String> = pool
7261 .all_tools()
7262 .iter()
7263 .map(|(name, _)| name.clone())
7264 .collect();
7265 assert_eq!(real_names, vec!["mcp_wikiserver_wiki_lookup".to_string()]);
7266 let catalog = pool.to_api_tools();
7267 assert!(
7268 catalog
7269 .iter()
7270 .any(|tool| tool.name == "mcp_wikiserver_wiki_lookup"),
7271 "real tools must be in the catalog after auth: {catalog:?}"
7272 );
7273 assert!(
7274 catalog
7275 .iter()
7276 .all(|tool| tool.name != "mcp_wikiserver_authenticate"),
7277 "a connected server no longer advertises the synthetic tool: {catalog:?}"
7278 );
7279
7280 mock.task.abort();
7281 }
7282
7283 #[tokio::test]
7284 async fn invalid_grant_refresh_adopts_rotated_on_disk_token() {
7285 let _env = crate::test_support::lock_test_env();
7286 let dir = tempfile::tempdir().unwrap();
7287 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7288 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7289 let _loopback = lock_mcp_loopback_tests().await;
7290
7291 let mock = OAuthMcpMock::spawn().await;
7292 let url = mock.url();
7293 let config = mock_oauth_server_config(mock.addr);
7294
7295 // This runtime loaded a stale credential; its refresh grant fails.
7296 seed_oauth_tokens("wikiserver", &url, "cw-stale-access", "rt-stale", Some(1));
7297 let runtime = oauth::McpOAuthRuntime::from_server_config(
7298 "wikiserver",
7299 &config,
7300 reqwest::header::HeaderMap::new(),
7301 )
7302 .await
7303 .unwrap()
7304 .expect("stored tokens produce a runtime");
7305 // Another process rotates the on-disk credential before our refresh runs.
7306 seed_oauth_tokens(
7307 "wikiserver",
7308 &url,
7309 "cw-rotated-access",
7310 "rt-rotated",
7311 Some(millis_from_now(3_600_000)),
7312 );
7313
7314 let header = runtime
7315 .authorization_header()
7316 .await
7317 .unwrap()
7318 .expect("adopted token produces a header");
7319 assert_eq!(header, "Bearer cw-rotated-access");
7320 assert_eq!(
7321 mock.token_requests.load(AtomicOrdering::SeqCst),
7322 1,
7323 "a fresh rotated token is adopted without burning a second refresh"
7324 );
7325
7326 mock.task.abort();
7327 }
7328
7329 #[tokio::test]
7330 async fn invalid_grant_refresh_retries_once_with_rotated_grant() {
7331 use oauth2::TokenResponse as _;
7332
7333 let _env = crate::test_support::lock_test_env();
7334 let dir = tempfile::tempdir().unwrap();
7335 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7336 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7337 let _loopback = lock_mcp_loopback_tests().await;
7338
7339 let mock = OAuthMcpMock::spawn().await;
7340 let url = mock.url();
7341 let config = mock_oauth_server_config(mock.addr);
7342
7343 seed_oauth_tokens("wikiserver", &url, "cw-stale-access", "rt-stale", Some(1));
7344 let runtime = oauth::McpOAuthRuntime::from_server_config(
7345 "wikiserver",
7346 &config,
7347 reqwest::header::HeaderMap::new(),
7348 )
7349 .await
7350 .unwrap()
7351 .expect("stored tokens produce a runtime");
7352 // The rotated credential is itself expired, so the refresh must be
7353 // retried once with the rotated grant.
7354 seed_oauth_tokens(
7355 "wikiserver",
7356 &url,
7357 "cw-rotated-stale",
7358 "rt-rotated",
7359 Some(1),
7360 );
7361
7362 let header = runtime
7363 .authorization_header()
7364 .await
7365 .unwrap()
7366 .expect("retry with the rotated grant succeeds");
7367 assert_eq!(header, "Bearer cw-rotated-access-2");
7368 assert_eq!(
7369 mock.token_requests.load(AtomicOrdering::SeqCst),
7370 2,
7371 "stale grant failed once, rotated grant retried once"
7372 );
7373 let stored = oauth::load_oauth_tokens("wikiserver", &url)
7374 .unwrap()
7375 .expect("refreshed tokens persisted");
7376 assert_eq!(
7377 stored.token_response.0.access_token().secret(),
7378 "cw-rotated-access-2"
7379 );
7380
7381 mock.task.abort();
7382 }
7383
7384 #[tokio::test]
7385 async fn invalid_grant_refresh_with_unchanged_store_invalidates_dead_grant() {
7386 let _env = crate::test_support::lock_test_env();
7387 let dir = tempfile::tempdir().unwrap();
7388 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7389 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7390 let _loopback = lock_mcp_loopback_tests().await;
7391
7392 let mock = OAuthMcpMock::spawn().await;
7393 let url = mock.url();
7394 let config = mock_oauth_server_config(mock.addr);
7395
7396 seed_oauth_tokens("wikiserver", &url, "cw-stale-access", "rt-stale", Some(1));
7397 let runtime = oauth::McpOAuthRuntime::from_server_config(
7398 "wikiserver",
7399 &config,
7400 reqwest::header::HeaderMap::new(),
7401 )
7402 .await
7403 .unwrap()
7404 .expect("stored tokens produce a runtime");
7405
7406 let err = runtime
7407 .authorization_header()
7408 .await
7409 .expect_err("an unchanged store must surface the provider error");
7410 assert!(format!("{err:#}").contains("invalid_grant"), "{err:#}");
7411 assert!(
7412 oauth::error_looks_auth_required(&err),
7413 "a dead grant is an auth-required failure: {err:#}"
7414 );
7415 assert!(
7416 oauth::load_oauth_tokens("wikiserver", &url)
7417 .unwrap()
7418 .is_none(),
7419 "a grant the provider definitively rejected, that nobody rotated, is invalidated"
7420 );
7421 assert_eq!(
7422 mock.token_requests.load(AtomicOrdering::SeqCst),
7423 1,
7424 "no retry when the on-disk credential is unchanged"
7425 );
7426
7427 mock.task.abort();
7428 }
7429
7430 #[tokio::test]
7431 async fn invalidation_never_deletes_a_credential_rotated_after_the_re_read() {
7432 use oauth2::TokenResponse as _;
7433
7434 let _env = crate::test_support::lock_test_env();
7435 let dir = tempfile::tempdir().unwrap();
7436 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7437 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7438 let _loopback = lock_mcp_loopback_tests().await;
7439
7440 let mock = OAuthMcpMock::spawn().await;
7441 let url = mock.url();
7442 let config = mock_oauth_server_config(mock.addr);
7443
7444 // Both the held and the rotated credential are dead grants, so the
7445 // rotated one is adopted, retried, and rejected too. The runtime then
7446 // invalidates — but a peer that wrote a third credential in between
7447 // owns the durable winner, which must survive.
7448 seed_oauth_tokens("wikiserver", &url, "cw-stale-access", "rt-stale", Some(1));
7449 let runtime = oauth::McpOAuthRuntime::from_server_config(
7450 "wikiserver",
7451 &config,
7452 reqwest::header::HeaderMap::new(),
7453 )
7454 .await
7455 .unwrap()
7456 .expect("stored tokens produce a runtime");
7457 let err = runtime
7458 .authorization_header()
7459 .await
7460 .expect_err("dead grant fails");
7461 assert!(format!("{err:#}").contains("invalid_grant"), "{err:#}");
7462 assert!(
7463 oauth::load_oauth_tokens("wikiserver", &url)
7464 .unwrap()
7465 .is_none(),
7466 "unchanged store: invalidated"
7467 );
7468
7469 // Now the peer-wins case: a different credential lands on disk before
7470 // the (second) runtime invalidates its own stale copy.
7471 seed_oauth_tokens("wikiserver", &url, "cw-stale-access", "rt-stale", Some(1));
7472 let runtime = oauth::McpOAuthRuntime::from_server_config(
7473 "wikiserver",
7474 &config,
7475 reqwest::header::HeaderMap::new(),
7476 )
7477 .await
7478 .unwrap()
7479 .expect("stored tokens produce a runtime");
7480 // Peer rotates to a fresh, non-expired credential the mock accepts.
7481 seed_oauth_tokens(
7482 "wikiserver",
7483 &url,
7484 "cw-peer-access",
7485 "rt-fresh",
7486 Some(millis_from_now(3_600_000)),
7487 );
7488 let header = runtime
7489 .authorization_header()
7490 .await
7491 .unwrap()
7492 .expect("adopts the peer's fresh credential");
7493 assert_eq!(header, "Bearer cw-peer-access");
7494 let stored = oauth::load_oauth_tokens("wikiserver", &url)
7495 .unwrap()
7496 .expect("the peer's credential is still on disk");
7497 assert_eq!(
7498 stored
7499 .token_response
7500 .0
7501 .refresh_token()
7502 .map(|t| t.secret().as_str()),
7503 Some("rt-fresh")
7504 );
7505
7506 mock.task.abort();
7507 }
7508
7509 #[tokio::test]
7510 async fn mid_session_revocation_lands_in_the_same_auth_required_state() {
7511 let _env = crate::test_support::lock_test_env();
7512 let dir = tempfile::tempdir().unwrap();
7513 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7514 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7515 let _loopback = lock_mcp_loopback_tests().await;
7516
7517 let mock = OAuthMcpMock::spawn().await;
7518 let url = mock.url();
7519 let config = mock_oauth_server_config(mock.addr);
7520
7521 // A healthy session: stored credential accepted, real tools advertised.
7522 seed_oauth_tokens(
7523 "wikiserver",
7524 &url,
7525 "cw-test-access",
7526 "rt-fresh",
7527 Some(millis_from_now(3_600_000)),
7528 );
7529 let mut mcp_config = McpConfig::default();
7530 mcp_config.servers.insert("wikiserver".to_string(), config);
7531 let mut pool = McpPool::new(mcp_config);
7532 let errors = pool.connect_all().await;
7533 assert!(errors.is_empty(), "{errors:?}");
7534 assert!(!pool.server_needs_auth("wikiserver"));
7535 assert!(
7536 pool.to_api_tools()
7537 .iter()
7538 .any(|tool| tool.name == "mcp_wikiserver_wiki_lookup")
7539 );
7540
7541 // The provider revokes every grant mid-session: the live call 401s, the
7542 // reactive refresh is rejected with invalid_grant, and the failure must
7543 // land in the same typed state a failed connect produces — not a dead
7544 // transport error on a connection the pool still calls "ready".
7545 // Cross a whole second: reloading the same durable credential now has a
7546 // smaller derived expires_in, which must not look like a peer rotation.
7547 tokio::time::sleep(Duration::from_millis(1_100)).await;
7548 mock.revoke_all_grants();
7549 let err = pool
7550 .call_tool("mcp_wikiserver_wiki_lookup", serde_json::json!({}))
7551 .await
7552 .expect_err("a revoked credential cannot serve real tools");
7553 let text = format!("{err:#}");
7554 assert!(text.contains("◆ auth required"), "{text}");
7555 assert!(text.contains("mcp_wikiserver_authenticate"), "{text}");
7556 assert!(pool.server_needs_auth("wikiserver"));
7557 assert!(
7558 oauth::load_oauth_tokens("wikiserver", &url)
7559 .unwrap()
7560 .is_none(),
7561 "the definitively rejected credential is invalidated; refresh requests: {}; failure: {text}",
7562 mock.token_requests
7563 .load(std::sync::atomic::Ordering::SeqCst)
7564 );
7565 let catalog = pool.to_api_tools();
7566 assert!(
7567 catalog
7568 .iter()
7569 .any(|tool| tool.name == "mcp_wikiserver_authenticate"),
7570 "{catalog:?}"
7571 );
7572 assert!(
7573 catalog
7574 .iter()
7575 .all(|tool| tool.name != "mcp_wikiserver_wiki_lookup"),
7576 "a dropped connection advertises no real tools: {catalog:?}"
7577 );
7578
7579 // Listing surfaces carry the same recovery, naming the synthetic tool.
7580 let resources = pool.list_resources(None).await.unwrap();
7581 let item = resources
7582 .iter()
7583 .find(|item| item["error"] == "authentication_required")
7584 .expect("needs-auth server yields an auth-required listing item");
7585 assert_eq!(item["server"], "wikiserver");
7586 assert_eq!(item["authenticate_tool"], "mcp_wikiserver_authenticate");
7587 assert!(
7588 item["message"]
7589 .as_str()
7590 .unwrap()
7591 .contains("mcp_wikiserver_authenticate"),
7592 "{item}"
7593 );
7594
7595 // Every TUI surface derives from the same typed state.
7596 let snapshot = pool.manager_snapshot(&dir.path().join("mcp.json"), false, &HashMap::new());
7597 let wiki = snapshot
7598 .servers
7599 .iter()
7600 .find(|server| server.name == "wikiserver")
7601 .expect("wikiserver in snapshot");
7602 assert!(wiki.auth_required, "{wiki:?}");
7603 assert!(!wiki.connected);
7604 assert_eq!(wiki.recovery_kind(false), Some(McpRecoveryKind::Reauth));
7605
7606 mock.task.abort();
7607 }
7608
7609 #[tokio::test]
7610 async fn authenticate_tool_via_pool_releases_the_lock_during_the_browser_wait() {
7611 let _env = crate::test_support::lock_test_env();
7612 let dir = tempfile::tempdir().unwrap();
7613 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7614 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7615 let _loopback = lock_mcp_loopback_tests().await;
7616
7617 let mock = OAuthMcpMock::spawn().await;
7618 let mut mcp_config = McpConfig::default();
7619 mcp_config.servers.insert(
7620 "wikiserver".to_string(),
7621 mock_oauth_server_config(mock.addr),
7622 );
7623 let pool = Arc::new(tokio::sync::Mutex::new(McpPool::new(mcp_config)));
7624 let errors = pool.lock().await.connect_all().await;
7625 assert!(
7626 errors
7627 .iter()
7628 .any(|(name, err)| name == "wikiserver" && oauth::error_looks_auth_required(err)),
7629 "{errors:?}"
7630 );
7631
7632 // The engine path: the URL reaches the runtime before the wait, and the
7633 // pool lock is free while the user signs in.
7634 let (url_tx, url_rx) = tokio::sync::oneshot::channel();
7635 let flow = tokio::spawn({
7636 let pool = Arc::clone(&pool);
7637 async move {
7638 authenticate_tool_via_pool(&pool, "wikiserver", |url| {
7639 let _ = url_tx.send(url.to_string());
7640 })
7641 .await
7642 }
7643 });
7644 let auth_url = url_rx
7645 .await
7646 .expect("authorization URL announced before the wait");
7647 {
7648 let guard = tokio::time::timeout(Duration::from_secs(5), pool.lock())
7649 .await
7650 .expect("pool lock must not be held during the browser wait");
7651 assert!(guard.server_needs_auth("wikiserver"));
7652 assert!(
7653 guard
7654 .to_api_tools()
7655 .iter()
7656 .any(|tool| tool.name == "mcp_wikiserver_authenticate"),
7657 "still needs-auth until the flow completes"
7658 );
7659 }
7660
7661 let parsed = reqwest::Url::parse(&auth_url).unwrap();
7662 let state = parsed
7663 .query_pairs()
7664 .find(|(key, _)| key == "state")
7665 .map(|(_, value)| value.into_owned())
7666 .expect("authorization URL carries state");
7667 let redirect_uri = parsed
7668 .query_pairs()
7669 .find(|(key, _)| key == "redirect_uri")
7670 .map(|(_, value)| value.into_owned())
7671 .expect("authorization URL carries redirect_uri");
7672 test_http_client()
7673 .get(format!("{redirect_uri}?code=cw-test-code&state={state}"))
7674 .send()
7675 .await
7676 .unwrap();
7677 let result = flow
7678 .await
7679 .unwrap()
7680 .expect("approved flow reconnects the server");
7681 assert_eq!(result["status"], "authenticated", "{result}");
7682 assert_eq!(result["authorization_url"], auth_url, "{result}");
7683 assert!(
7684 result["tools"]
7685 .as_array()
7686 .unwrap()
7687 .contains(&serde_json::json!("mcp_wikiserver_wiki_lookup")),
7688 "{result}"
7689 );
7690
7691 let guard = pool.lock().await;
7692 assert!(!guard.server_needs_auth("wikiserver"));
7693 let catalog = guard.to_api_tools();
7694 assert!(
7695 catalog
7696 .iter()
7697 .any(|tool| tool.name == "mcp_wikiserver_wiki_lookup"),
7698 "{catalog:?}"
7699 );
7700 assert!(
7701 catalog
7702 .iter()
7703 .all(|tool| tool.name != "mcp_wikiserver_authenticate"),
7704 "{catalog:?}"
7705 );
7706 drop(guard);
7707
7708 mock.task.abort();
7709 }
7710
7711 #[tokio::test]
7712 async fn plugin_contributed_server_auth_required_names_its_env_credential_not_oauth_login() {
7713 let _env = crate::test_support::lock_test_env();
7714 let dir = tempfile::tempdir().unwrap();
7715 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
7716 let _backend = crate::test_support::EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
7717 let _loopback = lock_mcp_loopback_tests().await;
7718
7719 let plugin_base = dir.path().join("plugins/wiki-plugin");
7720 fs::create_dir_all(&plugin_base).unwrap();
7721 fs::write(
7722 plugin_base.join("plugin.toml"),
7723 "schema_version = 1\n[plugin]\nname = \"wiki-plugin\"\nversion = \"1.0.0\"\n",
7724 )
7725 .unwrap();
7726 let (_, authority) = active_plugin_fixture(&plugin_base);
7727
7728 // A plugin-contributed server whose reviewed environment-backed header is
7729 // absent, against an endpoint that 401s without it.
7730 let mock = OAuthMcpMock::spawn().await;
7731 let endpoint = mock.url();
7732 let mut server = mock_oauth_server_config(mock.addr);
7733 server.env_headers.insert(
7734 "Authorization".to_string(),
7735 "CW_TEST_WIKI_PLUGIN_TOKEN".to_string(),
7736 );
7737 server.reviewed_plugin = Some(
7738 ReviewedPluginMcpSource::from_authority(
7739 authority,
7740 Some(&endpoint),
7741 Arc::new(crate::plugins::HostEnvironment::default()),
7742 )
7743 .unwrap(),
7744 );
7745 let mut mcp_config = McpConfig::default();
7746 mcp_config.servers.insert("wikiplugin".to_string(), server);
7747 let mut pool = McpPool::new(mcp_config);
7748
7749 let errors = pool.connect_all().await;
7750 let (_, err) = errors
7751 .iter()
7752 .find(|(name, _)| name == "wikiplugin")
7753 .expect("plugin server fails to connect");
7754 assert!(oauth::error_looks_auth_required(err), "{err:#}");
7755
7756 // Same typed state as an OAuth server, but never the OAuth tool: plugin
7757 // servers authenticate through their reviewed environment header.
7758 assert!(pool.server_needs_auth("wikiplugin"));
7759 assert_eq!(
7760 pool.authenticate_tool_target("mcp_wikiplugin_authenticate"),
7761 None
7762 );
7763 assert!(
7764 pool.to_api_tools()
7765 .iter()
7766 .all(|tool| !tool.name.starts_with("mcp_wikiplugin_")),
7767 "no synthetic OAuth tool for a plugin-contributed server"
7768 );
7769
7770 let err = pool
7771 .call_tool("mcp_wikiplugin_wiki_lookup", serde_json::json!({}))
7772 .await
7773 .expect_err("needs-auth plugin server cannot serve real tools");
7774 let text = format!("{err:#}");
7775 assert!(text.contains("◆ auth required"), "{text}");
7776 assert!(text.contains("plugin 'wiki-plugin'"), "{text}");
7777 assert!(text.contains("CW_TEST_WIKI_PLUGIN_TOKEN"), "{text}");
7778 assert!(text.contains("/mcp reload"), "{text}");
7779 assert!(!text.contains("/mcp login"), "{text}");
7780 assert!(!text.contains("mcp_wikiplugin_authenticate"), "{text}");
7781
7782 let snapshot = pool.manager_snapshot(&dir.path().join("mcp.json"), false, &HashMap::new());
7783 let plugin = snapshot
7784 .servers
7785 .iter()
7786 .find(|server| server.name == "wikiplugin")
7787 .expect("plugin server in snapshot");
7788 assert!(plugin.auth_required, "{plugin:?}");
7789
7790 mock.task.abort();
7791 }
7792
7793 #[test]
7794 fn mcp_display_target_shows_command_names_only() {
7795 // stdio: no `./…` path prefix, no directories, no args.
7796 assert_eq!(
7797 mcp_display_target("stdio", "./mcp/custom-server --port 8080"),
7798 "custom-server"
7799 );
7800 assert_eq!(mcp_display_target("stdio", "node server.js"), "node");
7801 assert_eq!(mcp_display_target("stdio", "/usr/local/bin/foo -x"), "foo");
7802 assert_eq!(
7803 mcp_display_target("stdio", "C:\\tools\\mcp.exe --stdio"),
7804 "mcp.exe"
7805 );
7806 assert_eq!(mcp_display_target("stdio", "(missing)"), "(missing)");
7807 // URL transports keep the full URL: it is the identity.
7808 assert_eq!(
7809 mcp_display_target("http/sse", "https://example.invalid/mcp"),
7810 "https://example.invalid/mcp"
7811 );
7812 assert_eq!(
7813 mcp_display_target("sse", "https://example.invalid/sse?token=abc"),
7814 "https://example.invalid/sse?token=abc"
7815 );
7816 }
7817
7818 fn test_mcp_http_client(url: &str) -> super::http_client::McpHttpClient {
7819 super::http_client::McpHttpClient::new(
7820 url,
7821 false,
7822 false,
7823 false,
7824 None,
7825 Duration::from_secs(10),
7826 Duration::from_secs(120),
7827 )
7828 .expect("MCP fixture client")
7829 }
7830
7831 fn ceiling_test_connection(name: &str, sent: Arc<Mutex<Vec<serde_json::Value>>>) -> McpConnection {
7832 let mut connection = test_connection(Box::new(ScriptedValueTransport {
7833 sent,
7834 responses: VecDeque::from([json_frame(serde_json::json!({
7835 "jsonrpc": "2.0", "id": 1, "result": {"ok": true}
7836 }))]),
7837 }));
7838 connection.name = name.to_string();
7839 connection.tools = ["read", "delete"]
7840 .into_iter()
7841 .map(|name| McpTool {
7842 name: name.to_string(),
7843 description: None,
7844 input_schema: serde_json::json!({}),
7845 })
7846 .collect();
7847 connection.resources = vec![McpResource {
7848 name: "one".to_string(),
7849 uri: "memory://one".to_string(),
7850 description: None,
7851 mime_type: None,
7852 }];
7853 connection.resource_templates = vec![McpResourceTemplate {
7854 name: "items".to_string(),
7855 uri_template: "memory://{id}".to_string(),
7856 description: None,
7857 mime_type: None,
7858 }];
7859 connection.prompts = vec![McpPrompt {
7860 name: "review".to_string(),
7861 description: None,
7862 arguments: vec![],
7863 }];
7864 connection
7865 }
7866
7867 #[tokio::test]
7868 async fn mcp_ceiling_denied_server_is_absent_across_cached_boot_meta_auth_and_runtime_paths() {
7869 let sent = Arc::new(Mutex::new(Vec::new()));
7870 let connection = ceiling_test_connection("private_a", Arc::clone(&sent));
7871 let mut config = connection.config.clone();
7872 config.required = true;
7873 config.url = Some("https://mcp.example.com".to_string());
7874 config.command = None;
7875 config.scopes = vec!["read".to_string()];
7876 let mut pool = McpPool::new(McpConfig {
7877 servers: HashMap::from([("private_a".to_string(), config.clone())]),
7878 timeouts: McpTimeouts::default(),
7879 })
7880 .with_disallowed_tools(vec!["MCP_PRIVATE_A_*".to_string()]);
7881 // A previously connected or auth-failed entry must not become reachable.
7882 pool.connections.insert("private_a".to_string(), connection);
7883 pool.needs_auth_servers.insert("private_a".to_string());
7884 assert!(pool.all_tools().is_empty());
7885 assert!(pool.all_resources().is_empty());
7886 assert!(pool.all_resource_templates().is_empty());
7887 assert!(pool.all_prompts().is_empty());
7888 assert!(pool.resolved_tool_servers().is_empty());
7889 assert!(pool.to_api_tools().is_empty());
7890 assert!(pool.model_tool_names(&pool.to_api_tools()).is_empty());
7891 assert!(pool.enabled_server_names().is_empty());
7892 assert!(pool.server_names().is_empty());
7893 assert!(pool.connected_servers().is_empty());
7894 assert!(!pool.server_needs_auth("private_a"));
7895 assert!(
7896 pool.authenticate_tool_target("mcp_private_a_authenticate")
7897 .is_none()
7898 );
7899 let (pending, errors) = pool.collect_pending_connects(None);
7900 assert!(pending.is_empty() && errors.is_empty());
7901 assert!(
7902 pool.connect_all().await.is_empty(),
7903 "a denied required server is absent"
7904 );
7905 assert!(
7906 pool.manager_snapshot(Path::new("/unused"), false, &HashMap::new())
7907 .servers
7908 .is_empty()
7909 );
7910 for method in [
7911 "list_mcp_resources",
7912 "list_mcp_resource_templates",
7913 "mcp_read_resource",
7914 "read_mcp_resource",
7915 "mcp_get_prompt",
7916 ] {
7917 let error = pool
7918 .call_tool(
7919 method,
7920 serde_json::json!({"server": "private_a", "uri": "memory://one", "name": "review"}),
7921 )
7922 .await
7923 .unwrap_err();
7924 assert_eq!(
7925 error.to_string(),
7926 "Failed to find MCP server: private_a",
7927 "{method}"
7928 );
7929 }
7930 for method in [
7931 "mcp_private_a_read",
7932 "mcp_private_a_delete",
7933 "mcp_private_a_authenticate",
7934 ] {
7935 assert_eq!(
7936 pool.call_tool(method, serde_json::json!({}))
7937 .await
7938 .unwrap_err()
7939 .to_string(),
7940 format!("Unknown MCP tool name: {method}")
7941 );
7942 }
7943 assert!(pool.begin_authenticate_tool("private_a").await.is_err());
7944 assert!(pool.retry_connection("private_a").await.is_err());
7945 assert!(
7946 pool.add_runtime_server_config("private_a".to_string(), config.clone())
7947 .is_err()
7948 );
7949 let denied = pool
7950 .get_or_connect("private_a")
7951 .await
7952 .err()
7953 .unwrap()
7954 .to_string();
7955 let mut absent = McpPool::new(McpConfig::default());
7956 let missing = absent
7957 .get_or_connect("private_a")
7958 .await
7959 .err()
7960 .unwrap()
7961 .to_string();
7962 assert_eq!(denied, missing);
7963 assert!(sent.lock().unwrap().is_empty(), "no MCP request is sent");
7964 let stale = ceiling_test_connection("private_a", Arc::clone(&sent));
7965 assert!(
7966 pool.store_ready_connection("private_a".to_string(), stale)
7967 .is_err()
7968 );
7969 }
7970
7971 #[tokio::test]
7972 async fn mcp_ceiling_individual_tool_denial_preserves_server_resources_and_sibling_tool() {
7973 let sent = Arc::new(Mutex::new(Vec::new()));
7974 let connection = ceiling_test_connection("private_a", Arc::clone(&sent));
7975 let mut pool = McpPool::new(McpConfig::default())
7976 .with_disallowed_tools(vec!["MCP_PRIVATE_A_DELETE".to_string()]);
7977 pool.connections.insert("private_a".to_string(), connection);
7978 assert_eq!(
7979 pool.all_tools()
7980 .iter()
7981 .map(|(name, _)| name.as_str())
7982 .collect::<Vec<_>>(),
7983 vec!["mcp_private_a_read"]
7984 );
7985 assert_eq!(pool.all_resources().len(), 1);
7986 assert_eq!(pool.all_prompts().len(), 1);
7987 assert!(
7988 pool.call_tool("mcp_private_a_delete", serde_json::json!({}))
7989 .await
7990 .is_err()
7991 );
7992 assert!(sent.lock().unwrap().is_empty());
7993 let resources = pool
7994 .call_tool(
7995 "list_mcp_resources",
7996 serde_json::json!({"server": "private_a"}),
7997 )
7998 .await
7999 .unwrap();
8000 assert_eq!(resources["resources"].as_array().unwrap().len(), 1);
8001 assert_eq!(
8002 pool.call_tool("mcp_private_a_read", serde_json::json!({}))
8003 .await
8004 .unwrap(),
8005 serde_json::json!({"ok": true})
8006 );
8007 assert_eq!(sent.lock().unwrap().len(), 1);
8008 assert_eq!(sent.lock().unwrap()[0]["params"]["name"], "read");
8009 }
8010
8011 #[tokio::test]
8012 async fn mcp_ceiling_child_scoped_meta_calls_do_not_widen_or_mutate_sibling_policy() {
8013 let sent = Arc::new(Mutex::new(Vec::new()));
8014 let private = ceiling_test_connection("private_a", Arc::clone(&sent));
8015 let public = ceiling_test_connection("public", Arc::clone(&sent));
8016 let mut pool = McpPool::new(McpConfig {
8017 servers: HashMap::from([
8018 ("private_a".to_string(), private.config.clone()),
8019 ("public".to_string(), public.config.clone()),
8020 ]),
8021 timeouts: McpTimeouts::default(),
8022 });
8023 pool.connections.insert("private_a".to_string(), private);
8024 pool.connections.insert("public".to_string(), public);
8025 let child_rules = vec!["mcp_private_a_*".to_string()];
8026 for (method, field) in [
8027 ("list_mcp_resources", "resources"),
8028 ("list_mcp_resource_templates", "templates"),
8029 ] {
8030 let child = pool
8031 .call_tool_with_disallowed(method, serde_json::json!({}), &child_rules)
8032 .await
8033 .unwrap();
8034 assert_eq!(child[field].as_array().unwrap().len(), 1);
8035 assert_eq!(child[field][0]["server"], "public");
8036 let sibling = pool
8037 .call_tool_with_disallowed(method, serde_json::json!({}), &[])
8038 .await
8039 .unwrap();
8040 assert_eq!(sibling[field].as_array().unwrap().len(), 2);
8041 }
8042 assert!(
8043 pool.call_tool_with_disallowed(
8044 "read_mcp_resource",
8045 serde_json::json!({"server":"private_a","uri":"memory://one"}),
8046 &child_rules
8047 )
8048 .await
8049 .is_err()
8050 );
8051 assert!(
8052 pool.call_tool_with_disallowed("mcp_private_a_read", serde_json::json!({}), &child_rules)
8053 .await
8054 .is_err()
8055 );
8056 assert!(sent.lock().unwrap().is_empty());
8057 assert_eq!(
8058 pool.call_tool_with_disallowed("mcp_private_a_read", serde_json::json!({}), &[])
8059 .await
8060 .unwrap(),
8061 serde_json::json!({"ok":true})
8062 );
8063 }
8064
8065 #[tokio::test]
8066 async fn mcp_ceiling_survives_source_reload_and_blocks_new_runtime_names() {
8067 let dir = tempfile::tempdir().unwrap();
8068 let source = dir.path().join("mcp.json");
8069 fs::write(&source, r#"{"mcpServers": {}}"#).unwrap();
8070 let mut pool = McpPool::from_config_path(&source)
8071 .unwrap()
8072 .with_disallowed_tools(vec!["mcp_private*".to_string()]);
8073 fs::write(&source, r#"{"mcpServers":{"private":{"command":"must-not-execute-private","required":true},"private_a":{"command":"must-not-execute-private"}}}"#).unwrap();
8074 pool.force_reload_config_sources().unwrap();
8075 assert!(pool.connect_all().await.is_empty());
8076 assert!(pool.enabled_server_names().is_empty());
8077 assert!(pool.get_or_connect("private_a").await.is_err());
8078 assert!(
8079 pool.add_runtime_server_config("private_new".to_string(), test_server_config())
8080 .is_err()
8081 );
8082 assert!(!pool.dynamic_servers.read().contains_key("private_new"));
8083 pool.add_runtime_server_config("public".to_string(), test_server_config())
8084 .unwrap();
8085 assert_eq!(pool.enabled_server_names(), vec!["public"]);
8086 }
8087
8088 #[test]
8089 fn mcp_ceiling_namespace_rules_keep_individual_denials_distinct_and_aliases_consistent() {
8090 assert!(McpPool::server_denied_by(&["MCP_A_B_*".to_string()], "a_b"));
8091 assert!(!McpPool::server_denied_by(&["MCP_A_B_*".to_string()], "a"));
8092 assert!(!McpPool::server_denied_by(
8093 &["mcp_a_delete".to_string()],
8094 "a"
8095 ));
8096 assert!(!McpPool::server_denied_by(
8097 &["mcp_a_delete*".to_string()],
8098 "a"
8099 ));
8100 assert!(McpPool::server_denied_by(
8101 &["mcp*".to_string()],
8102 "any_server"
8103 ));
8104 for name in ["mcp_read_resource", "read_mcp_resource"] {
8105 assert!(
8106 McpPool::authorize_call(
8107 &["mcp_a_*".to_string()],
8108 name,
8109 &serde_json::json!({"server":"a"})
8110 )
8111 .is_err()
8112 );
8113 }
8114 }
8115
8116 #[tokio::test]
8117 async fn mcp_ceiling_preserves_ordinary_tool_result_tools_field() {
8118 let sent = Arc::new(Mutex::new(Vec::new()));
8119 let expected = serde_json::json!({"tools":[{"name":"server-owned-data"}], "ok":true});
8120 let mut connection = ceiling_test_connection("public", Arc::clone(&sent));
8121 connection.transport = Box::new(ScriptedValueTransport {
8122 sent,
8123 responses: VecDeque::from([json_frame(
8124 serde_json::json!({"jsonrpc":"2.0","id":1,"result":expected}),
8125 )]),
8126 });
8127 let mut pool = McpPool::new(McpConfig::default());
8128 pool.connections.insert("public".to_string(), connection);
8129 assert_eq!(
8130 pool.call_tool_with_disallowed(
8131 "mcp_public_read",
8132 serde_json::json!({}),
8133 &["mcp_private_*".to_string()]
8134 )
8135 .await
8136 .unwrap(),
8137 expected
8138 );
8139 for alias in ["read_mcp_resource", "mcp_read_resource"] {
8140 for denied in ["read_mcp_resource", "mcp_read_resource"] {
8141 assert!(
8142 McpPool::authorize_call(
8143 &[denied.to_string()],
8144 alias,
8145 &serde_json::json!({"server":"public"})
8146 )
8147 .is_err()
8148 );
8149 }
8150 }
8151 }
8152
8153 /// #6213 T7: the resource-URI template check is an authorization decision that
8154 /// runs per URI per advertised template. Pin what it accepts, what it refuses,
8155 /// and that the anchored pattern is compiled once rather than per call.
8156 #[test]
8157 fn resource_uri_template_matching_is_anchored_and_fail_closed() {
8158 // Literal templates are anchored: no suffix may sneak past.
8159 assert!(resource_uri_matches_template(
8160 "file:///readme",
8161 "file:///readme"
8162 ));
8163 assert!(!resource_uri_matches_template(
8164 "file:///readme/extra",
8165 "file:///readme"
8166 ));
8167
8168 // `{id}` is a simple expansion, so it must not cross a path separator.
8169 assert!(resource_uri_matches_template("file:///a", "file:///{id}"));
8170 assert!(!resource_uri_matches_template(
8171 "file:///a/b",
8172 "file:///{id}"
8173 ));
8174
8175 // `{+path}` is a reserved expansion, so it may.
8176 assert!(resource_uri_matches_template(
8177 "file:///a/b/c",
8178 "file:///{+path}"
8179 ));
8180
8181 // An operator this subset does not implement, and a template that never
8182 // closes its expression, both stay uncallable rather than over-matching.
8183 assert!(!resource_uri_matches_template("x", "x{?query}"));
8184 assert!(!resource_uri_matches_template("x", "x{id"));
8185
8186 // The compile happens once per template and is reused.
8187 let first = compiled_resource_template("file:///{path}").expect("template compiles");
8188 let second = compiled_resource_template("file:///{path}").expect("template compiles");
8189 assert!(Arc::ptr_eq(&first, &second));
8190 assert!(compiled_resource_template("x{?query}").is_none());
8191 }
8192
8193 struct DeadTransport;
8194
8195 #[async_trait::async_trait]
8196 impl McpTransport for DeadTransport {
8197 async fn send(&mut self, _msg: Vec<u8>) -> Result<()> {
8198 Ok(())
8199 }
8200
8201 async fn recv(&mut self) -> Result<Vec<u8>> {
8202 Ok(Vec::new())
8203 }
8204
8205 fn probe_dead(&self) -> bool {
8206 true
8207 }
8208 }
8209
8210 fn supervised_pool(name: &str) -> McpPool {
8211 let mut servers = HashMap::new();
8212 servers.insert(name.to_string(), test_server_config());
8213 McpPool::new(McpConfig {
8214 timeouts: McpTimeouts::default(),
8215 servers,
8216 })
8217 }
8218
8219 /// #6187: a dead connection is planned for reconnect, and a failed attempt
8220 /// reports the death once with the diagnosis.
8221 #[test]
8222 fn supervisor_plans_dead_connection_and_reports_failed_reconnect() {
8223 let mut pool = supervised_pool("alpha");
8224 let mut connection = test_connection(Box::new(DeadTransport));
8225 connection.name = "alpha".to_string();
8226 pool.connections.insert("alpha".to_string(), connection);
8227
8228 let plan = pool.plan_supervision();
8229 assert_eq!(plan.due.len(), 1);
8230 assert_eq!(plan.due[0].name, "alpha");
8231 assert!(plan.due[0].fresh_death);
8232 assert!(plan.recovered.is_empty());
8233
8234 let update = pool.resolve_supervision_attempt(
8235 "alpha",
8236 true,
8237 Err(anyhow::anyhow!("connection reset by peer")),
8238 );
8239 assert_eq!(update.died.len(), 1);
8240 assert!(update.died[0].1.contains("connection reset"));
8241 assert!(update.failed.is_empty() && update.recovered.is_empty());
8242
8243 // The failure bought a cooldown: the next sweep attempts nothing and
8244 // reports nothing new.
8245 let plan = pool.plan_supervision();
8246 assert!(plan.due.is_empty());
8247 assert!(plan.recovered.is_empty() && plan.parked.is_empty());
8248 }
8249
8250 /// #6187: recovery is reported on the transition back to alive.
8251 #[test]
8252 fn supervisor_reports_recovery_on_transition() {
8253 let mut pool = supervised_pool("alpha");
8254 let mut dead = test_connection(Box::new(DeadTransport));
8255 dead.name = "alpha".to_string();
8256 pool.connections.insert("alpha".to_string(), dead);
8257
8258 let plan = pool.plan_supervision();
8259 assert_eq!(plan.due.len(), 1);
8260 let update = pool.resolve_supervision_attempt("alpha", true, Err(anyhow::anyhow!("boom")));
8261 assert_eq!(update.died.len(), 1);
8262
8263 // The transport reads alive again (a flapping probe, or a connection
8264 // restored outside the store path): the next sweep reports recovery.
8265 let mut live = test_connection(Box::new(DropCountingTransportForSupervision));
8266 live.name = "alpha".to_string();
8267 pool.connections.insert("alpha".to_string(), live);
8268 let plan = pool.plan_supervision();
8269 assert_eq!(plan.recovered, vec!["alpha".to_string()]);
8270 assert!(plan.due.is_empty());
8271
8272 // Reported once: the sweep after is silent.
8273 let plan = pool.plan_supervision();
8274 assert!(plan.recovered.is_empty() && plan.due.is_empty());
8275 }
8276
8277 struct DropCountingTransportForSupervision;
8278
8279 #[async_trait::async_trait]
8280 impl McpTransport for DropCountingTransportForSupervision {
8281 async fn send(&mut self, _msg: Vec<u8>) -> Result<()> {
8282 Ok(())
8283 }
8284
8285 async fn recv(&mut self) -> Result<Vec<u8>> {
8286 Ok(Vec::new())
8287 }
8288 }
8289
8290 /// #6187: five consecutive failures park the server — no more auto attempts
8291 /// until an explicit retry — and the park is reported once.
8292 #[test]
8293 fn supervisor_parks_after_repeated_failures() {
8294 let mut pool = supervised_pool("alpha");
8295 let mut dead = test_connection(Box::new(DeadTransport));
8296 dead.name = "alpha".to_string();
8297 pool.connections.insert("alpha".to_string(), dead);
8298
8299 for attempt in 0..5 {
8300 let update = pool.resolve_supervision_attempt(
8301 "alpha",
8302 attempt == 0,
8303 Err(anyhow::anyhow!("refused")),
8304 );
8305 if attempt < 4 {
8306 assert!(update.parked.is_empty(), "parks on the fifth failure");
8307 } else {
8308 assert_eq!(update.parked, vec!["alpha".to_string()]);
8309 }
8310 }
8311 // Parked: the plan attempts nothing further.
8312 let plan = pool.plan_supervision();
8313 assert!(plan.due.is_empty());
8314
8315 // A stored-ready connection clears the park.
8316 let mut live = test_connection(Box::new(DropCountingTransportForSupervision));
8317 live.name = "alpha".to_string();
8318 live.catalog_generation = pool.current_catalog_generation();
8319 pool.store_ready_connection("alpha".to_string(), live)
8320 .expect("stores");
8321 assert!(!pool.supervised_parked.contains("alpha"));
8322 assert!(!pool.supervised_dead.contains("alpha"));
8323 }
8324
8325 /// #6187: an explicit retry restarts supervision even when the retry itself
8326 /// fails — the user asked, so the park and dead mark clear.
8327 #[tokio::test]
8328 async fn manual_retry_clears_supervision_marks() {
8329 let mut pool = supervised_pool("alpha");
8330 pool.supervised_dead.insert("alpha".to_string());
8331 pool.supervised_parked.insert("alpha".to_string());
8332 // `mock` is not a real binary, so the retry fails; the marks still clear.
8333 let _ = pool.retry_connection("alpha").await;
8334 assert!(!pool.supervised_dead.contains("alpha"));
8335 assert!(!pool.supervised_parked.contains("alpha"));
8336 }
8337
8338 /// #6187: tool-call retry covers a dead pipe/socket, not just stale sessions.
8339 #[test]
8340 fn retriable_call_error_covers_closed_transports() {
8341 use super::wire::is_retriable_mcp_call_error;
8342 assert!(is_retriable_mcp_call_error(&anyhow::anyhow!(
8343 "MCP session expired"
8344 )));
8345 assert!(is_retriable_mcp_call_error(&anyhow::anyhow!(
8346 "connection reset by peer"
8347 )));
8348 assert!(is_retriable_mcp_call_error(&anyhow::anyhow!(
8349 "Stdio transport closed"
8350 )));
8351 assert!(!is_retriable_mcp_call_error(&anyhow::anyhow!(
8352 "tool returned an application error"
8353 )));
8354 }
8355
8356 // Executed both as an ordinary no-op test and as an isolated OS-process worker.
8357 #[test]
8358 fn mcp_transaction_child_worker() {
8359 let Some(path) = std::env::var_os("CW_MCP_TRANSACTION_TEST_PATH") else {
8360 return;
8361 };
8362 let path = PathBuf::from(path);
8363 let mode = std::env::var("CW_MCP_TRANSACTION_TEST_MODE").unwrap();
8364 if mode == "init" {
8365 init_config(&path, false).unwrap();
8366 return;
8367 }
8368 mutate_config(&path, None, |cfg| {
8369 if mode == "hold" {
8370 fs::write(path.with_extension("entered"), b"ready")?;
8371 let deadline = std::time::Instant::now() + Duration::from_secs(10);
8372 while !path.with_extension("release").exists() {
8373 anyhow::ensure!(
8374 std::time::Instant::now() < deadline,
8375 "fixture release timed out"
8376 );
8377 std::thread::sleep(Duration::from_millis(5));
8378 }
8379 }
8380 let server: McpServerConfig =
8381 serde_json::from_value(serde_json::json!({"command":"fixture-command"}))?;
8382 cfg.servers.insert(mode.clone(), server);
8383 Ok(())
8384 })
8385 .unwrap();
8386 }
8387
8388 fn mcp_transaction_spawn_worker(path: &Path, mode: &str) -> std::process::Child {
8389 std::process::Command::new(std::env::current_exe().unwrap())
8390 .args([
8391 "--exact",
8392 "mcp::tests::mcp_transaction_child_worker",
8393 "--nocapture",
8394 ])
8395 .env("CW_MCP_TRANSACTION_TEST_PATH", path)
8396 .env("CW_MCP_TRANSACTION_TEST_MODE", mode)
8397 .stdin(std::process::Stdio::null())
8398 .stdout(std::process::Stdio::piped())
8399 .stderr(std::process::Stdio::piped())
8400 .spawn()
8401 .unwrap()
8402 }
8403
8404 #[test]
8405 fn mcp_transaction_independent_process_writers_and_init_preserve_updates() {
8406 for mode in ["second", "init"] {
8407 let root = tempfile::tempdir().unwrap();
8408 let path = root.path().join("mcp.json");
8409 let first = mcp_transaction_spawn_worker(&path, "hold");
8410 let deadline = std::time::Instant::now() + Duration::from_secs(5);
8411 while !path.with_extension("entered").exists() {
8412 assert!(
8413 std::time::Instant::now() < deadline,
8414 "first worker did not acquire lock"
8415 );
8416 std::thread::sleep(Duration::from_millis(5));
8417 }
8418 // On Unix the competing process uses an alias of the same directory.
8419 #[cfg(unix)]
8420 let other_path = {
8421 let alias = root.path().join("alias");
8422 std::os::unix::fs::symlink(root.path(), &alias).unwrap();
8423 alias.join("mcp.json")
8424 };
8425 #[cfg(not(unix))]
8426 let other_path = path.clone();
8427 let mut second = mcp_transaction_spawn_worker(&other_path, mode);
8428 std::thread::sleep(Duration::from_millis(40));
8429 assert!(
8430 second.try_wait().unwrap().is_none(),
8431 "second writer must wait for shared lock"
8432 );
8433 fs::write(path.with_extension("release"), b"go").unwrap();
8434 for child in [first, second] {
8435 let result = child.wait_with_output().unwrap();
8436 assert!(
8437 result.status.success(),
8438 "{}",
8439 String::from_utf8_lossy(&result.stderr)
8440 );
8441 }
8442 let cfg = load_config(&path).unwrap();
8443 assert!(cfg.servers.contains_key("hold"));
8444 if mode == "second" {
8445 assert!(cfg.servers.contains_key("second"));
8446 }
8447 assert!(
8448 !cfg.servers.contains_key("example"),
8449 "init must not overwrite a concurrent add"
8450 );
8451 }
8452 }
8453
8454 #[test]
8455 fn mcp_transaction_preserves_unknown_fields_alias_and_rejects_stale_revision() {
8456 let root = tempfile::tempdir().unwrap();
8457 let path = root.path().join("mcp.json");
8458 fs::write(&path, r#"{"owner_note":{"keep":true},"timeouts":{"connect_timeout":10,"custom":42},"mcpServers":{"one":{"command":"one","extension":{"keep":1}}}}"#).unwrap();
8459 let before = read_config_revision(&path).unwrap();
8460 let (_, after) = mutate_config(&path, Some(&before), |cfg| {
8461 cfg.servers.get_mut("one").unwrap().enabled = false;
8462 Ok(())
8463 })
8464 .unwrap();
8465 assert_ne!(before, after);
8466 let raw: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
8467 assert_eq!(raw["owner_note"]["keep"], true);
8468 assert_eq!(raw["timeouts"]["custom"], 42);
8469 assert_eq!(raw["mcpServers"]["one"]["extension"]["keep"], 1);
8470 assert!(raw.get("servers").is_none());
8471 let bytes = fs::read(&path).unwrap();
8472 let err = mutate_config(&path, Some(&before), |cfg| {
8473 cfg.servers.clear();
8474 Ok(())
8475 })
8476 .unwrap_err();
8477 assert!(err.is::<McpRevisionConflict>());
8478 assert_eq!(fs::read(&path).unwrap(), bytes);
8479 assert_eq!(
8480 mutate_config(&path, Some(&after), |_| Ok(())).unwrap().1,
8481 after
8482 );
8483 assert_eq!(
8484 fs::read(&path).unwrap(),
8485 bytes,
8486 "no-op must not rewrite the document"
8487 );
8488 }
8489
8490 #[test]
8491 fn mcp_transaction_fails_closed_for_malformed_document_and_symlink() {
8492 let root = tempfile::tempdir().unwrap();
8493 let path = root.path().join("mcp.json");
8494 fs::write(&path, "{private-malformed-fixture").unwrap();
8495 let err = mutate_config(&path, None, |_| Ok(())).unwrap_err();
8496 assert!(!err.to_string().contains("private-malformed-fixture"));
8497 assert!(init_config(&path, true).is_err());
8498 assert_eq!(
8499 fs::read_to_string(&path).unwrap(),
8500 "{private-malformed-fixture"
8501 );
8502 #[cfg(unix)]
8503 {
8504 let link = root.path().join("linked.json");
8505 std::os::unix::fs::symlink(&path, &link).unwrap();
8506 assert!(mutate_config(&link, None, |_| Ok(())).is_err());
8507 assert!(init_config(&link, true).is_err());
8508 }
8509 }
8510
8510 lines RUST