1use std::fmt;
14use std::net::IpAddr;
15
16use serde_json::{Map, Value};
17
18use crate::diag::{Diagnostic, Origin, Rule};
19use crate::manifest::SpecVersion;
20use crate::path::{lexically_contained, RelativePath};
21use crate::template::{Placeholder, Segment, Template};
22
23pub const MCP_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
26
27#[derive(Debug, Clone, PartialEq)]
30#[non_exhaustive]
31pub struct McpConfig {
32 pub servers: Vec<ServerEntry>,
34}
35
36impl McpConfig {
37 pub fn server(&self, name: &str) -> Option<&McpServer> {
39 self.servers.iter().find(|entry| entry.name == name).map(|entry| &entry.server)
40 }
41}
42
43#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct ServerEntry {
47 pub name: String,
49 pub server: McpServer,
51}
52
53#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub enum McpServer {
57 Stdio(StdioServer),
59 StreamableHttp(RemoteServer),
61 Sse(RemoteServer),
63}
64
65impl McpServer {
66 pub fn transport(&self) -> Transport {
69 match self {
70 Self::Stdio(_) => Transport::Stdio,
71 Self::StreamableHttp(_) => Transport::StreamableHttp,
72 Self::Sse(_) => Transport::Sse,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum Transport {
80 Stdio,
82 StreamableHttp,
84 Sse,
86}
87
88impl fmt::Display for Transport {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 f.write_str(match self {
91 Self::Stdio => "stdio",
92 Self::StreamableHttp => "streamable-http",
93 Self::Sse => "sse",
94 })
95 }
96}
97
98#[derive(Debug, Clone, PartialEq)]
100#[non_exhaustive]
101pub struct StdioServer {
102 pub command: Command,
104 pub args: Vec<Template>,
106 pub env: Vec<(String, Template)>,
110 pub cwd: Cwd,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Command {
118 Bare(String),
121 Bundled(RelativePath),
123}
124
125impl Command {
126 pub fn as_str(&self) -> &str {
128 match self {
129 Self::Bare(name) => name,
130 Self::Bundled(path) => path.as_str(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum Cwd {
139 PluginRoot,
141 Relative(RelativePath),
143 RootAnchored(String),
145 DataAnchored(String),
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152#[non_exhaustive]
153pub struct RemoteServer {
154 pub url: McpUrl,
156 pub headers: Vec<(String, String)>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct McpUrl {
165 raw: String,
166 https: bool,
167 loopback: bool,
168}
169
170impl McpUrl {
171 pub fn parse(raw: &str) -> Result<Self, UrlInvalid> {
173 if raw.contains('#') {
174 return Err(UrlInvalid::HasFragment);
175 }
176 let (https, after_scheme) = if let Some(rest) = strip_scheme(raw, "https://") {
177 (true, rest)
178 } else if let Some(rest) = strip_scheme(raw, "http://") {
179 (false, rest)
180 } else {
181 return Err(UrlInvalid::NotHttp);
182 };
183 let authority_end = after_scheme.find(['/', '?']).unwrap_or(after_scheme.len());
184 let authority = &after_scheme[..authority_end];
185 if authority.contains('@') {
186 return Err(UrlInvalid::HasUserInfo);
187 }
188 let host = host_of(authority).ok_or(UrlInvalid::NoHost)?;
189 let loopback = is_loopback_host(&host);
190 if !https && !loopback {
191 return Err(UrlInvalid::PlainHttpBeyondLoopback);
192 }
193 Ok(Self { raw: raw.to_owned(), https, loopback })
194 }
195
196 pub fn as_str(&self) -> &str {
198 &self.raw
199 }
200
201 pub fn is_https(&self) -> bool {
203 self.https
204 }
205
206 pub fn is_loopback(&self) -> bool {
208 self.loopback
209 }
210}
211
212impl fmt::Display for McpUrl {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 f.write_str(&self.raw)
215 }
216}
217
218fn strip_scheme<'a>(raw: &'a str, scheme: &str) -> Option<&'a str> {
219 (raw.len() >= scheme.len() && raw[..scheme.len()].eq_ignore_ascii_case(scheme))
220 .then(|| &raw[scheme.len()..])
221}
222
223fn host_of(authority: &str) -> Option<String> {
226 if let Some(rest) = authority.strip_prefix('[') {
227 let end = rest.find(']')?;
228 let host = &rest[..end];
229 let port = &rest[end + 1..];
230 if !port.is_empty() && !port.strip_prefix(':')?.chars().all(|c| c.is_ascii_digit()) {
231 return None;
232 }
233 return (!host.is_empty()).then(|| format!("[{host}]"));
234 }
235 let host = match authority.split_once(':') {
236 Some((host, port)) => {
237 if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
238 return None;
239 }
240 host
241 }
242 None => authority,
243 };
244 (!host.is_empty()).then(|| host.to_owned())
245}
246
247fn is_loopback_host(host: &str) -> bool {
248 if host.eq_ignore_ascii_case("localhost") {
249 return true;
250 }
251 let bare = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
252 bare.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257#[non_exhaustive]
258pub enum UrlInvalid {
259 NotHttp,
261 HasUserInfo,
263 HasFragment,
265 NoHost,
267 PlainHttpBeyondLoopback,
269}
270
271impl fmt::Display for UrlInvalid {
272 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273 f.write_str(match self {
274 Self::NotHttp => "URL must be absolute http or https",
275 Self::HasUserInfo => "URL must not contain user information",
276 Self::HasFragment => "URL must not contain a fragment",
277 Self::NoHost => "URL has no valid host",
278 Self::PlainHttpBeyondLoopback => {
279 "http is only permitted when the host is localhost or a loopback IP literal"
280 }
281 })
282 }
283}
284
285impl std::error::Error for UrlInvalid {}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
289#[non_exhaustive]
290pub enum McpDisabledReason {
291 NotAFile,
293 LocationEscapes,
295 Unavailable {
297 detail: String,
299 },
300 NotJson {
302 detail: String,
304 },
305 NotAnObject,
307 MissingSchema,
309 SchemaMismatch {
312 declared: String,
314 },
315 UnexpectedField {
317 field: String,
319 },
320 MissingServers,
322}
323
324impl fmt::Display for McpDisabledReason {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 match self {
327 Self::NotAFile => f.write_str("mcp.json is not a regular file"),
328 Self::LocationEscapes => f.write_str("mcp.json resolves outside the plugin root"),
329 Self::Unavailable { detail } => write!(f, "mcp.json could not be read: {detail}"),
330 Self::NotJson { detail } => write!(f, "mcp.json is not valid JSON: {detail}"),
331 Self::NotAnObject => f.write_str("mcp.json must contain a top-level JSON object"),
332 Self::MissingSchema => f.write_str("required field `$schema` is missing"),
333 Self::SchemaMismatch { declared } => {
334 write!(f, "`$schema` declares an unsupported or mismatched version: {declared}")
335 }
336 Self::UnexpectedField { field } => {
337 write!(f, "unexpected top-level field `{field}` in a closed schema")
338 }
339 Self::MissingServers => {
340 f.write_str("required field `mcpServers` is missing or not an object")
341 }
342 }
343 }
344}
345
346impl std::error::Error for McpDisabledReason {}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
350#[non_exhaustive]
351pub enum ServerInvalid {
352 NotAnObject,
354 MissingType,
356 UnknownType {
358 declared: String,
360 },
361 ForeignField {
363 field: String,
365 },
366 WrongType {
368 field: &'static str,
370 expected: &'static str,
372 },
373 NotAToken {
375 command: String,
377 },
378 CommandEscapes,
380 BadCwd {
382 cwd: String,
384 },
385 ReservedEnv {
387 name: String,
389 },
390 BadHeaderName {
392 name: String,
394 },
395 BadHeaderValue {
397 name: String,
399 },
400 DuplicateHeader {
402 name: String,
404 },
405 BadUrl(UrlInvalid),
407}
408
409impl fmt::Display for ServerInvalid {
410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411 match self {
412 Self::NotAnObject => f.write_str("server entry must be a JSON object"),
413 Self::MissingType => f.write_str("required field `type` is missing"),
414 Self::UnknownType { declared } => write!(f, "unknown transport type {declared:?}"),
415 Self::ForeignField { field } => {
416 write!(f, "field `{field}` does not belong to this transport variant")
417 }
418 Self::WrongType { field, expected } => {
419 write!(f, "field `{field}` must be {expected}")
420 }
421 Self::NotAToken { command } => write!(
422 f,
423 "`command` {command:?} must be a single bare executable name or a `./` path"
424 ),
425 Self::CommandEscapes => f.write_str("`command` resolves outside the plugin root"),
426 Self::BadCwd { cwd } => write!(
427 f,
428 "`cwd` {cwd:?} must be `./…`, `${{PLUGIN_ROOT}}[/…]`, or `${{PLUGIN_DATA}}[/…]` \
429 and stay inside its anchor"
430 ),
431 Self::ReservedEnv { name } => {
432 write!(f, "`env` must not set reserved variable `{name}` (§9.2)")
433 }
434 Self::BadHeaderName { name } => write!(f, "invalid HTTP header name {name:?}"),
435 Self::BadHeaderValue { name } => write!(f, "invalid value for HTTP header {name:?}"),
436 Self::DuplicateHeader { name } => {
437 write!(f, "header {name:?} appears more than once (names are case-insensitive)")
438 }
439 Self::BadUrl(err) => write!(f, "invalid endpoint URL: {err}"),
440 }
441 }
442}
443
444impl std::error::Error for ServerInvalid {}
445
446impl McpConfig {
447 pub fn parse(
454 bytes: &[u8],
455 manifest_spec: SpecVersion,
456 ) -> Result<(Self, Vec<Diagnostic>), McpDisabledReason> {
457 let document: Value = serde_json::from_slice(bytes)
458 .map_err(|e| McpDisabledReason::NotJson { detail: e.to_string() })?;
459 let Value::Object(object) = document else {
460 return Err(McpDisabledReason::NotAnObject);
461 };
462
463 match object.get("$schema") {
464 Some(Value::String(id)) => {
465 let matches = match manifest_spec {
469 SpecVersion::V1_0_0 => id == MCP_SCHEMA_1_0_0,
470 };
471 if !matches {
472 return Err(McpDisabledReason::SchemaMismatch { declared: id.clone() });
473 }
474 }
475 _ => return Err(McpDisabledReason::MissingSchema),
476 }
477
478 if let Some(field) = object.keys().find(|k| *k != "$schema" && *k != "mcpServers") {
479 return Err(McpDisabledReason::UnexpectedField { field: field.clone() });
480 }
481
482 let Some(Value::Object(members)) = object.get("mcpServers") else {
483 return Err(McpDisabledReason::MissingServers);
484 };
485
486 let mut servers = Vec::new();
487 let mut diagnostics = Vec::new();
488 for (name, value) in members {
489 match parse_server(value) {
490 Ok(server) => servers.push(ServerEntry { name: name.clone(), server }),
491 Err(why) => diagnostics.push(Diagnostic::new(
492 Rule::ServerSkipped,
493 Origin::Server(name.clone()),
494 why.to_string(),
495 )),
496 }
497 }
498 Ok((Self { servers }, diagnostics))
499 }
500}
501
502fn parse_server(value: &Value) -> Result<McpServer, ServerInvalid> {
503 let Value::Object(fields) = value else {
504 return Err(ServerInvalid::NotAnObject);
505 };
506 let declared = match fields.get("type") {
507 Some(Value::String(t)) => t.as_str(),
508 Some(_) => return Err(ServerInvalid::WrongType { field: "type", expected: "a string" }),
509 None => return Err(ServerInvalid::MissingType),
510 };
511 match declared {
512 "stdio" => parse_stdio(fields).map(McpServer::Stdio),
513 "streamable-http" => parse_remote(fields).map(McpServer::StreamableHttp),
514 "sse" => parse_remote(fields).map(McpServer::Sse),
515 other => Err(ServerInvalid::UnknownType { declared: other.to_owned() }),
516 }
517}
518
519fn parse_stdio(fields: &Map<String, Value>) -> Result<StdioServer, ServerInvalid> {
520 for field in fields.keys() {
521 if !matches!(field.as_str(), "type" | "command" | "args" | "env" | "cwd") {
522 return Err(ServerInvalid::ForeignField { field: field.clone() });
523 }
524 }
525
526 let command = match fields.get("command") {
527 Some(Value::String(raw)) => parse_command(raw)?,
528 Some(_) => return Err(ServerInvalid::WrongType { field: "command", expected: "a string" }),
529 None => return Err(ServerInvalid::WrongType { field: "command", expected: "present" }),
530 };
531
532 let args = match fields.get("args") {
533 None => Vec::new(),
534 Some(Value::Array(items)) => items
535 .iter()
536 .map(|item| match item {
537 Value::String(s) => Ok(Template::parse(s)),
538 _ => {
539 Err(ServerInvalid::WrongType { field: "args", expected: "an array of strings" })
540 }
541 })
542 .collect::<Result<_, _>>()?,
543 Some(_) => {
544 return Err(ServerInvalid::WrongType { field: "args", expected: "an array of strings" })
545 }
546 };
547
548 let env = match fields.get("env") {
549 None => Vec::new(),
550 Some(Value::Object(entries)) => {
551 let mut env = Vec::with_capacity(entries.len());
552 for (name, value) in entries {
553 if name == "PLUGIN_ROOT" || name == "PLUGIN_DATA" {
554 return Err(ServerInvalid::ReservedEnv { name: name.clone() });
555 }
556 let Value::String(raw) = value else {
557 return Err(ServerInvalid::WrongType {
558 field: "env",
559 expected: "an object of strings",
560 });
561 };
562 env.push((name.clone(), Template::parse(raw)));
563 }
564 env
565 }
566 Some(_) => {
567 return Err(ServerInvalid::WrongType { field: "env", expected: "an object of strings" })
568 }
569 };
570
571 let cwd = match fields.get("cwd") {
572 None => Cwd::PluginRoot,
573 Some(Value::String(raw)) => parse_cwd(raw)?,
574 Some(_) => return Err(ServerInvalid::WrongType { field: "cwd", expected: "a string" }),
575 };
576
577 Ok(StdioServer { command, args, env, cwd })
578}
579
580fn parse_command(raw: &str) -> Result<Command, ServerInvalid> {
583 if raw.starts_with("./") {
584 return RelativePath::parse(raw)
585 .map(Command::Bundled)
586 .map_err(|_| ServerInvalid::CommandEscapes);
587 }
588 if raw.is_empty() || raw.contains('/') || raw.contains('\\') {
589 return Err(ServerInvalid::NotAToken { command: raw.to_owned() });
590 }
591 Ok(Command::Bare(raw.to_owned()))
592}
593
594fn parse_cwd(raw: &str) -> Result<Cwd, ServerInvalid> {
597 let bad = || ServerInvalid::BadCwd { cwd: raw.to_owned() };
598 let template = Template::parse(raw);
599 match template.segments() {
600 [Segment::Literal(_)] => RelativePath::parse(raw).map(Cwd::Relative).map_err(|_| bad()),
601 [Segment::Anchor(Placeholder::PluginRoot)] => Ok(Cwd::PluginRoot),
602 [Segment::Anchor(Placeholder::PluginData)] => Ok(Cwd::DataAnchored(String::new())),
603 [Segment::Anchor(anchor), Segment::Literal(rest)] => {
604 let subpath = rest.strip_prefix('/').ok_or_else(bad)?;
605 if !lexically_contained(subpath) {
606 return Err(bad());
607 }
608 Ok(match anchor {
609 Placeholder::PluginRoot => Cwd::RootAnchored(subpath.to_owned()),
610 Placeholder::PluginData => Cwd::DataAnchored(subpath.to_owned()),
611 })
612 }
613 _ => Err(bad()),
614 }
615}
616
617fn parse_remote(fields: &Map<String, Value>) -> Result<RemoteServer, ServerInvalid> {
618 for field in fields.keys() {
619 if !matches!(field.as_str(), "type" | "url" | "headers") {
620 return Err(ServerInvalid::ForeignField { field: field.clone() });
621 }
622 }
623 let url = match fields.get("url") {
624 Some(Value::String(raw)) => McpUrl::parse(raw).map_err(ServerInvalid::BadUrl)?,
625 Some(_) => return Err(ServerInvalid::WrongType { field: "url", expected: "a string" }),
626 None => return Err(ServerInvalid::WrongType { field: "url", expected: "present" }),
627 };
628 let headers = match fields.get("headers") {
629 None => Vec::new(),
630 Some(Value::Object(entries)) => {
631 let mut headers: Vec<(String, String)> = Vec::with_capacity(entries.len());
632 for (name, value) in entries {
633 if !is_valid_header_name(name) {
634 return Err(ServerInvalid::BadHeaderName { name: name.clone() });
635 }
636 let Value::String(v) = value else {
637 return Err(ServerInvalid::WrongType {
638 field: "headers",
639 expected: "an object of strings",
640 });
641 };
642 if !is_valid_header_value(v) {
643 return Err(ServerInvalid::BadHeaderValue { name: name.clone() });
644 }
645 if headers.iter().any(|(seen, _)| seen.eq_ignore_ascii_case(name)) {
646 return Err(ServerInvalid::DuplicateHeader { name: name.clone() });
647 }
648 headers.push((name.clone(), v.clone()));
649 }
650 headers
651 }
652 Some(_) => {
653 return Err(ServerInvalid::WrongType {
654 field: "headers",
655 expected: "an object of strings",
656 })
657 }
658 };
659 Ok(RemoteServer { url, headers })
660}
661
662fn is_valid_header_name(name: &str) -> bool {
664 !name.is_empty()
665 && name.bytes().all(|b| {
666 b.is_ascii_alphanumeric()
667 || matches!(
668 b,
669 b'!' | b'#'
670 | b'$'
671 | b'%'
672 | b'&'
673 | b'\''
674 | b'*'
675 | b'+'
676 | b'-'
677 | b'.'
678 | b'^'
679 | b'_'
680 | b'`'
681 | b'|'
682 | b'~'
683 )
684 })
685}
686
687fn is_valid_header_value(value: &str) -> bool {
689 value.bytes().all(|b| b == b'\t' || (b != 0x7f && b >= 0x20))
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn url_rules() {
698 assert!(McpUrl::parse("https://deploy.example.com/mcp").is_ok());
699 assert!(McpUrl::parse("http://localhost:8080/mcp").unwrap().is_loopback());
700 assert!(McpUrl::parse("http://127.0.0.1/mcp").unwrap().is_loopback());
701 assert!(McpUrl::parse("http://[::1]:9000/sse").unwrap().is_loopback());
702 assert_eq!(
703 McpUrl::parse("http://mcp.example.com/api"),
704 Err(UrlInvalid::PlainHttpBeyondLoopback)
705 );
706 assert_eq!(McpUrl::parse("https://a:b@example.com/"), Err(UrlInvalid::HasUserInfo));
707 assert_eq!(McpUrl::parse("https://example.com/api#frag"), Err(UrlInvalid::HasFragment));
708 assert_eq!(McpUrl::parse("ftp://example.com/"), Err(UrlInvalid::NotHttp));
709 assert_eq!(McpUrl::parse("https:///path"), Err(UrlInvalid::NoHost));
710 assert!(McpUrl::parse("http://127.8.9.1/x").unwrap().is_loopback());
712 }
713
714 #[test]
715 fn command_rules() {
716 assert_eq!(parse_command("npx"), Ok(Command::Bare("npx".into())));
717 assert!(matches!(parse_command("./bin/server"), Ok(Command::Bundled(_))));
718 assert!(matches!(parse_command("../bin/server"), Err(ServerInvalid::NotAToken { .. })));
719 assert!(matches!(parse_command("bin/server"), Err(ServerInvalid::NotAToken { .. })));
720 assert!(matches!(parse_command("./bin/../../server"), Err(ServerInvalid::CommandEscapes)));
721 assert!(matches!(parse_command(""), Err(ServerInvalid::NotAToken { .. })));
722 }
723
724 #[test]
725 fn cwd_forms() {
726 assert!(matches!(parse_cwd("./data"), Ok(Cwd::Relative(_))));
727 assert_eq!(parse_cwd("${PLUGIN_ROOT}"), Ok(Cwd::PluginRoot));
728 assert_eq!(parse_cwd("${PLUGIN_ROOT}/x"), Ok(Cwd::RootAnchored("x".into())));
729 assert_eq!(parse_cwd("${PLUGIN_DATA}"), Ok(Cwd::DataAnchored(String::new())));
730 assert_eq!(parse_cwd("${PLUGIN_DATA}/x/y"), Ok(Cwd::DataAnchored("x/y".into())));
731 assert!(parse_cwd("data").is_err());
732 assert!(parse_cwd("${PLUGIN_ROOT}/../x").is_err());
733 assert!(parse_cwd("./x/${PLUGIN_ROOT}").is_err());
734 assert!(parse_cwd("${PLUGIN_ROOT}x").is_err());
735 }
736}