Skip to main content

vtcode_mcp/
tool_discovery_cache.rs

1//! Tool discovery caching system for MCP to avoid redundant tool searches
2//!
3//! This module provides multi-level caching for MCP tool discovery with
4//! bloom filters for fast exact-name negative lookups and an LRU cache for
5//! positive results. The bloom filter only screens exact tool names; fuzzy
6//! discovery keywords are never screened against it because its definitive
7//! "no" does not extend to fuzzy matching.
8
9use 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/// Bloom filter for fast negative lookups (tool doesn't exist)
20///
21/// Probabilistic set membership: `contains` returning `false` is definitive
22/// (the item was never inserted), while `true` means "maybe" (bounded
23/// false-positive rate). The definitive-no guarantee only covers exact
24/// membership of previously inserted items, so the filter must only screen
25/// exact tool names — never fuzzy or transformed queries such as prefixes,
26/// substrings, or keyword variants.
27#[derive(Clone)]
28pub struct BloomFilter {
29    /// Packed bit array: bit `i` lives in `bits[i / WORD_BITS]`.
30    bits: Vec<u64>,
31    /// Number of hash functions
32    num_hashes: usize,
33    /// Size of the bit array in bits
34    size: usize,
35}
36
37impl BloomFilter {
38    /// Bits per storage word.
39    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    /// Add an item to the bloom filter
54    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    /// Check if an item might be in the set
63    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    /// Derive the bit indices for `item` from a single hash pass
72    /// (Kirsch–Mitzenmacher): `index_i = (base + i * step) % size`, with
73    /// `step` forced odd so consecutive hash functions never alias onto one
74    /// bit sequence. Indices are deterministic in the item, which preserves
75    /// the no-false-negatives guarantee; truncating the hash halves to
76    /// `usize` is harmless for hash-derived values, and the modulo bounds
77    /// every index to the filter size.
78    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    /// Clear the bloom filter
92    pub fn clear(&mut self) {
93        self.bits.fill(0);
94    }
95
96    /// Calculate optimal size for bloom filter
97    #[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    /// Calculate optimal number of hash functions
111    #[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/// Cache key for tool discovery results
126#[derive(Debug, Clone, Hash, PartialEq, Eq)]
127struct ToolDiscoveryCacheKey {
128    provider_name: String,
129    keyword: String,
130    detail_level: DetailLevel,
131}
132
133/// Cached tool discovery result (internal cache entry)
134#[derive(Clone)]
135struct CachedToolDiscoveryEntry {
136    // OPTIMIZATION: Use Arc to avoid cloning large vectors on cache hits
137    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/// Cached tool discovery result (matches actual API)
149#[derive(Debug, Clone)]
150pub struct ToolDiscoveryResult {
151    tool: McpToolInfo,
152    relevance_score: f64,
153    detail_level: DetailLevel,
154}
155
156/// Multi-level caching system for tool discovery
157pub(crate) struct ToolDiscoveryCache {
158    inner: Arc<RwLock<DiscoveryCacheInner>>,
159    /// Cache configuration
160    config: CacheConfig,
161}
162
163#[derive(Clone)]
164struct CacheConfig {
165    /// Maximum age for cached entries
166    max_age: Duration,
167    /// Maximum age for provider tool lists
168    provider_refresh_interval: Duration,
169    /// Expected number of tools for bloom filter sizing
170    expected_tool_count: usize,
171    /// Acceptable false positive rate for bloom filter
172    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),                  // 5 minutes
179            provider_refresh_interval: Duration::from_secs(60), // 1 minute
180            expected_tool_count: 1000,
181            false_positive_rate: 0.01, // 1% false positive rate
182        };
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    /// Check if a tool with this exact name might exist (fast negative lookup)
199    ///
200    /// `false` is definitive: no tool with this exact name was ever inserted,
201    /// so the caller can skip the real lookup. `true` may be a false positive
202    /// and must fall through to the authoritative check. Only exact tool
203    /// names may be screened here — the filter cannot answer fuzzy queries
204    /// (prefixes, substrings, keyword variants), and screening those would
205    /// produce false negatives.
206    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    /// Get cached tool discovery results
217    fn get_cached_discovery(
218        &self,
219        provider_name: &str,
220        keyword: &str,
221        detail_level: DetailLevel,
222    ) -> Option<Arc<Vec<ToolDiscoveryResult>>> {
223        // OPTIMIZATION: Use to_owned() for explicit String allocation
224        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            // Check if the cached entry is still fresh
240            if cached.timestamp.elapsed() < self.config.max_age {
241                return Some(Arc::clone(&cached.results));
242            } else {
243                // Entry is stale, remove it
244                drop(inner.detailed_cache.pop(&key));
245            }
246        }
247
248        None
249    }
250
251    /// Cache tool discovery results
252    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        // OPTIMIZATION: Use to_owned() for explicit String allocation
270        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            // OPTIMIZATION: Wrap in Arc once, share across cache hits
278            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    /// Get all cached tools for a provider (with refresh checking)
295    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; // Signal that refresh is needed
312        }
313
314        inner.all_tools_cache.get(provider_name).cloned()
315    }
316
317    /// Cache all tools for a provider
318    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        // Update bloom filter with all tool names
331        inner.bloom_filter.clear(); // Clear and rebuild for accuracy
332
333        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    /// Cache a single tool result (for read-only tools)
345    pub fn cache_tool_result(&self, _cache_key: String, _result: serde_json::Value) {
346        // This would be implemented for caching individual tool execution results
347        // For now, we'll just store it in a simple cache
348        // In a full implementation, this would use a separate cache with different TTL
349    }
350
351    /// Clear all caches
352    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    /// Get cache statistics
362    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/// Cache statistics for monitoring
388#[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
397/// Enhanced tool discovery with caching
398pub 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    /// Search for tools with multi-level caching
410    ///
411    /// The keyword is matched fuzzily (prefix, substring, description, and
412    /// similarity scoring in `perform_search`), so the bloom filter
413    /// must not screen it: its definitive "no" only holds for exact
414    /// membership of inserted tool names, and filtering non-name keywords
415    /// here would discard results the fuzzy search would find. Repeated
416    /// queries are instead short-circuited by the LRU detailed cache.
417    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        // Check detailed cache
425        if let Some(cached) = self.cache.get_cached_discovery(provider_name, keyword, detail_level) {
426            return cached;
427        }
428
429        // Perform the search
430        let results = Arc::new(self.perform_search(&all_tools, keyword, detail_level));
431
432        // Cache the results
433        self.cache
434            .cache_discovery_shared(provider_name, keyword, detail_level, Arc::clone(&results));
435
436        results
437    }
438
439    /// Get all tools for a provider with caching
440    pub fn get_all_tools_cached(&self, provider_name: &str, all_tools: Vec<McpToolInfo>) -> Vec<McpToolInfo> {
441        // Check cache first
442        if let Some(cached) = self.cache.get_all_tools(provider_name, true) {
443            return cached;
444        }
445
446        // Cache the results
447        self.cache.cache_all_tools(provider_name, all_tools.clone());
448
449        all_tools
450    }
451
452    /// Perform the actual search on tool list
453    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        // Sort by relevance score
472        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    /// Calculate relevance score for a tool
482    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        // Name exact match
489        if name_lower == keyword {
490            score += 1.0;
491        }
492        // Name starts with keyword
493        else if name_lower.starts_with(keyword) {
494            score += 0.8;
495        }
496        // Name contains keyword
497        else if name_lower.contains(keyword) {
498            score += 0.6;
499        }
500
501        // Description contains keyword
502        if description_lower.contains(keyword) {
503            score += 0.3;
504        }
505
506        // Input schema contains keyword
507        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        // Fuzzy fallback using Sørensen-Dice for partial keyword matches
513        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    /// Get cache statistics
526    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        // Every bit is zero after clear, so even false positives are impossible.
571        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        // Worked example from the bloom-filter reference (samwho.dev/bloom-filters):
594        // 1,000,000 items at a 1-in-a-million false-positive rate fit in ~3.59 MB.
595        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        // m = ceil(-n * ln(p) / ln(2)^2) = 9,586 bits, packed into 150 u64
604        // words instead of 9,586 one-byte bools.
605        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        // "read" is not an exact tool name, so a name-membership bloom screen
624        // would definitively reject it and discard the prefix match below.
625        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        // Cache miss
656        assert!(cache.get_cached_discovery(provider_name, keyword, detail_level).is_none());
657
658        // Cache some results
659        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        // Cache hit
676        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}