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 mcp-serve --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_secs(300);
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 mcp-serve
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 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        // Retry with well-known suffix if not already present.
159        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
185/// Returns `url` with `/.well-known/mcp-card` appended, unless the URL
186/// already contains `.well-known` (caller asked for the exact path).
187fn 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
195/// Drop a cached entry — exposed so tests can force a refresh without
196/// sleeping past the TTL.
197pub fn invalidate_cached(source: &str) {
198    CARD_CACHE
199        .lock()
200        .expect("card cache mutex poisoned")
201        .invalidate(source);
202}
203
204/// Errors the card consumer can surface. Stringified into user-facing
205/// VM errors by the builtin wrapper.
206#[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    // Serializes tests that touch the process-wide CARD_CACHE. Rust runs
231    // `#[test]`s across threads by default; without this guard, one test's
232    // `clear()` or `put()` can race with another's cache-hit assertion.
233    // Uses `tokio::sync::Mutex` so the guard is safe to hold across awaits.
234    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        // Overwrite — cache should still serve the old value.
294        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        // After invalidate, the new value shows up.
301        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}