1use std::collections::BTreeMap;
21use std::sync::Mutex;
22use std::time::{Duration, Instant};
23
24use serde_json::Value;
25
26const DEFAULT_TTL: Duration = Duration::from_secs(300);
30
31pub const WELL_KNOWN_PATH: &str = ".well-known/mcp-card";
35
36#[derive(Clone, Debug)]
39struct CacheEntry {
40 card: Value,
41 fetched_at: Instant,
42 ttl: Duration,
43}
44
45impl CacheEntry {
46 fn is_fresh(&self) -> bool {
47 self.fetched_at.elapsed() < self.ttl
48 }
49}
50
51struct CardCache {
52 entries: BTreeMap<String, CacheEntry>,
53}
54
55impl CardCache {
56 const fn new() -> Self {
57 Self {
58 entries: BTreeMap::new(),
59 }
60 }
61
62 fn get(&self, key: &str) -> Option<Value> {
63 self.entries
64 .get(key)
65 .filter(|e| e.is_fresh())
66 .map(|e| e.card.clone())
67 }
68
69 fn put(&mut self, key: String, card: Value, ttl: Duration) {
70 self.entries.insert(
71 key,
72 CacheEntry {
73 card,
74 fetched_at: Instant::now(),
75 ttl,
76 },
77 );
78 }
79
80 fn invalidate(&mut self, key: &str) {
81 self.entries.remove(key);
82 }
83
84 #[cfg(test)]
85 fn clear(&mut self) {
86 self.entries.clear();
87 }
88}
89
90static CARD_CACHE: Mutex<CardCache> = Mutex::new(CardCache::new());
91
92pub async fn fetch_server_card(source: &str, ttl: Option<Duration>) -> Result<Value, CardError> {
105 let ttl = ttl.unwrap_or(DEFAULT_TTL);
106 if let Some(cached) = CARD_CACHE
107 .lock()
108 .expect("card cache mutex poisoned")
109 .get(source)
110 {
111 return Ok(cached);
112 }
113
114 let card = if is_http_url(source) {
115 fetch_over_http(source).await?
116 } else {
117 load_from_path(source)?
118 };
119 CARD_CACHE.lock().expect("card cache mutex poisoned").put(
120 source.to_string(),
121 card.clone(),
122 ttl,
123 );
124 Ok(card)
125}
126
127pub fn load_server_card_from_path(path: &std::path::Path) -> Result<Value, CardError> {
130 let contents = std::fs::read_to_string(path)
131 .map_err(|e| CardError::Io(format!("read {}: {e}", path.display())))?;
132 serde_json::from_str::<Value>(&contents).map_err(|e| CardError::Parse(e.to_string()))
133}
134
135fn is_http_url(source: &str) -> bool {
136 source.starts_with("http://") || source.starts_with("https://")
137}
138
139fn load_from_path(source: &str) -> Result<Value, CardError> {
140 let path = std::path::Path::new(source);
141 load_server_card_from_path(path)
142}
143
144async fn fetch_over_http(url: &str) -> Result<Value, CardError> {
145 let client = reqwest::Client::builder()
146 .timeout(Duration::from_secs(10))
147 .build()
148 .map_err(|e| CardError::Http(format!("client build: {e}")))?;
149 let primary = match client.get(url).send().await {
150 Ok(resp) if resp.status().is_success() => Some(resp),
151 Ok(_) => None,
152 Err(_) => None,
153 };
154
155 let resp = if let Some(resp) = primary {
156 resp
157 } else {
158 let fallback = with_well_known_suffix(url);
160 if fallback.as_deref() == Some(url) {
161 return Err(CardError::Http(format!(
162 "GET {url} did not return a Server Card"
163 )));
164 }
165 let Some(fallback) = fallback else {
166 return Err(CardError::Http(format!("GET {url} failed")));
167 };
168 client
169 .get(&fallback)
170 .send()
171 .await
172 .map_err(|e| CardError::Http(format!("GET {fallback}: {e}")))?
173 };
174 if !resp.status().is_success() {
175 return Err(CardError::Http(format!(
176 "GET {url} returned HTTP {}",
177 resp.status()
178 )));
179 }
180 resp.json::<Value>()
181 .await
182 .map_err(|e| CardError::Parse(format!("body: {e}")))
183}
184
185fn with_well_known_suffix(url: &str) -> Option<String> {
188 if url.contains("/.well-known/") {
189 return None;
190 }
191 let trimmed = url.trim_end_matches('/');
192 Some(format!("{trimmed}/{WELL_KNOWN_PATH}"))
193}
194
195pub fn invalidate_cached(source: &str) {
198 CARD_CACHE
199 .lock()
200 .expect("card cache mutex poisoned")
201 .invalidate(source);
202}
203
204#[derive(Debug)]
207pub enum CardError {
208 Io(String),
209 Http(String),
210 Parse(String),
211}
212
213impl std::fmt::Display for CardError {
214 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215 match self {
216 CardError::Io(msg) => write!(f, "io: {msg}"),
217 CardError::Http(msg) => write!(f, "http: {msg}"),
218 CardError::Parse(msg) => write!(f, "parse: {msg}"),
219 }
220 }
221}
222
223impl std::error::Error for CardError {}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::io::Write as _;
229
230 async fn cache_guard() -> tokio::sync::MutexGuard<'static, ()> {
235 use std::sync::OnceLock;
236 static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
237 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
238 .lock()
239 .await
240 }
241
242 fn reset_cache() {
243 CARD_CACHE.lock().unwrap().clear();
244 }
245
246 #[test]
247 fn loads_card_from_local_path() {
248 let tmp = tempfile::NamedTempFile::new().unwrap();
249 let path = tmp.path().to_path_buf();
250 let mut f = std::fs::File::create(&path).unwrap();
251 write!(
252 f,
253 r#"{{"name":"demo","description":"Demo MCP server","tools":["a","b"]}}"#
254 )
255 .unwrap();
256 let card = load_server_card_from_path(&path).unwrap();
257 assert_eq!(card.get("name").and_then(|v| v.as_str()), Some("demo"));
258 }
259
260 #[test]
261 fn parse_error_is_reported() {
262 let tmp = tempfile::NamedTempFile::new().unwrap();
263 let path = tmp.path().to_path_buf();
264 std::fs::write(&path, "not json").unwrap();
265 let err = load_server_card_from_path(&path).unwrap_err();
266 assert!(matches!(err, CardError::Parse(_)));
267 }
268
269 #[test]
270 fn well_known_suffix_respects_existing_path() {
271 assert_eq!(
272 with_well_known_suffix("https://example.com"),
273 Some("https://example.com/.well-known/mcp-card".to_string())
274 );
275 assert_eq!(
276 with_well_known_suffix("https://example.com/.well-known/mcp-card"),
277 None
278 );
279 }
280
281 #[tokio::test(flavor = "current_thread")]
282 async fn cache_ttl_is_respected() {
283 let _guard = cache_guard().await;
284 reset_cache();
285 let tmp = tempfile::NamedTempFile::new().unwrap();
286 let path = tmp.path().to_str().unwrap().to_string();
287 std::fs::write(&path, r#"{"name":"cached"}"#).unwrap();
288 let card1 = fetch_server_card(&path, Some(Duration::from_secs(60)))
289 .await
290 .unwrap();
291 assert_eq!(card1.get("name").and_then(|v| v.as_str()), Some("cached"));
292
293 std::fs::write(&path, r#"{"name":"updated"}"#).unwrap();
295 let card2 = fetch_server_card(&path, Some(Duration::from_secs(60)))
296 .await
297 .unwrap();
298 assert_eq!(card2.get("name").and_then(|v| v.as_str()), Some("cached"));
299
300 invalidate_cached(&path);
302 let card3 = fetch_server_card(&path, Some(Duration::from_secs(60)))
303 .await
304 .unwrap();
305 assert_eq!(card3.get("name").and_then(|v| v.as_str()), Some("updated"));
306 }
307}