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