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_mins(5);
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 .redirect(crate::egress::redirect_policy("mcp_card_redirect", 10))
148 .build()
149 .map_err(|e| CardError::Http(format!("client build: {e}")))?;
150 let redacted_url = crate::redact::current_policy().redact_url(url);
151 let primary = match client.get(url).send().await {
152 Ok(resp) if resp.status().is_success() => Some(resp),
153 Ok(_) => None,
154 Err(_) => None,
155 };
156
157 let resp = if let Some(resp) = primary {
158 resp
159 } else {
160 let fallback = with_well_known_suffix(url);
162 if fallback.as_deref() == Some(url) {
163 return Err(CardError::Http(format!(
164 "GET {redacted_url} did not return a Server Card"
165 )));
166 }
167 let Some(fallback) = fallback else {
168 return Err(CardError::Http(format!("GET {redacted_url} failed")));
169 };
170 let redacted_fallback = crate::redact::current_policy().redact_url(&fallback);
171 client.get(&fallback).send().await.map_err(|e| {
172 CardError::Http(format!(
173 "GET {redacted_fallback}: {}",
174 crate::egress::redact_reqwest_error(&e)
175 ))
176 })?
177 };
178 if !resp.status().is_success() {
179 return Err(CardError::Http(format!(
180 "GET {redacted_url} returned HTTP {}",
181 resp.status()
182 )));
183 }
184 resp.json::<Value>()
185 .await
186 .map_err(|e| CardError::Parse(format!("body: {e}")))
187}
188
189fn with_well_known_suffix(url: &str) -> Option<String> {
192 if url.contains("/.well-known/") {
193 return None;
194 }
195 let trimmed = url.trim_end_matches('/');
196 Some(format!("{trimmed}/{WELL_KNOWN_PATH}"))
197}
198
199pub fn invalidate_cached(source: &str) {
202 CARD_CACHE
203 .lock()
204 .expect("card cache mutex poisoned")
205 .invalidate(source);
206}
207
208#[derive(Debug)]
211pub enum CardError {
212 Io(String),
213 Http(String),
214 Parse(String),
215}
216
217impl std::fmt::Display for CardError {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 CardError::Io(msg) => write!(f, "io: {msg}"),
221 CardError::Http(msg) => write!(f, "http: {msg}"),
222 CardError::Parse(msg) => write!(f, "parse: {msg}"),
223 }
224 }
225}
226
227impl std::error::Error for CardError {}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::io::Write as _;
233
234 async fn cache_guard() -> tokio::sync::MutexGuard<'static, ()> {
239 use std::sync::OnceLock;
240 static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
241 LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
242 .lock()
243 .await
244 }
245
246 fn reset_cache() {
247 CARD_CACHE.lock().unwrap().clear();
248 }
249
250 #[test]
251 fn loads_card_from_local_path() {
252 let tmp = tempfile::NamedTempFile::new().unwrap();
253 let path = tmp.path().to_path_buf();
254 let mut f = std::fs::File::create(&path).unwrap();
255 write!(
256 f,
257 r#"{{"name":"demo","description":"Demo MCP server","tools":["a","b"]}}"#
258 )
259 .unwrap();
260 let card = load_server_card_from_path(&path).unwrap();
261 assert_eq!(card.get("name").and_then(|v| v.as_str()), Some("demo"));
262 }
263
264 #[test]
265 fn parse_error_is_reported() {
266 let tmp = tempfile::NamedTempFile::new().unwrap();
267 let path = tmp.path().to_path_buf();
268 std::fs::write(&path, "not json").unwrap();
269 let err = load_server_card_from_path(&path).unwrap_err();
270 assert!(matches!(err, CardError::Parse(_)));
271 }
272
273 #[test]
274 fn well_known_suffix_respects_existing_path() {
275 assert_eq!(
276 with_well_known_suffix("https://example.com"),
277 Some("https://example.com/.well-known/mcp-card".to_string())
278 );
279 assert_eq!(
280 with_well_known_suffix("https://example.com/.well-known/mcp-card"),
281 None
282 );
283 }
284
285 #[tokio::test(flavor = "current_thread")]
286 async fn cache_ttl_is_respected() {
287 let _guard = cache_guard().await;
288 reset_cache();
289 let tmp = tempfile::NamedTempFile::new().unwrap();
290 let path = tmp.path().to_str().unwrap().to_string();
291 std::fs::write(&path, r#"{"name":"cached"}"#).unwrap();
292 let card1 = fetch_server_card(&path, Some(Duration::from_mins(1)))
293 .await
294 .unwrap();
295 assert_eq!(card1.get("name").and_then(|v| v.as_str()), Some("cached"));
296
297 std::fs::write(&path, r#"{"name":"updated"}"#).unwrap();
299 let card2 = fetch_server_card(&path, Some(Duration::from_mins(1)))
300 .await
301 .unwrap();
302 assert_eq!(card2.get("name").and_then(|v| v.as_str()), Some("cached"));
303
304 invalidate_cached(&path);
306 let card3 = fetch_server_card(&path, Some(Duration::from_mins(1)))
307 .await
308 .unwrap();
309 assert_eq!(card3.get("name").and_then(|v| v.as_str()), Some("updated"));
310 }
311}