Skip to main content

ailint_core/rules/structural/
mcp_schema.rs

1//! AIL004 `mcp-schema-validation` — MCP server config schema check.
2//!
3//! See: `docs/rules/structural/AIL004.md`
4
5use serde_json::Value;
6
7use crate::file_type::FileType;
8use crate::parser::{DocumentContent, ParsedDocument};
9use crate::rules::structural::AIL004;
10use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
11
12/// AIL004 mcp-schema-validation: MCP config files must define a valid
13/// `mcpServers` (or VS Code `servers`) map where each entry declares a
14/// transport (`command` for stdio or `url` for HTTP/SSE).
15#[derive(Debug, Default)]
16pub struct McpSchemaValidationRule;
17
18impl Rule for McpSchemaValidationRule {
19    fn id(&self) -> RuleId {
20        AIL004
21    }
22
23    fn default_severity(&self) -> Severity {
24        Severity::Error
25    }
26
27    fn description(&self) -> &'static str {
28        "MCP server config is missing required fields or uses the wrong types."
29    }
30
31    fn fix_hint(&self) -> &'static str {
32        "Give each server a `command` (stdio) or `url` (http/sse) and use the documented types."
33    }
34
35    fn applies_to(&self, file_type: FileType) -> bool {
36        matches!(file_type, FileType::McpConfig)
37    }
38
39    fn run(&self, doc: &ParsedDocument, _ctx: &RuleContext<'_>) -> Vec<Violation> {
40        let json = match &doc.content {
41            DocumentContent::Json(v) => v,
42            // Parse errors are handled by malformed-json / structural rules.
43            _ => return Vec::new(),
44        };
45
46        let mut out = Vec::new();
47
48        let root = match json.as_object() {
49            Some(o) => o,
50            None => {
51                out.push(finding(doc, "root must be a JSON object"));
52                return out;
53            }
54        };
55
56        // Either `mcpServers` (Claude/Cline/Cursor) or `servers` (VS Code).
57        let servers = root.get("mcpServers").or_else(|| root.get("servers"));
58        let servers = match servers {
59            Some(s) => s,
60            None => {
61                out.push(finding(
62                    doc,
63                    "missing top-level `mcpServers` (or `servers`) object",
64                ));
65                return out;
66            }
67        };
68
69        let servers_map = match servers.as_object() {
70            Some(m) => m,
71            None => {
72                out.push(finding(doc, "`mcpServers` must be a JSON object"));
73                return out;
74            }
75        };
76
77        if servers_map.is_empty() {
78            out.push(finding(doc, "`mcpServers` object is empty"));
79        }
80
81        for (name, cfg) in servers_map {
82            validate_server(doc, name, cfg, &mut out);
83        }
84        out
85    }
86}
87
88fn validate_server(doc: &ParsedDocument, name: &str, cfg: &Value, out: &mut Vec<Violation>) {
89    let obj = match cfg.as_object() {
90        Some(o) => o,
91        None => {
92            out.push(finding(
93                doc,
94                &format!("server `{name}` must be a JSON object"),
95            ));
96            return;
97        }
98    };
99
100    let has_command = obj.get("command").is_some();
101    let has_url = obj.get("url").is_some();
102    if !has_command && !has_url {
103        out.push(finding(
104            doc,
105            &format!("server `{name}` needs a `command` (stdio) or `url` (http/sse) field"),
106        ));
107    }
108
109    if let Some(cmd) = obj.get("command") {
110        if !cmd.is_string() {
111            out.push(finding(
112                doc,
113                &format!("server `{name}`: `command` must be a string"),
114            ));
115        }
116    }
117    if let Some(url) = obj.get("url") {
118        if !url.is_string() {
119            out.push(finding(
120                doc,
121                &format!("server `{name}`: `url` must be a string"),
122            ));
123        }
124    }
125    if let Some(args) = obj.get("args") {
126        let bad = !args.is_array()
127            || args
128                .as_array()
129                .is_some_and(|arr| arr.iter().any(|v| !v.is_string()));
130        if bad {
131            out.push(finding(
132                doc,
133                &format!("server `{name}`: `args` must be an array of strings"),
134            ));
135        }
136    }
137    if let Some(env) = obj.get("env") {
138        let bad = !env.is_object()
139            || env
140                .as_object()
141                .is_some_and(|m| m.values().any(|v| !v.is_string()));
142        if bad {
143            out.push(finding(
144                doc,
145                &format!("server `{name}`: `env` must be an object of string values"),
146            ));
147        }
148    }
149}
150
151fn finding(doc: &ParsedDocument, msg: &str) -> Violation {
152    Violation::new(
153        AIL004,
154        Severity::Error,
155        doc.path.clone(),
156        "invalid MCP server config",
157    )
158    .with_detail(msg.to_string())
159}