Skip to main content

oxicode_agent/mcp/
cache.rs

1//! MCP tool metadata disk cache.
2//!
3//! Persists the tool list discovered from each server so that proxy `search`,
4//! `list`, and `describe` operations can work without a live connection
5//! (and without paying the cost of spawning every server at startup).
6//!
7//! **Important:** the cache stores **original (unprefixed) tool names only**.
8//! The prefixed name is computed at runtime from the current `ToolPrefix` setting.
9//! This way, changing `tool_prefix` in `mcp.json` does not invalidate the cache.
10//!
11//! # File format
12//!
13//! ```json
14//! {
15//!   "version": 1,
16//!   "servers": {
17//!     "chrome-devtools": {
18//!       "updated_at": "2026-06-13T10:30:00Z",
19//!       "tools": [
20//!         { "name": "take_screenshot", "description": "...", "input_schema": { ... } }
21//!       ]
22//!     }
23//!   }
24//! }
25//! ```
26
27use crate::mcp::types::{McpToolDef, ToolMetadata, ToolPrefix, format_tool_name};
28use anyhow::{Context, Result};
29use parking_lot::RwLock;
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34/// Current cache schema version.
35const CACHE_VERSION: u32 = 1;
36
37/// On-disk representation of the metadata cache.
38#[derive(Debug, Default, Serialize, Deserialize)]
39struct CacheStore {
40    /// Schema version (currently 1).
41    #[serde(default = "default_version")]
42    version: u32,
43    /// Server name → cached tool list.
44    servers: HashMap<String, ServerCacheEntry>,
45}
46
47fn default_version() -> u32 {
48    1
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52struct ServerCacheEntry {
53    /// ISO-8601 timestamp of when this server's tools were last discovered.
54    updated_at: String,
55    /// Original (unprefixed) tool definitions.
56    tools: Vec<CachedToolDef>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60struct CachedToolDef {
61    name: String,
62    description: String,
63    #[serde(default)]
64    input_schema: Option<serde_json::Value>,
65}
66
67/// In-memory + on-disk metadata cache.
68pub struct MetadataCache {
69    /// Path to the cache file (e.g. `~/Library/Application Support/oxicode/mcp-cache.json`).
70    cache_path: PathBuf,
71    /// In-memory cache, guarded by a sync lock so it can be read from
72    /// both async and sync contexts (e.g. `render()`).
73    cache: RwLock<CacheStore>,
74}
75
76impl Default for MetadataCache {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl MetadataCache {
83    /// Create a new cache, resolving the default path under
84    /// `dirs::config_dir()/oxicode/mcp-cache.json`.
85    pub fn new() -> Self {
86        let cache_path = default_cache_path();
87        Self {
88            cache_path,
89            cache: RwLock::new(CacheStore::default()),
90        }
91    }
92
93    /// Create a cache rooted at a specific file path (used by tests).
94    pub fn with_path(cache_path: PathBuf) -> Self {
95        Self {
96            cache_path,
97            cache: RwLock::new(CacheStore::default()),
98        }
99    }
100
101    /// Returns the path the cache reads from / writes to.
102    pub fn path(&self) -> &Path {
103        &self.cache_path
104    }
105
106    /// Load the cache from disk. Missing file is not an error.
107    pub fn load(&self) -> Result<()> {
108        match std::fs::read_to_string(&self.cache_path) {
109            Ok(contents) => match serde_json::from_str::<CacheStore>(&contents) {
110                Ok(store) => {
111                    *self.cache.write() = store;
112                }
113                Err(e) => {
114                    tracing::warn!(
115                        "MCP cache: failed to parse {}: {} (starting fresh)",
116                        self.cache_path.display(),
117                        e
118                    );
119                }
120            },
121            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
122                // No cache yet — that's fine.
123            }
124            Err(e) => {
125                return Err(anyhow::anyhow!(
126                    "Failed to read MCP cache {}: {}",
127                    self.cache_path.display(),
128                    e
129                ));
130            }
131        }
132        Ok(())
133    }
134
135    /// Return the cached tools for a server, converted to `ToolMetadata`
136    /// using the supplied `prefix_mode` for the display name.
137    ///
138    /// Returns an empty `Vec` if the server has no cached tools.
139    pub fn get_tools(&self, server_name: &str, prefix_mode: &ToolPrefix) -> Vec<ToolMetadata> {
140        let cache = self.cache.read();
141        cache
142            .servers
143            .get(server_name)
144            .map(|entry| {
145                entry
146                    .tools
147                    .iter()
148                    .map(|t| ToolMetadata {
149                        name: format_tool_name(&t.name, server_name, prefix_mode),
150                        original_name: t.name.clone(),
151                        server_name: server_name.to_string(),
152                        description: t.description.clone(),
153                        input_schema: t.input_schema.clone(),
154                    })
155                    .collect()
156            })
157            .unwrap_or_default()
158    }
159
160    /// Update the cached tools for a server and atomically persist to disk.
161    pub fn update(&self, server_name: &str, tools: &[McpToolDef]) -> Result<()> {
162        let entry = ServerCacheEntry {
163            updated_at: chrono_now_iso8601(),
164            tools: tools
165                .iter()
166                .map(|t| CachedToolDef {
167                    name: t.name.clone(),
168                    description: t.description.clone().unwrap_or_default(),
169                    input_schema: t.input_schema.clone(),
170                })
171                .collect(),
172        };
173
174        {
175            let mut cache = self.cache.write();
176            cache.version = CACHE_VERSION;
177            cache.servers.insert(server_name.to_string(), entry);
178            // Write a clone to disk under the write lock to keep the
179            // in-memory state and disk state in sync from the caller's
180            // perspective.
181            let snapshot = CacheStore {
182                version: cache.version,
183                servers: cache.servers.clone(),
184            };
185            drop(cache);
186            self.write_to_disk(&snapshot)?;
187        }
188
189        Ok(())
190    }
191
192    /// Remove a server's cached tools and persist.
193    pub fn invalidate(&self, server_name: &str) -> Result<()> {
194        let snapshot;
195        {
196            let mut cache = self.cache.write();
197            cache.servers.remove(server_name);
198            snapshot = CacheStore {
199                version: cache.version,
200                servers: cache.servers.clone(),
201            };
202        }
203        self.write_to_disk(&snapshot)
204    }
205
206    /// All server names that have cached tools.
207    pub fn cached_servers(&self) -> Vec<String> {
208        self.cache.read().servers.keys().cloned().collect()
209    }
210
211    /// Atomic write: serialize to a temp file, fsync, rename over the target.
212    fn write_to_disk(&self, store: &CacheStore) -> Result<()> {
213        if let Some(parent) = self.cache_path.parent() {
214            std::fs::create_dir_all(parent).with_context(|| {
215                format!("Failed to create MCP cache directory {}", parent.display())
216            })?;
217        }
218
219        let json = serde_json::to_string_pretty(store).context("Failed to serialize MCP cache")?;
220
221        let tmp = self.cache_path.with_extension("json.tmp");
222        std::fs::write(&tmp, &json)
223            .with_context(|| format!("Failed to write MCP cache tmp {}", tmp.display()))?;
224        std::fs::rename(&tmp, &self.cache_path).with_context(|| {
225            format!(
226                "Failed to rename MCP cache {} → {}",
227                tmp.display(),
228                self.cache_path.display()
229            )
230        })?;
231        Ok(())
232    }
233}
234
235/// Default cache path: `dirs::config_dir()/oxicode/mcp-cache.json`.
236fn default_cache_path() -> PathBuf {
237    if let Some(config_dir) = dirs::config_dir() {
238        config_dir.join("oxicode").join("mcp-cache.json")
239    } else {
240        PathBuf::from(".oxicode/mcp-cache.json")
241    }
242}
243
244/// Minimal ISO-8601 timestamp (no chrono dependency).
245fn chrono_now_iso8601() -> String {
246    use std::time::{SystemTime, UNIX_EPOCH};
247    let secs = SystemTime::now()
248        .duration_since(UNIX_EPOCH)
249        .map(|d| d.as_secs())
250        .unwrap_or(0);
251    format!("epoch:{secs}")
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use tempfile::TempDir;
258
259    fn sample_tools() -> Vec<McpToolDef> {
260        vec![
261            McpToolDef {
262                name: "take_screenshot".to_string(),
263                description: Some("Take a screenshot".to_string()),
264                input_schema: Some(serde_json::json!({"type": "object"})),
265            },
266            McpToolDef {
267                name: "navigate".to_string(),
268                description: Some("Navigate to URL".to_string()),
269                input_schema: None,
270            },
271        ]
272    }
273
274    #[test]
275    fn empty_cache_loads_cleanly_from_missing_file() {
276        let dir = TempDir::new().unwrap();
277        let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
278        assert!(cache.load().is_ok());
279        assert!(cache.get_tools("any", &ToolPrefix::Server).is_empty());
280    }
281
282    #[test]
283    fn update_then_reload_round_trips() {
284        let dir = TempDir::new().unwrap();
285        let path = dir.path().join("mcp-cache.json");
286
287        let cache = MetadataCache::with_path(path.clone());
288        cache.load().unwrap();
289        cache.update("chrome", &sample_tools()).unwrap();
290
291        // New instance from same path
292        let cache2 = MetadataCache::with_path(path);
293        cache2.load().unwrap();
294        let tools = cache2.get_tools("chrome", &ToolPrefix::Server);
295        assert_eq!(tools.len(), 2);
296        assert_eq!(tools[0].original_name, "take_screenshot");
297        assert_eq!(tools[0].server_name, "chrome");
298        // Prefixed name with Server mode = "chrome_take_screenshot"
299        assert_eq!(tools[0].name, "chrome_take_screenshot");
300    }
301
302    #[test]
303    fn prefix_mode_changes_display_name_but_not_cache() {
304        let dir = TempDir::new().unwrap();
305        let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
306        cache.load().unwrap();
307        cache.update("chrome", &sample_tools()).unwrap();
308
309        // Same cache, two prefix modes
310        let server_mode = cache.get_tools("chrome", &ToolPrefix::Server);
311        let none_mode = cache.get_tools("chrome", &ToolPrefix::None);
312
313        assert_eq!(server_mode[0].name, "chrome_take_screenshot");
314        assert_eq!(none_mode[0].name, "take_screenshot");
315        // Original name is identical
316        assert_eq!(server_mode[0].original_name, none_mode[0].original_name);
317    }
318
319    #[test]
320    fn invalidate_removes_server() {
321        let dir = TempDir::new().unwrap();
322        let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
323        cache.load().unwrap();
324        cache.update("chrome", &sample_tools()).unwrap();
325        assert_eq!(cache.cached_servers(), vec!["chrome".to_string()]);
326
327        cache.invalidate("chrome").unwrap();
328        assert!(cache.cached_servers().is_empty());
329    }
330}