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}
103
104impl McpDescriptorCache {
105    pub fn empty() -> Self {
106        Self {
107            version: CACHE_FORMAT_VERSION,
108            servers: BTreeMap::new(),
109        }
110    }
111}
112
113/// Typed descriptor-cache boundary failures. All fail closed: a rejected
114/// cache yields ZERO dormant capabilities, never a partial or guessed set.
115#[derive(Debug, Error)]
116pub enum DescriptorCacheError {
117    #[error("descriptor cache not found")]
118    NotFound,
119    #[error("descriptor cache is not a regular file (symlinks and special files are rejected)")]
120    NotRegularFile,
121    #[error("descriptor cache is {actual} bytes, over the {max} byte bound")]
122    Oversize { actual: u64, max: u64 },
123    #[error("failed to read descriptor cache: {0}")]
124    Io(String),
125    #[error("descriptor cache is not valid for the expected shape: {0}")]
126    Parse(String),
127    #[error("unsupported descriptor cache version {0}")]
128    Version(u32),
129}
130
131fn valid_tool_name(name: &str) -> bool {
132    !name.is_empty() && name.len() <= TOOL_NAME_MAX_BYTES && !name.chars().any(char::is_control)
133}
134
135fn valid_server_name(name: &str) -> bool {
136    !name.is_empty() && name.len() <= SERVER_NAME_MAX_BYTES && !name.chars().any(char::is_control)
137}
138
139/// Load and sanitize a descriptor cache from an explicit path. Boundary
140/// rules: exactly ONE handle is opened (`O_NOFOLLOW|O_NONBLOCK` on Unix, so
141/// a symlink or FIFO at the path is refused without following/blocking) and
142/// ALL checks run against that opened handle's metadata — no
143/// metadata-then-reopen TOCTOU. The opened file must be regular and within
144/// [`CACHE_MAX_BYTES`]; the read itself is capped via `Read::take` and a
145/// post-read length check rejects concurrent growth past the bound. The
146/// content must be valid JSON of the supported version. Individual hostile
147/// descriptor entries (invalid name, non-object schema, oversized schema,
148/// duplicates) are dropped deterministically; descriptions are bounded.
149/// Never spawns, never touches the network.
150pub fn load_cache_from(path: &Path) -> Result<McpDescriptorCache, DescriptorCacheError> {
151    let content = read_bounded_regular_file(path, CACHE_MAX_BYTES)?;
152    let raw: McpDescriptorCache = serde_json::from_str(&content).map_err(|err| {
153        DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
154    })?;
155    if raw.version != CACHE_FORMAT_VERSION {
156        return Err(DescriptorCacheError::Version(raw.version));
157    }
158    sanitize_cache(raw)
159}
160
161/// Shared bounded local-file boundary (Task 19): exactly ONE handle is
162/// opened (`O_NOFOLLOW|O_NONBLOCK` on Unix — symlinks and FIFOs refused
163/// without following/blocking) and all checks run against that opened
164/// handle's metadata; the read is capped via `Read::take` with a post-read
165/// growth check. Used for both the descriptor cache and `mcp.json`.
166pub(crate) fn read_bounded_regular_file(
167    path: &Path,
168    max_bytes: u64,
169) -> Result<String, DescriptorCacheError> {
170    use std::io::Read;
171
172    let map_io =
173        |err: &std::io::Error| BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text;
174
175    #[cfg(unix)]
176    let file = {
177        use std::os::unix::fs::OpenOptionsExt;
178        std::fs::OpenOptions::new()
179            .read(true)
180            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
181            .open(path)
182            .map_err(|err| {
183                if err.raw_os_error() == Some(libc::ELOOP) {
184                    DescriptorCacheError::NotRegularFile
185                } else if err.kind() == std::io::ErrorKind::NotFound {
186                    DescriptorCacheError::NotFound
187                } else if err.raw_os_error() == Some(libc::EISDIR) {
188                    DescriptorCacheError::NotRegularFile
189                } else {
190                    DescriptorCacheError::Io(map_io(&err))
191                }
192            })?
193    };
194    #[cfg(not(unix))]
195    let file = {
196        // Best-effort symlink refusal first, then re-verify on the opened
197        // handle's metadata below (regular-file check).
198        let meta = std::fs::symlink_metadata(path).map_err(|err| {
199            if err.kind() == std::io::ErrorKind::NotFound {
200                DescriptorCacheError::NotFound
201            } else {
202                DescriptorCacheError::Io(map_io(&err))
203            }
204        })?;
205        if meta.file_type().is_symlink() {
206            return Err(DescriptorCacheError::NotRegularFile);
207        }
208        std::fs::File::open(path).map_err(|err| DescriptorCacheError::Io(map_io(&err)))?
209    };
210
211    // All trust decisions read the OPENED handle's metadata.
212    let meta = file
213        .metadata()
214        .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
215    if !meta.file_type().is_file() {
216        return Err(DescriptorCacheError::NotRegularFile);
217    }
218    if meta.len() > max_bytes {
219        return Err(DescriptorCacheError::Oversize {
220            actual: meta.len(),
221            max: max_bytes,
222        });
223    }
224    let mut content = String::new();
225    file.take(max_bytes + 1)
226        .read_to_string(&mut content)
227        .map_err(|err| DescriptorCacheError::Io(map_io(&err)))?;
228    if content.len() as u64 > max_bytes {
229        // The file grew past the bound between metadata and read.
230        return Err(DescriptorCacheError::Oversize {
231            actual: content.len() as u64,
232            max: max_bytes,
233        });
234    }
235    Ok(content)
236}
237
238fn sanitize_cache(raw: McpDescriptorCache) -> Result<McpDescriptorCache, DescriptorCacheError> {
239    let mut sanitized = McpDescriptorCache::empty();
240    for (server, entry) in raw.servers {
241        if !valid_server_name(&server) {
242            tracing::warn!(server = %BoundedText::new(&server, SERVER_NAME_MAX_BYTES).text,
243                "Dropping descriptor cache entry with invalid server name");
244            continue;
245        }
246        let mut seen: HashSet<String> = HashSet::new();
247        let mut tools = Vec::new();
248        for descriptor in entry.tools.into_iter() {
249            if tools.len() >= SERVER_MAX_TOOLS {
250                break;
251            }
252            if !valid_tool_name(&descriptor.name) {
253                tracing::warn!(server = %server, "Dropping cached MCP descriptor with invalid name");
254                continue;
255            }
256            if !seen.insert(descriptor.name.clone()) {
257                tracing::warn!(server = %server, tool = %descriptor.name,
258                    "Dropping duplicate cached MCP descriptor (keeping first)");
259                continue;
260            }
261            if !descriptor.input_schema.is_object() {
262                tracing::warn!(server = %server, tool = %descriptor.name,
263                    "Dropping cached MCP descriptor with non-object schema");
264                continue;
265            }
266            let schema_len = serde_json::to_vec(&descriptor.input_schema)
267                .map(|bytes| bytes.len())
268                .unwrap_or(usize::MAX);
269            if schema_len > TOOL_SCHEMA_MAX_BYTES {
270                tracing::warn!(server = %server, tool = %descriptor.name,
271                    "Dropping cached MCP descriptor with oversized schema");
272                continue;
273            }
274            tools.push(CachedToolDescriptor {
275                name: descriptor.name,
276                description: BoundedText::new(&descriptor.description, TOOL_DESCRIPTION_MAX_BYTES)
277                    .text,
278                input_schema: descriptor.input_schema,
279            });
280        }
281        sanitized.servers.insert(
282            server,
283            CachedServerDescriptors {
284                fingerprint: entry.fingerprint,
285                tools,
286            },
287        );
288    }
289    Ok(sanitized)
290}
291
292/// Profile-aware default cache path. Uses the same profile FALLBACK
293/// semantics as every other config read (`resolve_read_path`): the profile
294/// copy is used when it exists, otherwise the shared base file. A shared
295/// base cache can therefore be visible to multiple profiles; the per-server
296/// config fingerprint still gates trust in every entry.
297pub fn default_cache_path() -> PathBuf {
298    crate::config::resolve_read_path(DESCRIPTOR_CACHE_FILE)
299}
300
301/// Load the descriptor cache from the profile-resolved default location.
302pub fn load_default_cache() -> Result<McpDescriptorCache, DescriptorCacheError> {
303    load_cache_from(&default_cache_path())
304}
305
306/// Atomically persist a descriptor cache with private-filesystem policy
307/// (spec §5.4): parent directory ensured `0700`, temp file created
308/// `create_new` at mode `0600` (never create-then-chmod), then renamed over
309/// the target; symlinks at the target are refused typed. Used to
310/// seed/maintain operator-local state (and by tests); this task adds no
311/// production writer fed from live server responses.
312pub fn store_cache_at(path: &Path, cache: &McpDescriptorCache) -> Result<(), DescriptorCacheError> {
313    use agent_core::core::private_fs::{ensure_private_dir, write_atomic_private, PrivateFsError};
314
315    let bytes = serde_json::to_vec_pretty(cache).map_err(|err| {
316        DescriptorCacheError::Parse(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
317    })?;
318    let map_fs = |err: PrivateFsError| match err {
319        PrivateFsError::SymlinkRefused(_) => DescriptorCacheError::NotRegularFile,
320        PrivateFsError::Io(io) => {
321            DescriptorCacheError::Io(BoundedText::new(&io.to_string(), ERROR_ECHO_MAX_BYTES).text)
322        }
323    };
324    if let Some(parent) = path.parent() {
325        if !parent.as_os_str().is_empty() {
326            ensure_private_dir(parent).map_err(map_fs)?;
327        }
328    }
329    write_atomic_private(path, &bytes).map_err(map_fs)
330}
331
332/// `SYNAPS_MCP_CACHE_WRITEBACK=0` disables [`record_server_listing`].
333pub fn cache_writeback_enabled() -> bool {
334    !matches!(
335        std::env::var("SYNAPS_MCP_CACHE_WRITEBACK").as_deref(),
336        Ok("0") | Ok("false") | Ok("off")
337    )
338}
339
340/// Write-back after a successful, lease-authorized `tools/list` (daemon-mode
341/// phase 2, C4): merge `tools` into the cache at `path` under `server`,
342/// keyed by the launch `fingerprint`, so the NEXT boot's `setup_lazy_mcp`
343/// registers the same dormant descriptors without spawning anything (stable
344/// prompt-cache prefix across daemon restarts).
345///
346/// Only the exact-lease path calls this — the listing it records comes from
347/// a server the operator configured, started for an already gate-authorized
348/// call. The legacy server-wide gateway never writes here (cache-poisoning
349/// bridge, see module docs).
350///
351/// Concurrency: exclusive `fs4` lock on `<path>.lock` for the whole
352/// read-merge-write, then [`store_cache_at`] (0600, atomic rename). A cache
353/// that fails to load (corrupt/oversize/version) is REPLACED by a fresh one
354/// carrying only this server — the reader refuses such a file anyway.
355/// Entries are sanitized through the same bounds as a load, so a hostile
356/// listing cannot write what a load would reject. Returns the number of
357/// descriptors recorded for `server`.
358pub fn record_server_listing(
359    path: &Path,
360    server: &str,
361    fingerprint: &str,
362    tools: &[CachedToolDescriptor],
363) -> Result<usize, DescriptorCacheError> {
364    use fs4::fs_std::FileExt;
365
366    if !valid_server_name(server) {
367        return Err(DescriptorCacheError::Parse("invalid server name".to_string()));
368    }
369    let map_io = |err: std::io::Error| {
370        DescriptorCacheError::Io(BoundedText::new(&err.to_string(), ERROR_ECHO_MAX_BYTES).text)
371    };
372    if let Some(parent) = path.parent() {
373        if !parent.as_os_str().is_empty() {
374            agent_core::core::private_fs::ensure_private_dir(parent).map_err(|err| match err {
375                agent_core::core::private_fs::PrivateFsError::SymlinkRefused(_) => {
376                    DescriptorCacheError::NotRegularFile
377                }
378                agent_core::core::private_fs::PrivateFsError::Io(io) => map_io(io),
379            })?;
380        }
381    }
382    let lock_path = {
383        let mut name = path
384            .file_name()
385            .map(|n| n.to_os_string())
386            .unwrap_or_else(|| std::ffi::OsString::from(DESCRIPTOR_CACHE_FILE));
387        name.push(".lock");
388        path.with_file_name(name)
389    };
390    let lock_file = std::fs::OpenOptions::new()
391        .create(true)
392        .truncate(false)
393        .write(true)
394        .open(&lock_path)
395        .map_err(map_io)?;
396    FileExt::lock_exclusive(&lock_file).map_err(map_io)?;
397
398    let mut cache = match load_cache_from(path) {
399        Ok(cache) => cache,
400        Err(DescriptorCacheError::NotFound) => McpDescriptorCache::empty(),
401        Err(err) => {
402            tracing::warn!(error = %err,
403                "MCP descriptor cache unreadable; replacing with a fresh cache on write-back");
404            McpDescriptorCache::empty()
405        }
406    };
407    let entry = CachedServerDescriptors {
408        fingerprint: fingerprint.to_string(),
409        tools: tools.to_vec(),
410    };
411    cache.servers.insert(server.to_string(), entry);
412    let cache = sanitize_cache(cache)?;
413    let recorded = cache.servers.get(server).map_or(0, |e| e.tools.len());
414    store_cache_at(path, &cache)?;
415    let _ = FileExt::unlock(&lock_file);
416    Ok(recorded)
417}
418
419/// A dormant, descriptor-backed MCP tool. Registered like any live tool
420/// (stable identity, schema, digest — so wire resolution, discovery, exact
421/// grants, and the execution gate all work unchanged), but its
422/// implementation is DEFERRED: executing it requires the session MCP lease
423/// capability, which starts only this tool's server after the execution
424/// gate has already authorized the call. Without that capability it fails
425/// typed and spawns nothing.
426pub struct DeferredMcpTool {
427    server_name: String,
428    runtime_name: String,
429    server_tool_name: String,
430    description: String,
431    input_schema: Value,
432    expected_fingerprint: String,
433    expected_digest: SchemaDigest,
434}
435
436impl DeferredMcpTool {
437    fn new(server: &str, descriptor: &CachedToolDescriptor, fingerprint: &str) -> Self {
438        Self {
439            server_name: server.to_string(),
440            // Naming parity with legacy live registration ("ext__srv__tool")
441            // keeps wire naming stable across dormant and connected modes.
442            runtime_name: format!("ext__{}__{}", server, descriptor.name),
443            server_tool_name: descriptor.name.clone(),
444            description: format!("[MCP:{}] {}", server, descriptor.description),
445            input_schema: descriptor.input_schema.clone(),
446            expected_fingerprint: fingerprint.to_string(),
447            expected_digest: SchemaDigest::of_schema(&descriptor.input_schema),
448        }
449    }
450
451    pub fn server_name(&self) -> &str {
452        &self.server_name
453    }
454
455    pub fn server_tool_name(&self) -> &str {
456        &self.server_tool_name
457    }
458
459    /// Config fingerprint the pinned descriptor was recorded under.
460    pub fn expected_fingerprint(&self) -> &str {
461        &self.expected_fingerprint
462    }
463
464    /// Digest of the pinned descriptor schema — equal by construction to
465    /// this tool's catalog record digest, so the live listing check and the
466    /// grant digest check validate the same value.
467    pub fn expected_digest(&self) -> &SchemaDigest {
468        &self.expected_digest
469    }
470}
471
472#[async_trait::async_trait]
473impl Tool for DeferredMcpTool {
474    fn origin(&self) -> ToolOrigin {
475        ToolOrigin::Mcp {
476            server_id: self.server_name.clone(),
477            server_tool_name: self.server_tool_name.clone(),
478        }
479    }
480
481    fn name(&self) -> &str {
482        &self.runtime_name
483    }
484
485    fn description(&self) -> &str {
486        &self.description
487    }
488
489    fn parameters(&self) -> Value {
490        self.input_schema.clone()
491    }
492
493    async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
494        // Runs strictly AFTER the ExecutionGate authorized this exact call.
495        let binding = ctx
496            .capabilities
497            .memory_backend
498            .clone()
499            .unwrap_or_else(crate::memory_backend::MemoryBinding::configured_current);
500        if binding.exclusive() && crate::memory_backend::competing_note_tool(&self.server_tool_name)
501        {
502            return Err(crate::RuntimeError::Tool("independent MCP memory is disabled by the host-selected backend; use short memory tools".into()));
503        }
504        // Without the typed session lease capability, a deferred tool NEVER
505        // starts a process.
506        let Some(leases) = ctx.capabilities.mcp_leases.clone() else {
507            return Err(crate::RuntimeError::Tool(format!(
508                "MCP tool '{}' (server '{}') is activation-deferred and no MCP session \
509                 lease manager is available in this context; no process was started",
510                self.runtime_name, self.server_name
511            )));
512        };
513        leases
514            .call_exact(
515                &self.server_name,
516                &self.expected_fingerprint,
517                &self.server_tool_name,
518                &self.expected_digest,
519                params,
520            )
521            .await
522            .map_err(|err| {
523                // Grant invalidation (spec 7.4): config removal, fingerprint
524                // drift, and live name/schema mismatch poison the pinned
525                // descriptor, so the EXACT session grant must fall with the
526                // lease. Transport/capacity/revocation-race failures are
527                // transient and must NOT revoke.
528                use super::lease::McpLeaseError;
529                if matches!(
530                    err,
531                    McpLeaseError::ServerNotConfigured(_)
532                        | McpLeaseError::FingerprintDrift(_)
533                        | McpLeaseError::NameNotListed(_, _)
534                        | McpLeaseError::SchemaMismatch(_, _)
535                ) {
536                    if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
537                        if activation.revoke_exact_mcp_grant(
538                            leases.session(),
539                            &self.server_name,
540                            &self.server_tool_name,
541                        ) {
542                            tracing::debug!(
543                                "revoked exact MCP activation after descriptor invalidation"
544                            );
545                        }
546                    }
547                }
548                crate::RuntimeError::Tool(err.to_string())
549            })
550    }
551}
552
553/// Build the dormant deferred tools for the current config from the cache.
554/// Deterministic order (sorted server names, cached tool order). Only
555/// servers present in the CURRENT config whose cache entry fingerprint
556/// matches the current config fingerprint contribute tools; everything else
557/// is skipped with a warning. Never spawns, never touches the network.
558pub fn dormant_tools_for_config(
559    config: &McpConfig,
560    cache: &McpDescriptorCache,
561) -> Vec<Arc<dyn Tool>> {
562    let mut servers: Vec<(&String, &McpServerConfig)> = config.mcp_servers.iter().collect();
563    servers.sort_by(|a, b| a.0.cmp(b.0));
564
565    let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
566    for (server, server_config) in servers {
567        let Some(entry) = cache.servers.get(server) else {
568            tracing::debug!(server = %server,
569                "MCP server has no cached descriptors; it stays undiscoverable (no process is started to ask)");
570            continue;
571        };
572        let current = server_config_fingerprint(server_config);
573        if entry.fingerprint != current {
574            tracing::warn!(server = %server,
575                "MCP descriptor cache fingerprint does not match the current config; cached descriptors are invalid");
576            continue;
577        }
578        for descriptor in &entry.tools {
579            tools.push(Arc::new(DeferredMcpTool::new(
580                server,
581                descriptor,
582                &entry.fingerprint,
583            )));
584        }
585    }
586    tools
587}
588
589#[cfg(all(test, unix))]
590mod tests {
591    use super::*;
592    use serial_test::serial;
593    use std::os::unix::fs::PermissionsExt;
594
595    /// Restores the previous process umask on drop, even on panic.
596    struct UmaskGuard {
597        old: libc::mode_t,
598    }
599    impl UmaskGuard {
600        fn set(mask: libc::mode_t) -> Self {
601            Self {
602                old: unsafe { libc::umask(mask) },
603            }
604        }
605    }
606    impl Drop for UmaskGuard {
607        fn drop(&mut self) {
608            unsafe {
609                libc::umask(self.old);
610            }
611        }
612    }
613
614    fn mode_of(path: &Path) -> u32 {
615        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
616    }
617
618    /// Private modes must be application-enforced (spec §5.4), never an
619    /// accident of the process umask: under a fully permissive umask the
620    /// cache file must still be 0600 and its created parent directory 0700.
621    #[test]
622    #[serial(umask)]
623    fn store_is_private_even_under_permissive_umask() {
624        let base = std::env::temp_dir().join(format!("synaps-mcp-umask-{}", std::process::id()));
625        let _ = std::fs::remove_dir_all(&base);
626        let _guard = UmaskGuard::set(0);
627
628        let path = base.join("nested").join(DESCRIPTOR_CACHE_FILE);
629        store_cache_at(&path, &McpDescriptorCache::empty()).unwrap();
630
631        assert_eq!(mode_of(&path), 0o600, "cache file must be private");
632        assert_eq!(
633            mode_of(&base.join("nested")),
634            0o700,
635            "created cache dir must be private"
636        );
637        let _ = std::fs::remove_dir_all(&base);
638    }
639}