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}
40
41fn signature(config: &MCPServerConfig) -> String {
45 serde_json::to_string(config).unwrap_or_default()
48}
49
50impl McpPool {
51 pub fn new(shared: Arc<Mutex<ToolExecutor>>, reserved: HashSet<String>) -> Self {
53 Self {
54 shared,
55 reserved,
56 connected: StdMutex::new(HashMap::new()),
57 credential_store: leviath_core::CredentialStoreKind::default(),
58 allow_env_vars: Vec::new(),
59 }
60 }
61
62 pub fn with_env_allowlist(mut self, allow: Vec<String>) -> Self {
64 self.allow_env_vars = allow;
65 self
66 }
67
68 pub fn with_credential_store(mut self, kind: leviath_core::CredentialStoreKind) -> Self {
70 self.credential_store = kind;
71 self
72 }
73
74 pub fn for_daemon(
79 shared_mcp: Arc<Mutex<ToolExecutor>>,
80 config_servers: &[MCPServerConfig],
81 ) -> Arc<Self> {
82 Self::for_daemon_with(
83 shared_mcp,
84 config_servers,
85 leviath_core::CredentialStoreKind::default(),
86 Vec::new(),
87 )
88 }
89
90 pub fn for_daemon_with(
98 shared_mcp: Arc<Mutex<ToolExecutor>>,
99 config_servers: &[MCPServerConfig],
100 credential_store: leviath_core::CredentialStoreKind,
101 allow_env_vars: Vec<String>,
102 ) -> Arc<Self> {
103 let mut reserved: HashSet<String> =
104 leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(std::env::temp_dir()))
105 .names()
106 .into_iter()
107 .collect();
108 reserved.extend(leviath_tools::BuiltinTools::subagent_tool_names());
109 let pool = Arc::new(
110 Self::new(shared_mcp, reserved)
111 .with_credential_store(credential_store)
112 .with_env_allowlist(allow_env_vars),
113 );
114 for server in config_servers {
115 pool.seed(server, Vec::new());
116 }
117 pool
118 }
119
120 pub fn seed(&self, config: &MCPServerConfig, defs: Vec<Tool>) {
123 self.connected
124 .lock()
125 .unwrap_or_else(PoisonError::into_inner)
126 .insert(signature(config), defs);
127 }
128
129 pub async fn ensure(&self, config: &MCPServerConfig) -> Vec<Tool> {
134 let sig = signature(config);
135 if let Some(defs) = self
136 .connected
137 .lock()
138 .unwrap_or_else(PoisonError::into_inner)
139 .get(&sig)
140 {
141 return defs.clone();
142 }
143 let oauth = leviath_mcp::OAuthClient::new();
147 let store_path = leviath_mcp::AuthStore::default_path();
148 let credentials = crate::tools::credential_store_or_warn(crate::credentials::store_for(
149 self.credential_store,
150 ));
151 let auth = match crate::tools::resolve_bearer(
152 &oauth,
153 &config.name,
154 store_path.as_deref(),
155 crate::tools::unix_now_secs(),
156 credentials.as_deref(),
157 )
158 .await
159 {
160 Ok(header) => header,
161 Err(e) => {
162 let err = e.to_string();
163 tracing::warn!(server = %config.name, error = %err, "MCP auth unavailable - skipping");
164 return Vec::new();
165 }
166 };
167 let auth_was_resolved = auth.is_some();
168 let mut discovery = ToolDiscovery::new();
169 match discovery
170 .discover_from_config_with_auth(config, auth, &self.allow_env_vars)
171 .await
172 {
173 Ok((_metas, mut client)) => {
174 if auth_was_resolved && let Some(path) = store_path.clone() {
177 client.set_refresher(std::sync::Arc::new(
178 leviath_mcp::StoredTokenRefresher::new(config.name.clone(), path),
179 ));
180 }
181 let advertised = self.shared.lock().await.add_client_advertised(
182 config.name.clone(),
183 client,
184 &self.reserved,
185 );
186 let defs: Vec<Tool> = advertised
187 .into_iter()
188 .map(|m| Tool {
189 name: m.name,
190 description: m.description,
191 parameters: m.schema,
192 })
193 .collect();
194 self.connected
195 .lock()
196 .unwrap_or_else(PoisonError::into_inner)
197 .insert(sig, defs.clone());
198 let count = defs.len();
201 tracing::info!(server = %config.name, tools = count, "connected per-agent MCP server");
202 defs
203 }
204 Err(e) => {
205 let err = e.to_string();
206 tracing::warn!(server = %config.name, error = %err, "failed to connect per-agent MCP server");
207 Vec::new()
208 }
209 }
210 }
211
212 pub async fn ensure_all(self: Arc<Self>, servers: Vec<MCPServerConfig>) {
216 for server in servers {
217 self.ensure(&server).await;
218 }
219 }
220
221 pub async fn warm_recovered(&self, runs_dir: &std::path::Path) {
228 use leviath_core::run_meta::RunStatus;
229 let Ok(entries) = std::fs::read_dir(runs_dir) else {
230 return;
231 };
232 let mut paths: Vec<String> = Vec::new();
233 for entry in entries.flatten() {
234 let Ok(text) = std::fs::read_to_string(entry.path().join("meta.json")) else {
235 continue;
236 };
237 let Ok(meta) = serde_json::from_str::<leviath_core::run_meta::RunMeta>(&text) else {
238 continue;
239 };
240 if matches!(
242 meta.status,
243 RunStatus::Starting | RunStatus::Running | RunStatus::WaitingInput
244 ) {
245 paths.push(meta.agent_path);
246 }
247 }
248 for path in paths {
249 if let Ok(toml) = std::fs::read_to_string(&path) {
250 for server in parse_blueprint_mcp_servers(&toml) {
251 self.ensure(&server).await;
252 }
253 }
254 }
255 }
256
257 pub fn cached_defs_for(&self, configs: &[MCPServerConfig]) -> Vec<Tool> {
261 let cache = self
262 .connected
263 .lock()
264 .unwrap_or_else(PoisonError::into_inner);
265 configs
266 .iter()
267 .filter_map(|c| cache.get(&signature(c)))
268 .flatten()
269 .cloned()
270 .collect()
271 }
272}
273
274pub fn parse_blueprint_mcp_servers(manifest_toml: &str) -> Vec<MCPServerConfig> {
279 let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
285 return Vec::new();
286 };
287 let Some(array) = value.get("mcp_servers").and_then(|v| v.as_array()) else {
288 return Vec::new();
289 };
290 let mut out = Vec::new();
291 for entry in array {
292 match entry.clone().try_into::<MCPServerConfig>() {
293 Ok(cfg) => out.push(cfg),
294 Err(e) => tracing::warn!(error = %e, "skipping malformed [[mcp_servers]] entry"),
295 }
296 }
297 out
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::test_support::with_tracing;
304
305 const STUB: &str = r#"
308import sys, json
309def respond(i, r):
310 sys.stdout.write(json.dumps({"jsonrpc":"2.0","id":i,"result":r})+"\n"); sys.stdout.flush()
311for line in sys.stdin:
312 line=line.strip()
313 if not line: continue
314 req=json.loads(line); m=req.get("method",""); i=req.get("id")
315 if m=="initialize": respond(i,{"capabilities":{"tools":{"listChanged":True}},"protocolVersion":"2024-11-05"})
316 elif m=="notifications/initialized": pass
317 elif m=="tools/list": respond(i,{"tools":[{"name":"echo","description":"e","inputSchema":{"type":"object","properties":{}}}]})
318 elif m=="tools/call": respond(i,{"content":[{"type":"text","text":"ok"}],"isError":False})
319 else: respond(i,{})
320"#;
321
322 fn stub_config(name: &str) -> MCPServerConfig {
323 MCPServerConfig::stdio(name, "python3", vec!["-c".to_string(), STUB.to_string()])
324 }
325
326 fn pool() -> McpPool {
327 McpPool::new(Arc::new(Mutex::new(ToolExecutor::new())), HashSet::new())
328 }
329
330 async fn with_temp_home<F, Fut, T>(body: F) -> T
333 where
334 F: FnOnce() -> Fut,
335 Fut: std::future::Future<Output = T>,
336 {
337 let dir = tempfile::tempdir().unwrap();
338 temp_env::async_with_vars(
339 [("LEVIATH_HOME", Some(dir.path().to_str().unwrap()))],
340 body(),
341 )
342 .await
343 }
344
345 #[tokio::test]
346 async fn ensure_connects_and_caches_by_signature() {
347 with_tracing(|| {});
348 with_temp_home(|| async {
349 let pool = pool();
350 let cfg = stub_config("s");
351 let defs = pool.ensure(&cfg).await;
353 assert_eq!(defs.len(), 1);
354 assert_eq!(defs[0].name, "echo");
355 let again = pool.ensure(&cfg).await;
357 assert_eq!(again.len(), 1);
358 })
359 .await;
360 }
361
362 #[tokio::test]
363 async fn ensure_all_connects_each_server() {
364 with_tracing(|| {});
365 with_temp_home(|| async {
366 let pool = Arc::new(pool());
367 let cfg = stub_config("s");
368 pool.clone().ensure_all(vec![cfg.clone()]).await;
369 assert_eq!(pool.cached_defs_for(std::slice::from_ref(&cfg)).len(), 1);
370 })
371 .await;
372 }
373
374 #[tokio::test]
375 async fn ensure_failure_returns_empty_and_is_not_cached() {
376 with_tracing(|| {});
377 with_temp_home(|| async {
378 let pool = pool();
379 let bad = MCPServerConfig::stdio("bad", "definitely-not-a-binary-xyz", vec![]);
380 assert!(pool.ensure(&bad).await.is_empty());
381 assert!(pool.cached_defs_for(std::slice::from_ref(&bad)).is_empty());
383 })
384 .await;
385 }
386
387 async fn mock_http_mcp_server() -> String {
390 use axum::response::IntoResponse;
391 use axum::routing::post;
392 use axum::{Json, Router};
393 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
394 let base = format!("http://{}", listener.local_addr().unwrap());
395 let app = Router::new().route(
396 "/mcp",
397 post(|body: String| async move {
398 let req: serde_json::Value = serde_json::from_str(&body).unwrap();
399 let id = req.get("id").cloned().unwrap_or(serde_json::json!(1));
400 let result = match req.get("method").and_then(|m| m.as_str()) {
401 Some("initialize") => {
402 serde_json::json!({"capabilities": {}, "protocolVersion": "2024-11-05"})
403 }
404 Some("tools/list") => {
405 serde_json::json!({"tools": [{"name": "remote_tool", "inputSchema": {}}]})
406 }
407 _ => serde_json::json!({}),
408 };
409 (
410 [(axum::http::header::CONTENT_TYPE, "application/json")],
411 Json(serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result}))
412 .into_response()
413 .into_body(),
414 )
415 .into_response()
416 }),
417 );
418 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
419 listener, app,
420 )));
421 base
422 }
423
424 #[tokio::test]
425 async fn ensure_resolves_oauth_bearer_and_attaches_refresher() {
426 with_tracing(|| {});
428 let base = mock_http_mcp_server().await;
429 let defs = with_temp_home(|| async {
430 let mut store = leviath_mcp::AuthStore::default();
431 store.set(
432 "remote",
433 leviath_mcp::ServerAuth {
434 access_token: "live-token".to_string(),
435 expires_at: u64::MAX,
436 ..Default::default()
437 },
438 );
439 store
440 .save(&leviath_mcp::AuthStore::default_path().unwrap())
441 .unwrap();
442 let pool = pool();
443 pool.ensure(&MCPServerConfig::http("remote", format!("{base}/mcp")))
444 .await
445 })
446 .await;
447 assert_eq!(defs.len(), 1);
448 assert_eq!(defs[0].name, "remote_tool");
449 }
450
451 #[tokio::test]
452 async fn ensure_returns_empty_when_bearer_cannot_be_resolved() {
453 with_tracing(|| {});
456 let defs = with_temp_home(|| async {
457 let mut store = leviath_mcp::AuthStore::default();
458 store.set(
459 "remote",
460 leviath_mcp::ServerAuth {
461 token_endpoint: "http://127.0.0.1:1/token".to_string(),
462 access_token: "expired".to_string(),
463 refresh_token: Some("good".to_string()),
464 expires_at: 1,
465 ..Default::default()
466 },
467 );
468 store
469 .save(&leviath_mcp::AuthStore::default_path().unwrap())
470 .unwrap();
471 let pool = pool();
472 pool.ensure(&MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"))
473 .await
474 })
475 .await;
476 assert!(defs.is_empty());
477 }
478
479 #[test]
480 fn seed_then_cached_defs_for_reads_without_connecting() {
481 let pool = pool();
482 let cfg = stub_config("seeded");
483 pool.seed(
484 &cfg,
485 vec![Tool {
486 name: "seed_tool".into(),
487 description: String::new(),
488 parameters: serde_json::json!({}),
489 }],
490 );
491 let names: Vec<String> = pool
492 .cached_defs_for(std::slice::from_ref(&cfg))
493 .into_iter()
494 .map(|t| t.name)
495 .collect();
496 assert_eq!(names, vec!["seed_tool".to_string()]);
497 }
498
499 fn stub_py() -> (tempfile::TempDir, std::path::PathBuf) {
501 let dir = tempfile::tempdir().unwrap();
502 let path = dir.path().join("stub.py");
503 std::fs::write(&path, STUB).unwrap();
504 (dir, path)
505 }
506
507 fn blueprint_declaring(server: &str, stub: &std::path::Path) -> (tempfile::TempDir, String) {
510 let dir = tempfile::tempdir().unwrap();
511 let manifest = dir.path().join("agent.leviath");
512 std::fs::write(
513 &manifest,
514 format!(
515 "[agent]\nname = \"a\"\n\n[[mcp_servers]]\nname = \"{server}\"\ncommand = \"python3\"\nargs = ['{}']\n",
518 stub.to_string_lossy()
519 ),
520 )
521 .unwrap();
522 (dir, manifest.to_string_lossy().to_string())
523 }
524
525 fn write_run_meta(
526 runs_dir: &std::path::Path,
527 run_id: &str,
528 agent_path: &str,
529 status: leviath_core::run_meta::RunStatus,
530 ) {
531 let dir = runs_dir.join(run_id);
532 std::fs::create_dir_all(&dir).unwrap();
533 let mut meta = leviath_core::run_meta::RunMeta::new(
534 run_id.to_string(),
535 "a".to_string(),
536 agent_path.to_string(),
537 "t".to_string(),
538 None,
539 std::env::temp_dir().to_string_lossy().to_string(),
540 1,
541 );
542 meta.status = status;
543 std::fs::write(dir.join("meta.json"), serde_json::to_string(&meta).unwrap()).unwrap();
544 }
545
546 #[tokio::test]
547 async fn warm_recovered_connects_only_nonterminal_run_blueprints() {
548 use leviath_core::run_meta::RunStatus;
549 with_tracing(|| {});
550 with_temp_home(|| async {
551 let (_sd, stub) = stub_py();
552 let (_bd_live, live_bp) = blueprint_declaring("liveserver", &stub);
553 let (_bd_done, done_bp) = blueprint_declaring("doneserver", &stub);
554 let runs = tempfile::tempdir().unwrap();
555 write_run_meta(runs.path(), "run-live", &live_bp, RunStatus::Running);
556 write_run_meta(runs.path(), "run-done", &done_bp, RunStatus::Complete);
557 write_run_meta(
560 runs.path(),
561 "run-gone",
562 "/no/such/agent.leviath",
563 RunStatus::WaitingInput,
564 );
565 std::fs::create_dir_all(runs.path().join("junk")).unwrap();
567 std::fs::create_dir_all(runs.path().join("garbled")).unwrap();
569 std::fs::write(runs.path().join("garbled/meta.json"), "not json {{").unwrap();
570
571 let pool = pool();
572 pool.warm_recovered(runs.path()).await;
573
574 let live_servers =
576 parse_blueprint_mcp_servers(&std::fs::read_to_string(&live_bp).unwrap());
577 let done_servers =
578 parse_blueprint_mcp_servers(&std::fs::read_to_string(&done_bp).unwrap());
579 assert_eq!(pool.cached_defs_for(&live_servers).len(), 1);
580 assert!(pool.cached_defs_for(&done_servers).is_empty());
581 })
582 .await;
583 }
584
585 #[tokio::test]
586 async fn warm_recovered_missing_runs_dir_is_noop() {
587 let pool = pool();
588 pool.warm_recovered(std::path::Path::new("/no/such/runs"))
589 .await;
590 }
591
592 #[test]
593 fn for_daemon_reserves_core_names_and_seeds_globals() {
594 let global =
595 MCPServerConfig::stdio("g", "python3", vec!["-c".to_string(), "pass".to_string()]);
596 let pool = McpPool::for_daemon(
597 Arc::new(Mutex::new(ToolExecutor::new())),
598 std::slice::from_ref(&global),
599 );
600 assert!(
603 pool.cached_defs_for(std::slice::from_ref(&global))
604 .is_empty()
605 );
606 assert!(pool.reserved.contains("read_file"));
608 }
609
610 #[test]
611 fn parse_blueprint_mcp_servers_reads_array() {
612 let toml = r#"
613[agent]
614name = "x"
615[[mcp_servers]]
616name = "search"
617command = "leviath-search"
618args = ["--provider", "brave"]
619[[mcp_servers]]
620name = "http-one"
621url = "http://localhost:9/mcp"
622"#;
623 let servers = parse_blueprint_mcp_servers(toml);
624 assert_eq!(servers.len(), 2);
625 assert_eq!(servers[0].name, "search");
626 assert_eq!(servers[0].command.as_deref(), Some("leviath-search"));
627 assert_eq!(servers[1].url.as_deref(), Some("http://localhost:9/mcp"));
628 }
629
630 #[test]
631 fn parse_blueprint_mcp_servers_absent_or_malformed() {
632 assert!(parse_blueprint_mcp_servers("[agent]\nname='x'").is_empty());
634 assert!(parse_blueprint_mcp_servers("this is = = not toml").is_empty());
636 assert!(parse_blueprint_mcp_servers("mcp_servers = 5").is_empty());
638 with_tracing(|| {});
640 let servers = parse_blueprint_mcp_servers("[[mcp_servers]]\nname = 5\n");
641 assert!(servers.is_empty());
642 }
643}