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}