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    /// Per-run leases on per-agent servers (see [`Self::lease_blueprint`]).
40    /// Same `std` mutex discipline as `connected`: held briefly, never across
41    /// an `.await`.
42    leases: StdMutex<LeaseTable>,
43    /// How long a per-agent server may sit with zero leasing runs before its
44    /// connection (and, for stdio servers, its child process) is torn down.
45    /// Zero disables disconnection - the pre-lease behavior, where every
46    /// server any blueprint ever declared stayed connected for the daemon's
47    /// life.
48    idle_disconnect: std::time::Duration,
49}
50
51/// Which runs hold which per-agent servers open.
52#[derive(Default)]
53struct LeaseTable {
54    /// Signature → the server's lease state.
55    servers: HashMap<String, ServerLease>,
56    /// Run id → the signatures it holds, so a reap releases them all.
57    runs: HashMap<String, Vec<String>>,
58    /// Signatures of the global config servers, seeded at startup: their
59    /// lifecycle belongs to the daemon, never to a run, so they are exempt
60    /// from idle disconnection.
61    global: HashSet<String>,
62}
63
64/// One per-agent server's lease state.
65struct ServerLease {
66    /// The server's name - the key the executor stores its client under.
67    name: String,
68    /// The runs currently holding it open.
69    holders: HashSet<String>,
70    /// Bumped on every lease and release, so a disconnect scheduled when the
71    /// count hit zero is a no-op if anything touched the server since.
72    generation: u64,
73}
74
75/// A stable dedup key for a server config: its full serialized form. Two
76/// blueprints declaring an identical server share one connection; a difference in
77/// name/command/url/args/env/headers is a distinct server.
78fn signature(config: &MCPServerConfig) -> String {
79    // Serializing a plain config never fails; fall back to an empty key rather
80    // than carry a dead error closure.
81    serde_json::to_string(config).unwrap_or_default()
82}
83
84/// Default for how long a per-agent MCP server may sit with zero leasing runs
85/// before its connection is torn down. Long enough that back-to-back runs of
86/// the same blueprint reuse the warm connection (and never re-trigger an OAuth
87/// flow between them); short enough that a one-off run's servers do not hold
88/// child processes and buffers for the daemon's remaining life.
89pub const DEFAULT_MCP_IDLE_DISCONNECT_SECS: u64 = 60;
90
91impl McpPool {
92    /// Build a pool over `shared`, reserving `reserved` names from advertisement.
93    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    /// How long a per-agent server may sit unleased before disconnection.
106    /// `0` disables it.
107    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    /// Allow these credential-shaped variables in MCP `${VAR}` headers.
113    pub fn with_env_allowlist(mut self, allow: Vec<String>) -> Self {
114        self.allow_env_vars = allow;
115        self
116    }
117
118    /// Read and write MCP grants through `kind`'s backend.
119    pub fn with_credential_store(mut self, kind: leviath_core::CredentialStoreKind) -> Self {
120        self.credential_store = kind;
121        self
122    }
123
124    /// Build the daemon's shared pool over `shared_mcp`: reserve built-in and
125    /// sub-agent tool names (so a server tool can't shadow a core one) and seed
126    /// the already-connected global `config_servers` with empty defs, so a
127    /// blueprint that re-declares one doesn't open a duplicate connection.
128    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    /// [`for_daemon`](Self::for_daemon) reading and writing MCP OAuth grants
142    /// through `credential_store`'s backend.
143    ///
144    /// The pool refreshes lapsed tokens and writes them back, so it has to write
145    /// them where the user asked for them to be kept - otherwise the first
146    /// refresh after a keychain migration would put a fresh refresh token back
147    /// on disk.
148    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    /// Seed the cache with an already-connected server's defs (used at startup for
174    /// the global config servers, connected once by `ToolRegistry::build`).
175    /// Seeded servers are global: their lifecycle belongs to the daemon, so
176    /// they are exempt from lease-driven idle disconnection.
177    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    /// Record `run_id` as holding every per-agent server `blueprint_path`
191    /// declares, so the connections stay up exactly as long as some run needs
192    /// them. Global (seeded) servers are skipped. A missing or unreadable
193    /// manifest leases nothing.
194    ///
195    /// Called from every path that brings a run into the world with a
196    /// blueprint: the spawner, the restart reloader, and the fan-out worker
197    /// spawner. The matching release is [`Self::release_run`], from the reap
198    /// hook.
199    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    /// Release every lease `run_id` holds. Servers whose holder count reaches
225    /// zero get an idle-disconnect scheduled (when a runtime is available and
226    /// `idle_disconnect` is non-zero); a new lease during the grace window
227    /// bumps the generation and turns the pending disconnect into a no-op.
228    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; // no runtime (a sync test): bookkeeping only
235        };
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    /// The synchronous half of [`Self::release_run`]: drop the run's leases and
246    /// return the `(signature, name, generation)` of every server that now has
247    /// zero holders.
248    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    /// Tear a server down if nothing touched it since `generation`: forget its
268    /// cached defs (so the next spawn reconnects lazily), take its client out
269    /// of the shared executor, and shut it down - which is what actually ends
270    /// a stdio server's child process. Returns whether it disconnected.
271    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    /// The signatures currently holding leases, for tests and diagnostics.
299    #[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    /// Ensure `config` is connected (idempotent by signature) and return its
310    /// advertised tool defs. A connection failure logs and returns no defs (the
311    /// agent simply doesn't get that server's tools); it is not cached, so a later
312    /// spawn retries.
313    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        // Resolve a stored OAuth bearer for an HTTP server (refreshing it
324        // non-interactively if lapsed); `None` for stdio / unauthenticated /
325        // static-header servers. Mirrors `ToolRegistry::build`.
326        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                // Attach a refresher so an OAuth-backed server that outlives its
355                // access token re-auths on a 401 instead of failing every call.
356                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                // Pre-format the count so the tracing field carries no inline
379                // method call (an uncoverable macro sub-region otherwise).
380                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    /// Connect every server in `servers` (idempotent). Takes `Arc<Self>` + owned
393    /// `servers` so it can be `tokio::spawn`ed directly as a detached warm task
394    /// (e.g. by the fan-out spawner) without a wrapping closure.
395    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    /// Warm the per-agent `[[mcp_servers]]` of every non-terminal persisted run in
402    /// `runs_dir`, so a run reloaded on daemon restart can still *execute* its
403    /// blueprint MCP tools (their advertisement is restored from the snapshot;
404    /// only the shared connection is lost across a restart). Blueprint paths are
405    /// collected synchronously, then connected - no fs iterator is held across an
406    /// `.await`.
407    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            // Only runs that recovery will actually reload (non-terminal).
421            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    /// The cached defs for every config in `configs` (pool must already be warm
438    /// for them - call [`Self::ensure`] first). Unknown/unconnected configs
439    /// contribute nothing. This is the sync read the spawner uses.
440    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
454/// Parse a blueprint manifest's `[[mcp_servers]]` array. Parsed
455/// CLI-side because `leviath-core` cannot depend on `leviath-mcp` (that crate
456/// already depends on core - a cycle). Returns an empty vec when the section is
457/// absent or malformed; a malformed entry is skipped with a warning.
458pub fn parse_blueprint_mcp_servers(manifest_toml: &str) -> Vec<MCPServerConfig> {
459    // `toml::from_str`, not `manifest_toml.parse::<toml::Value>()`. In toml 1.x
460    // `FromStr for Value` parses a single *value*, not a document - so a real
461    // manifest starting with `[agent]` reads as an array literal followed by
462    // junk and fails. It still compiles, so the change is silent; the tests are
463    // what caught it.
464    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    /// A minimal stdio MCP server (python3) speaking initialize / tools/list /
486    /// tools/call - mirrors the fixtures in `tools.rs`.
487    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    /// Run `body` with `LEVIATH_HOME` at a fresh temp dir so the OAuth auth store
511    /// resolves to an empty, hermetic location rather than the real `~/.leviath`.
512    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            // A stdio server has no OAuth bearer (the `None` auth path).
532            let defs = pool.ensure(&cfg).await;
533            assert_eq!(defs.len(), 1);
534            assert_eq!(defs[0].name, "echo");
535            // Second ensure of the same signature hits the cache (no reconnect).
536            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            // Not cached: cached_defs_for finds nothing for it.
562            assert!(pool.cached_defs_for(std::slice::from_ref(&bad)).is_empty());
563        })
564        .await;
565    }
566
567    /// A minimal streamable-HTTP MCP server that lists one tool. Returns its base
568    /// URL. Mirrors the `tools.rs` OAuth fixture.
569    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        // A live stored token → the auth-resolved branch + set_refresher.
607        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        // An expired token with an unreachable refresh endpoint → resolve_bearer
634        // errors → the auth `Err` arm returns no defs.
635        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    /// Write a python MCP stub to a temp file; returns (tempdir, path).
680    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    /// Write a blueprint declaring one stdio `[[mcp_servers]]` → `stub_py`; returns
688    /// its manifest path.
689    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                // Single-quoted TOML literal so a Windows path's backslashes
696                // aren't parsed as string escapes (`\\U…` → invalid unicode).
697                "[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            // A non-terminal run whose blueprint file no longer exists → the
738            // "unreadable manifest" arm (skipped, no panic).
739            write_run_meta(
740                runs.path(),
741                "run-gone",
742                "/no/such/agent.leviath",
743                RunStatus::WaitingInput,
744            );
745            // A junk dir with no meta.json is skipped without error.
746            std::fs::create_dir_all(runs.path().join("junk")).unwrap();
747            // A dir with an unparseable meta.json is skipped (the parse-error arm).
748            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            // The non-terminal run's server is connected; the terminal one is not.
755            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    /// The lease lifecycle end to end: runs hold a server open, the last
773    /// release zeroes it, and the idle disconnect tears the connection down so
774    /// the next spawn reconnects lazily.
775    #[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            // Connect for real, so there is a live client to tear down.
785            assert_eq!(pool.ensure(cfg).await.len(), 1);
786
787            pool.lease_blueprint(&bp, "run-a");
788            pool.lease_blueprint(&bp, "run-b");
789            // Leasing twice from the same run holds once.
790            pool.lease_blueprint(&bp, "run-b");
791            assert_eq!(pool.leased_holders(cfg), 2);
792
793            // Releasing one run leaves the server held (nothing zeroed, no
794            // timer scheduled).
795            pool.release_run("run-a");
796            assert_eq!(pool.leased_holders(cfg), 1);
797            assert!(!pool.cached_defs_for(&servers).is_empty());
798
799            // The last release zeroes it; drive the disconnect directly (the
800            // scheduled timer runs the same call after the grace window).
801            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            // Defs are forgotten, so the next spawn reconnects lazily...
806            assert!(pool.cached_defs_for(&servers).is_empty());
807            // ...and a replayed disconnect finds nothing to do.
808            assert!(!pool.disconnect_if_still_idle(&sig, &name, generation).await);
809        })
810        .await;
811    }
812
813    /// A lease taken during the grace window outdates the scheduled
814    /// disconnect: the generation moved, so the timer's callback is a no-op.
815    #[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            // A new run leases before the timer would have fired.
830            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    /// Outside a runtime (a sync harness driving the reap hook directly), a
842    /// release is bookkeeping only: there is nowhere to spawn the grace
843    /// timer, and that must be a quiet no-op rather than a panic.
844    #[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    /// The scheduled path end to end: a real release on a live runtime spawns
851    /// the grace timer, and after the window the server is gone. With the
852    /// grace set to zero, releasing schedules nothing and the connection
853    /// stays.
854    #[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            // Within the grace window the connection survives...
866            assert!(!timed.cached_defs_for(&servers).is_empty());
867            // ...and after it, the timer has torn it down.
868            tokio::time::sleep(std::time::Duration::from_millis(2500)).await;
869            assert!(timed.cached_defs_for(&servers).is_empty());
870
871            // Grace zero: releasing disconnects nothing, ever.
872            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    /// The defensive arms: a lease that never connected disconnects to a
883    /// no-op (no client in the executor), and a run entry pointing at a
884    /// server the table no longer holds is skipped rather than panicking.
885    #[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            // Leased but never `ensure`d: nothing in the executor to remove.
893            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            // A runs-map entry whose server row is gone (cannot happen through
902            // the public API, which mutates both under one lock) is skipped.
903            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    /// Global (seeded) servers belong to the daemon: they are never leased,
915    /// and releasing runs never schedules them for disconnection. A missing
916    /// manifest and an unknown run are no-ops.
917    #[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"); // nothing held → nothing zeroed
930            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        // The global server is seeded (cached with empty defs → deduped on a
946        // re-declaration).
947        assert!(
948            pool.cached_defs_for(std::slice::from_ref(&global))
949                .is_empty()
950        );
951        // Built-in names are reserved.
952        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        // No section → empty.
978        assert!(parse_blueprint_mcp_servers("[agent]\nname='x'").is_empty());
979        // Not even valid TOML → empty.
980        assert!(parse_blueprint_mcp_servers("this is = = not toml").is_empty());
981        // Section present but not an array of tables → empty (as_array is None).
982        assert!(parse_blueprint_mcp_servers("mcp_servers = 5").is_empty());
983        // A malformed entry (name is not a string) is skipped with a warning.
984        with_tracing(|| {});
985        let servers = parse_blueprint_mcp_servers("[[mcp_servers]]\nname = 5\n");
986        assert!(servers.is_empty());
987    }
988}