Skip to main content

agent_plugins/
mcp.rs

1//! The `mcp.json` configuration (§7.2): a closed union of transports.
2//!
3//! The spec models each server entry as "exactly one of a closed set of
4//! variants" — which is to say, a Rust enum. [`McpServer`] carries only the
5//! fields its transport defines, so an entry mixing stdio and HTTP fields is
6//! unrepresentable here and is rejected during parsing, exactly as §7.2.1
7//! demands.
8//!
9//! Failure boundaries follow §7.2.2: a top-level problem disables MCP for the
10//! plugin ([`McpDisabledReason`]); a per-entry problem skips that one server
11//! ([`ServerInvalid`]) and the rest keep loading.
12
13use 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
23/// The canonical `$schema` identifier for Agent Plugins 1.0.0 MCP
24/// configuration (§7.2.1).
25pub const MCP_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
26
27/// A validated `mcp.json`: the servers that survived per-entry validation,
28/// in declaration order.
29#[derive(Debug, Clone, PartialEq)]
30#[non_exhaustive]
31pub struct McpConfig {
32    /// Valid server entries, in `mcpServers` declaration order.
33    pub servers: Vec<ServerEntry>,
34}
35
36impl McpConfig {
37    /// Look up a surviving server by its `mcpServers` member name.
38    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/// One named server from `mcpServers`.
44#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct ServerEntry {
47    /// The `mcpServers` member name identifying this server.
48    pub name: String,
49    /// Its validated configuration.
50    pub server: McpServer,
51}
52
53/// The closed union of §7.2.1 server variants, discriminated by `type`.
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub enum McpServer {
57    /// `"type": "stdio"` — a local subprocess speaking MCP over stdio.
58    Stdio(StdioServer),
59    /// `"type": "streamable-http"` — the current MCP Streamable HTTP transport.
60    StreamableHttp(RemoteServer),
61    /// `"type": "sse"` — the deprecated HTTP+SSE transport (MCP 2024-11-05).
62    Sse(RemoteServer),
63}
64
65impl McpServer {
66    /// The transport this entry declares — the one a client MUST use for its
67    /// initial connection attempt (§7.2.1).
68    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/// The three declared transports.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum Transport {
80    /// Local subprocess over stdio.
81    Stdio,
82    /// MCP Streamable HTTP.
83    StreamableHttp,
84    /// Deprecated HTTP+SSE (MCP 2024-11-05).
85    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/// A stdio server: how to launch the subprocess (§7.2.1).
99#[derive(Debug, Clone, PartialEq)]
100#[non_exhaustive]
101pub struct StdioServer {
102    /// The single executable token to launch.
103    pub command: Command,
104    /// Arguments, each subject to placeholder expansion (§9.2).
105    pub args: Vec<Template>,
106    /// Environment overlay, values subject to placeholder expansion (§9.2).
107    /// Names `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved and already
108    /// rejected at parse time.
109    pub env: Vec<(String, Template)>,
110    /// Working directory; defaults to the plugin root when omitted.
111    pub cwd: Cwd,
112}
113
114/// The `command` field: one executable token, never a shell string, never
115/// expanded (§7.2.1).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Command {
118    /// A bare executable name, resolved by the platform's search rules.
119    /// Contains no path separators — a bare name is a *name*.
120    Bare(String),
121    /// A plugin-relative path to an executable bundled in the package.
122    Bundled(RelativePath),
123}
124
125impl Command {
126    /// The token exactly as configured.
127    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/// The working directory of a stdio server, in the three permitted forms of
136/// §7.2.1 — plus the default. Any other shape never becomes a `Cwd`.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum Cwd {
139    /// `cwd` omitted, or exactly `${PLUGIN_ROOT}`: the plugin root.
140    PluginRoot,
141    /// A plugin-relative path beginning with `./`.
142    Relative(RelativePath),
143    /// `${PLUGIN_ROOT}/subpath`, the subpath lexically contained.
144    RootAnchored(String),
145    /// Exactly `${PLUGIN_DATA}`, or `${PLUGIN_DATA}/subpath` with the
146    /// subpath lexically contained in the data directory.
147    DataAnchored(String),
148}
149
150/// A remote server: Streamable HTTP or legacy SSE (§7.2.1).
151#[derive(Debug, Clone, PartialEq, Eq)]
152#[non_exhaustive]
153pub struct RemoteServer {
154    /// The validated MCP endpoint URL.
155    pub url: McpUrl,
156    /// Fixed literal headers, in declaration order. Never expanded, never a
157    /// secret mechanism (§7.2.1).
158    pub headers: Vec<(String, String)>,
159}
160
161/// An MCP endpoint URL that satisfied §7.2.1: absolute `http`/`https`, no
162/// user information, no fragment, and `http` only toward loopback.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct McpUrl {
165    raw: String,
166    https: bool,
167    loopback: bool,
168}
169
170impl McpUrl {
171    /// Validate an endpoint URL against §7.2.1.
172    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    /// The URL exactly as configured.
197    pub fn as_str(&self) -> &str {
198        &self.raw
199    }
200
201    /// Whether the scheme is `https`.
202    pub fn is_https(&self) -> bool {
203        self.https
204    }
205
206    /// Whether the host is `localhost` or a loopback IP literal.
207    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
223/// Extract the host from an authority, honoring IPv6 bracket syntax.
224/// Returns `None` for an empty or malformed host.
225fn 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/// Why a URL failed §7.2.1.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257#[non_exhaustive]
258pub enum UrlInvalid {
259    /// Not an absolute `http`/`https` URL.
260    NotHttp,
261    /// The URL carries user information.
262    HasUserInfo,
263    /// The URL carries a fragment.
264    HasFragment,
265    /// The URL has no usable host.
266    NoHost,
267    /// Plain `http` toward a non-loopback host.
268    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/// Why MCP was disabled for the whole plugin (§7.2.2 rule 2, §6.2).
288#[derive(Debug, Clone, PartialEq, Eq)]
289#[non_exhaustive]
290pub enum McpDisabledReason {
291    /// `mcp.json` exists but is not a regular file (§6.2).
292    NotAFile,
293    /// `mcp.json` resolves outside the plugin root (§4.1 rule 2).
294    LocationEscapes,
295    /// The IO source could not provide `mcp.json`.
296    Unavailable {
297        /// The source's explanation.
298        detail: String,
299    },
300    /// `mcp.json` is not valid JSON.
301    NotJson {
302        /// The JSON parser's explanation.
303        detail: String,
304    },
305    /// The document is valid JSON but not an object.
306    NotAnObject,
307    /// The required `$schema` field is missing or not a string.
308    MissingSchema,
309    /// `$schema` declares an unsupported version, or one that does not match
310    /// the version declared by `plugin.json` (§10.1).
311    SchemaMismatch {
312        /// The `$schema` value `mcp.json` declared.
313        declared: String,
314    },
315    /// A field other than `$schema` and `mcpServers` appears at the top level.
316    UnexpectedField {
317        /// The unexpected field.
318        field: String,
319    },
320    /// The required `mcpServers` field is missing or not an object.
321    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/// Why one server entry was skipped (§7.2.2 rule 3).
349#[derive(Debug, Clone, PartialEq, Eq)]
350#[non_exhaustive]
351pub enum ServerInvalid {
352    /// The entry is not a JSON object.
353    NotAnObject,
354    /// The `type` field is missing or not a string.
355    MissingType,
356    /// The `type` value is not a variant this specification defines.
357    UnknownType {
358        /// The declared `type` value.
359        declared: String,
360    },
361    /// A field outside the declared variant's closed set appears.
362    ForeignField {
363        /// The foreign field.
364        field: String,
365    },
366    /// A field has the wrong JSON type.
367    WrongType {
368        /// The offending field.
369        field: &'static str,
370        /// What the specification requires there.
371        expected: &'static str,
372    },
373    /// `command` is empty, or a bare name containing path separators.
374    NotAToken {
375        /// The configured command.
376        command: String,
377    },
378    /// A plugin-relative `command` escapes the plugin root.
379    CommandEscapes,
380    /// `cwd` matches none of the three permitted forms, or escapes its anchor.
381    BadCwd {
382        /// The configured working directory.
383        cwd: String,
384    },
385    /// `env` names a reserved variable (§9.2).
386    ReservedEnv {
387        /// The reserved name: `PLUGIN_ROOT` or `PLUGIN_DATA`.
388        name: String,
389    },
390    /// A header name is not a valid HTTP field name.
391    BadHeaderName {
392        /// The offending header name.
393        name: String,
394    },
395    /// A header value is not a valid HTTP field value.
396    BadHeaderValue {
397        /// The header whose value is invalid.
398        name: String,
399    },
400    /// The same header name appears more than once under different casing.
401    DuplicateHeader {
402        /// The duplicated header name (first spelling seen).
403        name: String,
404    },
405    /// The endpoint URL failed §7.2.1.
406    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    /// Parse and validate an `mcp.json` document against the version declared
448    /// by `plugin.json` (§7.2, §10.1).
449    ///
450    /// A top-level problem disables MCP entirely (`Err`); per-entry problems
451    /// skip only that server and are returned as diagnostics alongside the
452    /// surviving configuration.
453    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                // §10.1: the MCP `$schema` must declare the same Agent
466                // Plugins version as `plugin.json`. With one recognized
467                // version, that means exactly this identifier.
468                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
580/// §7.2.1: `command` is one executable token — a bare name (no separators)
581/// or a plugin-relative `./` path. Never a shell string, never expanded.
582fn 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
594/// §7.2.1: `cwd` has exactly three permitted forms. A placeholder anywhere
595/// but the very start is none of them.
596fn 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
662/// RFC 9110 token characters, the legal alphabet of an HTTP field name.
663fn 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
687/// An HTTP field value: no control characters other than horizontal tab.
688fn 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        // Loopback by IP range, not just 127.0.0.1 exactly.
711        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}