1use std::collections::{HashMap, HashSet};
14use std::sync::{Arc, Mutex as StdMutex, PoisonError};
15
16use leviath_mcp::{MCPServerConfig, ToolDiscovery, ToolExecutor};
17use leviath_providers::Tool;
18use tokio::sync::Mutex;
19
20pub struct McpPool {
23 shared: Arc<Mutex<ToolExecutor>>,
25 reserved: HashSet<String>,
28 connected: StdMutex<HashMap<String, Vec<Tool>>>,
32 credential_store: leviath_core::CredentialStoreKind,
36 allow_env_vars: Vec<String>,
39 leases: StdMutex<LeaseTable>,
43 idle_disconnect: std::time::Duration,
49}
50
51#[derive(Default)]
53struct LeaseTable {
54 servers: HashMap<String, ServerLease>,
56 runs: HashMap<String, Vec<String>>,
58 global: HashSet<String>,
62}
63
64struct ServerLease {
66 name: String,
68 holders: HashSet<String>,
70 generation: u64,
73}
74
75fn signature(config: &MCPServerConfig) -> String {
79 serde_json::to_string(config).unwrap_or_default()
82}
83
84pub const DEFAULT_MCP_IDLE_DISCONNECT_SECS: u64 = 60;
90
91impl McpPool {
92 pub fn new(shared: Arc<Mutex<ToolExecutor>>, reserved: HashSet<String>) -> Self {
94 Self {
95 shared,
96 reserved,
97 connected: StdMutex::new(HashMap::new()),
98 credential_store: leviath_core::CredentialStoreKind::default(),
99 allow_env_vars: Vec::new(),
100 leases: StdMutex::new(LeaseTable::default()),
101 idle_disconnect: std::time::Duration::from_secs(DEFAULT_MCP_IDLE_DISCONNECT_SECS),
102 }
103 }
104
105 pub fn with_idle_disconnect_secs(mut self, secs: u64) -> Self {
108 self.idle_disconnect = std::time::Duration::from_secs(secs);
109 self
110 }
111
112 pub fn with_env_allowlist(mut self, allow: Vec<String>) -> Self {
114 self.allow_env_vars = allow;
115 self
116 }
117
118 pub fn with_credential_store(mut self, kind: leviath_core::CredentialStoreKind) -> Self {
120 self.credential_store = kind;
121 self
122 }
123
124 pub fn for_daemon(
129 shared_mcp: Arc<Mutex<ToolExecutor>>,
130 config_servers: &[MCPServerConfig],
131 ) -> Arc<Self> {
132 Self::for_daemon_with(
133 shared_mcp,
134 config_servers,
135 leviath_core::CredentialStoreKind::default(),
136 Vec::new(),
137 DEFAULT_MCP_IDLE_DISCONNECT_SECS,
138 )
139 }
140
141 pub fn for_daemon_with(
149 shared_mcp: Arc<Mutex<ToolExecutor>>,
150 config_servers: &[MCPServerConfig],
151 credential_store: leviath_core::CredentialStoreKind,
152 allow_env_vars: Vec<String>,
153 idle_disconnect_secs: u64,
154 ) -> Arc<Self> {
155 let mut reserved: HashSet<String> =
156 leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(std::env::temp_dir()))
157 .names()
158 .into_iter()
159 .collect();
160 reserved.extend(leviath_tools::BuiltinTools::subagent_tool_names());
161 let pool = Arc::new(
162 Self::new(shared_mcp, reserved)
163 .with_credential_store(credential_store)
164 .with_env_allowlist(allow_env_vars)
165 .with_idle_disconnect_secs(idle_disconnect_secs),
166 );
167 for server in config_servers {
168 pool.seed(server, Vec::new());
169 }
170 pool
171 }
172
173 pub fn seed(&self, config: &MCPServerConfig, defs: Vec<Tool>) {
178 let sig = signature(config);
179 self.leases
180 .lock()
181 .unwrap_or_else(PoisonError::into_inner)
182 .global
183 .insert(sig.clone());
184 self.connected
185 .lock()
186 .unwrap_or_else(PoisonError::into_inner)
187 .insert(sig, defs);
188 }
189
190 pub fn lease_blueprint(&self, blueprint_path: &str, run_id: &str) {
200 let Ok(toml) = std::fs::read_to_string(blueprint_path) else {
201 return;
202 };
203 let mut table = self.leases.lock().unwrap_or_else(PoisonError::into_inner);
204 for server in parse_blueprint_mcp_servers(&toml) {
205 let sig = signature(&server);
206 if table.global.contains(&sig) {
207 continue;
208 }
209 let entry = table
210 .servers
211 .entry(sig.clone())
212 .or_insert_with(|| ServerLease {
213 name: server.name.clone(),
214 holders: HashSet::new(),
215 generation: 0,
216 });
217 entry.generation += 1;
218 if entry.holders.insert(run_id.to_string()) {
219 table.runs.entry(run_id.to_string()).or_default().push(sig);
220 }
221 }
222 }
223
224 pub fn release_run(self: &Arc<Self>, run_id: &str) {
229 let zeroed = self.release_run_bookkeeping(run_id);
230 if self.idle_disconnect.is_zero() {
231 return;
232 }
233 let Ok(handle) = tokio::runtime::Handle::try_current() else {
234 return; };
236 for (sig, name, generation) in zeroed {
237 let pool = Arc::clone(self);
238 handle.spawn(async move {
239 tokio::time::sleep(pool.idle_disconnect).await;
240 pool.disconnect_if_still_idle(&sig, &name, generation).await;
241 });
242 }
243 }
244
245 fn release_run_bookkeeping(&self, run_id: &str) -> Vec<(String, String, u64)> {
249 let mut table = self.leases.lock().unwrap_or_else(PoisonError::into_inner);
250 let Some(sigs) = table.runs.remove(run_id) else {
251 return Vec::new();
252 };
253 let mut zeroed = Vec::new();
254 for sig in sigs {
255 let Some(entry) = table.servers.get_mut(&sig) else {
256 continue;
257 };
258 entry.holders.remove(run_id);
259 entry.generation += 1;
260 if entry.holders.is_empty() {
261 zeroed.push((sig.clone(), entry.name.clone(), entry.generation));
262 }
263 }
264 zeroed
265 }
266
267 pub async fn disconnect_if_still_idle(&self, sig: &str, name: &str, generation: u64) -> bool {
272 {
273 let mut table = self.leases.lock().unwrap_or_else(PoisonError::into_inner);
274 let still_idle = table
275 .servers
276 .get(sig)
277 .is_some_and(|e| e.holders.is_empty() && e.generation == generation);
278 if !still_idle {
279 return false;
280 }
281 table.servers.remove(sig);
282 }
283 self.connected
284 .lock()
285 .unwrap_or_else(PoisonError::into_inner)
286 .remove(sig);
287 let client = self.shared.lock().await.remove_client(name);
288 match client {
289 Some(mut client) => {
290 let _ = client.shutdown().await;
291 tracing::info!(server = %name, "disconnected idle per-agent MCP server");
292 true
293 }
294 None => false,
295 }
296 }
297
298 #[cfg(test)]
300 fn leased_holders(&self, config: &MCPServerConfig) -> usize {
301 self.leases
302 .lock()
303 .unwrap_or_else(PoisonError::into_inner)
304 .servers
305 .get(&signature(config))
306 .map_or(0, |e| e.holders.len())
307 }
308
309 pub async fn ensure(&self, config: &MCPServerConfig) -> Vec<Tool> {
314 let sig = signature(config);
315 if let Some(defs) = self
316 .connected
317 .lock()
318 .unwrap_or_else(PoisonError::into_inner)
319 .get(&sig)
320 {
321 return defs.clone();
322 }
323 let oauth = leviath_mcp::OAuthClient::new();
327 let store_path = leviath_mcp::AuthStore::default_path();
328 let credentials = crate::tools::credential_store_or_warn(crate::credentials::store_for(
329 self.credential_store,
330 ));
331 let auth = match crate::tools::resolve_bearer(
332 &oauth,
333 &config.name,
334 store_path.as_deref(),
335 crate::tools::unix_now_secs(),
336 credentials.as_deref(),
337 )
338 .await
339 {
340 Ok(header) => header,
341 Err(e) => {
342 let err = e.to_string();
343 tracing::warn!(server = %config.name, error = %err, "MCP auth unavailable - skipping");
344 return Vec::new();
345 }
346 };
347 let auth_was_resolved = auth.is_some();
348 let mut discovery = ToolDiscovery::new();
349 match discovery
350 .discover_from_config_with_auth(config, auth, &self.allow_env_vars)
351 .await
352 {
353 Ok((_metas, mut client)) => {
354 if auth_was_resolved && let Some(path) = store_path.clone() {
357 client.set_refresher(std::sync::Arc::new(
358 leviath_mcp::StoredTokenRefresher::new(config.name.clone(), path),
359 ));
360 }
361 let advertised = self.shared.lock().await.add_client_advertised(
362 config.name.clone(),
363 client,
364 &self.reserved,
365 );
366 let defs: Vec<Tool> = advertised
367 .into_iter()
368 .map(|m| Tool {
369 name: m.name,
370 description: m.description,
371 parameters: m.schema,
372 })
373 .collect();
374 self.connected
375 .lock()
376 .unwrap_or_else(PoisonError::into_inner)
377 .insert(sig, defs.clone());
378 let count = defs.len();
381 tracing::info!(server = %config.name, tools = count, "connected per-agent MCP server");
382 defs
383 }
384 Err(e) => {
385 let err = e.to_string();
386 tracing::warn!(server = %config.name, error = %err, "failed to connect per-agent MCP server");
387 Vec::new()
388 }
389 }
390 }
391
392 pub async fn ensure_all(self: Arc<Self>, servers: Vec<MCPServerConfig>) {
396 for server in servers {
397 self.ensure(&server).await;
398 }
399 }
400
401 pub async fn warm_recovered(&self, runs_dir: &std::path::Path) {
408 use leviath_core::run_meta::RunStatus;
409 let Ok(entries) = std::fs::read_dir(runs_dir) else {
410 return;
411 };
412 let mut paths: Vec<String> = Vec::new();
413 for entry in entries.flatten() {
414 let Ok(text) = std::fs::read_to_string(entry.path().join("meta.json")) else {
415 continue;
416 };
417 let Ok(meta) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text) else {
418 continue;
419 };
420 if matches!(
422 meta.status,
423 RunStatus::Starting | RunStatus::Running | RunStatus::WaitingInput
424 ) {
425 paths.push(meta.agent_path);
426 }
427 }
428 for path in paths {
429 if let Ok(toml) = std::fs::read_to_string(&path) {
430 for server in parse_blueprint_mcp_servers(&toml) {
431 self.ensure(&server).await;
432 }
433 }
434 }
435 }
436
437 pub fn cached_defs_for(&self, configs: &[MCPServerConfig]) -> Vec<Tool> {
441 let cache = self
442 .connected
443 .lock()
444 .unwrap_or_else(PoisonError::into_inner);
445 configs
446 .iter()
447 .filter_map(|c| cache.get(&signature(c)))
448 .flatten()
449 .cloned()
450 .collect()
451 }
452}
453
454pub fn parse_blueprint_mcp_servers(manifest_toml: &str) -> Vec<MCPServerConfig> {
459 let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
465 return Vec::new();
466 };
467 let Some(array) = value.get("mcp_servers").and_then(|v| v.as_array()) else {
468 return Vec::new();
469 };
470 let mut out = Vec::new();
471 for entry in array {
472 match entry.clone().try_into::<MCPServerConfig>() {
473 Ok(cfg) => out.push(cfg),
474 Err(e) => tracing::warn!(error = %e, "skipping malformed [[mcp_servers]] entry"),
475 }
476 }
477 out
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::test_support::with_tracing;
484
485 const STUB: &str = r#"
488import sys, json
489def respond(i, r):
490 sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":i,"result":r})+"\n"); sys.stdout.flush()
491for line in sys.stdin:
492 line=line.strip()
493 if not line: continue
494 req=json.loads(line); m=req.get("method",""); i=req.get("id")
495 if m=="initialize": respond(i,{"capabilities":{"tools":{"listChanged":True}},"protocolVersion":"2024-11-05"})
496 elif m=="notifications/initialized": pass
497 elif m=="tools/list": respond(i,{"tools":[{"name":"echo","description":"e","inputSchema":{"type":"object","properties":{}}}]})
498 elif m=="tools/call": respond(i,{"content":[{"type":"text","text":"ok"}],"isError":False})
499 else: respond(i,{})
500"#;
501
502 fn stub_config(name: &str) -> MCPServerConfig {
503 MCPServerConfig::stdio(name, "python3", vec!["-c".to_string(), STUB.to_string()])
504 }
505
506 fn pool() -> McpPool {
507 McpPool::new(Arc::new(Mutex::new(ToolExecutor::new())), HashSet::new())
508 }
509
510 async fn with_temp_home<F, Fut, T>(body: F) -> T
513 where
514 F: FnOnce() -> Fut,
515 Fut: std::future::Future<Output = T>,
516 {
517 let dir = tempfile::tempdir().unwrap();
518 temp_env::async_with_vars(
519 [("LEVIATH_HOME", Some(dir.path().to_str().unwrap()))],
520 body(),
521 )
522 .await
523 }
524
525 #[tokio::test]
526 async fn ensure_connects_and_caches_by_signature() {
527 with_tracing(|| {});
528 with_temp_home(|| async {
529 let pool = pool();
530 let cfg = stub_config("s");
531 let defs = pool.ensure(&cfg).await;
533 assert_eq!(defs.len(), 1);
534 assert_eq!(defs[0].name, "echo");
535 let again = pool.ensure(&cfg).await;
537 assert_eq!(again.len(), 1);
538 })
539 .await;
540 }
541
542 #[tokio::test]
543 async fn ensure_all_connects_each_server() {
544 with_tracing(|| {});
545 with_temp_home(|| async {
546 let pool = Arc::new(pool());
547 let cfg = stub_config("s");
548 pool.clone().ensure_all(vec![cfg.clone()]).await;
549 assert_eq!(pool.cached_defs_for(std::slice::from_ref(&cfg)).len(), 1);
550 })
551 .await;
552 }
553
554 #[tokio::test]
555 async fn ensure_failure_returns_empty_and_is_not_cached() {
556 with_tracing(|| {});
557 with_temp_home(|| async {
558 let pool = pool();
559 let bad = MCPServerConfig::stdio("bad", "definitely-not-a-binary-xyz", vec![]);
560 assert!(pool.ensure(&bad).await.is_empty());
561 assert!(pool.cached_defs_for(std::slice::from_ref(&bad)).is_empty());
563 })
564 .await;
565 }
566
567 async fn mock_http_mcp_server() -> String {
570 use axum::response::IntoResponse;
571 use axum::routing::post;
572 use axum::{Json, Router};
573 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
574 let base = format!("http://{}", listener.local_addr().unwrap());
575 let app = Router::new().route(
576 "/mcp",
577 post(|body: String| async move {
578 let req: serde_json::Value = serde_json::from_str(&body).unwrap();
579 let id = req.get("id").cloned().unwrap_or(serde_json::json!(1));
580 let result = match req.get("method").and_then(|m| m.as_str()) {
581 Some("initialize") => {
582 serde_json::json!({"capabilities": {}, "protocolVersion": "2024-11-05"})
583 }
584 Some("tools/list") => {
585 serde_json::json!({"tools": [{"name": "remote_tool", "inputSchema": {}}]})
586 }
587 _ => serde_json::json!({}),
588 };
589 (
590 [(axum::http::header::CONTENT_TYPE, "application/json")],
591 Json(serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result}))
592 .into_response()
593 .into_body(),
594 )
595 .into_response()
596 }),
597 );
598 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
599 listener, app,
600 )));
601 base
602 }
603
604 #[tokio::test]
605 async fn ensure_resolves_oauth_bearer_and_attaches_refresher() {
606 with_tracing(|| {});
608 let base = mock_http_mcp_server().await;
609 let defs = with_temp_home(|| async {
610 let mut store = leviath_mcp::AuthStore::default();
611 store.set(
612 "remote",
613 leviath_mcp::ServerAuth {
614 access_token: "live-token".to_string(),
615 expires_at: u64::MAX,
616 ..Default::default()
617 },
618 );
619 store
620 .save(&leviath_mcp::AuthStore::default_path().unwrap())
621 .unwrap();
622 let pool = pool();
623 pool.ensure(&MCPServerConfig::http("remote", format!("{base}/mcp")))
624 .await
625 })
626 .await;
627 assert_eq!(defs.len(), 1);
628 assert_eq!(defs[0].name, "remote_tool");
629 }
630
631 #[tokio::test]
632 async fn ensure_returns_empty_when_bearer_cannot_be_resolved() {
633 with_tracing(|| {});
636 let defs = with_temp_home(|| async {
637 let mut store = leviath_mcp::AuthStore::default();
638 store.set(
639 "remote",
640 leviath_mcp::ServerAuth {
641 token_endpoint: "http://127.0.0.1:1/token".to_string(),
642 access_token: "expired".to_string(),
643 refresh_token: Some("good".to_string()),
644 expires_at: 1,
645 ..Default::default()
646 },
647 );
648 store
649 .save(&leviath_mcp::AuthStore::default_path().unwrap())
650 .unwrap();
651 let pool = pool();
652 pool.ensure(&MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"))
653 .await
654 })
655 .await;
656 assert!(defs.is_empty());
657 }
658
659 #[test]
660 fn seed_then_cached_defs_for_reads_without_connecting() {
661 let pool = pool();
662 let cfg = stub_config("seeded");
663 pool.seed(
664 &cfg,
665 vec![Tool {
666 name: "seed_tool".into(),
667 description: String::new(),
668 parameters: serde_json::json!({}),
669 }],
670 );
671 let names: Vec<String> = pool
672 .cached_defs_for(std::slice::from_ref(&cfg))
673 .into_iter()
674 .map(|t| t.name)
675 .collect();
676 assert_eq!(names, vec!["seed_tool".to_string()]);
677 }
678
679 fn stub_py() -> (tempfile::TempDir, std::path::PathBuf) {
681 let dir = tempfile::tempdir().unwrap();
682 let path = dir.path().join("stub.py");
683 std::fs::write(&path, STUB).unwrap();
684 (dir, path)
685 }
686
687 fn blueprint_declaring(server: &str, stub: &std::path::Path) -> (tempfile::TempDir, String) {
690 let dir = tempfile::tempdir().unwrap();
691 let manifest = dir.path().join("agent.leviath");
692 std::fs::write(
693 &manifest,
694 format!(
695 "[agent]\nname = \"a\"\n\n[[mcp_servers]]\nname = \"{server}\"\ncommand = \"python3\"\nargs = ['{}']\n",
698 stub.to_string_lossy()
699 ),
700 )
701 .unwrap();
702 (dir, manifest.to_string_lossy().to_string())
703 }
704
705 fn write_run_meta(
706 runs_dir: &std::path::Path,
707 run_id: &str,
708 agent_path: &str,
709 status: leviath_core::run_meta::RunStatus,
710 ) {
711 let dir = runs_dir.join(run_id);
712 std::fs::create_dir_all(&dir).unwrap();
713 let mut meta = leviath_core::run_meta::RunMeta::new(
714 run_id.to_string(),
715 "a".to_string(),
716 agent_path.to_string(),
717 "t".to_string(),
718 None,
719 std::env::temp_dir().to_string_lossy().to_string(),
720 1,
721 );
722 meta.status = status;
723 std::fs::write(dir.join("meta.json"), serde_json::to_string(&meta).unwrap()).unwrap();
724 }
725
726 #[tokio::test]
727 async fn warm_recovered_connects_only_nonterminal_run_blueprints() {
728 use leviath_core::run_meta::RunStatus;
729 with_tracing(|| {});
730 with_temp_home(|| async {
731 let (_sd, stub) = stub_py();
732 let (_bd_live, live_bp) = blueprint_declaring("liveserver", &stub);
733 let (_bd_done, done_bp) = blueprint_declaring("doneserver", &stub);
734 let runs = tempfile::tempdir().unwrap();
735 write_run_meta(runs.path(), "run-live", &live_bp, RunStatus::Running);
736 write_run_meta(runs.path(), "run-done", &done_bp, RunStatus::Complete);
737 write_run_meta(
740 runs.path(),
741 "run-gone",
742 "/no/such/agent.leviath",
743 RunStatus::WaitingInput,
744 );
745 std::fs::create_dir_all(runs.path().join("junk")).unwrap();
747 std::fs::create_dir_all(runs.path().join("garbled")).unwrap();
749 std::fs::write(runs.path().join("garbled/meta.json"), "not json {{").unwrap();
750
751 let pool = pool();
752 pool.warm_recovered(runs.path()).await;
753
754 let live_servers =
756 parse_blueprint_mcp_servers(&std::fs::read_to_string(&live_bp).unwrap());
757 let done_servers =
758 parse_blueprint_mcp_servers(&std::fs::read_to_string(&done_bp).unwrap());
759 assert_eq!(pool.cached_defs_for(&live_servers).len(), 1);
760 assert!(pool.cached_defs_for(&done_servers).is_empty());
761 })
762 .await;
763 }
764
765 #[tokio::test]
766 async fn warm_recovered_missing_runs_dir_is_noop() {
767 let pool = pool();
768 pool.warm_recovered(std::path::Path::new("/no/such/runs"))
769 .await;
770 }
771
772 #[tokio::test]
776 async fn leases_hold_a_server_and_the_last_release_disconnects_it() {
777 with_tracing(|| {});
778 with_temp_home(|| async {
779 let (_sd, stub) = stub_py();
780 let (_bd, bp) = blueprint_declaring("leaseserver", &stub);
781 let pool = Arc::new(pool().with_idle_disconnect_secs(1));
782 let servers = parse_blueprint_mcp_servers(&std::fs::read_to_string(&bp).unwrap());
783 let cfg = &servers[0];
784 assert_eq!(pool.ensure(cfg).await.len(), 1);
786
787 pool.lease_blueprint(&bp, "run-a");
788 pool.lease_blueprint(&bp, "run-b");
789 pool.lease_blueprint(&bp, "run-b");
791 assert_eq!(pool.leased_holders(cfg), 2);
792
793 pool.release_run("run-a");
796 assert_eq!(pool.leased_holders(cfg), 1);
797 assert!(!pool.cached_defs_for(&servers).is_empty());
798
799 let zeroed = pool.release_run_bookkeeping("run-b");
802 assert_eq!(zeroed.len(), 1);
803 let (sig, name, generation) = zeroed[0].clone();
804 assert!(pool.disconnect_if_still_idle(&sig, &name, generation).await);
805 assert!(pool.cached_defs_for(&servers).is_empty());
807 assert!(!pool.disconnect_if_still_idle(&sig, &name, generation).await);
809 })
810 .await;
811 }
812
813 #[tokio::test]
816 async fn a_lease_during_the_grace_window_cancels_the_disconnect() {
817 with_tracing(|| {});
818 with_temp_home(|| async {
819 let (_sd, stub) = stub_py();
820 let (_bd, bp) = blueprint_declaring("graceserver", &stub);
821 let pool = Arc::new(pool().with_idle_disconnect_secs(1));
822 let servers = parse_blueprint_mcp_servers(&std::fs::read_to_string(&bp).unwrap());
823 let cfg = &servers[0];
824 assert_eq!(pool.ensure(cfg).await.len(), 1);
825
826 pool.lease_blueprint(&bp, "run-a");
827 let zeroed = pool.release_run_bookkeeping("run-a");
828 let (sig, name, generation) = zeroed[0].clone();
829 pool.lease_blueprint(&bp, "run-b");
831 assert!(
832 !pool.disconnect_if_still_idle(&sig, &name, generation).await,
833 "a stale generation must not tear down a re-leased server"
834 );
835 assert_eq!(pool.leased_holders(cfg), 1);
836 assert!(!pool.cached_defs_for(&servers).is_empty());
837 })
838 .await;
839 }
840
841 #[test]
845 fn release_run_without_a_runtime_is_bookkeeping_only() {
846 let pool = Arc::new(pool());
847 pool.release_run("no-runtime-run");
848 }
849
850 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
855 async fn release_run_schedules_the_grace_disconnect() {
856 with_tracing(|| {});
857 with_temp_home(|| async {
858 let (_sd, stub) = stub_py();
859 let (_bd, bp) = blueprint_declaring("timedserver", &stub);
860 let timed = Arc::new(pool().with_idle_disconnect_secs(1));
861 let servers = parse_blueprint_mcp_servers(&std::fs::read_to_string(&bp).unwrap());
862 assert_eq!(timed.ensure(&servers[0]).await.len(), 1);
863 timed.lease_blueprint(&bp, "run-a");
864 timed.release_run("run-a");
865 assert!(!timed.cached_defs_for(&servers).is_empty());
867 tokio::time::sleep(std::time::Duration::from_millis(2500)).await;
869 assert!(timed.cached_defs_for(&servers).is_empty());
870
871 let keeper = Arc::new(pool().with_idle_disconnect_secs(0));
873 assert_eq!(keeper.ensure(&servers[0]).await.len(), 1);
874 keeper.lease_blueprint(&bp, "run-b");
875 keeper.release_run("run-b");
876 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
877 assert!(!keeper.cached_defs_for(&servers).is_empty());
878 })
879 .await;
880 }
881
882 #[tokio::test]
886 async fn disconnect_without_a_client_and_a_dangling_lease_are_noops() {
887 with_tracing(|| {});
888 with_temp_home(|| async {
889 let (_sd, stub) = stub_py();
890 let (_bd, bp) = blueprint_declaring("neverconnected", &stub);
891 let pool = Arc::new(pool());
892 pool.lease_blueprint(&bp, "run-a");
894 let zeroed = pool.release_run_bookkeeping("run-a");
895 let (sig, name, generation) = zeroed[0].clone();
896 assert!(
897 !pool.disconnect_if_still_idle(&sig, &name, generation).await,
898 "no client to remove is a no-op, not an error"
899 );
900
901 pool.lease_blueprint(&bp, "run-b");
904 pool.leases
905 .lock()
906 .unwrap_or_else(PoisonError::into_inner)
907 .servers
908 .clear();
909 assert!(pool.release_run_bookkeeping("run-b").is_empty());
910 })
911 .await;
912 }
913
914 #[tokio::test]
918 async fn seeded_servers_are_exempt_and_bad_inputs_are_noops() {
919 with_tracing(|| {});
920 with_temp_home(|| async {
921 let (_sd, stub) = stub_py();
922 let (_bd, bp) = blueprint_declaring("globalserver", &stub);
923 let pool = Arc::new(pool());
924 let servers = parse_blueprint_mcp_servers(&std::fs::read_to_string(&bp).unwrap());
925 pool.seed(&servers[0], Vec::new());
926
927 pool.lease_blueprint(&bp, "run-a");
928 assert_eq!(pool.leased_holders(&servers[0]), 0, "global: no lease");
929 pool.release_run("run-a"); assert!(pool.release_run_bookkeeping("never-leased").is_empty());
931 pool.lease_blueprint("/no/such/agent.leviath", "run-b");
932 assert!(pool.release_run_bookkeeping("run-b").is_empty());
933 })
934 .await;
935 }
936
937 #[test]
938 fn for_daemon_reserves_core_names_and_seeds_globals() {
939 let global =
940 MCPServerConfig::stdio("g", "python3", vec!["-c".to_string(), "pass".to_string()]);
941 let pool = McpPool::for_daemon(
942 Arc::new(Mutex::new(ToolExecutor::new())),
943 std::slice::from_ref(&global),
944 );
945 assert!(
948 pool.cached_defs_for(std::slice::from_ref(&global))
949 .is_empty()
950 );
951 assert!(pool.reserved.contains("read_file"));
953 }
954
955 #[test]
956 fn parse_blueprint_mcp_servers_reads_array() {
957 let toml = r#"
958[agent]
959name = "x"
960[[mcp_servers]]
961name = "search"
962command = "leviath-search"
963args = ["--provider", "brave"]
964[[mcp_servers]]
965name = "http-one"
966url = "http://localhost:9/mcp"
967"#;
968 let servers = parse_blueprint_mcp_servers(toml);
969 assert_eq!(servers.len(), 2);
970 assert_eq!(servers[0].name, "search");
971 assert_eq!(servers[0].command.as_deref(), Some("leviath-search"));
972 assert_eq!(servers[1].url.as_deref(), Some("http://localhost:9/mcp"));
973 }
974
975 #[test]
976 fn parse_blueprint_mcp_servers_absent_or_malformed() {
977 assert!(parse_blueprint_mcp_servers("[agent]\nname='x'").is_empty());
979 assert!(parse_blueprint_mcp_servers("this is = = not toml").is_empty());
981 assert!(parse_blueprint_mcp_servers("mcp_servers = 5").is_empty());
983 with_tracing(|| {});
985 let servers = parse_blueprint_mcp_servers("[[mcp_servers]]\nname = 5\n");
986 assert!(servers.is_empty());
987 }
988}