Skip to main content

harn_vm/
mcp_json_discovery.rs

1//! Unofficial `/.well-known/mcp.json` server discovery.
2//!
3//! This descriptor is not part of the MCP protocol, but a few clients and
4//! providers use it as a low-friction way to discover the actual Streamable
5//! HTTP endpoint from a website URL. Keep this module intentionally narrow:
6//! it fetches and validates the descriptor, while OAuth metadata discovery and
7//! MCP protocol negotiation remain in their dedicated modules.
8
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12use url::Url;
13
14pub const WELL_KNOWN_MCP_JSON_PATH: &str = "/.well-known/mcp.json";
15const MCP_JSON_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct McpJsonDescriptor {
20    pub name: String,
21    pub description: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub icon: Option<String>,
24    pub endpoint: String,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct McpJsonDiscovery {
30    pub source: String,
31    pub descriptor: McpJsonDescriptor,
32}
33
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct McpJsonDiscoveryReport {
37    pub found: bool,
38    pub source: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub descriptor: Option<McpJsonDescriptor>,
41}
42
43#[derive(Debug, Deserialize)]
44struct RawMcpJsonDescriptor {
45    name: String,
46    description: String,
47    #[serde(default)]
48    icon: Option<String>,
49    endpoint: String,
50}
51
52/// Return the canonical `/.well-known/mcp.json` URL for a website or server URL.
53pub fn mcp_json_url_for(input: &str) -> Result<Url, String> {
54    let url = Url::parse(input.trim())
55        .map_err(|error| format!("invalid MCP discovery URL `{input}`: {error}"))?;
56    match url.scheme() {
57        "http" | "https" => {}
58        scheme => {
59            return Err(format!(
60                "MCP discovery URL must use http or https, got `{scheme}`"
61            ))
62        }
63    }
64    let origin = url.origin().ascii_serialization();
65    Url::parse(&format!("{origin}{WELL_KNOWN_MCP_JSON_PATH}"))
66        .map_err(|error| format!("failed to build MCP discovery URL for `{input}`: {error}"))
67}
68
69/// Fetch and validate the unofficial MCP discovery descriptor. A missing
70/// descriptor is reported as `Ok(None)`; malformed or non-404 error responses
71/// are surfaced as diagnostics because they usually indicate server setup drift.
72pub async fn discover_mcp_json(input: &str) -> Result<Option<McpJsonDiscovery>, String> {
73    let source = mcp_json_url_for(input)?;
74    let builder = reqwest::Client::builder()
75        .timeout(MCP_JSON_DISCOVERY_TIMEOUT)
76        .redirect(crate::egress::redirect_policy(
77            "mcp_json_discovery_redirect",
78            10,
79        ));
80    let client = crate::egress::install_ssrf_guard(builder)
81        .build()
82        .map_err(|error| format!("failed to build MCP discovery client: {error}"))?;
83    discover_mcp_json_with_client(&client, source).await
84}
85
86async fn discover_mcp_json_with_client(
87    client: &reqwest::Client,
88    source: Url,
89) -> Result<Option<McpJsonDiscovery>, String> {
90    let display_source = crate::egress::redact_diagnostic_text(source.as_str());
91    let response = client.get(source.clone()).send().await.map_err(|error| {
92        format!(
93            "failed to fetch MCP discovery descriptor at {display_source}: {}",
94            crate::egress::redact_reqwest_error(&error)
95        )
96    })?;
97    if response.status() == reqwest::StatusCode::NOT_FOUND {
98        return Ok(None);
99    }
100    if !response.status().is_success() {
101        return Err(format!(
102            "MCP discovery descriptor at {display_source} returned {}",
103            response.status()
104        ));
105    }
106    let descriptor = response
107        .json::<RawMcpJsonDescriptor>()
108        .await
109        .map_err(|error| format!("invalid MCP discovery descriptor JSON at {source}: {error}"))?;
110    let descriptor = validate_descriptor(&source, descriptor)?;
111    Ok(Some(McpJsonDiscovery {
112        source: source.to_string(),
113        descriptor,
114    }))
115}
116
117fn validate_descriptor(
118    source: &Url,
119    descriptor: RawMcpJsonDescriptor,
120) -> Result<McpJsonDescriptor, String> {
121    let name = required_string("name", descriptor.name)?;
122    let description = required_string("description", descriptor.description)?;
123    let endpoint = required_string("endpoint", descriptor.endpoint)?;
124    let endpoint_url = origin_base_url(source)?
125        .join(&endpoint)
126        .map_err(|error| format!("MCP discovery endpoint `{endpoint}` is not a URL: {error}"))?;
127    match endpoint_url.scheme() {
128        "http" | "https" => {}
129        scheme => {
130            return Err(format!(
131                "MCP discovery endpoint must use http or https, got `{scheme}`"
132            ))
133        }
134    }
135    let icon = descriptor.icon.and_then(|icon| {
136        let icon = icon.trim();
137        (!icon.is_empty()).then(|| icon.to_string())
138    });
139    Ok(McpJsonDescriptor {
140        name,
141        description,
142        icon,
143        endpoint: endpoint_url.to_string(),
144    })
145}
146
147fn origin_base_url(source: &Url) -> Result<Url, String> {
148    Url::parse(&format!("{}/", source.origin().ascii_serialization()))
149        .map_err(|error| format!("failed to build MCP discovery origin URL: {error}"))
150}
151
152fn required_string(field: &str, value: String) -> Result<String, String> {
153    let value = value.trim();
154    if value.is_empty() {
155        return Err(format!(
156            "MCP discovery descriptor `{field}` must not be empty"
157        ));
158    }
159    Ok(value.to_string())
160}
161
162pub fn discovery_report(
163    input: &str,
164    discovery: Option<McpJsonDiscovery>,
165) -> Result<McpJsonDiscoveryReport, String> {
166    let source = match discovery.as_ref() {
167        Some(discovery) => discovery.source.clone(),
168        None => mcp_json_url_for(input)?.to_string(),
169    };
170    Ok(McpJsonDiscoveryReport {
171        found: discovery.is_some(),
172        source,
173        descriptor: discovery.map(|discovery| discovery.descriptor),
174    })
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn mcp_json_url_uses_origin_well_known_path() {
183        let url = mcp_json_url_for("https://example.com/docs/mcp?x=1").unwrap();
184        assert_eq!(url.as_str(), "https://example.com/.well-known/mcp.json");
185    }
186
187    #[test]
188    fn mcp_json_url_rejects_non_http_schemes() {
189        let error = mcp_json_url_for("file:///tmp/mcp.json").unwrap_err();
190        assert!(error.contains("http or https"), "{error}");
191    }
192
193    #[tokio::test]
194    async fn discover_mcp_json_resolves_relative_endpoint() {
195        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
196        let origin = format!("http://{}", listener.local_addr().unwrap());
197        let server = tokio::spawn(async move {
198            let Ok((mut stream, _)) = listener.accept().await else {
199                return;
200            };
201            let mut buf = [0u8; 1024];
202            let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
203            let body = r#"{"name":"Demo","description":"Demo MCP server","endpoint":"mcp","icon":"https://example.com/icon.png"}"#;
204            let response = format!(
205                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
206                body.len(),
207                body
208            );
209            let _ = tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes()).await;
210        });
211
212        let discovery = discover_mcp_json(&format!("{origin}/docs"))
213            .await
214            .unwrap()
215            .unwrap();
216        assert_eq!(
217            discovery.source,
218            format!("{origin}{WELL_KNOWN_MCP_JSON_PATH}")
219        );
220        assert_eq!(discovery.descriptor.name, "Demo");
221        assert_eq!(discovery.descriptor.endpoint, format!("{origin}/mcp"));
222        server.await.unwrap();
223    }
224
225    #[tokio::test]
226    async fn discover_mcp_json_returns_none_for_404() {
227        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
228        let origin = format!("http://{}", listener.local_addr().unwrap());
229        let server = tokio::spawn(async move {
230            let Ok((mut stream, _)) = listener.accept().await else {
231                return;
232            };
233            let mut buf = [0u8; 1024];
234            let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
235            let response =
236                "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
237            let _ = tokio::io::AsyncWriteExt::write_all(&mut stream, response.as_bytes()).await;
238        });
239
240        assert!(discover_mcp_json(&origin).await.unwrap().is_none());
241        server.await.unwrap();
242    }
243}