harn_vm/
mcp_json_discovery.rs1use 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
52pub 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
69pub 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}