1use 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
34pub const DESCRIPTOR_CACHE_FILE: &str = "mcp-descriptors.json";
37pub const CACHE_FORMAT_VERSION: u32 = 1;
39pub const CACHE_MAX_BYTES: u64 = 1024 * 1024;
41pub const SERVER_MAX_TOOLS: usize = 256;
43pub const TOOL_NAME_MAX_BYTES: usize = 128;
45pub const TOOL_DESCRIPTION_MAX_BYTES: usize = 1024;
47pub const TOOL_SCHEMA_MAX_BYTES: usize = 64 * 1024;
49const SERVER_NAME_MAX_BYTES: usize = 64;
51const ERROR_ECHO_MAX_BYTES: usize = 160;
53
54pub 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#[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#[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#[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#[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
139pub 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
161pub(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 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 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 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
292pub fn default_cache_path() -> PathBuf {
298 crate::config::resolve_read_path(DESCRIPTOR_CACHE_FILE)
299}
300
301pub fn load_default_cache() -> Result<McpDescriptorCache, DescriptorCacheError> {
303 load_cache_from(&default_cache_path())
304}
305
306pub 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
332pub 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
340pub 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
419pub 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 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 pub fn expected_fingerprint(&self) -> &str {
461 &self.expected_fingerprint
462 }
463
464 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 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 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 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
553pub 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 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 #[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}