Skip to main content

leviath_cli/daemon/
mcp_pool.rs

1//! The shared, lazily-connected MCP pool.
2//!
3//! Per-agent `[[mcp_servers]]` let a blueprint carry its own MCP tool
4//! dependencies. Rather than spawn a connection per agent, all agents share one
5//! [`leviath_mcp::ToolExecutor`] (the client store) fronted by this pool: a
6//! server is connected **on first use**, deduped by its config signature, and
7//! its tools reused by every agent that declares it. Connection is async and is
8//! driven from every spawn path: the spawn preprocessor for top-level and
9//! sub-agent spawns (both run in the serve loop), [`McpPool::warm_recovered`] for
10//! runs reloaded on daemon restart, and a detached warm task for fan-out workers.
11//! So the pool is warm by the time an agent's tools are advertised.
12
13use 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
20/// A shared MCP connection pool over one executor, caching each connected
21/// server's advertised tool defs by config signature.
22pub struct McpPool {
23    /// The shared client store; every agent dispatches MCP calls through it.
24    shared: Arc<Mutex<ToolExecutor>>,
25    /// Names reserved against MCP advertisement (built-in + sub-agent tools) so a
26    /// server tool can't collide with a core tool.
27    reserved: HashSet<String>,
28    /// Signature → the server's advertised `Tool` defs (once connected). A `std`
29    /// mutex (held only briefly, never across `.await`) so the sync spawner can
30    /// read it from a runtime thread without `blocking_lock`'s panic.
31    connected: StdMutex<HashMap<String, Vec<Tool>>>,
32    /// Where MCP OAuth grants are kept, so a refreshed token is written back to
33    /// the backend it came from. Defaults to the file store, which is also the
34    /// config default - a pool built without being told reads `mcp-auth.json`.
35    credential_store: leviath_core::CredentialStoreKind,
36    /// `[security] allow_env_vars`: which credential-shaped variables an MCP
37    /// server's `${VAR}` headers may interpolate.
38    allow_env_vars: Vec<String>,
39}
40
41/// A stable dedup key for a server config: its full serialized form. Two
42/// blueprints declaring an identical server share one connection; a difference in
43/// name/command/url/args/env/headers is a distinct server.
44fn signature(config: &MCPServerConfig) -> String {
45    // Serializing a plain config never fails; fall back to an empty key rather
46    // than carry a dead error closure.
47    serde_json::to_string(config).unwrap_or_default()
48}
49
50impl McpPool {
51    /// Build a pool over `shared`, reserving `reserved` names from advertisement.
52    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    /// Allow these credential-shaped variables in MCP `${VAR}` headers.
63    pub fn with_env_allowlist(mut self, allow: Vec<String>) -> Self {
64        self.allow_env_vars = allow;
65        self
66    }
67
68    /// Read and write MCP grants through `kind`'s backend.
69    pub fn with_credential_store(mut self, kind: leviath_core::CredentialStoreKind) -> Self {
70        self.credential_store = kind;
71        self
72    }
73
74    /// Build the daemon's shared pool over `shared_mcp`: reserve built-in and
75    /// sub-agent tool names (so a server tool can't shadow a core one) and seed
76    /// the already-connected global `config_servers` with empty defs, so a
77    /// blueprint that re-declares one doesn't open a duplicate connection.
78    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    /// [`for_daemon`](Self::for_daemon) reading and writing MCP OAuth grants
91    /// through `credential_store`'s backend.
92    ///
93    /// The pool refreshes lapsed tokens and writes them back, so it has to write
94    /// them where the user asked for them to be kept - otherwise the first
95    /// refresh after a keychain migration would put a fresh refresh token back
96    /// on disk.
97    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    /// Seed the cache with an already-connected server's defs (used at startup for
121    /// the global config servers, connected once by `ToolRegistry::build`).
122    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    /// Ensure `config` is connected (idempotent by signature) and return its
130    /// advertised tool defs. A connection failure logs and returns no defs (the
131    /// agent simply doesn't get that server's tools); it is not cached, so a later
132    /// spawn retries.
133    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        // Resolve a stored OAuth bearer for an HTTP server (refreshing it
144        // non-interactively if lapsed); `None` for stdio / unauthenticated /
145        // static-header servers. Mirrors `ToolRegistry::build`.
146        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                // Attach a refresher so an OAuth-backed server that outlives its
175                // access token re-auths on a 401 instead of failing every call.
176                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                // Pre-format the count so the tracing field carries no inline
199                // method call (an uncoverable macro sub-region otherwise).
200                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    /// Connect every server in `servers` (idempotent). Takes `Arc<Self>` + owned
213    /// `servers` so it can be `tokio::spawn`ed directly as a detached warm task
214    /// (e.g. by the fan-out spawner) without a wrapping closure.
215    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    /// Warm the per-agent `[[mcp_servers]]` of every non-terminal persisted run in
222    /// `runs_dir`, so a run reloaded on daemon restart can still *execute* its
223    /// blueprint MCP tools (their advertisement is restored from the snapshot;
224    /// only the shared connection is lost across a restart). Blueprint paths are
225    /// collected synchronously, then connected - no fs iterator is held across an
226    /// `.await`.
227    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            // Only runs that recovery will actually reload (non-terminal).
241            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    /// The cached defs for every config in `configs` (pool must already be warm
258    /// for them - call [`Self::ensure`] first). Unknown/unconnected configs
259    /// contribute nothing. This is the sync read the spawner uses.
260    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
274/// Parse a blueprint manifest's `[[mcp_servers]]` array. Parsed
275/// CLI-side because `leviath-core` cannot depend on `leviath-mcp` (that crate
276/// already depends on core - a cycle). Returns an empty vec when the section is
277/// absent or malformed; a malformed entry is skipped with a warning.
278pub fn parse_blueprint_mcp_servers(manifest_toml: &str) -> Vec<MCPServerConfig> {
279    // `toml::from_str`, not `manifest_toml.parse::<toml::Value>()`. In toml 1.x
280    // `FromStr for Value` parses a single *value*, not a document - so a real
281    // manifest starting with `[agent]` reads as an array literal followed by
282    // junk and fails. It still compiles, so the change is silent; the tests are
283    // what caught it.
284    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    /// A minimal stdio MCP server (python3) speaking initialize / tools/list /
306    /// tools/call - mirrors the fixtures in `tools.rs`.
307    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    /// Run `body` with `LEVIATH_HOME` at a fresh temp dir so the OAuth auth store
331    /// resolves to an empty, hermetic location rather than the real `~/.leviath`.
332    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            // A stdio server has no OAuth bearer (the `None` auth path).
352            let defs = pool.ensure(&cfg).await;
353            assert_eq!(defs.len(), 1);
354            assert_eq!(defs[0].name, "echo");
355            // Second ensure of the same signature hits the cache (no reconnect).
356            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            // Not cached: cached_defs_for finds nothing for it.
382            assert!(pool.cached_defs_for(std::slice::from_ref(&bad)).is_empty());
383        })
384        .await;
385    }
386
387    /// A minimal streamable-HTTP MCP server that lists one tool. Returns its base
388    /// URL. Mirrors the `tools.rs` OAuth fixture.
389    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        // A live stored token → the auth-resolved branch + set_refresher.
427        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        // An expired token with an unreachable refresh endpoint → resolve_bearer
454        // errors → the auth `Err` arm returns no defs.
455        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    /// Write a python MCP stub to a temp file; returns (tempdir, path).
500    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    /// Write a blueprint declaring one stdio `[[mcp_servers]]` → `stub_py`; returns
508    /// its manifest path.
509    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                // Single-quoted TOML literal so a Windows path's backslashes
516                // aren't parsed as string escapes (`\\U…` → invalid unicode).
517                "[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            // A non-terminal run whose blueprint file no longer exists → the
558            // "unreadable manifest" arm (skipped, no panic).
559            write_run_meta(
560                runs.path(),
561                "run-gone",
562                "/no/such/agent.leviath",
563                RunStatus::WaitingInput,
564            );
565            // A junk dir with no meta.json is skipped without error.
566            std::fs::create_dir_all(runs.path().join("junk")).unwrap();
567            // A dir with an unparseable meta.json is skipped (the parse-error arm).
568            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            // The non-terminal run's server is connected; the terminal one is not.
575            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        // The global server is seeded (cached with empty defs → deduped on a
601        // re-declaration).
602        assert!(
603            pool.cached_defs_for(std::slice::from_ref(&global))
604                .is_empty()
605        );
606        // Built-in names are reserved.
607        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        // No section → empty.
633        assert!(parse_blueprint_mcp_servers("[agent]\nname='x'").is_empty());
634        // Not even valid TOML → empty.
635        assert!(parse_blueprint_mcp_servers("this is = = not toml").is_empty());
636        // Section present but not an array of tables → empty (as_array is None).
637        assert!(parse_blueprint_mcp_servers("mcp_servers = 5").is_empty());
638        // A malformed entry (name is not a string) is skipped with a warning.
639        with_tracing(|| {});
640        let servers = parse_blueprint_mcp_servers("[[mcp_servers]]\nname = 5\n");
641        assert!(servers.is_empty());
642    }
643}