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}