Skip to main content

agent_engine/mcp/
lease.rs

1//! Task 19 (Commit B) — session-scoped exact MCP runtime leases (spec §7.4).
2//!
3//! Deferred descriptor-backed MCP tools execute ONLY through this manager,
4//! AFTER the `ExecutionGate` has already authorized the exact call. The
5//! manager owns every live MCP child for the runtime:
6//!
7//! - a lease is keyed by (session, server) and PINNED to the config
8//!   fingerprint it was acquired under; per-key SINGLE-FLIGHT acquisition
9//!   (a `Starting` placeholder holding a `watch` receiver, whose stored
10//!   state makes wakeups lost-proof) guarantees concurrent first calls
11//!   never spawn duplicate children, and the manager map lock is NEVER
12//!   held across process/pipe I/O;
13//! - acquisition starts exactly one child (`kill_on_drop`), initializes
14//!   and lists ONCE, and pins the bounded listing; every call validates
15//!   the selected exact runtime name and canonical schema digest against
16//!   the cached expectation BEFORE `tools/call` — a mismatch terminates
17//!   the poisoned lease without calling;
18//! - revocation/termination semantics (exact and honest): revocation
19//!   marks the lease cancelled under the per-lease call gate. A call that
20//!   already passed the gate check (taken while holding the connection
21//!   lock) MAY still complete; NO call passes the gate after revocation
22//!   returns, and a completing call that observes cancellation kills the
23//!   child before releasing the connection. Teardown is guaranteed by
24//!   `kill_on_drop` once the last `Arc` drops (map removal plus bailing
25//!   callers make that inevitable) with an immediate best-effort
26//!   `start_kill` accelerator — the manager retains nothing;
27//! - idle leases are reaped opportunistically with a CONSTANT per-pass
28//!   scan cap; session end is the drop of the LAST owner of the shared
29//!   durable [`McpSessionEndGuard`] scope (runtime clones + in-flight
30//!   streams hold it; no per-turn guard exists). No PID signalling, no
31//!   long-lived task, no unbounded channel.
32
33use std::collections::HashMap;
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use serde_json::Value;
38use thiserror::Error;
39
40use super::connection::McpConnection;
41use super::descriptors::server_config_fingerprint;
42use super::McpServerConfig;
43use crate::tools::activation::SessionId;
44use crate::tools::catalog::SchemaDigest;
45
46/// Injectable current-config lookup: production reads `mcp.json` per
47/// acquisition (bounded local file read); tests inject a mutable map.
48pub type ConfigSource = Arc<dyn Fn(&str) -> Option<McpServerConfig> + Send + Sync>;
49
50/// Production config source: profile-resolved `mcp.json` lookup.
51pub fn config_source_from_disk() -> ConfigSource {
52    Arc::new(|server: &str| {
53        super::load_mcp_config().and_then(|c| c.mcp_servers.get(server).cloned())
54    })
55}
56
57/// Default idle bound for opportunistic reaping.
58pub const DEFAULT_IDLE_MAX: Duration = Duration::from_secs(300);
59/// Hard cap on simultaneously live leases per manager, so termination
60/// fanout (and cleanup-task count) is always bounded.
61pub const MAX_LIVE_LEASES: usize = 64;
62/// Reap passes scan up to the FULL capped map (equal to the live-lease
63/// cap), so idle entries can never starve behind an active HashMap
64/// iteration prefix.
65pub const REAP_SCAN_MAX: usize = MAX_LIVE_LEASES;
66/// Outer total bound on one lease cleanup task (lock wait + shutdown).
67const CLEANUP_TOTAL_TIMEOUT: Duration = Duration::from_secs(3);
68
69/// Typed lease failures. Provider-controlled content never appears here —
70/// only static text, local identities, and numeric metadata.
71#[derive(Debug, Error)]
72pub enum McpLeaseError {
73    #[error("MCP server '{0}' is no longer configured; lease revoked")]
74    ServerNotConfigured(String),
75    #[error(
76        "MCP server '{0}' config fingerprint changed; cached descriptors and lease invalidated"
77    )]
78    FingerprintDrift(String),
79    #[error("MCP server '{0}' did not list expected tool '{1}'; poisoned lease terminated without calling")]
80    NameNotListed(String, String),
81    #[error("MCP tool '{1}' on server '{0}' listed a schema that does not match the pinned descriptor digest; poisoned lease terminated without calling")]
82    SchemaMismatch(String, String),
83    #[error("MCP lease for server '{0}' was revoked before the call could start")]
84    Revoked(String),
85    #[error("MCP transport failure for server '{0}': {1}")]
86    Transport(String, String),
87    #[error(
88        "MCP lease capacity of {0} live leases reached; try again after idle leases are reaped"
89    )]
90    Capacity(usize),
91}
92
93struct LeaseInner {
94    fingerprint: String,
95    connection: tokio::sync::Mutex<McpConnection>,
96    /// Bounded pinned listing from the single `tools/list`.
97    listing: HashMap<String, SchemaDigest>,
98    /// Per-lease call/revocation gate: `true` = cancelled. Held only for
99    /// flag reads/writes — never across I/O.
100    gate: std::sync::Mutex<bool>,
101    last_used: std::sync::Mutex<Instant>,
102    /// Test-observable ownership token: a `Weak` to this proves when the
103    /// manager (and any bounded shutdown task) released the lease.
104    liveness: Arc<()>,
105}
106
107impl LeaseInner {
108    fn cancelled(&self) -> bool {
109        *self
110            .gate
111            .lock()
112            .unwrap_or_else(std::sync::PoisonError::into_inner)
113    }
114
115    /// Mark cancelled: no NEW call passes the gate after this returns.
116    fn set_cancelled(&self) {
117        *self
118            .gate
119            .lock()
120            .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
121    }
122}
123
124enum LeaseState {
125    /// Single-flight placeholder. The `watch` channel stores completion
126    /// state, so a follower that clones the receiver under the map lock and
127    /// awaits after releasing it can never miss the wakeup (`changed()`
128    /// resolves immediately once the starter sent or dropped the sender).
129    Starting {
130        token: u64,
131        done: tokio::sync::watch::Receiver<bool>,
132    },
133    Ready(Arc<LeaseInner>),
134}
135
136/// Session-scoped exact MCP runtime lease manager. See module docs.
137pub struct McpRuntimeManager {
138    leases: std::sync::Mutex<HashMap<(String, String), LeaseState>>,
139    config_source: ConfigSource,
140    idle_max: Duration,
141    next_token: std::sync::atomic::AtomicU64,
142}
143
144impl McpRuntimeManager {
145    pub fn new(config_source: ConfigSource, idle_max: Duration) -> Self {
146        Self {
147            leases: std::sync::Mutex::new(HashMap::new()),
148            config_source,
149            idle_max,
150            next_token: std::sync::atomic::AtomicU64::new(1),
151        }
152    }
153
154    /// Sync RAII-safe termination: mark cancelled, then — when the
155    /// connection is not in-flight — close stdin immediately (graceful EOF)
156    /// and hand the Arc to one BOUNDED spawned task that waits ~250ms for
157    /// the child to exit before `start_kill` + bounded reap. Outside a
158    /// runtime the fallback is an immediate `start_kill`. If the connection
159    /// IS in-flight, the completing call observes cancellation and runs the
160    /// async graceful shutdown itself. In every path the last `Arc` drop
161    /// triggers `kill_on_drop` as the hard backstop.
162    fn cancel_state(state: LeaseState) {
163        match state {
164            LeaseState::Ready(inner) => {
165                inner.set_cancelled();
166                match inner.connection.try_lock() {
167                    Ok(mut conn) => {
168                        conn.close_stdin();
169                        drop(conn);
170                        Self::spawn_bounded_cleanup(Arc::clone(&inner));
171                    }
172                    // In-flight: the completion path observes cancellation
173                    // and shuts down; the bounded cleanup task below is a
174                    // second, outer-timeout-bounded chance to reap sooner.
175                    Err(_) => Self::spawn_bounded_cleanup(Arc::clone(&inner)),
176                }
177            }
178            // The in-flight starter detects its placeholder is gone and
179            // kills its own child instead of publishing the lease; dropping
180            // the receiver here wakes nobody wrongly (watch state persists).
181            LeaseState::Starting { .. } => {}
182        }
183    }
184
185    /// One BOUNDED one-shot cleanup task per terminated lease: strict
186    /// outer timeout around (connection lock wait + graceful 250ms EOF +
187    /// kill/1s reap). Finite input, no producer — not a long-lived task.
188    /// If the outer timeout is exceeded (a call still in flight), the
189    /// completion path sees the cancelled gate and shuts the child down;
190    /// `kill_on_drop` on the final `Arc` drop remains the hard backstop.
191    /// Without a runtime handle: sync close_stdin + start_kill, then drop.
192    fn spawn_bounded_cleanup(inner: Arc<LeaseInner>) {
193        match tokio::runtime::Handle::try_current() {
194            Ok(handle) => {
195                handle.spawn(async move {
196                    let _ = tokio::time::timeout(CLEANUP_TOTAL_TIMEOUT, async {
197                        let mut conn = inner.connection.lock().await;
198                        conn.finish_shutdown().await;
199                    })
200                    .await;
201                });
202            }
203            Err(_) => {
204                if let Ok(mut conn) = inner.connection.try_lock() {
205                    conn.start_kill();
206                }
207            }
208        }
209    }
210
211    /// Number of live (Ready or Starting) leases.
212    pub fn lease_count(&self) -> usize {
213        self.leases
214            .lock()
215            .unwrap_or_else(std::sync::PoisonError::into_inner)
216            .len()
217    }
218
219    /// Terminate one session's runtime lease on one SERVER (the lease unit:
220    /// one child process shared by that session's exact tools of that
221    /// server).
222    pub fn revoke_server_lease(&self, session: &SessionId, server: &str) {
223        let removed = {
224            let mut map = self
225                .leases
226                .lock()
227                .unwrap_or_else(std::sync::PoisonError::into_inner);
228            map.remove(&(session.as_str().to_string(), server.to_string()))
229        };
230        if let Some(state) = removed {
231            Self::cancel_state(state);
232        }
233    }
234
235    /// Revoke one EXACT tool grant's runtime backing. Grant revocation
236    /// itself is gate-level (the `SessionToolSet`); at the runtime layer the
237    /// lease unit is the server child, so this conservatively terminates
238    /// the shared server lease — the child loses all session access rather
239    /// than retaining any after an exact revocation.
240    pub fn revoke_exact_tool(&self, session: &SessionId, server: &str, server_tool_name: &str) {
241        let _ = server_tool_name; // exactness lives in the gate; lease unit is the server child
242        self.revoke_server_lease(session, server);
243    }
244
245    /// Terminate every lease held by one session. Returns how many.
246    pub fn terminate_session(&self, session: &SessionId) -> usize {
247        let removed: Vec<LeaseState> = {
248            let mut map = self
249                .leases
250                .lock()
251                .unwrap_or_else(std::sync::PoisonError::into_inner);
252            let keys: Vec<(String, String)> = map
253                .keys()
254                .filter(|(s, _)| s == session.as_str())
255                .cloned()
256                .collect();
257            keys.into_iter().filter_map(|k| map.remove(&k)).collect()
258        };
259        let count = removed.len();
260        for state in removed {
261            Self::cancel_state(state);
262        }
263        count
264    }
265
266    /// Terminate every lease (runtime shutdown).
267    pub fn terminate_all(&self) {
268        let removed: Vec<LeaseState> = {
269            let mut map = self
270                .leases
271                .lock()
272                .unwrap_or_else(std::sync::PoisonError::into_inner);
273            map.drain().map(|(_, v)| v).collect()
274        };
275        for state in removed {
276            Self::cancel_state(state);
277        }
278    }
279
280    /// Reap Ready leases idle past the configured bound. Work per pass is
281    /// bounded by [`REAP_SCAN_MAX`] examined keys.
282    pub fn reap_idle(&self) {
283        let removed: Vec<LeaseState> = {
284            let mut map = self
285                .leases
286                .lock()
287                .unwrap_or_else(std::sync::PoisonError::into_inner);
288            let idle: Vec<(String, String)> = map
289                .iter()
290                .take(REAP_SCAN_MAX)
291                .filter(|(_, state)| match state {
292                    LeaseState::Ready(inner) => {
293                        inner
294                            .last_used
295                            .lock()
296                            .unwrap_or_else(std::sync::PoisonError::into_inner)
297                            .elapsed()
298                            >= self.idle_max
299                    }
300                    LeaseState::Starting { .. } => false,
301                })
302                .map(|(k, _)| k.clone())
303                .collect();
304            idle.into_iter().filter_map(|k| map.remove(&k)).collect()
305        };
306        for state in removed {
307            Self::cancel_state(state);
308        }
309    }
310
311    /// Execute one EXACT already-gate-authorized MCP tool call. See module
312    /// docs for the lease/single-flight/validation/revocation contract.
313    pub async fn call_exact(
314        &self,
315        session: &SessionId,
316        server: &str,
317        expected_fingerprint: &str,
318        server_tool_name: &str,
319        expected_digest: &SchemaDigest,
320        params: Value,
321    ) -> Result<String, McpLeaseError> {
322        // 1. Current config + fingerprint (pure local; no lock held).
323        let Some(config) = (self.config_source)(server) else {
324            self.revoke_server_lease(session, server);
325            return Err(McpLeaseError::ServerNotConfigured(server.to_string()));
326        };
327        if server_config_fingerprint(&config) != expected_fingerprint {
328            self.revoke_server_lease(session, server);
329            return Err(McpLeaseError::FingerprintDrift(server.to_string()));
330        }
331        self.reap_idle();
332
333        let key = (session.as_str().to_string(), server.to_string());
334        let inner: Arc<LeaseInner> = loop {
335            // 2. Single-flight acquisition. The guard-scoped decision below
336            // returns an owned action, so the map lock is NEVER held across
337            // I/O or awaits (also keeps this future Send).
338            enum Acquire {
339                Use(Arc<LeaseInner>),
340                CancelStale(LeaseState),
341                Wait(tokio::sync::watch::Receiver<bool>),
342                Start {
343                    token: u64,
344                    done_tx: tokio::sync::watch::Sender<bool>,
345                },
346                AtCapacity,
347            }
348            let action = {
349                let mut map = self
350                    .leases
351                    .lock()
352                    .unwrap_or_else(std::sync::PoisonError::into_inner);
353                match map.get(&key) {
354                    Some(LeaseState::Ready(inner)) => {
355                        if inner.cancelled() || inner.fingerprint != expected_fingerprint {
356                            match map.remove(&key) {
357                                Some(state) => Acquire::CancelStale(state),
358                                None => continue,
359                            }
360                        } else {
361                            Acquire::Use(Arc::clone(inner))
362                        }
363                    }
364                    Some(LeaseState::Starting { done, .. }) => Acquire::Wait(done.clone()),
365                    None => {
366                        if map.len() >= MAX_LIVE_LEASES {
367                            Acquire::AtCapacity
368                        } else {
369                            let token = self
370                                .next_token
371                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
372                            let (done_tx, done_rx) = tokio::sync::watch::channel(false);
373                            map.insert(
374                                key.clone(),
375                                LeaseState::Starting {
376                                    token,
377                                    done: done_rx,
378                                },
379                            );
380                            Acquire::Start { token, done_tx }
381                        }
382                    }
383                }
384            };
385            match action {
386                Acquire::Use(inner) => break inner,
387                Acquire::CancelStale(state) => {
388                    Self::cancel_state(state);
389                    continue;
390                }
391                Acquire::AtCapacity => {
392                    self.reap_idle();
393                    let still_full = self
394                        .leases
395                        .lock()
396                        .unwrap_or_else(std::sync::PoisonError::into_inner)
397                        .len()
398                        >= MAX_LIVE_LEASES;
399                    if still_full {
400                        return Err(McpLeaseError::Capacity(MAX_LIVE_LEASES));
401                    }
402                    continue;
403                }
404                Acquire::Wait(mut follower) => {
405                    // Lost-wakeup-proof: the watch channel stores state, so
406                    // if the starter finished between our map release and
407                    // this await, `changed()` resolves immediately; sender
408                    // drop also wakes us.
409                    if !*follower.borrow() {
410                        let _ = follower.changed().await;
411                    }
412                    continue;
413                }
414                Acquire::Start { token, done_tx } => {
415                    let started = self
416                        .start_lease(expected_fingerprint, &config)
417                        .await
418                        .map_err(|e| McpLeaseError::Transport(server.to_string(), e));
419                    let ours = {
420                        let mut map = self
421                            .leases
422                            .lock()
423                            .unwrap_or_else(std::sync::PoisonError::into_inner);
424                        let ours = matches!(
425                            map.get(&key),
426                            Some(LeaseState::Starting { token: t, .. }) if *t == token
427                        );
428                        match &started {
429                            Ok(inner) if ours => {
430                                map.insert(key.clone(), LeaseState::Ready(Arc::clone(inner)));
431                            }
432                            Err(_) if ours => {
433                                map.remove(&key);
434                            }
435                            _ => {}
436                        }
437                        ours
438                    };
439                    match started {
440                        Ok(inner) => {
441                            if !ours {
442                                // Terminated while starting (session end or
443                                // revocation): never resurrect.
444                                inner.set_cancelled();
445                                if let Ok(mut conn) = inner.connection.try_lock() {
446                                    conn.shutdown().await;
447                                }
448                                let _ = done_tx.send(true);
449                                return Err(McpLeaseError::Revoked(server.to_string()));
450                            }
451                            let _ = done_tx.send(true);
452                            break inner;
453                        }
454                        Err(err) => {
455                            let _ = done_tx.send(true);
456                            return Err(err);
457                        }
458                    }
459                }
460            }
461        };
462
463        // 3. Exact validation against the pinned listing BEFORE any call.
464        match inner.listing.get(server_tool_name) {
465            None => {
466                self.revoke_server_lease(session, server);
467                return Err(McpLeaseError::NameNotListed(
468                    server.to_string(),
469                    server_tool_name.to_string(),
470                ));
471            }
472            Some(listed) if listed != expected_digest => {
473                self.revoke_server_lease(session, server);
474                return Err(McpLeaseError::SchemaMismatch(
475                    server.to_string(),
476                    server_tool_name.to_string(),
477                ));
478            }
479            Some(_) => {}
480        }
481
482        // 4. Call under the connection lock; the revocation gate check is
483        // taken while HOLDING that lock, so after `cancel()` returns no new
484        // call can pass. A call that already passed the gate may complete;
485        // if it then observes cancellation it kills the child before
486        // releasing the connection.
487        let mut conn = inner.connection.lock().await;
488        if inner.cancelled() {
489            return Err(McpLeaseError::Revoked(server.to_string()));
490        }
491        let result = conn.call_tool(server_tool_name, params).await;
492        if inner.cancelled() {
493            // Revoked while this already-started call ran: shut the child
494            // down (graceful EOF, bounded kill fallback) before releasing.
495            conn.shutdown().await;
496        }
497        drop(conn);
498        *inner
499            .last_used
500            .lock()
501            .unwrap_or_else(std::sync::PoisonError::into_inner) = Instant::now();
502        result.map_err(|e| McpLeaseError::Transport(server.to_string(), e))
503    }
504
505    /// Spawn + initialize + single bounded tools/list for one server. No
506    /// manager lock is held. Any failure kills the child before returning.
507    async fn start_lease(
508        &self,
509        fingerprint: &str,
510        config: &McpServerConfig,
511    ) -> Result<Arc<LeaseInner>, String> {
512        let mut conn = McpConnection::start(config).await?;
513        let defs = match conn.list_tools().await {
514            Ok(defs) => defs,
515            Err(e) => {
516                conn.start_kill();
517                return Err(e);
518            }
519        };
520        let mut listing = HashMap::new();
521        for def in defs {
522            listing.insert(def.name.clone(), SchemaDigest::of_schema(&def.input_schema));
523        }
524        Ok(Arc::new(LeaseInner {
525            fingerprint: fingerprint.to_string(),
526            connection: tokio::sync::Mutex::new(conn),
527            listing,
528            gate: std::sync::Mutex::new(false),
529            last_used: std::sync::Mutex::new(Instant::now()),
530            liveness: Arc::new(()),
531        }))
532    }
533
534    /// Test seam: `Weak` ownership token of one lease. `upgrade() == None`
535    /// proves the manager (and any bounded shutdown task) released it.
536    #[doc(hidden)]
537    pub fn lease_liveness_for_tests(
538        &self,
539        session: &SessionId,
540        server: &str,
541    ) -> Option<std::sync::Weak<()>> {
542        let map = self
543            .leases
544            .lock()
545            .unwrap_or_else(std::sync::PoisonError::into_inner);
546        match map.get(&(session.as_str().to_string(), server.to_string())) {
547            Some(LeaseState::Ready(inner)) => Some(Arc::downgrade(&inner.liveness)),
548            _ => None,
549        }
550    }
551}
552
553impl Drop for McpRuntimeManager {
554    fn drop(&mut self) {
555        self.terminate_all();
556    }
557}
558
559impl std::fmt::Debug for McpRuntimeManager {
560    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        f.debug_struct("McpRuntimeManager")
562            .field("leases", &self.lease_count())
563            .finish_non_exhaustive()
564    }
565}
566
567/// Typed per-session lease capability handed to tool contexts: the exact
568/// session identity plus the shared manager. Never widens to a server
569/// grant — every call re-validates the exact tool and fingerprint.
570#[derive(Clone)]
571pub struct McpLeaseCapability {
572    session: SessionId,
573    manager: Arc<McpRuntimeManager>,
574}
575
576impl McpLeaseCapability {
577    pub fn new(session: SessionId, manager: Arc<McpRuntimeManager>) -> Self {
578        Self { session, manager }
579    }
580
581    /// The exact session identity this capability is scoped to.
582    pub fn session(&self) -> &SessionId {
583        &self.session
584    }
585
586    pub async fn call_exact(
587        &self,
588        server: &str,
589        expected_fingerprint: &str,
590        server_tool_name: &str,
591        expected_digest: &SchemaDigest,
592        params: Value,
593    ) -> Result<String, McpLeaseError> {
594        self.manager
595            .call_exact(
596                &self.session,
597                server,
598                expected_fingerprint,
599                server_tool_name,
600                expected_digest,
601                params,
602            )
603            .await
604    }
605}
606
607impl std::fmt::Debug for McpLeaseCapability {
608    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609        f.debug_struct("McpLeaseCapability").finish_non_exhaustive()
610    }
611}
612
613/// Durable last-owner session scope. The Runtime mints ONE of these per
614/// tool session (wrapped in an `Arc` shared by every runtime clone and
615/// in-flight stream); streams HOLD it, never construct or drop their own.
616/// Leases therefore survive across provider turns, and only the drop of
617/// the LAST owner — true session end — terminates the session's leases.
618pub struct McpSessionEndGuard {
619    session: SessionId,
620    manager: Arc<McpRuntimeManager>,
621}
622
623impl McpSessionEndGuard {
624    pub fn new(session: SessionId, manager: Arc<McpRuntimeManager>) -> Self {
625        Self { session, manager }
626    }
627}
628
629impl Drop for McpSessionEndGuard {
630    fn drop(&mut self) {
631        let terminated = self.manager.terminate_session(&self.session);
632        if terminated > 0 {
633            tracing::debug!(
634                leases = terminated,
635                "MCP session leases terminated at stream end"
636            );
637        }
638    }
639}