1use std::collections::HashMap;
2
3use crate::errors::PluginError;
4
5const SUPPORTED_SCHEMAS: &[&str] = &["https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"];
6
7#[derive(Debug, Clone)]
8pub struct McpConfig {
9 pub schema: String,
10 pub servers: HashMap<String, ServerConfig>,
11 pub unknown_fields: Vec<String>,
12}
13
14#[derive(Debug, Clone)]
15pub enum ServerConfig {
16 Stdio(StdioServerConfig),
17 StreamableHttp(HttpServerConfig),
18 Sse(SseServerConfig),
19}
20
21#[derive(Debug, Clone)]
22pub struct StdioServerConfig {
23 pub command: String,
24 pub args: Vec<String>,
25 pub env: HashMap<String, String>,
26 pub cwd: Option<String>,
27}
28
29#[derive(Debug, Clone)]
30pub struct HttpServerConfig {
31 pub url: String,
32 pub headers: HashMap<String, String>,
33}
34
35#[derive(Debug, Clone)]
36pub struct SseServerConfig {
37 pub url: String,
38 pub headers: HashMap<String, String>,
39}
40
41impl McpConfig {
42 pub fn parse(content: &str) -> Result<Self, PluginError> {
43 let value: serde_json::Value = serde_json::from_str(content).map_err(PluginError::Json)?;
44 let map = value
45 .as_object()
46 .ok_or_else(|| PluginError::InvalidMcp("mcp.json must be a JSON object".into()))?;
47
48 let mut unknown = Vec::new();
49 let mut config = McpConfig {
50 schema: String::new(),
51 servers: HashMap::new(),
52 unknown_fields: Vec::new(),
53 };
54
55 for (key, val) in map {
56 match key.as_str() {
57 "$schema" => {
58 let s = val
59 .as_str()
60 .ok_or_else(|| PluginError::InvalidMcp("$schema must be a string".into()))?;
61 config.schema = s.to_string();
62 }
63 "mcpServers" => {
64 let servers_map = val
65 .as_object()
66 .ok_or_else(|| PluginError::InvalidMcp("mcpServers must be an object".into()))?;
67 for (name, server_val) in servers_map {
68 match parse_server_config(name, server_val) {
69 Ok(server) => {
70 drop(config.servers.insert(name.clone(), server));
71 }
72 Err(ServerConfigError::MissingField(msg)) => {
78 tracing::warn!(
79 server = name,
80 error = msg,
81 "skipping MCP server entry with missing required fields"
82 );
83 }
84 Err(ServerConfigError::WrongType(msg)) => {
85 tracing::warn!(
86 server = name,
87 error = msg,
88 "skipping MCP server entry with wrong-typed fields"
89 );
90 }
91 }
92 }
93 }
94 _ => unknown.push(key.clone()),
95 }
96 }
97
98 if config.schema.is_empty() {
99 return Err(PluginError::InvalidMcp("missing required field: $schema".into()));
100 }
101 if !SUPPORTED_SCHEMAS.contains(&config.schema.as_str()) {
102 return Err(PluginError::InvalidMcp(format!(
103 "unsupported $schema '{}' (expected {})",
104 config.schema,
105 SUPPORTED_SCHEMAS.join(" or ")
106 )));
107 }
108
109 config.unknown_fields = unknown;
110 Ok(config)
111 }
112}
113
114enum ServerConfigError {
120 MissingField(String),
121 WrongType(String),
122}
123
124fn require_string_field<'a>(
126 obj: &'a serde_json::Map<String, serde_json::Value>,
127 name: &'a str,
128 field: &str,
129) -> Result<&'a str, ServerConfigError> {
130 match obj.get(field) {
131 Some(serde_json::Value::String(s)) => Ok(s),
132 Some(_) => Err(ServerConfigError::WrongType(format!("server '{name}' field '{field}' must be a string"))),
133 None => Err(ServerConfigError::MissingField(format!("server '{name}' missing required field: {field}"))),
134 }
135}
136
137fn parse_server_config(name: &str, value: &serde_json::Value) -> Result<ServerConfig, ServerConfigError> {
138 let obj = value
139 .as_object()
140 .ok_or_else(|| ServerConfigError::WrongType(format!("server '{}' must be an object", name)))?;
141
142 let type_val = require_string_field(obj, name, "type")?;
143
144 match type_val {
145 "stdio" => {
146 reject_unexpected_fields(obj, name, &["type", "command", "args", "env", "cwd"])?;
147 let command = require_string_field(obj, name, "command")?.to_string();
148 if let Err(reason) = crate::expansion::validate_command_token(&command) {
149 return Err(ServerConfigError::WrongType(format!("server '{name}' has invalid command: {reason}")));
150 }
151
152 let args = obj
153 .get("args")
154 .map(|v| expect_string_array(v, &format!("server '{name}' args")))
155 .transpose()?
156 .unwrap_or_default();
157
158 let env = obj
159 .get("env")
160 .map(|v| expect_string_map(v, &format!("server '{name}' env")))
161 .transpose()?
162 .unwrap_or_default();
163 for key in env.keys() {
164 if crate::expansion::is_reserved_env_key(key) {
165 return Err(ServerConfigError::WrongType(format!(
166 "server '{name}' env must not set reserved '{key}'"
167 )));
168 }
169 if key.contains('\0') || key.contains('\n') || key.contains('\r') {
170 return Err(ServerConfigError::WrongType(format!("server '{name}' has invalid env key")));
171 }
172 }
173
174 let cwd = obj
175 .get("cwd")
176 .map(|v| {
177 v.as_str()
178 .map(String::from)
179 .ok_or_else(|| ServerConfigError::WrongType(format!("server '{name}' cwd must be a string")))
180 })
181 .transpose()?;
182 if let Some(ref raw_cwd) = cwd {
183 if let Err(reason) = crate::expansion::validate_cwd_form(raw_cwd) {
184 return Err(ServerConfigError::WrongType(format!("server '{name}' has invalid cwd: {reason}")));
185 }
186 }
187
188 Ok(ServerConfig::Stdio(StdioServerConfig { command, args, env, cwd }))
189 }
190 "streamable-http" => {
191 reject_unexpected_fields(obj, name, &["type", "url", "headers"])?;
192 let url = require_string_field(obj, name, "url")?.to_string();
193 validate_remote_url(&url)
194 .map_err(|reason| ServerConfigError::WrongType(format!("server '{name}' has invalid url: {reason}")))?;
195
196 let headers = obj
197 .get("headers")
198 .map(|v| expect_string_map(v, &format!("server '{name}' headers")))
199 .transpose()?
200 .unwrap_or_default();
201 validate_headers(&headers, name)?;
202
203 Ok(ServerConfig::StreamableHttp(HttpServerConfig { url, headers }))
204 }
205 "sse" => {
206 reject_unexpected_fields(obj, name, &["type", "url", "headers"])?;
207 let url = require_string_field(obj, name, "url")?.to_string();
208 validate_remote_url(&url)
209 .map_err(|reason| ServerConfigError::WrongType(format!("server '{name}' has invalid url: {reason}")))?;
210
211 let headers = obj
212 .get("headers")
213 .map(|v| expect_string_map(v, &format!("server '{name}' headers")))
214 .transpose()?
215 .unwrap_or_default();
216 validate_headers(&headers, name)?;
217
218 Ok(ServerConfig::Sse(SseServerConfig { url, headers }))
219 }
220 _ => Err(ServerConfigError::WrongType(format!("server '{}' has unsupported type: {}", name, type_val))),
221 }
222}
223
224fn reject_unexpected_fields(
225 obj: &serde_json::Map<String, serde_json::Value>,
226 name: &str,
227 allowed: &[&str],
228) -> Result<(), ServerConfigError> {
229 for key in obj.keys() {
230 if !allowed.contains(&key.as_str()) {
231 return Err(ServerConfigError::WrongType(format!("server '{name}' has unsupported field '{key}'")));
232 }
233 }
234 Ok(())
235}
236
237fn validate_remote_url(url: &str) -> Result<(), String> {
238 let (scheme, rest) = if url.get(..8).is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) {
240 ("https", url.get(8..).unwrap_or_default())
241 } else if url.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://")) {
242 ("http", url.get(7..).unwrap_or_default())
243 } else {
244 return Err("url must be absolute http or https".into());
245 };
246 if rest.is_empty() {
247 return Err("url must have a host".into());
248 }
249 if url.contains('#') {
250 return Err("url must not contain a fragment".into());
251 }
252 let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
255 let Some(authority) = rest.get(..authority_end) else {
256 return Err("url must have a host".into());
257 };
258 if authority.is_empty() {
259 return Err("url must have a host".into());
260 }
261 if authority.contains('@') {
262 return Err("url must not contain user information".into());
263 }
264 let host_port = authority;
266 let host = if let Some(stripped) = host_port.strip_prefix('[') {
267 let Some(close) = stripped.find(']') else {
268 return Err("url has malformed IPv6 host".into());
269 };
270 let Some(inside) = stripped.get(..close) else {
271 return Err("url has malformed IPv6 host".into());
272 };
273 let Some(after) = stripped.get(close + 1..) else {
274 return Err("url has malformed host".into());
275 };
276 if !after.is_empty() && !after.starts_with(':') {
277 return Err("url has malformed host".into());
278 }
279 inside
280 } else {
281 host_port.split(':').next().unwrap_or_default()
282 };
283 if host.is_empty() {
284 return Err("url must have a host".into());
285 }
286 if scheme == "http" && !is_loopback_host(host) {
287 return Err("http url must use localhost or a loopback IP".into());
288 }
289 Ok(())
290}
291
292fn is_loopback_host(host: &str) -> bool {
293 let lower = host.to_ascii_lowercase();
294 if lower == "localhost" {
295 return true;
296 }
297 if is_loopback_ipv4(&lower) {
298 return true;
299 }
300 if lower == "::1" || lower == "0:0:0:0:0:0:0:1" {
302 return true;
303 }
304 false
305}
306
307fn is_loopback_ipv4(host: &str) -> bool {
308 let mut parts = host.split('.');
309 let Some(first) = parts.next() else {
310 return false;
311 };
312 if first != "127" {
313 return false;
314 }
315 for _ in 0..3 {
316 let Some(part) = parts.next() else {
317 return false;
318 };
319 if part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) {
320 return false;
321 }
322 if part.parse::<u8>().is_err() {
323 return false;
324 }
325 }
326 parts.next().is_none()
327}
328
329fn validate_headers(headers: &HashMap<String, String>, server: &str) -> Result<(), ServerConfigError> {
330 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
331 for (name, value) in headers {
332 if !is_valid_header_name(name) {
333 return Err(ServerConfigError::WrongType(format!("server '{server}' has invalid header name '{name}'")));
334 }
335 if !is_valid_header_value(value) {
336 return Err(ServerConfigError::WrongType(format!(
337 "server '{server}' has invalid header value for '{name}'"
338 )));
339 }
340 let folded = name.to_ascii_lowercase();
341 if !seen.insert(folded) {
342 return Err(ServerConfigError::WrongType(format!("server '{server}' has duplicate header '{name}'")));
343 }
344 }
345 Ok(())
346}
347
348fn is_valid_header_name(name: &str) -> bool {
349 if name.is_empty() {
350 return false;
351 }
352 name.bytes().all(|b| {
353 b.is_ascii_alphanumeric()
354 || matches!(
355 b,
356 b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
357 )
358 })
359}
360
361fn is_valid_header_value(value: &str) -> bool {
362 !value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
364}
365
366fn expect_string_array(value: &serde_json::Value, context: &str) -> Result<Vec<String>, ServerConfigError> {
367 value
368 .as_array()
369 .map(|arr| {
370 arr.iter()
371 .map(|v| {
372 v.as_str()
373 .map(String::from)
374 .ok_or_else(|| ServerConfigError::WrongType(format!("{context} must contain only strings")))
375 })
376 .collect::<Result<Vec<_>, _>>()
377 })
378 .unwrap_or_else(|| Err(ServerConfigError::WrongType(format!("{context} must be an array"))))
379}
380
381fn expect_string_map(value: &serde_json::Value, context: &str) -> Result<HashMap<String, String>, ServerConfigError> {
382 value
383 .as_object()
384 .map(|obj| {
385 obj.iter()
386 .map(|(k, v)| {
387 v.as_str()
388 .map(|s| (k.clone(), s.to_string()))
389 .ok_or_else(|| ServerConfigError::WrongType(format!("{context}.{k} must be a string")))
390 })
391 .collect::<Result<HashMap<_, _>, _>>()
392 })
393 .unwrap_or_else(|| Err(ServerConfigError::WrongType(format!("{context} must be an object"))))
394}