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.
58pub fn server_config_fingerprint(config: &McpServerConfig) -> String {
59    let env: BTreeMap<&str, &str> = config
60        .env
61        .iter()
62        .map(|(k, v)| (k.as_str(), v.as_str()))
63        .collect();
64    SchemaDigest::of_schema(&json!({
65        "command": config.command,
66        "args": config.args,
67        "env": env,
68    }))
69    .as_hex()
70    .to_string()
71}
72
73/// One cached MCP tool descriptor: exactly the fields a live `tools/list`
74/// would provide, pinned locally so activation can validate the live
75/// listing against operator-known expectations.
76#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
77pub struct CachedToolDescriptor {
78    pub name: String,
79    #[serde(default)]
80    pub description: String,
81    pub input_schema: Value,
82}
83
84/// Cached descriptors for one configured server, keyed to the exact config
85/// fingerprint they were recorded under.
86#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
87pub struct CachedServerDescriptors {
88    pub fingerprint: String,
89    #[serde(default)]
90    pub tools: Vec<CachedToolDescriptor>,
91}
92
93/// Versioned on-disk descriptor cache model.
94#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
95pub struct McpDescriptorCache {
96    pub version: u32,
97    #[serde(default)]
98    pub servers: BTreeMap<String, CachedServerDescriptors>,
99}
100
101impl McpDescriptorCache {
102    pub fn empty() -> Self {
103        Self {
104            version: CACHE_FORMAT_VERSION,
105            servers: BTreeMap::new(),
106        }
107    }
108}
109
110/// Typed descriptor-cache boundary failures. All fail closed: a rejected
111/// cache yields ZERO dormant capabilities, never a partial or guessed set.
112#[derive(Debug, Error)]
113pub enum DescriptorCacheError {
114    #[error("descriptor cache not found")]
115    NotFound,
116    #[error("descriptor cache is not a regular file (symlinks and special files are rejected)")]
117    NotRegularFile,
118    #[error("descriptor cache is {actual} bytes, over the {max} byte bound")]
119    Oversize { actual: u64, max: u64 },
120    #[error("failed to read descriptor cache: {0}")]
121    Io(String),
122    #[error("descriptor cache is not valid for the expected shape: {0}")]
123    Parse(String),
124    #[error("unsupported descriptor cache version {0}")]
125    Version(u32),
126}
127
128fn valid_tool_name(name: &str) -> bool {
129    !name.is_empty() && name.len() <= TOOL_NAME_MAX_BYTES && !name.chars().any(char::is_control)
130}
131
132fn valid_server_name(name: &str) -> bool {
133    !name.is_empty() && name.len() <= SERVER_NAME_MAX_BYTES && !name.chars().any(char::is_control)
134}
135
136/// Load and sanitize a descriptor cache from an explicit path. Boundary
137/// rules: exactly ONE handle is opened (`O_NOFOLLOW|O_NONBLOCK` on Unix, so
138/// a symlink or FIFO at the path is refused without following/blocking) and
139/// ALL checks run against that opened handle's metadata — no
140/// metadata-then-reopen TOCTOU. The opened file must be regular and within
141/// [`CACHE_MAX_BYTES`]; the read itself is capped via `Read::take` and a
142/// post-read length check rejects concurrent growth past the bound. The
143/// content must be valid JSON of the supported version. Individual hostile
144/// descriptor entries (invalid name, non-object schema, oversized schema,
145/// duplicates) are dropped deterministically; descriptions are bounded.
146/// Never spawns, never touches the network.
147pub fn load_cache_from(path: &Path) -> Result<McpDescriptorCache, DescriptorCacheError> {
148    let content = read_bounded_regular_file(path, CACHE_MAX_BYTES)?;
149    let raw: McpDescriptorCache = serde_json::from_str(&content).map_err(|err| {
150        DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
151    })?;
152    if raw.version != CACHE_FORMAT_VERSION {
153        return Err(DescriptorCacheError::Version(raw.version));
154    }
155    sanitize_cache(raw)
156}
157
158/// Shared bounded local-file boundary (Task 19): exactly ONE handle is
159/// opened (`O_NOFOLLOW|O_NONBLOCK` on Unix — symlinks and FIFOs refused
160/// without following/blocking) and all checks run against that opened
161/// handle's metadata; the read is capped via `Read::take` with a post-read
162/// growth check. Used for both the descriptor cache and `mcp.json`.
163pub(crate) fn read_bounded_regular_file(
164    path: &Path,
165    max_bytes: u64,
166) -> Result<String, DescriptorCacheError> {
167    use std::io::Read;
168
169    let map_io =
170        |err: &std::io::Error| BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text;
171
172    #[cfg(unix)]
173    let file = {
174        use std::os::unix::fs::OpenOptionsExt;
175        std::fs::OpenOptions::new()
176            .read(true)
177            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
178            .open(path)
179            .map_err(|err| {
180                if err.raw_os_error() == Some(libc::ELOOP) {
181                    DescriptorCacheError::NotRegularFile
182                } else if err.kind() == std::io::ErrorKind::NotFound {
183                    DescriptorCacheError::NotFound
184                } else if err.raw_os_error() == Some(libc::EISDIR) {
185                    DescriptorCacheError::NotRegularFile
186                } else {
187                    DescriptorCacheError::Io(map_io(&err))
188                }
189            })?
190    };
191    #[cfg(not(unix))]
192    let file = {
193        // Best-effort symlink refusal first, then re-verify on the opened
194        // handle's metadata below (regular-file check).
195        let meta = std::fs::symlink_metadata(path).map_err(|err| {
196            if err.kind() == std::io::ErrorKind::NotFound {
197                DescriptorCacheError::NotFound
198            } else {
199                DescriptorCacheError::Io(map_io(&err))
200            }
201        })?;
202        if meta.file_type().is_symlink() {
203            return Err(DescriptorCacheError::NotRegularFile);
204        }
205        std::fs::File::open(path).map_err(|err| DescriptorCacheError::Io(map_io(&err)))?
206    };
207
208    // All trust decisions read the OPENED handle's metadata.
209    let meta = file
210        .metadata()
211        .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
212    if !meta.file_type().is_file() {
213        return Err(DescriptorCacheError::NotRegularFile);
214    }
215    if meta.len() > max_bytes {
216        return Err(DescriptorCacheError::Oversize {
217            actual: meta.len(),
218            max: max_bytes,
219        });
220    }
221    let mut content = String::new();
222    file.take(max_bytes + 1)
223        .read_to_string(&mut content)
224        .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
225    if content.len() as u64 > max_bytes {
226        // The file grew past the bound between metadata and read.
227        return Err(DescriptorCacheError::Oversize {
228            actual: content.len() as u64,
229            max: max_bytes,
230        });
231    }
232    Ok(content)
233}
234
235fn sanitize_cache(raw: McpDescriptorCache) -> Result<McpDescriptorCache, DescriptorCacheError> {
236    let mut sanitized = McpDescriptorCache::empty();
237    for (server, entry) in raw.servers {
238        if !valid_server_name(&server) {
239            tracing::warn!(server = %BoundedText::new(&server, SERVER_NAME_MAX_BYTES).text,
240                "Dropping descriptor cache entry with invalid server name");
241            continue;
242        }
243        let mut seen: HashSet<String> = HashSet::new();
244        let mut tools = Vec::new();
245        for descriptor in entry.tools.into_iter() {
246            if tools.len() >= SERVER_MAX_TOOLS {
247                break;
248            }
249            if !valid_tool_name(&descriptor.name) {
250                tracing::warn!(server = %server, "Dropping cached MCP descriptor with invalid name");
251                continue;
252            }
253            if !seen.insert(descriptor.name.clone()) {
254                tracing::warn!(server = %server, tool = %descriptor.name,
255                    "Dropping duplicate cached MCP descriptor (keeping first)");
256                continue;
257            }
258            if !descriptor.input_schema.is_object() {
259                tracing::warn!(server = %server, tool = %descriptor.name,
260                    "Dropping cached MCP descriptor with non-object schema");
261                continue;
262            }
263            let schema_len = serde_json::to_vec(&descriptor.input_schema)
264                .map(|bytes| bytes.len())
265                .unwrap_or(usize::MAX);
266            if schema_len > TOOL_SCHEMA_MAX_BYTES {
267                tracing::warn!(server = %server, tool = %descriptor.name,
268                    "Dropping cached MCP descriptor with oversized schema");
269                continue;
270            }
271            tools.push(CachedToolDescriptor {
272                name: descriptor.name,
273                description: BoundedText::new(&descriptor.description, TOOL_DESCRIPTION_MAX_BYTES)
274                    .text,
275                input_schema: descriptor.input_schema,
276            });
277        }
278        sanitized.servers.insert(
279            server,
280            CachedServerDescriptors {
281                fingerprint: entry.fingerprint,
282                tools,
283            },
284        );
285    }
286    Ok(sanitized)
287}
288
289/// Profile-aware default cache path. Uses the same profile FALLBACK
290/// semantics as every other config read (`resolve_read_path`): the profile
291/// copy is used when it exists, otherwise the shared base file. A shared
292/// base cache can therefore be visible to multiple profiles; the per-server
293/// config fingerprint still gates trust in every entry.
294pub fn default_cache_path() -> PathBuf {
295    crate::config::resolve_read_path(DESCRIPTOR_CACHE_FILE)
296}
297
298/// Load the descriptor cache from the profile-resolved default location.
299pub fn load_default_cache() -> Result<McpDescriptorCache, DescriptorCacheError> {
300    load_cache_from(&default_cache_path())
301}
302
303/// Atomically persist a descriptor cache with private-filesystem policy
304/// (spec §5.4): parent directory ensured `0700`, temp file created
305/// `create_new` at mode `0600` (never create-then-chmod), then renamed over
306/// the target; symlinks at the target are refused typed. Used to
307/// seed/maintain operator-local state (and by tests); this task adds no
308/// production writer fed from live server responses.
309pub fn store_cache_at(path: &Path, cache: &McpDescriptorCache) -> Result<(), DescriptorCacheError> {
310    use agent_core::core::private_fs::{ensure_private_dir, write_atomic_private, PrivateFsError};
311
312    let bytes = serde_json::to_vec_pretty(cache).map_err(|err| {
313        DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
314    })?;
315    let map_fs = |err: PrivateFsError| match err {
316        PrivateFsError::SymlinkRefused(_) => DescriptorCacheError::NotRegularFile,
317        PrivateFsError::Io(io) => {
318            DescriptorCacheError::Io(BoundedText::new(&io.to_string(), ERROR_ECHO_MAX_BYTES).text)
319        }
320    };
321    if let Some(parent) = path.parent() {
322        if !parent.as_os_str().is_empty() {
323            ensure_private_dir(parent).map_err(map_fs)?;
324        }
325    }
326    write_atomic_private(path, &bytes).map_err(map_fs)
327}
328
329/// A dormant, descriptor-backed MCP tool. Registered like any live tool
330/// (stable identity, schema, digest — so wire resolution, discovery, exact
331/// grants, and the execution gate all work unchanged), but its
332/// implementation is DEFERRED: executing it requires the session MCP lease
333/// capability, which starts only this tool's server after the execution
334/// gate has already authorized the call. Without that capability it fails
335/// typed and spawns nothing.
336pub struct DeferredMcpTool {
337    server_name: String,
338    runtime_name: String,
339    server_tool_name: String,
340    description: String,
341    input_schema: Value,
342    expected_fingerprint: String,
343    expected_digest: SchemaDigest,
344}
345
346impl DeferredMcpTool {
347    fn new(server: &str, descriptor: &CachedToolDescriptor, fingerprint: &str) -> Self {
348        Self {
349            server_name: server.to_string(),
350            // Naming parity with legacy live registration ("ext__srv__tool")
351            // keeps wire naming stable across dormant and connected modes.
352            runtime_name: format!("ext__{}__{}", server, descriptor.name),
353            server_tool_name: descriptor.name.clone(),
354            description: format!("[MCP:{}] {}", server, descriptor.description),
355            input_schema: descriptor.input_schema.clone(),
356            expected_fingerprint: fingerprint.to_string(),
357            expected_digest: SchemaDigest::of_schema(&descriptor.input_schema),
358        }
359    }
360
361    pub fn server_name(&self) -> &str {
362        &self.server_name
363    }
364
365    pub fn server_tool_name(&self) -> &str {
366        &self.server_tool_name
367    }
368
369    /// Config fingerprint the pinned descriptor was recorded under.
370    pub fn expected_fingerprint(&self) -> &str {
371        &self.expected_fingerprint
372    }
373
374    /// Digest of the pinned descriptor schema — equal by construction to
375    /// this tool's catalog record digest, so the live listing check and the
376    /// grant digest check validate the same value.
377    pub fn expected_digest(&self) -> &SchemaDigest {
378        &self.expected_digest
379    }
380}
381
382#[async_trait::async_trait]
383impl Tool for DeferredMcpTool {
384    fn origin(&self) -> ToolOrigin {
385        ToolOrigin::Mcp {
386            server_id: self.server_name.clone(),
387            server_tool_name: self.server_tool_name.clone(),
388        }
389    }
390
391    fn name(&self) -> &str {
392        &self.runtime_name
393    }
394
395    fn description(&self) -> &str {
396        &self.description
397    }
398
399    fn parameters(&self) -> Value {
400        self.input_schema.clone()
401    }
402
403    async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
404        // Runs strictly AFTER the ExecutionGate authorized this exact call.
405        // Without the typed session lease capability, a deferred tool NEVER
406        // starts a process.
407        let Some(leases) = ctx.capabilities.mcp_leases.clone() else {
408            return Err(crate::RuntimeError::Tool(format!(
409                "MCP tool '{}' (server '{}') is activation-deferred and no MCP session \
410                 lease manager is available in this context; no process was started",
411                self.runtime_name, self.server_name
412            )));
413        };
414        leases
415            .call_exact(
416                &self.server_name,
417                &self.expected_fingerprint,
418                &self.server_tool_name,
419                &self.expected_digest,
420                params,
421            )
422            .await
423            .map_err(|err| {
424                // Grant invalidation (spec 7.4): config removal, fingerprint
425                // drift, and live name/schema mismatch poison the pinned
426                // descriptor, so the EXACT session grant must fall with the
427                // lease. Transport/capacity/revocation-race failures are
428                // transient and must NOT revoke.
429                use super::lease::McpLeaseError;
430                if matches!(
431                    err,
432                    McpLeaseError::ServerNotConfigured(_)
433                        | McpLeaseError::FingerprintDrift(_)
434                        | McpLeaseError::NameNotListed(_, _)
435                        | McpLeaseError::SchemaMismatch(_, _)
436                ) {
437                    if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
438                        if activation.revoke_exact_mcp_grant(
439                            leases.session(),
440                            &self.server_name,
441                            &self.server_tool_name,
442                        ) {
443                            tracing::debug!(
444                                "revoked exact MCP activation after descriptor invalidation"
445                            );
446                        }
447                    }
448                }
449                crate::RuntimeError::Tool(err.to_string())
450            })
451    }
452}
453
454/// Build the dormant deferred tools for the current config from the cache.
455/// Deterministic order (sorted server names, cached tool order). Only
456/// servers present in the CURRENT config whose cache entry fingerprint
457/// matches the current config fingerprint contribute tools; everything else
458/// is skipped with a warning. Never spawns, never touches the network.
459pub fn dormant_tools_for_config(
460    config: &McpConfig,
461    cache: &McpDescriptorCache,
462) -> Vec<Arc<dyn Tool>> {
463    let mut servers: Vec<(&String, &McpServerConfig)> = config.mcp_servers.iter().collect();
464    servers.sort_by(|a, b| a.0.cmp(b.0));
465
466    let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
467    for (server, server_config) in servers {
468        let Some(entry) = cache.servers.get(server) else {
469            tracing::debug!(server = %server,
470                "MCP server has no cached descriptors; it stays undiscoverable (no process is started to ask)");
471            continue;
472        };
473        let current = server_config_fingerprint(server_config);
474        if entry.fingerprint != current {
475            tracing::warn!(server = %server,
476                "MCP descriptor cache fingerprint does not match the current config; cached descriptors are invalid");
477            continue;
478        }
479        for descriptor in &entry.tools {
480            tools.push(Arc::new(DeferredMcpTool::new(
481                server,
482                descriptor,
483                &entry.fingerprint,
484            )));
485        }
486    }
487    tools
488}
489
490#[cfg(all(test, unix))]
491mod tests {
492    use super::*;
493    use serial_test::serial;
494    use std::os::unix::fs::PermissionsExt;
495
496    /// Restores the previous process umask on drop, even on panic.
497    struct UmaskGuard {
498        old: libc::mode_t,
499    }
500    impl UmaskGuard {
501        fn set(mask: libc::mode_t) -> Self {
502            Self {
503                old: unsafe { libc::umask(mask) },
504            }
505        }
506    }
507    impl Drop for UmaskGuard {
508        fn drop(&mut self) {
509            unsafe {
510                libc::umask(self.old);
511            }
512        }
513    }
514
515    fn mode_of(path: &Path) -> u32 {
516        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
517    }
518
519    /// Private modes must be application-enforced (spec §5.4), never an
520    /// accident of the process umask: under a fully permissive umask the
521    /// cache file must still be 0600 and its created parent directory 0700.
522    #[test]
523    #[serial(umask)]
524    fn store_is_private_even_under_permissive_umask() {
525        let base = std::env::temp_dir().join(format!("synaps-mcp-umask-{}", std::process::id()));
526        let _ = std::fs::remove_dir_all(&base);
527        let _guard = UmaskGuard::set(0);
528
529        let path = base.join("nested").join(DESCRIPTOR_CACHE_FILE);
530        store_cache_at(&path, &McpDescriptorCache::empty()).unwrap();
531
532        assert_eq!(mode_of(&path), 0o600, "cache file must be private");
533        assert_eq!(
534            mode_of(&base.join("nested")),
535            0o700,
536            "created cache dir must be private"
537        );
538        let _ = std::fs::remove_dir_all(&base);
539    }
540}