ailint_core/rules/structural/
mcp_schema.rs1use 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#[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 _ => 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 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}