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 #[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
121pub const DISCOVERY_FAILURES_MAX: usize = 64;
123const FINGERPRINT_MAX_BYTES: usize = 128;
125pub const DISCOVERY_BACKOFF_BASE_SECS: u64 = 60 * 60;
127pub const DISCOVERY_BACKOFF_MAX_SECS: u64 = 24 * 60 * 60;
129
130#[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 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 pub fn retry_after_unix(&self) -> u64 {
149 self.last_attempt_unix.saturating_add(self.backoff_secs())
150 }
151
152 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#[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
189pub 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
211pub(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 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 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 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
343fn 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 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
378pub fn default_cache_path() -> PathBuf {
384 crate::config::resolve_read_path(DESCRIPTOR_CACHE_FILE)
385}
386
387pub fn load_default_cache() -> Result<McpDescriptorCache, DescriptorCacheError> {
389 load_cache_from(&default_cache_path())
390}
391
392pub 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
418pub 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
426pub 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 cache.discovery_failures.remove(server);
462 })?;
463 Ok(cache.servers.get(server).map_or(0, |e| e.tools.len()))
464}
465
466pub 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
502fn 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
557pub 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 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 pub fn expected_fingerprint(&self) -> &str {
599 &self.expected_fingerprint
600 }
601
602 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 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 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 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
691pub 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 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 #[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 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}