use crate::mcp::types::{McpToolDef, ToolMetadata, ToolPrefix, format_tool_name};
use anyhow::{Context, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const CACHE_VERSION: u32 = 1;
#[derive(Debug, Default, Serialize, Deserialize)]
struct CacheStore {
#[serde(default = "default_version")]
version: u32,
servers: HashMap<String, ServerCacheEntry>,
}
fn default_version() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ServerCacheEntry {
updated_at: String,
tools: Vec<CachedToolDef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedToolDef {
name: String,
description: String,
#[serde(default)]
input_schema: Option<serde_json::Value>,
}
pub struct MetadataCache {
cache_path: PathBuf,
cache: RwLock<CacheStore>,
}
impl MetadataCache {
pub fn new() -> Self {
let cache_path = default_cache_path();
Self {
cache_path,
cache: RwLock::new(CacheStore::default()),
}
}
pub fn with_path(cache_path: PathBuf) -> Self {
Self {
cache_path,
cache: RwLock::new(CacheStore::default()),
}
}
pub fn path(&self) -> &Path {
&self.cache_path
}
pub fn load(&self) -> Result<()> {
match std::fs::read_to_string(&self.cache_path) {
Ok(contents) => {
match serde_json::from_str::<CacheStore>(&contents) {
Ok(store) => {
*self.cache.write() = store;
}
Err(e) => {
tracing::warn!(
"MCP cache: failed to parse {}: {} (starting fresh)",
self.cache_path.display(),
e
);
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(e) => {
return Err(anyhow::anyhow!(
"Failed to read MCP cache {}: {}",
self.cache_path.display(),
e
));
}
}
Ok(())
}
pub fn get_tools(&self, server_name: &str, prefix_mode: &ToolPrefix) -> Vec<ToolMetadata> {
let cache = self.cache.read();
cache
.servers
.get(server_name)
.map(|entry| {
entry
.tools
.iter()
.map(|t| ToolMetadata {
name: format_tool_name(&t.name, server_name, prefix_mode),
original_name: t.name.clone(),
server_name: server_name.to_string(),
description: t.description.clone(),
input_schema: t.input_schema.clone(),
})
.collect()
})
.unwrap_or_default()
}
pub fn update(&self, server_name: &str, tools: &[McpToolDef]) -> Result<()> {
let entry = ServerCacheEntry {
updated_at: chrono_now_iso8601(),
tools: tools
.iter()
.map(|t| CachedToolDef {
name: t.name.clone(),
description: t.description.clone().unwrap_or_default(),
input_schema: t.input_schema.clone(),
})
.collect(),
};
{
let mut cache = self.cache.write();
cache.version = CACHE_VERSION;
cache.servers.insert(server_name.to_string(), entry);
let snapshot = CacheStore {
version: cache.version,
servers: cache.servers.clone(),
};
drop(cache);
self.write_to_disk(&snapshot)?;
}
Ok(())
}
pub fn invalidate(&self, server_name: &str) -> Result<()> {
let snapshot;
{
let mut cache = self.cache.write();
cache.servers.remove(server_name);
snapshot = CacheStore {
version: cache.version,
servers: cache.servers.clone(),
};
}
self.write_to_disk(&snapshot)
}
pub fn cached_servers(&self) -> Vec<String> {
self.cache.read().servers.keys().cloned().collect()
}
fn write_to_disk(&self, store: &CacheStore) -> Result<()> {
if let Some(parent) = self.cache_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create MCP cache directory {}",
parent.display()
)
})?;
}
let json = serde_json::to_string_pretty(store)
.context("Failed to serialize MCP cache")?;
let tmp = self.cache_path.with_extension("json.tmp");
std::fs::write(&tmp, &json)
.with_context(|| format!("Failed to write MCP cache tmp {}", tmp.display()))?;
std::fs::rename(&tmp, &self.cache_path).with_context(|| {
format!(
"Failed to rename MCP cache {} → {}",
tmp.display(),
self.cache_path.display()
)
})?;
Ok(())
}
}
fn default_cache_path() -> PathBuf {
if let Some(config_dir) = dirs::config_dir() {
config_dir.join("oxi").join("mcp-cache.json")
} else {
PathBuf::from(".oxi/mcp-cache.json")
}
}
fn chrono_now_iso8601() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("epoch:{secs}")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_tools() -> Vec<McpToolDef> {
vec![
McpToolDef {
name: "take_screenshot".to_string(),
description: Some("Take a screenshot".to_string()),
input_schema: Some(serde_json::json!({"type": "object"})),
},
McpToolDef {
name: "navigate".to_string(),
description: Some("Navigate to URL".to_string()),
input_schema: None,
},
]
}
#[test]
fn empty_cache_loads_cleanly_from_missing_file() {
let dir = TempDir::new().unwrap();
let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
assert!(cache.load().is_ok());
assert!(cache
.get_tools("any", &ToolPrefix::Server)
.is_empty());
}
#[test]
fn update_then_reload_round_trips() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("mcp-cache.json");
let cache = MetadataCache::with_path(path.clone());
cache.load().unwrap();
cache.update("chrome", &sample_tools()).unwrap();
let cache2 = MetadataCache::with_path(path);
cache2.load().unwrap();
let tools = cache2.get_tools("chrome", &ToolPrefix::Server);
assert_eq!(tools.len(), 2);
assert_eq!(tools[0].original_name, "take_screenshot");
assert_eq!(tools[0].server_name, "chrome");
assert_eq!(tools[0].name, "chrome_take_screenshot");
}
#[test]
fn prefix_mode_changes_display_name_but_not_cache() {
let dir = TempDir::new().unwrap();
let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
cache.load().unwrap();
cache.update("chrome", &sample_tools()).unwrap();
let server_mode = cache.get_tools("chrome", &ToolPrefix::Server);
let none_mode = cache.get_tools("chrome", &ToolPrefix::None);
assert_eq!(server_mode[0].name, "chrome_take_screenshot");
assert_eq!(none_mode[0].name, "take_screenshot");
assert_eq!(server_mode[0].original_name, none_mode[0].original_name);
}
#[test]
fn invalidate_removes_server() {
let dir = TempDir::new().unwrap();
let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
cache.load().unwrap();
cache.update("chrome", &sample_tools()).unwrap();
assert_eq!(cache.cached_servers(), vec!["chrome".to_string()]);
cache.invalidate("chrome").unwrap();
assert!(cache.cached_servers().is_empty());
}
}