Skip to main content

agent_engine/mcp/
descriptors.rs

1//! Task 19 (Commit A) — safe local MCP descriptor cache and dormant
2//! deferred-tool integration (spec §7.4).
3//!
4//! Before exact selection, MCP capability knowledge comes ONLY from bounded
5//! local state: the user's `mcp.json` server config plus this descriptor
6//! cache (`mcp-descriptors.json`), treated as pre-existing operator-managed
7//! local state. Loading NEVER spawns a process or touches the network, and
8//! descriptors are never invented from server config alone: a server without
9//! a fingerprint-matching cache entry contributes no capabilities. This task
10//! deliberately adds no cache-refresh path from live server responses — a
11//! server-wide legacy connection is not exact-tool authorized and must not
12//! become a cache-poisoning bridge.
13//!
14//! Under progressive tool disclosure, cached descriptors become truthful
15//! DORMANT registry entries: live tools-map/catalog entries with a stable
16//! identity, schema, and digest whose implementation is deferred
17//! ([`DeferredMcpTool`]). Exact activation therefore never mutates the
18//! catalog generation; execution is separately authorization-gated and
19//! (lease lifecycle commit) acquires a session-scoped runtime lease before
20//! any process starts.
21
22use std::collections::{BTreeMap, HashSet};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use serde_json::{json, Value};
27use thiserror::Error;
28
29use super::{McpConfig, McpServerConfig};
30use crate::tools::catalog::SchemaDigest;
31use crate::tools::{Tool, ToolContext, ToolOrigin};
32use agent_core::BoundedText;
33
34/// File name of the descriptor cache, resolved through the profile-aware
35/// config path helper so profiles cannot collide on one cache file.
36pub const DESCRIPTOR_CACHE_FILE: &str = "mcp-descriptors.json";
37/// Supported cache format version. Anything else fails typed.
38pub const CACHE_FORMAT_VERSION: u32 = 1;
39/// Hard byte bound on the cache file — larger files are rejected unread.
40pub const CACHE_MAX_BYTES: u64 = 1024 * 1024;
41/// Maximum retained descriptors per server entry.
42pub const SERVER_MAX_TOOLS: usize = 256;
43/// Maximum bytes of a cached tool name (also: nonempty, no control chars).
44pub const TOOL_NAME_MAX_BYTES: usize = 128;
45/// Byte bound applied to cached descriptions on load.
46pub const TOOL_DESCRIPTION_MAX_BYTES: usize = 1024;
47/// Maximum serialized bytes of one cached input schema.
48pub const TOOL_SCHEMA_MAX_BYTES: usize = 64 * 1024;
49/// Maximum bytes of a server key in the cache file.
50const SERVER_NAME_MAX_BYTES: usize = 64;
51/// Byte bound for echoing parse failures into typed errors.
52const ERROR_ECHO_MAX_BYTES: usize = 160;
53
54/// Deterministic fingerprint of one server's local launch configuration
55/// (command, args, env). Cached descriptors are only trusted while the
56/// current config still fingerprints identically; any drift invalidates
57/// them. Pure local computation — no process, no network.
58///
59/// `shared` is deliberately EXCLUDED: it changes the lease key, not the
60/// launched process, so flipping it must not discard a valid listing.
61pub fn server_config_fingerprint(config: &McpServerConfig) -> String {
62    let env: BTreeMap<&str, &str> = config
63        .env
64        .iter()
65        .map(|(k, v)| (k.as_str(), v.as_str()))
66        .collect();
67    SchemaDigest::of_schema(&json!({
68        "command": config.command,
69        "args": config.args,
70        "env": env,
71    }))
72    .as_hex()
73    .to_string()
74}
75
76/// One cached MCP tool descriptor: exactly the fields a live `tools/list`
77/// would provide, pinned locally so activation can validate the live
78/// listing against operator-known expectations.
79#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
80pub struct CachedToolDescriptor {
81    pub name: String,
82    #[serde(default)]
83    pub description: String,
84    pub input_schema: Value,
85}
86
87/// Cached descriptors for one configured server, keyed to the exact config
88/// fingerprint they were recorded under.
89#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
90pub struct CachedServerDescriptors {
91    pub fingerprint: String,
92    #[serde(default)]
93    pub tools: Vec<CachedToolDescriptor>,
94}
95
96/// Versioned on-disk descriptor cache model.
97#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
98pub struct McpDescriptorCache {
99    pub version: u32,
100    #[serde(default)]
101    pub servers: BTreeMap<String, CachedServerDescriptors>,
102    /// Negative cache for boot discovery: servers whose last discovery
103    /// attempt failed (error or timeout), keyed by server name. Additive and
104    /// optional on disk (omitted when empty, defaulted when absent), so the
105    /// format version is unchanged and older readers — which do not deny
106    /// unknown fields — simply ignore it.
107    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
108    pub discovery_failures: BTreeMap<String, DiscoveryFailure>,
109}
110
111impl McpDescriptorCache {
112    pub fn empty() -> Self {
113        Self {
114            version: CACHE_FORMAT_VERSION,
115            servers: BTreeMap::new(),
116            discovery_failures: BTreeMap::new(),
117        }
118    }
119}
120
121/// Maximum retained discovery-failure entries (oldest attempts evicted).
122pub const DISCOVERY_FAILURES_MAX: usize = 64;
123/// Byte bound on a recorded failure fingerprint (a hex digest in practice).
124const FINGERPRINT_MAX_BYTES: usize = 128;
125/// First retry delay after a failed boot discovery.
126pub const DISCOVERY_BACKOFF_BASE_SECS: u64 = 60 * 60;
127/// Retry delay cap after repeated failed boot discoveries.
128pub const DISCOVERY_BACKOFF_MAX_SECS: u64 = 24 * 60 * 60;
129
130/// One server's failed boot-discovery record: the launch fingerprint it
131/// failed under, when it was last tried (unix seconds) and how many
132/// consecutive attempts under that fingerprint failed.
133#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
134pub struct DiscoveryFailure {
135    pub fingerprint: String,
136    pub last_attempt_unix: u64,
137    pub attempts: u32,
138}
139
140impl DiscoveryFailure {
141    /// `1 h × 2^(attempts−1)`, capped at 24 h.
142    pub fn backoff_secs(&self) -> u64 {
143        let exp = self.attempts.max(1).saturating_sub(1).min(16);
144        (DISCOVERY_BACKOFF_BASE_SECS << exp).min(DISCOVERY_BACKOFF_MAX_SECS)
145    }
146
147    /// Unix second from which the server may be retried.
148    pub fn retry_after_unix(&self) -> u64 {
149        self.last_attempt_unix.saturating_add(self.backoff_secs())
150    }
151
152    /// Whether boot discovery for a server currently fingerprinting as
153    /// `fingerprint` should be skipped at `now_unix`. A changed fingerprint
154    /// (config edit) retries immediately; so does a record stamped in the
155    /// future (the clock moved backwards), rather than stretching the backoff.
156    pub fn suppresses(&self, fingerprint: &str, now_unix: u64) -> bool {
157        self.fingerprint == fingerprint
158            && self.last_attempt_unix <= now_unix
159            && now_unix < self.retry_after_unix()
160    }
161}
162
163/// Typed descriptor-cache boundary failures. All fail closed: a rejected
164/// cache yields ZERO dormant capabilities, never a partial or guessed set.
165#[derive(Debug, Error)]
166pub enum DescriptorCacheError {
167    #[error("descriptor cache not found")]
168    NotFound,
169    #[error("descriptor cache is not a regular file (symlinks and special files are rejected)")]
170    NotRegularFile,
171    #[error("descriptor cache is {actual} bytes, over the {max} byte bound")]
172    Oversize { actual: u64, max: u64 },
173    #[error("failed to read descriptor cache: {0}")]
174    Io(String),
175    #[error("descriptor cache is not valid for the expected shape: {0}")]
176    Parse(String),
177    #[error("unsupported descriptor cache version {0}")]
178    Version(u32),
179}
180
181fn valid_tool_name(name: &str) -> bool {
182    !name.is_empty() && name.len() <= TOOL_NAME_MAX_BYTES && !name.chars().any(char::is_control)
183}
184
185fn valid_server_name(name: &str) -> bool {
186    !name.is_empty() && name.len() <= SERVER_NAME_MAX_BYTES && !name.chars().any(char::is_control)
187}
188
189/// Load and sanitize a descriptor cache from an explicit path. Boundary
190/// rules: exactly ONE handle is opened (`O_NOFOLLOW|O_NONBLOCK` on Unix, so
191/// a symlink or FIFO at the path is refused without following/blocking) and
192/// ALL checks run against that opened handle's metadata — no
193/// metadata-then-reopen TOCTOU. The opened file must be regular and within
194/// [`CACHE_MAX_BYTES`]; the read itself is capped via `Read::take` and a
195/// post-read length check rejects concurrent growth past the bound. The
196/// content must be valid JSON of the supported version. Individual hostile
197/// descriptor entries (invalid name, non-object schema, oversized schema,
198/// duplicates) are dropped deterministically; descriptions are bounded.
199/// Never spawns, never touches the network.
200pub fn load_cache_from(path: &Path) -> Result<McpDescriptorCache, DescriptorCacheError> {
201    let content = read_bounded_regular_file(path, CACHE_MAX_BYTES)?;
202    let raw: McpDescriptorCache = serde_json::from_str(&content).map_err(|err| {
203        DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
204    })?;
205    if raw.version != CACHE_FORMAT_VERSION {
206        return Err(DescriptorCacheError::Version(raw.version));
207    }
208    sanitize_cache(raw)
209}
210
211/// Shared bounded local-file boundary (Task 19): exactly ONE handle is
212/// opened (`O_NOFOLLOW|O_NONBLOCK` on Unix — symlinks and FIFOs refused
213/// without following/blocking) and all checks run against that opened
214/// handle's metadata; the read is capped via `Read::take` with a post-read
215/// growth check. Used for both the descriptor cache and `mcp.json`.
216pub(crate) fn read_bounded_regular_file(
217    path: &Path,
218    max_bytes: u64,
219) -> Result<String, DescriptorCacheError> {
220    use std::io::Read;
221
222    let map_io =
223        |err: &std::io::Error| BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text;
224
225    #[cfg(unix)]
226    let file = {
227        use std::os::unix::fs::OpenOptionsExt;
228        std::fs::OpenOptions::new()
229            .read(true)
230            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
231            .open(path)
232            .map_err(|err| {
233                if err.raw_os_error() == Some(libc::ELOOP) {
234                    DescriptorCacheError::NotRegularFile
235                } else if err.kind() == std::io::ErrorKind::NotFound {
236                    DescriptorCacheError::NotFound
237                } else if err.raw_os_error() == Some(libc::EISDIR) {
238                    DescriptorCacheError::NotRegularFile
239                } else {
240                    DescriptorCacheError::Io(map_io(&err))
241                }
242            })?
243    };
244    #[cfg(not(unix))]
245    let file = {
246        // Best-effort symlink refusal first, then re-verify on the opened
247        // handle's metadata below (regular-file check).
248        let meta = std::fs::symlink_metadata(path).map_err(|err| {
249            if err.kind() == std::io::ErrorKind::NotFound {
250                DescriptorCacheError::NotFound
251            } else {
252                DescriptorCacheError::Io(map_io(&err))
253            }
254        })?;
255        if meta.file_type().is_symlink() {
256            return Err(DescriptorCacheError::NotRegularFile);
257        }
258        std::fs::File::open(path).map_err(|err| DescriptorCacheError::Io(map_io(&err)))?
259    };
260
261    // All trust decisions read the OPENED handle's metadata.
262    let meta = file
263        .metadata()
264        .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
265    if !meta.file_type().is_file() {
266        return Err(DescriptorCacheError::NotRegularFile);
267    }
268    if meta.len() > max_bytes {
269        return Err(DescriptorCacheError::Oversize {
270            actual: meta.len(),
271            max: max_bytes,
272        });
273    }
274    let mut content = String::new();
275    file.take(max_bytes + 1)
276        .read_to_string(&mut content)
277        .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
278    if content.len() as u64 > max_bytes {
279        // The file grew past the bound between metadata and read.
280        return Err(DescriptorCacheError::Oversize {
281            actual: content.len() as u64,
282            max: max_bytes,
283        });
284    }
285    Ok(content)
286}
287
288fn sanitize_cache(raw: McpDescriptorCache) -> Result<McpDescriptorCache, DescriptorCacheError> {
289    let mut sanitized = McpDescriptorCache::empty();
290    for (server, entry) in raw.servers {
291        if !valid_server_name(&server) {
292            tracing::warn!(server = %BoundedText::new(&server, SERVER_NAME_MAX_BYTES).text,
293                "Dropping descriptor cache entry with invalid server name");
294            continue;
295        }
296        let mut seen: HashSet<String> = HashSet::new();
297        let mut tools = Vec::new();
298        for descriptor in entry.tools.into_iter() {
299            if tools.len() >= SERVER_MAX_TOOLS {
300                break;
301            }
302            if !valid_tool_name(&descriptor.name) {
303                tracing::warn!(server = %server, "Dropping cached MCP descriptor with invalid name");
304                continue;
305            }
306            if !seen.insert(descriptor.name.clone()) {
307                tracing::warn!(server = %server, tool = %descriptor.name,
308                    "Dropping duplicate cached MCP descriptor (keeping first)");
309                continue;
310            }
311            if !descriptor.input_schema.is_object() {
312                tracing::warn!(server = %server, tool = %descriptor.name,
313                    "Dropping cached MCP descriptor with non-object schema");
314                continue;
315            }
316            let schema_len = serde_json::to_vec(&descriptor.input_schema)
317                .map(|bytes| bytes.len())
318                .unwrap_or(usize::MAX);
319            if schema_len > TOOL_SCHEMA_MAX_BYTES {
320                tracing::warn!(server = %server, tool = %descriptor.name,
321                    "Dropping cached MCP descriptor with oversized schema");
322                continue;
323            }
324            tools.push(CachedToolDescriptor {
325                name: descriptor.name,
326                description: BoundedText::new(&descriptor.description, TOOL_DESCRIPTION_MAX_BYTES)
327                    .text,
328                input_schema: descriptor.input_schema,
329            });
330        }
331        sanitized.servers.insert(
332            server,
333            CachedServerDescriptors {
334                fingerprint: entry.fingerprint,
335                tools,
336            },
337        );
338    }
339    sanitized.discovery_failures = sanitize_discovery_failures(raw.discovery_failures);
340    Ok(sanitized)
341}
342
343/// Bound the negative cache like the rest of the file: valid server names,
344/// bounded control-free fingerprints, `attempts >= 1`, and at most
345/// [`DISCOVERY_FAILURES_MAX`] entries (the most recent attempts are kept).
346fn sanitize_discovery_failures(
347    raw: BTreeMap<String, DiscoveryFailure>,
348) -> BTreeMap<String, DiscoveryFailure> {
349    let mut kept: Vec<(String, DiscoveryFailure)> = raw
350        .into_iter()
351        .filter(|(server, failure)| {
352            let ok = valid_server_name(server)
353                && !failure.fingerprint.is_empty()
354                && failure.fingerprint.len() <= FINGERPRINT_MAX_BYTES
355                && !failure.fingerprint.chars().any(char::is_control);
356            if !ok {
357                tracing::warn!("Dropping invalid MCP discovery-failure cache entry");
358            }
359            ok
360        })
361        .map(|(server, mut failure)| {
362            failure.attempts = failure.attempts.max(1);
363            (server, failure)
364        })
365        .collect();
366    if kept.len() > DISCOVERY_FAILURES_MAX {
367        // Newest first; ties broken by name for determinism.
368        kept.sort_by(|a, b| {
369            b.1.last_attempt_unix
370                .cmp(&a.1.last_attempt_unix)
371                .then_with(|| a.0.cmp(&b.0))
372        });
373        kept.truncate(DISCOVERY_FAILURES_MAX);
374    }
375    kept.into_iter().collect()
376}
377
378/// Profile-aware default cache path. Uses the same profile FALLBACK
379/// semantics as every other config read (`resolve_read_path`): the profile
380/// copy is used when it exists, otherwise the shared base file. A shared
381/// base cache can therefore be visible to multiple profiles; the per-server
382/// config fingerprint still gates trust in every entry.
383pub fn default_cache_path() -> PathBuf {
384    crate::config::resolve_read_path(DESCRIPTOR_CACHE_FILE)
385}
386
387/// Load the descriptor cache from the profile-resolved default location.
388pub fn load_default_cache() -> Result<McpDescriptorCache, DescriptorCacheError> {
389    load_cache_from(&default_cache_path())
390}
391
392/// Atomically persist a descriptor cache with private-filesystem policy
393/// (spec §5.4): parent directory ensured `0700`, temp file created
394/// `create_new` at mode `0600` (never create-then-chmod), then renamed over
395/// the target; symlinks at the target are refused typed. Used to
396/// seed/maintain operator-local state (and by tests); this task adds no
397/// production writer fed from live server responses.
398pub fn store_cache_at(path: &Path, cache: &McpDescriptorCache) -> Result<(), DescriptorCacheError> {
399    use agent_core::core::private_fs::{ensure_private_dir, write_atomic_private, PrivateFsError};
400
401    let bytes = serde_json::to_vec_pretty(cache).map_err(|err| {
402        DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
403    })?;
404    let map_fs = |err: PrivateFsError| match err {
405        PrivateFsError::SymlinkRefused(_) => DescriptorCacheError::NotRegularFile,
406        PrivateFsError::Io(io) => {
407            DescriptorCacheError::Io(BoundedText::new(&io.to_string(), ERROR_ECHO_MAX_BYTES).text)
408        }
409    };
410    if let Some(parent) = path.parent() {
411        if !parent.as_os_str().is_empty() {
412            ensure_private_dir(parent).map_err(map_fs)?;
413        }
414    }
415    write_atomic_private(path, &bytes).map_err(map_fs)
416}
417
418/// `SYNAPS_MCP_CACHE_WRITEBACK=0` disables [`record_server_listing`].
419pub fn cache_writeback_enabled() -> bool {
420    !matches!(
421        std::env::var("SYNAPS_MCP_CACHE_WRITEBACK").as_deref(),
422        Ok("0") | Ok("false") | Ok("off")
423    )
424}
425
426/// Write-back after a successful, lease-authorized `tools/list` (daemon-mode
427/// phase 2, C4): merge `tools` into the cache at `path` under `server`,
428/// keyed by the launch `fingerprint`, so the NEXT boot's `setup_lazy_mcp`
429/// registers the same dormant descriptors without spawning anything (stable
430/// prompt-cache prefix across daemon restarts).
431///
432/// Only the exact-lease path calls this — the listing it records comes from
433/// a server the operator configured, started for an already gate-authorized
434/// call. The legacy server-wide gateway never writes here (cache-poisoning
435/// bridge, see module docs).
436///
437/// Concurrency: exclusive `fs4` lock on `<path>.lock` for the whole
438/// read-merge-write, then [`store_cache_at`] (0600, atomic rename). A cache
439/// that fails to load (corrupt/oversize/version) is REPLACED by a fresh one
440/// carrying only this server — the reader refuses such a file anyway.
441/// Entries are sanitized through the same bounds as a load, so a hostile
442/// listing cannot write what a load would reject. A recorded listing also
443/// clears the server's boot-discovery failure entry, if any. Returns the
444/// number of descriptors recorded for `server`.
445pub fn record_server_listing(
446    path: &Path,
447    server: &str,
448    fingerprint: &str,
449    tools: &[CachedToolDescriptor],
450) -> Result<usize, DescriptorCacheError> {
451    if !valid_server_name(server) {
452        return Err(DescriptorCacheError::Parse("invalid server name".to_string()));
453    }
454    let cache = update_cache_locked(path, |cache| {
455        let entry = CachedServerDescriptors {
456            fingerprint: fingerprint.to_string(),
457            tools: tools.to_vec(),
458        };
459        cache.servers.insert(server.to_string(), entry);
460        // A successful listing clears any recorded discovery failure.
461        cache.discovery_failures.remove(server);
462    })?;
463    Ok(cache.servers.get(server).map_or(0, |e| e.tools.len()))
464}
465
466/// Negative-cache sibling of [`record_server_listing`]: record that boot
467/// discovery of `server` under `fingerprint` failed at `now_unix`. Same
468/// locked read-merge-write and sanitisation. Consecutive failures under the
469/// same fingerprint increment `attempts` (driving the backoff); a different
470/// fingerprint restarts at 1. Returns the stored record.
471pub fn record_discovery_failure(
472    path: &Path,
473    server: &str,
474    fingerprint: &str,
475    now_unix: u64,
476) -> Result<DiscoveryFailure, DescriptorCacheError> {
477    if !valid_server_name(server) {
478        return Err(DescriptorCacheError::Parse("invalid server name".to_string()));
479    }
480    let cache = update_cache_locked(path, |cache| {
481        let attempts = cache
482            .discovery_failures
483            .get(server)
484            .filter(|prior| prior.fingerprint == fingerprint)
485            .map_or(1, |prior| prior.attempts.saturating_add(1));
486        cache.discovery_failures.insert(
487            server.to_string(),
488            DiscoveryFailure {
489                fingerprint: fingerprint.to_string(),
490                last_attempt_unix: now_unix,
491                attempts,
492            },
493        );
494    })?;
495    cache
496        .discovery_failures
497        .get(server)
498        .cloned()
499        .ok_or_else(|| DescriptorCacheError::Parse("discovery failure not retained".to_string()))
500}
501
502/// Shared locked read-merge-write for the cache writers: exclusive `fs4`
503/// lock on `<path>.lock`, load (an unreadable cache is replaced by a fresh
504/// one), apply `mutate`, sanitise, [`store_cache_at`]. Returns the stored
505/// (sanitised) cache.
506fn update_cache_locked(
507    path: &Path,
508    mutate: impl FnOnce(&mut McpDescriptorCache),
509) -> Result<McpDescriptorCache, DescriptorCacheError> {
510    use fs4::fs_std::FileExt;
511
512    let map_io = |err: std::io::Error| {
513        DescriptorCacheError::Io(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
514    };
515    if let Some(parent) = path.parent() {
516        if !parent.as_os_str().is_empty() {
517            agent_core::core::private_fs::ensure_private_dir(parent).map_err(|err| match err {
518                agent_core::core::private_fs::PrivateFsError::SymlinkRefused(_) => {
519                    DescriptorCacheError::NotRegularFile
520                }
521                agent_core::core::private_fs::PrivateFsError::Io(io) => map_io(io),
522            })?;
523        }
524    }
525    let lock_path = {
526        let mut name = path
527            .file_name()
528            .map(|n| n.to_os_string())
529            .unwrap_or_else(|| std::ffi::OsString::from(DESCRIPTOR_CACHE_FILE));
530        name.push(".lock");
531        path.with_file_name(name)
532    };
533    let lock_file = std::fs::OpenOptions::new()
534        .create(true)
535        .truncate(false)
536        .write(true)
537        .open(&lock_path)
538        .map_err(map_io)?;
539    FileExt::lock_exclusive(&lock_file).map_err(map_io)?;
540
541    let mut cache = match load_cache_from(path) {
542        Ok(cache) => cache,
543        Err(DescriptorCacheError::NotFound) => McpDescriptorCache::empty(),
544        Err(err) => {
545            tracing::warn!(error = %err,
546                "MCP descriptor cache unreadable; replacing with a fresh cache on write-back");
547            McpDescriptorCache::empty()
548        }
549    };
550    mutate(&mut cache);
551    let cache = sanitize_cache(cache)?;
552    store_cache_at(path, &cache)?;
553    let _ = FileExt::unlock(&lock_file);
554    Ok(cache)
555}
556
557/// A dormant, descriptor-backed MCP tool. Registered like any live tool
558/// (stable identity, schema, digest — so wire resolution, discovery, exact
559/// grants, and the execution gate all work unchanged), but its
560/// implementation is DEFERRED: executing it requires the session MCP lease
561/// capability, which starts only this tool's server after the execution
562/// gate has already authorized the call. Without that capability it fails
563/// typed and spawns nothing.
564pub struct DeferredMcpTool {
565    server_name: String,
566    runtime_name: String,
567    server_tool_name: String,
568    description: String,
569    input_schema: Value,
570    expected_fingerprint: String,
571    expected_digest: SchemaDigest,
572}
573
574impl DeferredMcpTool {
575    fn new(server: &str, descriptor: &CachedToolDescriptor, fingerprint: &str) -> Self {
576        Self {
577            server_name: server.to_string(),
578            // Naming parity with legacy live registration ("ext__srv__tool")
579            // keeps wire naming stable across dormant and connected modes.
580            runtime_name: format!("ext__{}__{}", server, descriptor.name),
581            server_tool_name: descriptor.name.clone(),
582            description: format!("[MCP:{}] {}", server, descriptor.description),
583            input_schema: descriptor.input_schema.clone(),
584            expected_fingerprint: fingerprint.to_string(),
585            expected_digest: SchemaDigest::of_schema(&descriptor.input_schema),
586        }
587    }
588
589    pub fn server_name(&self) -> &str {
590        &self.server_name
591    }
592
593    pub fn server_tool_name(&self) -> &str {
594        &self.server_tool_name
595    }
596
597    /// Config fingerprint the pinned descriptor was recorded under.
598    pub fn expected_fingerprint(&self) -> &str {
599        &self.expected_fingerprint
600    }
601
602    /// Digest of the pinned descriptor schema — equal by construction to
603    /// this tool's catalog record digest, so the live listing check and the
604    /// grant digest check validate the same value.
605    pub fn expected_digest(&self) -> &SchemaDigest {
606        &self.expected_digest
607    }
608}
609
610#[async_trait::async_trait]
611impl Tool for DeferredMcpTool {
612    fn origin(&self) -> ToolOrigin {
613        ToolOrigin::Mcp {
614            server_id: self.server_name.clone(),
615            server_tool_name: self.server_tool_name.clone(),
616        }
617    }
618
619    fn name(&self) -> &str {
620        &self.runtime_name
621    }
622
623    fn description(&self) -> &str {
624        &self.description
625    }
626
627    fn parameters(&self) -> Value {
628        self.input_schema.clone()
629    }
630
631    async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
632        // Runs strictly AFTER the ExecutionGate authorized this exact call.
633        let binding = ctx
634            .capabilities
635            .memory_backend
636            .clone()
637            .unwrap_or_else(crate::memory_backend::MemoryBinding::configured_current);
638        if binding.exclusive() && crate::memory_backend::competing_note_tool(&self.server_tool_name)
639        {
640            return Err(crate::RuntimeError::Tool("independent MCP memory is disabled by the host-selected backend; use short memory tools".into()));
641        }
642        // Without the typed session lease capability, a deferred tool NEVER
643        // starts a process.
644        let Some(leases) = ctx.capabilities.mcp_leases.clone() else {
645            return Err(crate::RuntimeError::Tool(format!(
646                "MCP tool '{}' (server '{}') is activation-deferred and no MCP session \
647                 lease manager is available in this context; no process was started",
648                self.runtime_name, self.server_name
649            )));
650        };
651        leases
652            .call_exact(
653                &self.server_name,
654                &self.expected_fingerprint,
655                &self.server_tool_name,
656                &self.expected_digest,
657                params,
658            )
659            .await
660            .map_err(|err| {
661                // Grant invalidation (spec 7.4): config removal, fingerprint
662                // drift, and live name/schema mismatch poison the pinned
663                // descriptor, so the EXACT session grant must fall with the
664                // lease. Transport/capacity/revocation-race failures are
665                // transient and must NOT revoke.
666                use super::lease::McpLeaseError;
667                if matches!(
668                    err,
669                    McpLeaseError::ServerNotConfigured(_)
670                        | McpLeaseError::FingerprintDrift(_)
671                        | McpLeaseError::NameNotListed(_, _)
672                        | McpLeaseError::SchemaMismatch(_, _)
673                ) {
674                    if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
675                        if activation.revoke_exact_mcp_grant(
676                            leases.session(),
677                            &self.server_name,
678                            &self.server_tool_name,
679                        ) {
680                            tracing::debug!(
681                                "revoked exact MCP activation after descriptor invalidation"
682                            );
683                        }
684                    }
685                }
686                crate::RuntimeError::Tool(err.to_string())
687            })
688    }
689}
690
691/// Build the dormant deferred tools for the current config from the cache.
692/// Deterministic order (sorted server names, cached tool order). Only
693/// servers present in the CURRENT config whose cache entry fingerprint
694/// matches the current config fingerprint contribute tools; everything else
695/// is skipped with a warning. Never spawns, never touches the network.
696pub fn dormant_tools_for_config(
697    config: &McpConfig,
698    cache: &McpDescriptorCache,
699) -> Vec<Arc<dyn Tool>> {
700    let mut servers: Vec<(&String, &McpServerConfig)> = config.mcp_servers.iter().collect();
701    servers.sort_by(|a, b| a.0.cmp(b.0));
702
703    let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
704    for (server, server_config) in servers {
705        let Some(entry) = cache.servers.get(server) else {
706            tracing::debug!(server = %server,
707                "MCP server has no cached descriptors; it stays undiscoverable (no process is started to ask)");
708            continue;
709        };
710        let current = server_config_fingerprint(server_config);
711        if entry.fingerprint != current {
712            tracing::warn!(server = %server,
713                "MCP descriptor cache fingerprint does not match the current config; cached descriptors are invalid");
714            continue;
715        }
716        for descriptor in &entry.tools {
717            tools.push(Arc::new(DeferredMcpTool::new(
718                server,
719                descriptor,
720                &entry.fingerprint,
721            )));
722        }
723    }
724    tools
725}
726
727#[cfg(all(test, unix))]
728mod tests {
729    use super::*;
730    use serial_test::serial;
731    use std::os::unix::fs::PermissionsExt;
732
733    /// Restores the previous process umask on drop, even on panic.
734    struct UmaskGuard {
735        old: libc::mode_t,
736    }
737    impl UmaskGuard {
738        fn set(mask: libc::mode_t) -> Self {
739            Self {
740                old: unsafe { libc::umask(mask) },
741            }
742        }
743    }
744    impl Drop for UmaskGuard {
745        fn drop(&mut self) {
746            unsafe {
747                libc::umask(self.old);
748            }
749        }
750    }
751
752    fn mode_of(path: &Path) -> u32 {
753        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
754    }
755
756    /// Private modes must be application-enforced (spec §5.4), never an
757    /// accident of the process umask: under a fully permissive umask the
758    /// cache file must still be 0600 and its created parent directory 0700.
759    #[test]
760    #[serial(umask)]
761    fn store_is_private_even_under_permissive_umask() {
762        let base = std::env::temp_dir().join(format!("synaps-mcp-umask-{}", std::process::id()));
763        let _ = std::fs::remove_dir_all(&base);
764        let _guard = UmaskGuard::set(0);
765
766        let path = base.join("nested").join(DESCRIPTOR_CACHE_FILE);
767        store_cache_at(&path, &McpDescriptorCache::empty()).unwrap();
768
769        assert_eq!(mode_of(&path), 0o600, "cache file must be private");
770        assert_eq!(
771            mode_of(&base.join("nested")),
772            0o700,
773            "created cache dir must be private"
774        );
775        let _ = std::fs::remove_dir_all(&base);
776    }
777}
778
779#[cfg(test)]
780mod discovery_failure_tests {
781    use super::*;
782
783    fn failure(fingerprint: &str, last: u64, attempts: u32) -> DiscoveryFailure {
784        DiscoveryFailure {
785            fingerprint: fingerprint.to_string(),
786            last_attempt_unix: last,
787            attempts,
788        }
789    }
790
791    #[test]
792    fn backoff_doubles_per_attempt_and_caps_at_a_day() {
793        let hour = DISCOVERY_BACKOFF_BASE_SECS;
794        assert_eq!(failure("fp", 0, 0).backoff_secs(), hour, "0 is treated as 1");
795        assert_eq!(failure("fp", 0, 1).backoff_secs(), hour);
796        assert_eq!(failure("fp", 0, 2).backoff_secs(), 2 * hour);
797        assert_eq!(failure("fp", 0, 5).backoff_secs(), 16 * hour);
798        assert_eq!(failure("fp", 0, 6).backoff_secs(), DISCOVERY_BACKOFF_MAX_SECS);
799        assert_eq!(failure("fp", 0, u32::MAX).backoff_secs(), DISCOVERY_BACKOFF_MAX_SECS);
800        assert_eq!(failure("fp", u64::MAX, 1).retry_after_unix(), u64::MAX, "saturates");
801    }
802
803    #[test]
804    fn suppression_is_keyed_to_fingerprint_and_window() {
805        let f = failure("fp", 1_000, 1);
806        assert!(f.suppresses("fp", 1_000));
807        assert!(f.suppresses("fp", 1_000 + 3_599));
808        assert!(!f.suppresses("fp", 1_000 + 3_600), "retry once the backoff expires");
809        assert!(!f.suppresses("other", 1_001), "a config edit retries immediately");
810        assert!(!f.suppresses("fp", 999), "a clock that went backwards retries");
811    }
812
813    #[test]
814    fn failures_are_optional_on_disk_and_omitted_when_empty() {
815        // An older cache (no field) still loads; an empty field is not written.
816        let old: McpDescriptorCache =
817            serde_json::from_str(r#"{"version":1,"servers":{}}"#).unwrap();
818        assert!(old.discovery_failures.is_empty());
819        let json = serde_json::to_string(&McpDescriptorCache::empty()).unwrap();
820        assert!(!json.contains("discovery_failures"), "{json}");
821    }
822
823    #[test]
824    fn sanitize_drops_invalid_entries_and_bounds_the_count() {
825        let mut raw = McpDescriptorCache::empty();
826        raw.discovery_failures.insert("ok".into(), failure("fp", 5, 0));
827        raw.discovery_failures.insert("bad\u{7}".into(), failure("fp", 5, 1));
828        raw.discovery_failures.insert("nofp".into(), failure("", 5, 1));
829        raw.discovery_failures
830            .insert("longfp".into(), failure(&"f".repeat(FINGERPRINT_MAX_BYTES + 1), 5, 1));
831        let clean = sanitize_cache(raw).unwrap();
832        assert_eq!(clean.discovery_failures.len(), 1);
833        assert_eq!(clean.discovery_failures["ok"].attempts, 1, "attempts clamped to >= 1");
834
835        let mut raw = McpDescriptorCache::empty();
836        for i in 0..(DISCOVERY_FAILURES_MAX + 10) {
837            raw.discovery_failures
838                .insert(format!("s{i}"), failure("fp", i as u64, 1));
839        }
840        let clean = sanitize_cache(raw).unwrap();
841        assert_eq!(clean.discovery_failures.len(), DISCOVERY_FAILURES_MAX);
842        assert!(
843            !clean.discovery_failures.contains_key("s0"),
844            "the oldest attempts are evicted first"
845        );
846        let newest = format!("s{}", DISCOVERY_FAILURES_MAX + 9);
847        assert!(clean.discovery_failures.contains_key(&newest));
848    }
849
850    #[test]
851    fn record_failure_counts_per_fingerprint_and_listing_clears_it() {
852        let dir = tempfile::tempdir().unwrap();
853        let path = dir.path().join(DESCRIPTOR_CACHE_FILE);
854
855        let first = record_discovery_failure(&path, "srv", "fp1", 100).unwrap();
856        assert_eq!((first.attempts, first.last_attempt_unix), (1, 100));
857        let second = record_discovery_failure(&path, "srv", "fp1", 200).unwrap();
858        assert_eq!((second.attempts, second.last_attempt_unix), (2, 200));
859        let changed = record_discovery_failure(&path, "srv", "fp2", 300).unwrap();
860        assert_eq!(changed.attempts, 1, "a new fingerprint restarts the count");
861        record_discovery_failure(&path, "other", "fp", 300).unwrap();
862
863        let loaded = load_cache_from(&path).unwrap();
864        assert_eq!(loaded.discovery_failures.len(), 2);
865        assert_eq!(loaded.discovery_failures["srv"].fingerprint, "fp2");
866
867        let tools = [CachedToolDescriptor {
868            name: "t".into(),
869            description: String::new(),
870            input_schema: json!({"type": "object"}),
871        }];
872        assert_eq!(record_server_listing(&path, "srv", "fp2", &tools).unwrap(), 1);
873        let loaded = load_cache_from(&path).unwrap();
874        assert!(!loaded.discovery_failures.contains_key("srv"), "success clears");
875        assert!(loaded.discovery_failures.contains_key("other"), "others kept");
876        assert_eq!(loaded.servers["srv"].tools.len(), 1);
877
878        assert!(record_discovery_failure(&path, "bad\u{7}", "fp", 1).is_err());
879    }
880}