use serde::Deserialize;
#[derive(Debug, Clone, PartialEq)]
pub struct McpTool {
pub name: String,
pub description: String,
pub input_schema: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct McpManifest {
pub name: String,
pub description: String,
pub server_url: Option<String>,
pub tools: Vec<McpTool>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiscoveryResult {
Found(McpManifest),
NotFound,
Error(String),
}
#[derive(Deserialize)]
struct RawManifest {
#[serde(default)]
name: String,
#[serde(default)]
description: String,
#[serde(rename = "serverUrl")]
server_url: Option<String>,
#[serde(default)]
tools: Vec<RawTool>,
}
#[derive(Deserialize)]
struct RawTool {
name: String,
#[serde(default)]
description: String,
#[serde(rename = "inputSchema")]
input_schema: Option<serde_json::Value>,
}
impl McpManifest {
pub fn from_json(json: &str) -> Result<Self, String> {
let raw: RawManifest =
serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?;
Ok(Self::from_raw(raw))
}
fn from_raw(raw: RawManifest) -> Self {
Self {
name: raw.name,
description: raw.description,
server_url: raw.server_url,
tools: raw.tools.into_iter().map(McpTool::from_raw).collect(),
}
}
}
impl McpTool {
fn from_raw(raw: RawTool) -> Self {
let input_schema = raw
.input_schema
.map(|v| serde_json::to_string(&v).unwrap_or_default());
Self {
name: raw.name,
description: raw.description,
input_schema,
}
}
}
#[must_use]
pub fn well_known_url(base_url: &str) -> Option<String> {
let parsed = url::Url::parse(base_url).ok()?;
let origin = parsed.origin().ascii_serialization();
Some(format!("{origin}/.well-known/mcp.json"))
}
#[must_use]
pub fn extract_link_href(html: &str) -> Option<String> {
use scraper::{Html, Selector};
let document = Html::parse_document(html);
let selector = Selector::parse(r#"link[rel~="mcp"]"#).ok()?;
document
.select(&selector)
.find_map(|el| el.value().attr("href").map(str::to_owned))
}
#[must_use]
pub fn resolve_manifest_url(base_url: &str, href: &str) -> Option<String> {
let base = url::Url::parse(base_url).ok()?;
let resolved = base.join(href).ok()?;
Some(resolved.to_string())
}
#[must_use]
pub fn parse_manifest_bytes(bytes: &[u8]) -> DiscoveryResult {
match std::str::from_utf8(bytes) {
Ok(json) => match McpManifest::from_json(json) {
Ok(manifest) => DiscoveryResult::Found(manifest),
Err(msg) => DiscoveryResult::Error(msg),
},
Err(e) => DiscoveryResult::Error(format!("invalid UTF-8: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_manifest_succeeds() {
let json = r#"{"name":"Shop","description":"Buy things","tools":[]}"#;
let manifest = McpManifest::from_json(json).unwrap();
assert_eq!(manifest.name, "Shop");
assert_eq!(manifest.description, "Buy things");
assert!(manifest.tools.is_empty());
assert!(manifest.server_url.is_none());
}
#[test]
fn parse_manifest_with_server_url() {
let json =
r#"{"name":"X","description":"Y","serverUrl":"https://mcp.example.com","tools":[]}"#;
let manifest = McpManifest::from_json(json).unwrap();
assert_eq!(
manifest.server_url.as_deref(),
Some("https://mcp.example.com")
);
}
#[test]
fn parse_manifest_with_multiple_tools() {
let json = r#"{
"name":"Docs",
"description":"Documentation site",
"tools":[
{"name":"search","description":"Full-text search"},
{"name":"toc","description":"Table of contents"}
]
}"#;
let manifest = McpManifest::from_json(json).unwrap();
assert_eq!(manifest.tools.len(), 2);
assert_eq!(manifest.tools[0].name, "search");
assert_eq!(manifest.tools[1].name, "toc");
}
#[test]
fn parse_tool_with_input_schema() {
let json = r#"{
"name":"Site",
"description":"",
"tools":[{
"name":"search",
"description":"Search",
"inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}
}]
}"#;
let manifest = McpManifest::from_json(json).unwrap();
let schema = manifest.tools[0].input_schema.as_deref().unwrap();
assert!(schema.contains("\"type\""));
assert!(schema.contains("object"));
}
#[test]
fn parse_empty_object_returns_defaults() {
let json = r"{}";
let manifest = McpManifest::from_json(json).unwrap();
assert_eq!(manifest.name, "");
assert_eq!(manifest.description, "");
assert!(manifest.tools.is_empty());
assert!(manifest.server_url.is_none());
}
#[test]
fn parse_invalid_json_returns_error() {
let result = McpManifest::from_json("not json at all");
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.starts_with("invalid JSON:"), "unexpected: {msg}");
}
#[test]
fn parse_wrong_type_returns_error() {
let result = McpManifest::from_json(r#"["search","toc"]"#);
assert!(result.is_err());
}
#[test]
fn well_known_url_strips_path() {
assert_eq!(
well_known_url("https://example.com/docs/getting-started"),
Some("https://example.com/.well-known/mcp.json".to_owned()),
);
}
#[test]
fn well_known_url_handles_root() {
assert_eq!(
well_known_url("https://example.com"),
Some("https://example.com/.well-known/mcp.json".to_owned()),
);
}
#[test]
fn well_known_url_preserves_non_standard_port() {
assert_eq!(
well_known_url("http://localhost:8080/api"),
Some("http://localhost:8080/.well-known/mcp.json".to_owned()),
);
}
#[test]
fn well_known_url_returns_none_for_invalid() {
assert!(well_known_url("not a url").is_none());
}
#[test]
fn extract_link_href_finds_mcp_link() {
let html = r#"<html><head><link rel="mcp" href="/mcp.json"></head></html>"#;
assert_eq!(extract_link_href(html), Some("/mcp.json".to_owned()),);
}
#[test]
fn extract_link_href_returns_none_when_absent() {
let html = r#"<html><head><link rel="stylesheet" href="/style.css"></head></html>"#;
assert!(extract_link_href(html).is_none());
}
#[test]
fn extract_link_href_returns_absolute_url() {
let html = r#"<link rel="mcp" href="https://cdn.example.com/mcp-manifest.json">"#;
assert_eq!(
extract_link_href(html),
Some("https://cdn.example.com/mcp-manifest.json".to_owned()),
);
}
#[test]
fn resolve_manifest_url_relative_path() {
assert_eq!(
resolve_manifest_url("https://example.com/page", "/mcp.json"),
Some("https://example.com/mcp.json".to_owned()),
);
}
#[test]
fn resolve_manifest_url_absolute_href_passthrough() {
assert_eq!(
resolve_manifest_url("https://example.com", "https://cdn.example.com/mcp.json"),
Some("https://cdn.example.com/mcp.json".to_owned()),
);
}
#[test]
fn resolve_manifest_url_invalid_base_returns_none() {
assert!(resolve_manifest_url("not-a-url", "/mcp.json").is_none());
}
#[test]
fn parse_manifest_bytes_valid_json() {
let bytes = br#"{"name":"Acme","description":"","tools":[]}"#;
let result = parse_manifest_bytes(bytes);
assert!(matches!(result, DiscoveryResult::Found(_)));
if let DiscoveryResult::Found(m) = result {
assert_eq!(m.name, "Acme");
}
}
#[test]
fn parse_manifest_bytes_invalid_json_returns_error() {
let result = parse_manifest_bytes(b"garbage");
assert!(matches!(result, DiscoveryResult::Error(_)));
}
#[test]
fn parse_manifest_bytes_invalid_utf8_returns_error() {
let bytes: &[u8] = &[0xFF, 0xFE, 0x00];
let result = parse_manifest_bytes(bytes);
assert!(matches!(result, DiscoveryResult::Error(ref msg) if msg.contains("UTF-8")));
}
}