Skip to main content

vtcode_agent_plugins/
mcp.rs

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                            // Per the Agent Plugins spec, an invalid individual
73                            // server entry is skipped while valid peers continue
74                            // loading. Both missing-field and wrong-type errors
75                            // are entry-local failures — one bad server must not
76                            // disable every MCP server in the plugin.
77                            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
114/// Distinguishes missing required fields from wrong-typed fields during
115/// MCP server config parsing. Both are entry-local failures: the individual
116/// server is skipped while valid peers continue loading (Agent Plugins spec).
117/// The distinction improves diagnostic messages so users can tell whether a
118/// field was absent or had the wrong JSON type.
119enum ServerConfigError {
120    MissingField(String),
121    WrongType(String),
122}
123
124/// Extract a required string field, distinguishing absent from wrong-typed.
125fn 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    // Schemes are case-insensitive per RFC; compare case-folded prefixes.
239    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    // Authority is up to the first /, ?, or end. `/` and `?` are ASCII so the
253    // byte index is always a char boundary; use `get` to stay clippy-clean.
254    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    // Host without port and without IPv6 brackets.
265    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    // IPv6 loopback is exactly ::1 (compressed) or its expanded form.
301    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    // Visible package data: reject control bytes that would split the header.
363    !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}