1use 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
34const CACHE_VERSION: u32 = 1;
36
37#[derive(Debug, Default, Serialize, Deserialize)]
39struct CacheStore {
40 #[serde(default = "default_version")]
42 version: u32,
43 servers: HashMap<String, ServerCacheEntry>,
45}
46
47fn default_version() -> u32 {
48 1
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52struct ServerCacheEntry {
53 updated_at: String,
55 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
67pub struct MetadataCache {
69 cache_path: PathBuf,
71 cache: RwLock<CacheStore>,
74}
75
76impl Default for MetadataCache {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl MetadataCache {
83 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 pub fn with_path(cache_path: PathBuf) -> Self {
95 Self {
96 cache_path,
97 cache: RwLock::new(CacheStore::default()),
98 }
99 }
100
101 pub fn path(&self) -> &Path {
103 &self.cache_path
104 }
105
106 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 }
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 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 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 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 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 pub fn cached_servers(&self) -> Vec<String> {
208 self.cache.read().servers.keys().cloned().collect()
209 }
210
211 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
235fn 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
244fn 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 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 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 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 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}