Skip to main content

xtask/
mcp_registry.rs

1use anyhow::{anyhow, bail, Context, Result};
2use jsonschema::Draft;
3
4use serde_json::Value;
5use std::path::{Path, PathBuf};
6
7const DEFAULT_MANIFEST: &str = "server.json";
8const DEFAULT_SCHEMA: &str = "docs/contracts/mcp-server.schema.json";
9
10#[derive(Debug)]
11struct Options {
12    manifest: PathBuf,
13    schema: PathBuf,
14}
15
16impl Default for Options {
17    fn default() -> Self {
18        Self {
19            manifest: PathBuf::from(DEFAULT_MANIFEST),
20            schema: PathBuf::from(DEFAULT_SCHEMA),
21        }
22    }
23}
24
25impl Options {
26    fn parse(args: &[String]) -> Result<Option<Self>> {
27        let mut options = Self::default();
28        let mut index = 0usize;
29        while index < args.len() {
30            match args[index].as_str() {
31                "--manifest" => {
32                    index += 1;
33                    options.manifest = PathBuf::from(
34                        args.get(index)
35                            .context("--manifest requires a path")?
36                            .as_str(),
37                    );
38                }
39                "--schema" => {
40                    index += 1;
41                    options.schema = PathBuf::from(
42                        args.get(index)
43                            .context("--schema requires a path")?
44                            .as_str(),
45                    );
46                }
47                "--help" | "-h" => {
48                    println!(
49                        "Usage: cargo xtask check-mcp-registry [--manifest server.json] [--schema docs/contracts/mcp-server.schema.json]"
50                    );
51                    return Ok(None);
52                }
53                unknown => bail!("unknown check-mcp-registry option: {unknown}"),
54            }
55            index += 1;
56        }
57        Ok(Some(options))
58    }
59}
60
61pub fn check_cmd(root: &Path, args: &[String]) -> Result<()> {
62    let Some(options) = Options::parse(args)? else {
63        return Ok(());
64    };
65    check(root, &options)?;
66    println!(
67        "MCP registry manifest is valid: {} against {}",
68        display_path(root, &options.manifest),
69        display_path(root, &options.schema)
70    );
71    Ok(())
72}
73
74pub fn check_default(root: &Path) -> Result<()> {
75    check(root, &Options::default())
76}
77
78fn check(root: &Path, options: &Options) -> Result<()> {
79    let manifest_path = resolve(root, &options.manifest);
80    let schema_path = resolve(root, &options.schema);
81    let manifest = read_json(&manifest_path)?;
82    let schema = read_json(&schema_path)?;
83    validate_manifest(&schema, &manifest)
84}
85
86fn validate_manifest(schema: &Value, manifest: &Value) -> Result<()> {
87    let schema_id = schema
88        .get("$id")
89        .and_then(Value::as_str)
90        .context("MCP registry schema is missing string $id")?;
91    let manifest_schema = manifest
92        .get("$schema")
93        .and_then(Value::as_str)
94        .context("server.json is missing string $schema")?;
95    if manifest_schema != schema_id {
96        bail!(
97            "server.json $schema must match vendored MCP registry schema $id: expected {schema_id:?}, found {manifest_schema:?}"
98        );
99    }
100
101    let compiled = jsonschema::options()
102        .with_draft(Draft::Draft7)
103        .build(schema)
104        .map_err(|error| anyhow!("failed to compile MCP registry schema: {error}"))?;
105    let messages = compiled
106        .iter_errors(manifest)
107        .map(|error| {
108            let path = error.instance_path().to_string();
109            let path = if path.is_empty() { "<root>" } else { &path };
110            format!("{path}: {error}")
111        })
112        .collect::<Vec<_>>();
113    if !messages.is_empty() {
114        bail!(
115            "server.json does not validate against the MCP registry schema:\n{}",
116            messages.join("\n")
117        );
118    }
119    Ok(())
120}
121
122fn read_json(path: &Path) -> Result<Value> {
123    let text = std::fs::read_to_string(path)
124        .with_context(|| format!("failed to read {}", path.display()))?;
125    serde_json::from_str(&text).with_context(|| format!("invalid JSON in {}", path.display()))
126}
127
128fn resolve(root: &Path, path: &Path) -> PathBuf {
129    if path.is_absolute() {
130        path.to_owned()
131    } else {
132        root.join(path)
133    }
134}
135
136fn display_path(root: &Path, path: &Path) -> String {
137    resolve(root, path)
138        .strip_prefix(root)
139        .unwrap_or(path)
140        .display()
141        .to_string()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use serde_json::json;
148
149    fn schema() -> Value {
150        json!({
151            "$id": "https://example.test/server.schema.json",
152            "$schema": "http://json-schema.org/draft-07/schema#",
153            "type": "object",
154            "required": ["$schema", "name"],
155            "properties": {
156                "$schema": {"type": "string"},
157                "name": {"type": "string"}
158            }
159        })
160    }
161
162    #[test]
163    fn accepts_manifest_matching_schema_id_and_shape() {
164        let manifest = json!({
165            "$schema": "https://example.test/server.schema.json",
166            "name": "dinglebear.ai/soma"
167        });
168        validate_manifest(&schema(), &manifest).unwrap();
169    }
170
171    #[test]
172    fn rejects_schema_id_mismatch() {
173        let manifest = json!({
174            "$schema": "https://example.test/other.schema.json",
175            "name": "dinglebear.ai/soma"
176        });
177        let error = validate_manifest(&schema(), &manifest).unwrap_err();
178        assert!(error.to_string().contains("$schema must match"));
179    }
180
181    #[test]
182    fn rejects_manifest_schema_violations() {
183        let manifest = json!({
184            "$schema": "https://example.test/server.schema.json",
185            "name": 42
186        });
187        let error = validate_manifest(&schema(), &manifest).unwrap_err();
188        assert!(error.to_string().contains("server.json does not validate"));
189        assert!(error.to_string().contains("name"));
190    }
191}