Skip to main content

harn_vm/
mcp_card.rs

1//! MCP Server Card consumer + publisher (2026 MCP v2.1 spec, harn#75).
2//!
3//! A Server Card is a small JSON document that describes an MCP server
4//! without requiring a full handshake. It lets skill matchers, tool
5//! indexers, and hosts decide whether to even connect to a server.
6//!
7//! This module implements both sides:
8//! - **Consumer**: `fetch_server_card(source, ttl)` loads the card from a
9//!   `.well-known/mcp-card` URL or a local file path, caches it in a
10//!   per-process LRU with a TTL so repeated reads are free.
11//! - **Publisher**: `load_server_card_from_path` parses a local card
12//!   file for `harn serve mcp --card path/to/card.json`, which embeds
13//!   the card into the `initialize` response and exposes it as a static
14//!   resource at `well-known://mcp-card`.
15//!
16//! The card schema intentionally mirrors the MCP v2.1 draft rather than
17//! inventing a Harn-specific shape. Fields Harn doesn't recognize pass
18//! through unchanged — forward-compat.
19
20use std::collections::BTreeMap;
21use std::sync::Mutex;
22use std::time::{Duration, Instant};
23
24use serde_json::Value;
25
26/// Default cache TTL (5 minutes) — long enough to avoid thundering
27/// herds when a skill activation probes several cards in sequence,
28/// short enough that an updated card reaches users within a coffee break.
29const DEFAULT_TTL: Duration = Duration::from_mins(5);
30
31/// Well-known path a compliant MCP server publishes its card at (per the
32/// 2026 roadmap). Harn's consumer tries this suffix when given a bare
33/// server URL without a `.well-known` path.
34pub const WELL_KNOWN_PATH: &str = ".well-known/mcp-card";
35
36/// One cached card entry. Stored in the process-wide cache keyed by
37/// (server_name | fetch_source).
38#[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
92/// Fetch (with cache) an MCP Server Card from a local file or HTTP(S)
93/// URL.
94///
95/// - If `source` starts with `http://` / `https://`, Harn issues a GET
96///   request. If the URL does not already contain `.well-known`, the
97///   consumer also tries appending `/.well-known/mcp-card` on a 404.
98/// - Otherwise `source` is treated as a local path (absolute or
99///   relative to `$PWD`).
100///
101/// The cache key is the raw `source` string — different spellings of
102/// the same URL get separate entries, which is safer than trying to
103/// canonicalize.
104pub 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
127/// Synchronous card loader from a local path — used by `harn serve mcp
128/// --card` at startup (before the tokio runtime is involved).
129pub 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 builder = reqwest::Client::builder()
146        .timeout(Duration::from_secs(10))
147        .redirect(crate::egress::redirect_policy("mcp_card_redirect", 10));
148    let client = crate::egress::install_ssrf_guard(builder)
149        .build()
150        .map_err(|e| CardError::Http(format!("client build: {e}")))?;
151    let redacted_url = crate::redact::current_policy().redact_url(url);
152    let primary = match client.get(url).send().await {
153        Ok(resp) if resp.status().is_success() => Some(resp),
154        Ok(_) => None,
155        Err(_) => None,
156    };
157
158    let resp = if let Some(resp) = primary {
159        resp
160    } else {
161        // Retry with well-known suffix if not already present.
162        let fallback = with_well_known_suffix(url);
163        if fallback.as_deref() == Some(url) {
164            return Err(CardError::Http(format!(
165                "GET {redacted_url} did not return a Server Card"
166            )));
167        }
168        let Some(fallback) = fallback else {
169            return Err(CardError::Http(format!("GET {redacted_url} failed")));
170        };
171        let redacted_fallback = crate::redact::current_policy().redact_url(&fallback);
172        client.get(&fallback).send().await.map_err(|e| {
173            CardError::Http(format!(
174                "GET {redacted_fallback}: {}",
175                crate::egress::redact_reqwest_error(&e)
176            ))
177        })?
178    };
179    if !resp.status().is_success() {
180        return Err(CardError::Http(format!(
181            "GET {redacted_url} returned HTTP {}",
182            resp.status()
183        )));
184    }
185    resp.json::<Value>()
186        .await
187        .map_err(|e| CardError::Parse(format!("body: {e}")))
188}
189
190/// Returns `url` with `/.well-known/mcp-card` appended, unless the URL
191/// already contains `.well-known` (caller asked for the exact path).
192fn with_well_known_suffix(url: &str) -> Option<String> {
193    if url.contains("/.well-known/") {
194        return None;
195    }
196    let trimmed = url.trim_end_matches('/');
197    Some(format!("{trimmed}/{WELL_KNOWN_PATH}"))
198}
199
200/// Drop a cached entry — exposed so tests can force a refresh without
201/// sleeping past the TTL.
202pub fn invalidate_cached(source: &str) {
203    CARD_CACHE
204        .lock()
205        .expect("card cache mutex poisoned")
206        .invalidate(source);
207}
208
209/// Errors the card consumer can surface. Stringified into user-facing
210/// VM errors by the builtin wrapper.
211#[derive(Debug)]
212pub enum CardError {
213    Io(String),
214    Http(String),
215    Parse(String),
216}
217
218impl std::fmt::Display for CardError {
219    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220        match self {
221            CardError::Io(msg) => write!(f, "io: {msg}"),
222            CardError::Http(msg) => write!(f, "http: {msg}"),
223            CardError::Parse(msg) => write!(f, "parse: {msg}"),
224        }
225    }
226}
227
228impl std::error::Error for CardError {}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::io::Write as _;
234
235    // Serializes tests that touch the process-wide CARD_CACHE. Rust runs
236    // `#[test]`s across threads by default; without this guard, one test's
237    // `clear()` or `put()` can race with another's cache-hit assertion.
238    // Uses `tokio::sync::Mutex` so the guard is safe to hold across awaits.
239    async fn cache_guard() -> tokio::sync::MutexGuard<'static, ()> {
240        use std::sync::OnceLock;
241        static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
242        LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
243            .lock()
244            .await
245    }
246
247    fn reset_cache() {
248        CARD_CACHE.lock().unwrap().clear();
249    }
250
251    #[test]
252    fn loads_card_from_local_path() {
253        let tmp = tempfile::NamedTempFile::new().unwrap();
254        let path = tmp.path().to_path_buf();
255        let mut f = std::fs::File::create(&path).unwrap();
256        write!(
257            f,
258            r#"{{"name":"demo","description":"Demo MCP server","tools":["a","b"]}}"#
259        )
260        .unwrap();
261        let card = load_server_card_from_path(&path).unwrap();
262        assert_eq!(card.get("name").and_then(|v| v.as_str()), Some("demo"));
263    }
264
265    #[test]
266    fn parse_error_is_reported() {
267        let tmp = tempfile::NamedTempFile::new().unwrap();
268        let path = tmp.path().to_path_buf();
269        std::fs::write(&path, "not json").unwrap();
270        let err = load_server_card_from_path(&path).unwrap_err();
271        assert!(matches!(err, CardError::Parse(_)));
272    }
273
274    #[test]
275    fn well_known_suffix_respects_existing_path() {
276        assert_eq!(
277            with_well_known_suffix("https://example.com"),
278            Some("https://example.com/.well-known/mcp-card".to_string())
279        );
280        assert_eq!(
281            with_well_known_suffix("https://example.com/.well-known/mcp-card"),
282            None
283        );
284    }
285
286    #[tokio::test(flavor = "current_thread")]
287    async fn cache_ttl_is_respected() {
288        let _guard = cache_guard().await;
289        reset_cache();
290        let tmp = tempfile::NamedTempFile::new().unwrap();
291        let path = tmp.path().to_str().unwrap().to_string();
292        std::fs::write(&path, r#"{"name":"cached"}"#).unwrap();
293        let card1 = fetch_server_card(&path, Some(Duration::from_mins(1)))
294            .await
295            .unwrap();
296        assert_eq!(card1.get("name").and_then(|v| v.as_str()), Some("cached"));
297
298        // Overwrite — cache should still serve the old value.
299        std::fs::write(&path, r#"{"name":"updated"}"#).unwrap();
300        let card2 = fetch_server_card(&path, Some(Duration::from_mins(1)))
301            .await
302            .unwrap();
303        assert_eq!(card2.get("name").and_then(|v| v.as_str()), Some("cached"));
304
305        // After invalidate, the new value shows up.
306        invalidate_cached(&path);
307        let card3 = fetch_server_card(&path, Some(Duration::from_mins(1)))
308            .await
309            .unwrap();
310        assert_eq!(card3.get("name").and_then(|v| v.as_str()), Some("updated"));
311    }
312}