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}