1use lru::LruCache;
10use rustc_hash::FxHashMap;
11use std::num::NonZeroUsize;
12use std::sync::{Arc, RwLock};
13use std::time::{Duration, Instant};
14use tracing::error;
15
16use super::McpToolInfo;
17use super::tool_discovery::DetailLevel;
18
19#[derive(Clone)]
28pub struct BloomFilter {
29 bits: Vec<u64>,
31 num_hashes: usize,
33 size: usize,
35}
36
37impl BloomFilter {
38 const WORD_BITS: usize = 64;
40
41 fn new(expected_items: usize, false_positive_rate: f64) -> Self {
42 let expected_items = expected_items.max(1);
43 let size = Self::optimal_size(expected_items, false_positive_rate).max(1);
44 let num_hashes = Self::optimal_num_hashes(size, expected_items).max(1);
45
46 Self {
47 bits: vec![0; size.div_ceil(Self::WORD_BITS)],
48 num_hashes,
49 size,
50 }
51 }
52
53 fn insert(&mut self, item: &str) {
55 for index in Self::indices(item, self.num_hashes, self.size) {
56 if let Some(word) = self.bits.get_mut(index / Self::WORD_BITS) {
57 *word |= 1u64 << (index % Self::WORD_BITS);
58 }
59 }
60 }
61
62 fn contains(&self, item: &str) -> bool {
64 Self::indices(item, self.num_hashes, self.size).all(|index| {
65 self.bits
66 .get(index / Self::WORD_BITS)
67 .is_some_and(|word| word & (1u64 << (index % Self::WORD_BITS)) != 0)
68 })
69 }
70
71 fn indices(item: &str, num_hashes: usize, size: usize) -> impl Iterator<Item = usize> {
79 use std::collections::hash_map::DefaultHasher;
80 use std::hash::{Hash, Hasher};
81
82 let mut hasher = DefaultHasher::new();
83 item.hash(&mut hasher);
84 let hash = hasher.finish();
85 let base = (hash >> 32) as usize;
86 let step = ((hash & 0xffff_ffff) as usize) | 1;
87
88 (0..num_hashes).map(move |i| base.wrapping_add(i.wrapping_mul(step)) % size)
89 }
90
91 pub fn clear(&mut self) {
93 self.bits.fill(0);
94 }
95
96 #[allow(
98 clippy::cast_sign_loss,
99 reason = "Intentional compatibility, platform, or test-only suppression."
100 )]
101 #[expect(
102 clippy::cast_possible_truncation,
103 reason = "The calculated Bloom filter size is bounded by the target usize range before conversion."
104 )]
105 fn optimal_size(expected_items: usize, false_positive_rate: f64) -> usize {
106 let size = -(expected_items as f64 * false_positive_rate.ln() / (2.0_f64.ln().powi(2)));
107 size.max(0.0).ceil() as usize
108 }
109
110 #[allow(
112 clippy::cast_sign_loss,
113 reason = "Intentional compatibility, platform, or test-only suppression."
114 )]
115 #[expect(
116 clippy::cast_possible_truncation,
117 reason = "The calculated Bloom filter hash count is bounded by the target usize range before conversion."
118 )]
119 fn optimal_num_hashes(size: usize, expected_items: usize) -> usize {
120 let num_hashes = (size as f64 / expected_items as f64) * 2.0_f64.ln();
121 num_hashes.max(0.0).ceil() as usize
122 }
123}
124
125#[derive(Debug, Clone, Hash, PartialEq, Eq)]
127struct ToolDiscoveryCacheKey {
128 provider_name: String,
129 keyword: String,
130 detail_level: DetailLevel,
131}
132
133#[derive(Clone)]
135struct CachedToolDiscoveryEntry {
136 results: Arc<Vec<ToolDiscoveryResult>>,
138 timestamp: Instant,
139}
140
141struct DiscoveryCacheInner {
142 bloom_filter: BloomFilter,
143 detailed_cache: LruCache<ToolDiscoveryCacheKey, CachedToolDiscoveryEntry>,
144 all_tools_cache: FxHashMap<String, Vec<McpToolInfo>>,
145 last_refresh: FxHashMap<String, Instant>,
146}
147
148#[derive(Debug, Clone)]
150pub struct ToolDiscoveryResult {
151 tool: McpToolInfo,
152 relevance_score: f64,
153 detail_level: DetailLevel,
154}
155
156pub(crate) struct ToolDiscoveryCache {
158 inner: Arc<RwLock<DiscoveryCacheInner>>,
159 config: CacheConfig,
161}
162
163#[derive(Clone)]
164struct CacheConfig {
165 max_age: Duration,
167 provider_refresh_interval: Duration,
169 expected_tool_count: usize,
171 false_positive_rate: f64,
173}
174
175impl ToolDiscoveryCache {
176 pub(crate) fn new(capacity: usize) -> Self {
177 let config = CacheConfig {
178 max_age: Duration::from_secs(300), provider_refresh_interval: Duration::from_secs(60), expected_tool_count: 1000,
181 false_positive_rate: 0.01, };
183
184 let bloom_filter = BloomFilter::new(config.expected_tool_count, config.false_positive_rate);
185 let cache_size = NonZeroUsize::new(capacity).or(NonZeroUsize::new(100));
186
187 Self {
188 inner: Arc::new(RwLock::new(DiscoveryCacheInner {
189 bloom_filter,
190 detailed_cache: LruCache::new(cache_size.unwrap_or(NonZeroUsize::MIN)),
191 all_tools_cache: FxHashMap::default(),
192 last_refresh: FxHashMap::default(),
193 })),
194 config,
195 }
196 }
197
198 pub fn might_have_tool(&self, tool_name: &str) -> bool {
207 match self.inner.read() {
208 Ok(inner) => inner.bloom_filter.contains(tool_name),
209 Err(_) => {
210 tracing::warn!("Bloom filter lock poisoned, assuming tool might exist");
211 true
212 }
213 }
214 }
215
216 fn get_cached_discovery(
218 &self,
219 provider_name: &str,
220 keyword: &str,
221 detail_level: DetailLevel,
222 ) -> Option<Arc<Vec<ToolDiscoveryResult>>> {
223 let key = ToolDiscoveryCacheKey {
225 provider_name: provider_name.to_owned(),
226 keyword: keyword.to_owned(),
227 detail_level,
228 };
229
230 let mut inner = match self.inner.write() {
231 Ok(inner) => inner,
232 Err(e) => {
233 tracing::error!("Detailed cache lock poisoned: {}", e);
234 return None;
235 }
236 };
237
238 if let Some(cached) = inner.detailed_cache.get(&key) {
239 if cached.timestamp.elapsed() < self.config.max_age {
241 return Some(Arc::clone(&cached.results));
242 } else {
243 drop(inner.detailed_cache.pop(&key));
245 }
246 }
247
248 None
249 }
250
251 fn cache_discovery(
253 &self,
254 provider_name: &str,
255 keyword: &str,
256 detail_level: DetailLevel,
257 results: Vec<ToolDiscoveryResult>,
258 ) {
259 self.cache_discovery_shared(provider_name, keyword, detail_level, Arc::new(results));
260 }
261
262 fn cache_discovery_shared(
263 &self,
264 provider_name: &str,
265 keyword: &str,
266 detail_level: DetailLevel,
267 results: Arc<Vec<ToolDiscoveryResult>>,
268 ) {
269 let key = ToolDiscoveryCacheKey {
271 provider_name: provider_name.to_owned(),
272 keyword: keyword.to_owned(),
273 detail_level,
274 };
275
276 let cached = CachedToolDiscoveryEntry {
277 results: Arc::clone(&results),
279 timestamp: Instant::now(),
280 };
281
282 let Ok(mut inner) = self.inner.write() else {
283 tracing::error!("Failed to acquire discovery cache lock for writing");
284 return;
285 };
286
287 drop(inner.detailed_cache.put(key, cached));
288
289 for result in results.iter() {
290 inner.bloom_filter.insert(&result.tool.name);
291 }
292 }
293
294 pub fn get_all_tools(&self, provider_name: &str, refresh_if_stale: bool) -> Option<Vec<McpToolInfo>> {
296 let inner = match self.inner.read() {
297 Ok(inner) => inner,
298 Err(e) => {
299 error!("Discovery cache lock poisoned: {}", e);
300 return None;
301 }
302 };
303
304 let should_refresh = if let Some(last) = inner.last_refresh.get(provider_name) {
305 last.elapsed() > self.config.provider_refresh_interval
306 } else {
307 true
308 };
309
310 if should_refresh && refresh_if_stale {
311 return None; }
313
314 inner.all_tools_cache.get(provider_name).cloned()
315 }
316
317 pub fn cache_all_tools(&self, provider_name: &str, tools: Vec<McpToolInfo>) {
319 let mut inner = match self.inner.write() {
320 Ok(inner) => inner,
321 Err(e) => {
322 tracing::error!("Discovery cache lock poisoned: {}", e);
323 return;
324 }
325 };
326
327 drop(inner.all_tools_cache.insert(provider_name.to_owned(), tools.clone()));
328 let _previous = inner.last_refresh.insert(provider_name.to_owned(), Instant::now());
329
330 inner.bloom_filter.clear(); let all_tool_names: Vec<String> = inner
334 .all_tools_cache
335 .values()
336 .flat_map(|provider_tools| provider_tools.iter().map(|tool| tool.name.clone()))
337 .collect();
338
339 for tool_name in all_tool_names {
340 inner.bloom_filter.insert(&tool_name);
341 }
342 }
343
344 pub fn cache_tool_result(&self, _cache_key: String, _result: serde_json::Value) {
346 }
350
351 pub fn clear(&self) {
353 if let Ok(mut inner) = self.inner.write() {
354 inner.bloom_filter.clear();
355 inner.detailed_cache.clear();
356 inner.all_tools_cache.clear();
357 inner.last_refresh.clear();
358 }
359 }
360
361 pub(crate) fn stats(&self) -> ToolCacheStats {
363 let (detailed_entries, detailed_capacity, all_tools_entries, bf_size, bf_hashes) = self
364 .inner
365 .read()
366 .map(|inner| {
367 (
368 inner.detailed_cache.len(),
369 inner.detailed_cache.cap().get(),
370 inner.all_tools_cache.len(),
371 inner.bloom_filter.size,
372 inner.bloom_filter.num_hashes,
373 )
374 })
375 .unwrap_or((0, 0, 0, 0, 0));
376
377 ToolCacheStats {
378 detailed_cache_entries: detailed_entries,
379 detailed_cache_capacity: detailed_capacity,
380 all_tools_cache_entries: all_tools_entries,
381 bloom_filter_size: bf_size,
382 bloom_filter_hashes: bf_hashes,
383 }
384 }
385}
386
387#[derive(Debug, Clone)]
389pub struct ToolCacheStats {
390 detailed_cache_entries: usize,
391 detailed_cache_capacity: usize,
392 all_tools_cache_entries: usize,
393 bloom_filter_size: usize,
394 bloom_filter_hashes: usize,
395}
396
397pub struct CachedToolDiscovery {
399 cache: Arc<ToolDiscoveryCache>,
400}
401
402impl CachedToolDiscovery {
403 pub fn new(cache_capacity: usize) -> Self {
404 Self {
405 cache: Arc::new(ToolDiscoveryCache::new(cache_capacity)),
406 }
407 }
408
409 pub fn search_tools(
418 &self,
419 provider_name: &str,
420 keyword: &str,
421 detail_level: DetailLevel,
422 all_tools: Vec<McpToolInfo>,
423 ) -> Arc<Vec<ToolDiscoveryResult>> {
424 if let Some(cached) = self.cache.get_cached_discovery(provider_name, keyword, detail_level) {
426 return cached;
427 }
428
429 let results = Arc::new(self.perform_search(&all_tools, keyword, detail_level));
431
432 self.cache
434 .cache_discovery_shared(provider_name, keyword, detail_level, Arc::clone(&results));
435
436 results
437 }
438
439 pub fn get_all_tools_cached(&self, provider_name: &str, all_tools: Vec<McpToolInfo>) -> Vec<McpToolInfo> {
441 if let Some(cached) = self.cache.get_all_tools(provider_name, true) {
443 return cached;
444 }
445
446 self.cache.cache_all_tools(provider_name, all_tools.clone());
448
449 all_tools
450 }
451
452 fn perform_search(
454 &self,
455 tools: &[McpToolInfo],
456 keyword: &str,
457 detail_level: DetailLevel,
458 ) -> Vec<ToolDiscoveryResult> {
459 let keyword_lower = keyword.to_lowercase();
460 let mut results = Vec::new();
461
462 for tool in tools {
463 let relevance_score = self.calculate_relevance(tool, &keyword_lower);
464
465 if relevance_score > 0.0 {
466 let result = ToolDiscoveryResult { tool: tool.clone(), relevance_score, detail_level };
467 results.push(result);
468 }
469 }
470
471 results.sort_by(|a, b| {
473 b.relevance_score
474 .partial_cmp(&a.relevance_score)
475 .unwrap_or(std::cmp::Ordering::Equal)
476 });
477
478 results
479 }
480
481 fn calculate_relevance(&self, tool: &McpToolInfo, keyword: &str) -> f64 {
483 let name_lower = tool.name.to_lowercase();
484 let description_lower = tool.description.to_lowercase();
485
486 let mut score: f64 = 0.0;
487
488 if name_lower == keyword {
490 score += 1.0;
491 }
492 else if name_lower.starts_with(keyword) {
494 score += 0.8;
495 }
496 else if name_lower.contains(keyword) {
498 score += 0.6;
499 }
500
501 if description_lower.contains(keyword) {
503 score += 0.3;
504 }
505
506 let schema_str = serde_json::to_string(&tool.input_schema).unwrap_or_default().to_lowercase();
508 if schema_str.contains(keyword) {
509 score += 0.2;
510 }
511
512 if score == 0.0 {
514 let sd_name = strsim::sorensen_dice(&name_lower, keyword);
515 let sd_desc = strsim::sorensen_dice(&description_lower, keyword);
516 let max_sd = sd_name.max(sd_desc);
517 if max_sd > 0.3 {
518 score = max_sd * 0.5;
519 }
520 }
521
522 score.min(1.0)
523 }
524
525 pub fn stats(&self) -> ToolCacheStats {
527 self.cache.stats()
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
536 fn test_bloom_filter() {
537 let mut filter = BloomFilter::new(100, 0.01);
538
539 filter.insert("tool1");
540 filter.insert("tool2");
541 filter.insert("tool3");
542
543 assert!(filter.contains("tool1"));
544 assert!(filter.contains("tool2"));
545 assert!(filter.contains("tool3"));
546 assert!(!filter.contains("tool4"));
547 }
548
549 #[test]
550 fn bloom_filter_has_no_false_negatives() {
551 let mut filter = BloomFilter::new(1_000, 0.01);
552 let inserted: Vec<String> = (0..1_000).map(|i| format!("tool-{i}")).collect();
553
554 for name in &inserted {
555 filter.insert(name);
556 }
557
558 for name in &inserted {
559 assert!(filter.contains(name), "inserted item {name} must never be reported absent");
560 }
561 }
562
563 #[test]
564 fn bloom_filter_clear_resets_membership() {
565 let mut filter = BloomFilter::new(1_000, 0.01);
566
567 filter.insert("tool-a");
568 filter.clear();
569
570 assert!(!filter.contains("tool-a"));
572 assert!(!filter.contains("tool-b"));
573 }
574
575 #[test]
576 fn bloom_filter_false_positive_rate_stays_near_target() {
577 let mut filter = BloomFilter::new(10_000, 0.01);
578 for i in 0..10_000 {
579 filter.insert(&format!("tool-{i}"));
580 }
581
582 let probes = 100_000u64;
583 let false_positives = (0..probes).filter(|i| filter.contains(&format!("absent-{i}"))).count();
584 let observed = false_positives as f64 / probes as f64;
585 assert!(
586 observed <= 0.02,
587 "observed false-positive rate {observed} exceeded 2x the 1% target ({false_positives}/{probes})"
588 );
589 }
590
591 #[test]
592 fn bloom_filter_sizing_matches_reference_formulas() {
593 assert_eq!(BloomFilter::optimal_size(1_000_000, 1e-6), 28_755_176);
596 assert_eq!(BloomFilter::optimal_num_hashes(28_755_176, 1_000_000), 20);
597 }
598
599 #[test]
600 fn bloom_filter_packs_bits_into_words() {
601 let filter = BloomFilter::new(1_000, 0.01);
602
603 assert_eq!(filter.size, 9_586);
606 assert_eq!(filter.bits.len(), 150);
607 assert_eq!(filter.num_hashes, 7);
608 }
609
610 #[test]
611 fn search_tools_returns_fuzzy_matches_for_non_name_keywords() {
612 let discovery = CachedToolDiscovery::new(10);
613 let tools = vec![McpToolInfo {
614 name: "read_file".to_string(),
615 description: "Read a file".to_string(),
616 provider: "test".to_string(),
617 input_schema: serde_json::json!({}),
618 output_schema: None,
619 ui: None,
620 visibility: crate::types::McpToolVisibility::default(),
621 }];
622
623 let results = discovery.search_tools("test_provider", "read", DetailLevel::Full, tools);
626 assert_eq!(results.len(), 1);
627 assert_eq!(results.first().map(|result| result.tool.name.as_str()), Some("read_file"));
628 }
629
630 #[test]
631 fn test_cache_key_equality() {
632 let key1 = ToolDiscoveryCacheKey {
633 provider_name: "test".to_string(),
634 keyword: "search".to_string(),
635 detail_level: DetailLevel::Full,
636 };
637
638 let key2 = ToolDiscoveryCacheKey {
639 provider_name: "test".to_string(),
640 keyword: "search".to_string(),
641 detail_level: DetailLevel::Full,
642 };
643
644 assert_eq!(key1, key2);
645 }
646
647 #[test]
648 fn test_tool_discovery_cache() {
649 let cache = ToolDiscoveryCache::new(10);
650
651 let provider_name = "test_provider";
652 let keyword = "search";
653 let detail_level = DetailLevel::Full;
654
655 assert!(cache.get_cached_discovery(provider_name, keyword, detail_level).is_none());
657
658 let results = vec![ToolDiscoveryResult {
660 tool: McpToolInfo {
661 name: "search_files".to_string(),
662 description: "Search for files".to_string(),
663 provider: "test".to_string(),
664 input_schema: serde_json::json!({}),
665 output_schema: None,
666 ui: None,
667 visibility: crate::types::McpToolVisibility::default(),
668 },
669 relevance_score: 0.9,
670 detail_level,
671 }];
672
673 cache.cache_discovery(provider_name, keyword, detail_level, results.clone());
674
675 let cached = cache.get_cached_discovery(provider_name, keyword, detail_level);
677 assert!(cached.is_some());
678 assert_eq!(cached.unwrap().len(), 1);
679 }
680}