Skip to main content

mcp_execution_core/
server_config.rs

1//! MCP server configuration with command, arguments, and environment.
2//!
3//! This module provides type-safe server configuration for launching MCP servers
4//! with security validation of commands, arguments, and environment variables.
5//!
6//! # Transport Types
7//!
8//! Supports two transport types:
9//! - Stdio: Subprocess communication via stdin/stdout (default)
10//! - HTTP: Communication via HTTP/HTTPS API
11//!
12//! # Security
13//!
14//! The configuration enforces:
15//! - Command validation (absolute path or binary name)
16//! - Argument sanitization (no shell metacharacters)
17//! - Environment variable validation (block dangerous names)
18//! - Forbidden characters: `;`, `|`, `&`, `>`, `<`, `` ` ``, `$`, `(`, `)`, `\n`, `\r`
19//! - Forbidden env vars: dynamic-linker (`LD_PRELOAD`, `LD_LIBRARY_PATH`,
20//!   `LD_AUDIT`, `DYLD_*`), `PATH`, and interpreter hijack vectors
21//!   (`NODE_OPTIONS`, `PYTHONPATH`, `PYTHONSTARTUP`, `RUBYOPT`, `PERL5OPT`,
22//!   `JAVA_TOOL_OPTIONS`) — see `command::FORBIDDEN_ENV_NAMES` for the full
23//!   list and its documented threat model
24//!
25//! # Examples
26//!
27//! ```
28//! use mcp_execution_core::ServerConfig;
29//! use std::collections::HashMap;
30//!
31//! // Simple configuration with just command
32//! let config = ServerConfig::builder()
33//!     .command("docker".to_string())
34//!     .build().unwrap();
35//!
36//! // Full configuration with args and env
37//! let config = ServerConfig::builder()
38//!     .command("mcp-server".to_string())
39//!     .arg("--port".to_string())
40//!     .arg("8080".to_string())
41//!     .env("LOG_LEVEL".to_string(), "debug".to_string())
42//!     .build().unwrap();
43//!
44//! // HTTP transport configuration
45//! let config = ServerConfig::builder()
46//!     .http_transport("https://api.example.com/mcp".to_string())
47//!     .header("Authorization".to_string(), "Bearer token".to_string())
48//!     .build().unwrap();
49//! ```
50
51use crate::path::sanitize_path_for_error;
52use crate::redact::{RedactedItems, RedactedMapValues, RedactedUrl};
53use serde::de::Error as _;
54use serde::{Deserialize, Serialize};
55use std::collections::HashMap;
56use std::fmt;
57use std::path::{Path, PathBuf};
58use std::sync::LazyLock;
59use std::time::Duration;
60
61/// Default timeout for establishing an MCP server connection (handshake).
62const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
63
64/// Default timeout for the `list_all_tools` discovery call after connecting.
65const DEFAULT_DISCOVER_TIMEOUT: Duration = Duration::from_secs(30);
66
67const fn default_connect_timeout() -> Duration {
68    DEFAULT_CONNECT_TIMEOUT
69}
70
71const fn default_discover_timeout() -> Duration {
72    DEFAULT_DISCOVER_TIMEOUT
73}
74
75/// Shared empty map returned by [`ServerConfig::env`]/[`ServerConfig::headers`] for the
76/// transport that doesn't carry that field (e.g. `headers()` for a `Stdio` config), so those
77/// accessors can keep returning a plain `&HashMap` instead of an `Option`.
78static EMPTY_MAP: LazyLock<HashMap<String, String>> = LazyLock::new(HashMap::new);
79
80/// Transport-specific configuration for connecting to an MCP server.
81///
82/// Each variant carries exactly the fields meaningful for that transport, so a config with
83/// stdio's `command`/`args`/`env`/`cwd` set alongside http/sse's `url`/`headers` — or a
84/// `Stdio` variant missing `command`, or an `Http`/`Sse` variant missing `url` — cannot be
85/// constructed at all; the combination is unrepresentable rather than merely unvalidated
86/// (issue #313). [`ServerConfig::builder`] is the normal way to construct one; a bare
87/// `Transport` value is mostly useful for matching on [`ServerConfig::transport`].
88///
89/// # Examples
90///
91/// ```
92/// use mcp_execution_core::{ServerConfig, Transport};
93///
94/// let config = ServerConfig::builder()
95///     .command("docker".to_string())
96///     .build()
97///     .unwrap();
98///
99/// assert!(matches!(config.transport(), Transport::Stdio { .. }));
100/// ```
101#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(tag = "transport", rename_all = "lowercase")]
103pub enum Transport {
104    /// Stdio transport: subprocess communication via stdin/stdout.
105    Stdio {
106        /// Command to execute (binary name or absolute path).
107        ///
108        /// Can be either:
109        /// - Binary name (e.g., "docker", "python") - resolved via PATH
110        /// - Absolute path (e.g., "/usr/local/bin/mcp-server")
111        command: String,
112
113        /// Arguments to pass to command.
114        ///
115        /// Each argument is passed separately to avoid shell interpretation.
116        /// Do not include the command itself in arguments.
117        #[serde(default)]
118        args: Vec<String>,
119
120        /// Environment variables to set for the subprocess.
121        ///
122        /// These are added to (or override) the parent process environment.
123        /// Security validation blocks dangerous variables like `LD_PRELOAD`.
124        /// Values routinely hold secrets (e.g. `GITHUB_PERSONAL_ACCESS_TOKEN`); see
125        /// the redaction note on [`ServerConfig`]'s own doc comment.
126        #[serde(default)]
127        env: HashMap<String, String>,
128
129        /// Working directory for the subprocess (optional).
130        ///
131        /// If `None`, inherits the parent process working directory.
132        #[serde(default)]
133        cwd: Option<PathBuf>,
134    },
135    /// HTTP transport: communication via HTTP/HTTPS API.
136    Http {
137        /// URL for HTTP transport.
138        ///
139        /// Example: `https://api.example.com/mcp`
140        ///
141        /// This crate does not apply SSRF allowlisting to this URL — it is
142        /// treated like a `curl` target, appropriate for a local CLI tool.
143        /// Embedders that expose this config in a multi-tenant or server context
144        /// should apply their own URL allowlisting before connecting.
145        url: String,
146
147        /// HTTP headers for HTTP transport.
148        ///
149        /// Common headers include:
150        /// - `Authorization`: Authentication token
151        /// - `Content-Type`: Request content type
152        ///
153        /// Values routinely hold secrets (e.g. a bearer token); see the redaction
154        /// note on [`ServerConfig`]'s own doc comment.
155        #[serde(default)]
156        headers: HashMap<String, String>,
157    },
158    /// SSE transport: Server-Sent Events for streaming communication.
159    Sse {
160        /// URL for SSE transport.
161        ///
162        /// Same shape and SSRF caveat as [`Transport::Http`]'s `url`.
163        url: String,
164
165        /// HTTP headers for SSE transport.
166        ///
167        /// Same shape and redaction guarantee as [`Transport::Http`]'s `headers`.
168        #[serde(default)]
169        headers: HashMap<String, String>,
170    },
171}
172
173impl fmt::Debug for Transport {
174    /// Redacts the same fields, the same way, as [`ServerConfig`]'s own hand-written impl —
175    /// see the "Security" section on that type's doc comment for the full rationale. Kept as a
176    /// standalone impl (rather than `ServerConfig` delegating to it) so each type's `Debug`
177    /// output stays exactly what it was already documented and tested to be.
178    ///
179    /// # Examples
180    ///
181    /// ```
182    /// use mcp_execution_core::Transport;
183    /// use std::collections::HashMap;
184    ///
185    /// let transport = Transport::Http {
186    ///     url: "https://user:sk-secret@api.example.com/mcp?token=sk-secret".to_string(),
187    ///     headers: HashMap::from([("Authorization".to_string(), "Bearer sk-secret".to_string())]),
188    /// };
189    ///
190    /// let debug_output = format!("{transport:?}");
191    /// assert!(debug_output.contains("Authorization"));
192    /// assert!(debug_output.contains("api.example.com/mcp"));
193    /// assert!(!debug_output.contains("sk-secret"));
194    /// ```
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            Self::Stdio {
198                command,
199                args,
200                env,
201                cwd,
202            } => f
203                .debug_struct("Stdio")
204                .field("command", &sanitize_path_for_error(Path::new(command)))
205                .field("args", &RedactedItems(args))
206                .field("env", &RedactedMapValues(env))
207                .field("cwd", &cwd.as_deref().map(sanitize_path_for_error))
208                .finish(),
209            Self::Http { url, headers } => f
210                .debug_struct("Http")
211                .field("url", &RedactedUrl(url))
212                .field("headers", &RedactedMapValues(headers))
213                .finish(),
214            Self::Sse { url, headers } => f
215                .debug_struct("Sse")
216                .field("url", &RedactedUrl(url))
217                .field("headers", &RedactedMapValues(headers))
218                .finish(),
219        }
220    }
221}
222
223/// MCP server configuration with command, arguments, and environment.
224///
225/// Represents the configuration needed to communicate with an MCP server,
226/// supporting both stdio (subprocess) and HTTP transports.
227///
228/// # Transport Types
229///
230/// - **Stdio**: Launches a subprocess and communicates via stdin/stdout
231/// - **HTTP**: Connects to an HTTP/HTTPS API endpoint
232///
233/// # Security
234///
235/// Both fields are private and the type's [`Deserialize`] impl is hand-written (see below), so
236/// a `ServerConfig` cannot be obtained — via [`ServerConfigBuilder::build`], a struct literal,
237/// or `serde_json::from_str` — without having passed [`validate_server_config`]. This closes
238/// the gap that existed when every field was `pub` and `Deserialize` was derived: a
239/// hand-edited `mcp.json` or a directly-constructed struct literal could previously skip
240/// validation entirely.
241///
242/// `headers`, `env`, `args`, and `url` routinely carry secrets (e.g. an
243/// `Authorization: Bearer <token>` header, a `GITHUB_PERSONAL_ACCESS_TOKEN`
244/// environment variable, an `--api-key sk-...`-style argument, or a
245/// `?token=`-style query string), so this type's [`Debug`] implementation is
246/// hand-written to redact them (this guarantee is `Debug`-only — see
247/// "Serialization Hazard" below for `Serialize`, which does not redact
248/// anything):
249///
250/// - `headers`/`env`: keys stay visible, values are replaced — a legitimately
251///   configured key (a chosen header or env var name, e.g. `"Authorization"`)
252///   is not itself a secret and remains useful for debugging. This mirrors
253///   the discipline already applied to header values in `command.rs`'s
254///   `validate_header_value_string`, which never echoes a header value into
255///   an error message.
256/// - `args`: every entry is replaced wholesale (via [`crate::RedactedItems`])
257///   since an argument has no key/value split to preserve half of.
258/// - `url`: userinfo credentials and any query string are stripped (via
259///   [`crate::RedactedUrl`]); scheme, host, and path stay readable.
260/// - `command`/`cwd`: passed through [`crate::sanitize_path_for_error`] —
261///   not a secret, but an absolute path leaks the OS username, and the
262///   program name itself (`docker`, `npx`) is worth keeping readable for
263///   telling server entries apart in a log.
264///
265/// This is a narrower guarantee than `command.rs`'s header-*name* validation
266/// errors, which redact the name too: those fire on input that has not yet
267/// been confirmed well-formed (e.g. a `Name=Value` CLI argument mis-split on
268/// the wrong separator can leave a secret value sitting in the name
269/// position), so any name reaching that error path must be treated as
270/// secret-shaped. Once [`validate_server_config`] has accepted a config,
271/// its `headers`/`env` keys are the caller's own identifiers rather than
272/// unvalidated split output, so trusting them for `Debug` output is not in
273/// tension with distrusting an unvalidated name in an error message.
274///
275/// [`ServerConfigBuilder`] carries the same [`Debug`] treatment for
276/// consistency, but that guarantee does not extend to it: the builder is
277/// populated *before* [`validate_server_config`] runs, so a caller that
278/// feeds it unvalidated input (e.g. a mis-split CLI argument) can still end
279/// up with a secret-shaped key in `format!("{builder:?}")`. Keys are shown
280/// there deliberately regardless — redacting them would defeat the point of
281/// a debug impl for a type whose purpose is to be inspected before
282/// `build()`.
283///
284/// ## Serialization Hazard
285///
286/// `Serialize` is a separate code path from [`Debug`] and is deliberately
287/// left unredacted: whatever consumes the serialized form must still be able
288/// to round-trip real values, so `serde_json::to_string(&config)` (or any
289/// other `Serialize`-driven output) emits every field, including secrets, in
290/// full. **The `Debug`-redaction guarantee documented above does not extend
291/// to `Serialize` in any way** — do not assume "this type redacts secrets"
292/// covers both.
293///
294/// Consequently, a serialized `ServerConfig` must never be logged or printed
295/// directly. It exists for whatever trusted persistence an embedder builds
296/// on top of this crate (a caller-owned config store, for instance) — not
297/// for a specific path this crate implements itself; as of this writing no
298/// non-test code in this workspace serializes a `ServerConfig` and then
299/// logs or prints the result.
300///
301/// ```
302/// use mcp_execution_core::ServerConfig;
303///
304/// let config = ServerConfig::builder()
305///     .command("docker".to_string())
306///     .env(
307///         "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
308///         "ghp_supersecretvalue".to_string(),
309///     )
310///     .build()
311///     .unwrap();
312///
313/// // Unlike `Debug`, `Serialize` does not redact anything.
314/// let json = serde_json::to_string(&config).unwrap();
315/// assert!(json.contains("ghp_supersecretvalue"));
316/// ```
317///
318/// # Examples
319///
320/// ```
321/// use mcp_execution_core::ServerConfig;
322///
323/// // Stdio transport
324/// let config = ServerConfig::builder()
325///     .command("docker".to_string())
326///     .arg("run".to_string())
327///     .arg("mcp-server".to_string())
328///     .build().unwrap();
329///
330/// assert_eq!(config.command(), Some("docker"));
331/// assert_eq!(config.args().len(), 2);
332///
333/// // HTTP transport
334/// let config = ServerConfig::builder()
335///     .http_transport("https://api.example.com/mcp".to_string())
336///     .header("Authorization".to_string(), "Bearer token".to_string())
337///     .build().unwrap();
338/// ```
339///
340/// Debug output redacts header/env values but keeps keys:
341///
342/// ```
343/// use mcp_execution_core::ServerConfig;
344///
345/// let config = ServerConfig::builder()
346///     .http_transport("https://api.example.com/mcp".to_string())
347///     .header("Authorization".to_string(), "Bearer sk-secret-value".to_string())
348///     .build();
349///
350/// let debug_output = format!("{config:?}");
351/// assert!(debug_output.contains("Authorization"));
352/// assert!(!debug_output.contains("sk-secret-value"));
353/// ```
354///
355/// Debug output redacts `args` wholesale and strips URL userinfo/query,
356/// while keeping `command` and the URL host/path readable:
357///
358/// ```
359/// use mcp_execution_core::ServerConfig;
360///
361/// let config = ServerConfig::builder()
362///     .command("docker".to_string())
363///     .arg("--api-key".to_string())
364///     .arg("sk-secret-arg".to_string())
365///     .build();
366///
367/// let debug_output = format!("{config:?}");
368/// assert!(debug_output.contains("docker"));
369/// assert!(!debug_output.contains("sk-secret-arg"));
370///
371/// let config = ServerConfig::builder()
372///     .http_transport("https://user:sk-secret@api.example.com/mcp?token=sk-secret".to_string())
373///     .build();
374///
375/// let debug_output = format!("{config:?}");
376/// assert!(debug_output.contains("api.example.com/mcp"));
377/// assert!(!debug_output.contains("sk-secret"));
378/// ```
379///
380/// [`validate_server_config`]: fn.validate_server_config.html
381#[derive(Clone, Serialize, PartialEq, Eq)]
382pub struct ServerConfig {
383    /// Transport-specific fields (command/args/env/cwd, or url/headers).
384    #[serde(flatten)]
385    transport: Transport,
386
387    /// Timeout for establishing a connection to the server (handshake).
388    ///
389    /// Bounds how long `Introspector::discover_server` waits for the initial
390    /// rmcp `serve` handshake before giving up. Defaults to 30 seconds.
391    #[serde(default = "default_connect_timeout")]
392    connect_timeout: Duration,
393
394    /// Timeout for the tool discovery call after a connection is established.
395    ///
396    /// Bounds how long `Introspector::discover_server` waits for
397    /// `list_all_tools` to respond. Defaults to 30 seconds.
398    #[serde(default = "default_discover_timeout")]
399    discover_timeout: Duration,
400}
401
402impl<'de> Deserialize<'de> for ServerConfig {
403    /// Deserializes into a private shadow shape and then runs
404    /// `validate_server_config` before returning, so a hand-edited `mcp.json` (or any other
405    /// JSON source) can never produce an unvalidated `ServerConfig` — see the "Security"
406    /// section on [`ServerConfig`]'s own doc comment.
407    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
408    where
409        D: serde::Deserializer<'de>,
410    {
411        #[derive(Deserialize)]
412        struct Raw {
413            #[serde(flatten)]
414            transport: Transport,
415            #[serde(default = "default_connect_timeout")]
416            connect_timeout: Duration,
417            #[serde(default = "default_discover_timeout")]
418            discover_timeout: Duration,
419        }
420
421        let raw = Raw::deserialize(deserializer)?;
422        let config = Self {
423            transport: raw.transport,
424            connect_timeout: raw.connect_timeout,
425            discover_timeout: raw.discover_timeout,
426        };
427        crate::validate_server_config(&config).map_err(D::Error::custom)?;
428        Ok(config)
429    }
430}
431
432impl fmt::Debug for ServerConfig {
433    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434        let mut s = f.debug_struct("ServerConfig");
435        match &self.transport {
436            Transport::Stdio {
437                command,
438                args,
439                env,
440                cwd,
441            } => {
442                s.field("transport", &"stdio")
443                    .field("command", &sanitize_path_for_error(Path::new(command)))
444                    .field("args", &RedactedItems(args))
445                    .field("env", &RedactedMapValues(env))
446                    .field("cwd", &cwd.as_deref().map(sanitize_path_for_error));
447            }
448            Transport::Http { url, headers } => {
449                s.field("transport", &"http")
450                    .field("url", &RedactedUrl(url))
451                    .field("headers", &RedactedMapValues(headers));
452            }
453            Transport::Sse { url, headers } => {
454                s.field("transport", &"sse")
455                    .field("url", &RedactedUrl(url))
456                    .field("headers", &RedactedMapValues(headers));
457            }
458        }
459        s.field("connect_timeout", &self.connect_timeout)
460            .field("discover_timeout", &self.discover_timeout)
461            .finish()
462    }
463}
464
465impl ServerConfig {
466    /// Creates a new builder for `ServerConfig`.
467    ///
468    /// # Examples
469    ///
470    /// ```
471    /// use mcp_execution_core::ServerConfig;
472    ///
473    /// let config = ServerConfig::builder()
474    ///     .command("docker".to_string())
475    ///     .build().unwrap();
476    /// ```
477    #[must_use]
478    pub fn builder() -> ServerConfigBuilder {
479        ServerConfigBuilder::default()
480    }
481
482    /// Returns the transport-specific configuration.
483    ///
484    /// # Examples
485    ///
486    /// ```
487    /// use mcp_execution_core::{ServerConfig, Transport};
488    ///
489    /// let config = ServerConfig::builder()
490    ///     .command("test".to_string())
491    ///     .build().unwrap();
492    ///
493    /// assert!(matches!(config.transport(), Transport::Stdio { .. }));
494    /// ```
495    #[must_use]
496    pub const fn transport(&self) -> &Transport {
497        &self.transport
498    }
499
500    /// Returns the command as a string slice, or `None` for a config that isn't
501    /// [`Transport::Stdio`].
502    ///
503    /// Mirrors [`Self::url`], which is `None` for [`Transport::Stdio`] and `Some` for the
504    /// other two variants — `command` only ever exists for `Stdio`, so absence is
505    /// represented the same way rather than as an empty string a caller would have to
506    /// remember to check for.
507    ///
508    /// # Examples
509    ///
510    /// ```
511    /// use mcp_execution_core::ServerConfig;
512    ///
513    /// let config = ServerConfig::builder()
514    ///     .command("test-server".to_string())
515    ///     .build().unwrap();
516    ///
517    /// assert_eq!(config.command(), Some("test-server"));
518    ///
519    /// let config = ServerConfig::builder()
520    ///     .http_transport("https://api.example.com/mcp".to_string())
521    ///     .build().unwrap();
522    ///
523    /// assert_eq!(config.command(), None);
524    /// ```
525    #[must_use]
526    pub fn command(&self) -> Option<&str> {
527        match &self.transport {
528            Transport::Stdio { command, .. } => Some(command),
529            Transport::Http { .. } | Transport::Sse { .. } => None,
530        }
531    }
532
533    /// Returns a slice of arguments, or an empty slice for a config that isn't
534    /// [`Transport::Stdio`].
535    ///
536    /// # Examples
537    ///
538    /// ```
539    /// use mcp_execution_core::ServerConfig;
540    ///
541    /// let config = ServerConfig::builder()
542    ///     .command("server".to_string())
543    ///     .args(vec!["--port".to_string(), "8080".to_string()])
544    ///     .build().unwrap();
545    ///
546    /// assert_eq!(config.args(), &["--port", "8080"]);
547    /// ```
548    #[must_use]
549    pub fn args(&self) -> &[String] {
550        match &self.transport {
551            Transport::Stdio { args, .. } => args,
552            Transport::Http { .. } | Transport::Sse { .. } => &[],
553        }
554    }
555
556    /// Returns a reference to the environment variables map, or an empty map for a config
557    /// that isn't [`Transport::Stdio`].
558    ///
559    /// # Examples
560    ///
561    /// ```
562    /// use mcp_execution_core::ServerConfig;
563    ///
564    /// let config = ServerConfig::builder()
565    ///     .command("server".to_string())
566    ///     .env("VAR_NAME".to_string(), "var_value".to_string())
567    ///     .build().unwrap();
568    ///
569    /// assert_eq!(config.env().get("VAR_NAME"), Some(&"var_value".to_string()));
570    /// ```
571    #[must_use]
572    pub fn env(&self) -> &HashMap<String, String> {
573        match &self.transport {
574            Transport::Stdio { env, .. } => env,
575            Transport::Http { .. } | Transport::Sse { .. } => &EMPTY_MAP,
576        }
577    }
578
579    /// Returns the working directory, if set. Always `None` for a config that isn't
580    /// [`Transport::Stdio`].
581    ///
582    /// # Examples
583    ///
584    /// ```
585    /// use mcp_execution_core::ServerConfig;
586    ///
587    /// let config = ServerConfig::builder()
588    ///     .command("server".to_string())
589    ///     .build().unwrap();
590    ///
591    /// assert_eq!(config.cwd(), None);
592    /// ```
593    #[must_use]
594    pub const fn cwd(&self) -> Option<&PathBuf> {
595        match &self.transport {
596            Transport::Stdio { cwd, .. } => cwd.as_ref(),
597            Transport::Http { .. } | Transport::Sse { .. } => None,
598        }
599    }
600
601    /// Returns the URL for HTTP/SSE transport, if set. Always `None` for
602    /// [`Transport::Stdio`].
603    ///
604    /// # Examples
605    ///
606    /// ```
607    /// use mcp_execution_core::ServerConfig;
608    ///
609    /// let config = ServerConfig::builder()
610    ///     .command("server".to_string())
611    ///     .build().unwrap();
612    ///
613    /// assert_eq!(config.url(), None);
614    /// ```
615    #[must_use]
616    pub fn url(&self) -> Option<&str> {
617        match &self.transport {
618            Transport::Stdio { .. } => None,
619            Transport::Http { url, .. } | Transport::Sse { url, .. } => Some(url),
620        }
621    }
622
623    /// Returns a reference to the HTTP headers map, or an empty map for
624    /// [`Transport::Stdio`].
625    ///
626    /// # Examples
627    ///
628    /// ```
629    /// use mcp_execution_core::ServerConfig;
630    /// use std::collections::HashMap;
631    ///
632    /// let mut headers_map = HashMap::new();
633    /// headers_map.insert("Authorization".to_string(), "Bearer token".to_string());
634    ///
635    /// let config = ServerConfig::builder()
636    ///     .http_transport("https://api.example.com/mcp".to_string())
637    ///     .headers(headers_map)
638    ///     .build().unwrap();
639    ///
640    /// assert_eq!(config.headers().get("Authorization"), Some(&"Bearer token".to_string()));
641    /// ```
642    #[must_use]
643    pub fn headers(&self) -> &HashMap<String, String> {
644        match &self.transport {
645            Transport::Stdio { .. } => &EMPTY_MAP,
646            Transport::Http { headers, .. } | Transport::Sse { headers, .. } => headers,
647        }
648    }
649
650    /// Returns the connection (handshake) timeout.
651    ///
652    /// # Examples
653    ///
654    /// ```
655    /// use mcp_execution_core::ServerConfig;
656    /// use std::time::Duration;
657    ///
658    /// let config = ServerConfig::builder()
659    ///     .command("docker".to_string())
660    ///     .build().unwrap();
661    ///
662    /// assert_eq!(config.connect_timeout(), Duration::from_secs(30));
663    /// ```
664    #[must_use]
665    pub const fn connect_timeout(&self) -> Duration {
666        self.connect_timeout
667    }
668
669    /// Returns the tool discovery timeout.
670    ///
671    /// # Examples
672    ///
673    /// ```
674    /// use mcp_execution_core::ServerConfig;
675    /// use std::time::Duration;
676    ///
677    /// let config = ServerConfig::builder()
678    ///     .command("docker".to_string())
679    ///     .build().unwrap();
680    ///
681    /// assert_eq!(config.discover_timeout(), Duration::from_secs(30));
682    /// ```
683    #[must_use]
684    pub const fn discover_timeout(&self) -> Duration {
685        self.discover_timeout
686    }
687}
688
689/// Which transport [`ServerConfigBuilder::build`] should assemble.
690///
691/// Internal to the builder only: unlike the public [`Transport`] enum (the assembled config's
692/// actual representation), this carries no data — the builder still accumulates
693/// `command`/`args`/`env`/`cwd`/`url`/`headers` as flat, independently-settable fields before
694/// `build()` decides, based on this discriminant, which of them become the final
695/// [`Transport`] variant's payload.
696#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
697enum TransportKind {
698    /// Stdio transport: subprocess communication via stdin/stdout.
699    #[default]
700    Stdio,
701    /// HTTP transport: communication via HTTP/HTTPS API.
702    Http,
703    /// SSE transport: Server-Sent Events for streaming communication.
704    Sse,
705}
706
707/// Builder for constructing `ServerConfig` instances.
708///
709/// Provides a fluent API for building server configurations with
710/// optional arguments, environment variables, and HTTP settings.
711///
712/// # Examples
713///
714/// ```
715/// use mcp_execution_core::ServerConfig;
716///
717/// // Stdio transport
718/// let config = ServerConfig::builder()
719///     .command("mcp-server".to_string())
720///     .arg("--verbose".to_string())
721///     .env("DEBUG".to_string(), "1".to_string())
722///     .build().unwrap();
723///
724/// // HTTP transport
725/// let config = ServerConfig::builder()
726///     .http_transport("https://api.example.com/mcp".to_string())
727///     .header("Authorization".to_string(), "Bearer token".to_string())
728///     .build().unwrap();
729/// ```
730///
731/// Like [`ServerConfig`] itself, this builder accumulates `env`/`headers`
732/// before secrets they may carry are known to be well-formed, so its
733/// [`Debug`] impl redacts values the same way — see the redaction note on
734/// [`ServerConfig`]'s doc comment.
735#[derive(Clone)]
736pub struct ServerConfigBuilder {
737    transport: TransportKind,
738    command: Option<String>,
739    args: Vec<String>,
740    env: HashMap<String, String>,
741    cwd: Option<PathBuf>,
742    url: Option<String>,
743    headers: HashMap<String, String>,
744    connect_timeout: Duration,
745    discover_timeout: Duration,
746}
747
748impl fmt::Debug for ServerConfigBuilder {
749    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
750        f.debug_struct("ServerConfigBuilder")
751            .field("transport", &self.transport)
752            .field(
753                "command",
754                &self
755                    .command
756                    .as_deref()
757                    .map(|command| sanitize_path_for_error(Path::new(command))),
758            )
759            .field("args", &RedactedItems(&self.args))
760            .field("env", &RedactedMapValues(&self.env))
761            .field("cwd", &self.cwd.as_deref().map(sanitize_path_for_error))
762            .field("url", &self.url.as_deref().map(RedactedUrl))
763            .field("headers", &RedactedMapValues(&self.headers))
764            .field("connect_timeout", &self.connect_timeout)
765            .field("discover_timeout", &self.discover_timeout)
766            .finish()
767    }
768}
769
770impl Default for ServerConfigBuilder {
771    fn default() -> Self {
772        Self {
773            transport: TransportKind::default(),
774            command: None,
775            args: Vec::new(),
776            env: HashMap::new(),
777            cwd: None,
778            url: None,
779            headers: HashMap::new(),
780            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
781            discover_timeout: DEFAULT_DISCOVER_TIMEOUT,
782        }
783    }
784}
785
786impl ServerConfigBuilder {
787    /// Sets the command to execute.
788    ///
789    /// # Examples
790    ///
791    /// ```
792    /// use mcp_execution_core::ServerConfig;
793    ///
794    /// let config = ServerConfig::builder()
795    ///     .command("docker".to_string())
796    ///     .build().unwrap();
797    /// ```
798    #[must_use]
799    pub fn command(mut self, command: String) -> Self {
800        self.command = Some(command);
801        self
802    }
803
804    /// Adds a single argument.
805    ///
806    /// # Examples
807    ///
808    /// ```
809    /// use mcp_execution_core::ServerConfig;
810    ///
811    /// let config = ServerConfig::builder()
812    ///     .command("docker".to_string())
813    ///     .arg("run".to_string())
814    ///     .arg("--rm".to_string())
815    ///     .build().unwrap();
816    /// ```
817    #[must_use]
818    pub fn arg(mut self, arg: String) -> Self {
819        self.args.push(arg);
820        self
821    }
822
823    /// Sets all arguments at once, replacing any previously added.
824    ///
825    /// # Examples
826    ///
827    /// ```
828    /// use mcp_execution_core::ServerConfig;
829    ///
830    /// let config = ServerConfig::builder()
831    ///     .command("docker".to_string())
832    ///     .args(vec!["run".to_string(), "--rm".to_string()])
833    ///     .build().unwrap();
834    /// ```
835    #[must_use]
836    pub fn args(mut self, args: Vec<String>) -> Self {
837        self.args = args;
838        self
839    }
840
841    /// Adds a single environment variable.
842    ///
843    /// # Examples
844    ///
845    /// ```
846    /// use mcp_execution_core::ServerConfig;
847    ///
848    /// let config = ServerConfig::builder()
849    ///     .command("mcp-server".to_string())
850    ///     .env("LOG_LEVEL".to_string(), "debug".to_string())
851    ///     .build().unwrap();
852    /// ```
853    #[must_use]
854    pub fn env(mut self, key: String, value: String) -> Self {
855        self.env.insert(key, value);
856        self
857    }
858
859    /// Sets all environment variables at once, replacing any previously added.
860    ///
861    /// # Examples
862    ///
863    /// ```
864    /// use mcp_execution_core::ServerConfig;
865    /// use std::collections::HashMap;
866    ///
867    /// let mut env_map = HashMap::new();
868    /// env_map.insert("DEBUG".to_string(), "1".to_string());
869    ///
870    /// let config = ServerConfig::builder()
871    ///     .command("mcp-server".to_string())
872    ///     .environment(env_map)
873    ///     .build().unwrap();
874    /// ```
875    #[must_use]
876    pub fn environment(mut self, env: HashMap<String, String>) -> Self {
877        self.env = env;
878        self
879    }
880
881    /// Sets the working directory for the subprocess.
882    ///
883    /// # Examples
884    ///
885    /// ```
886    /// use mcp_execution_core::ServerConfig;
887    /// use std::path::PathBuf;
888    ///
889    /// let config = ServerConfig::builder()
890    ///     .command("mcp-server".to_string())
891    ///     .cwd(PathBuf::from("/tmp"))
892    ///     .build().unwrap();
893    /// ```
894    #[must_use]
895    pub fn cwd(mut self, cwd: PathBuf) -> Self {
896        self.cwd = Some(cwd);
897        self
898    }
899
900    /// Configures HTTP transport with the given URL.
901    ///
902    /// This sets the transport type to HTTP and configures the endpoint URL.
903    ///
904    /// # Examples
905    ///
906    /// ```
907    /// use mcp_execution_core::ServerConfig;
908    ///
909    /// let config = ServerConfig::builder()
910    ///     .http_transport("https://api.example.com/mcp".to_string())
911    ///     .build().unwrap();
912    /// ```
913    #[must_use]
914    pub fn http_transport(mut self, url: String) -> Self {
915        self.transport = TransportKind::Http;
916        self.url = Some(url);
917        self
918    }
919
920    /// Configures SSE transport with the given URL.
921    ///
922    /// This sets the transport type to SSE (Server-Sent Events) and configures the endpoint URL.
923    ///
924    /// # Examples
925    ///
926    /// ```
927    /// use mcp_execution_core::ServerConfig;
928    ///
929    /// let config = ServerConfig::builder()
930    ///     .sse_transport("https://api.example.com/sse".to_string())
931    ///     .build().unwrap();
932    /// ```
933    #[must_use]
934    pub fn sse_transport(mut self, url: String) -> Self {
935        self.transport = TransportKind::Sse;
936        self.url = Some(url);
937        self
938    }
939
940    /// Sets the URL for HTTP transport.
941    ///
942    /// # Examples
943    ///
944    /// ```
945    /// use mcp_execution_core::ServerConfig;
946    ///
947    /// let config = ServerConfig::builder()
948    ///     .http_transport("https://api.example.com/mcp".to_string())
949    ///     .url("https://api.example.com/mcp/v2".to_string())
950    ///     .build().unwrap();
951    /// ```
952    #[must_use]
953    pub fn url(mut self, url: String) -> Self {
954        self.url = Some(url);
955        self
956    }
957
958    /// Adds a single HTTP header.
959    ///
960    /// # Examples
961    ///
962    /// ```
963    /// use mcp_execution_core::ServerConfig;
964    ///
965    /// let config = ServerConfig::builder()
966    ///     .http_transport("https://api.example.com/mcp".to_string())
967    ///     .header("Authorization".to_string(), "Bearer token".to_string())
968    ///     .build().unwrap();
969    /// ```
970    #[must_use]
971    pub fn header(mut self, key: String, value: String) -> Self {
972        self.headers.insert(key, value);
973        self
974    }
975
976    /// Sets all HTTP headers at once, replacing any previously added.
977    ///
978    /// # Examples
979    ///
980    /// ```
981    /// use mcp_execution_core::ServerConfig;
982    /// use std::collections::HashMap;
983    ///
984    /// let mut headers = HashMap::new();
985    /// headers.insert("Authorization".to_string(), "Bearer token".to_string());
986    ///
987    /// let config = ServerConfig::builder()
988    ///     .http_transport("https://api.example.com/mcp".to_string())
989    ///     .headers(headers)
990    ///     .build().unwrap();
991    /// ```
992    #[must_use]
993    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
994        self.headers = headers;
995        self
996    }
997
998    /// Sets the connection (handshake) timeout, overriding the 30-second default.
999    ///
1000    /// # Examples
1001    ///
1002    /// ```
1003    /// use mcp_execution_core::ServerConfig;
1004    /// use std::time::Duration;
1005    ///
1006    /// let config = ServerConfig::builder()
1007    ///     .command("docker".to_string())
1008    ///     .connect_timeout(Duration::from_secs(5))
1009    ///     .build().unwrap();
1010    ///
1011    /// assert_eq!(config.connect_timeout(), Duration::from_secs(5));
1012    /// ```
1013    #[must_use]
1014    pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
1015        self.connect_timeout = timeout;
1016        self
1017    }
1018
1019    /// Sets the tool discovery timeout, overriding the 30-second default.
1020    ///
1021    /// # Examples
1022    ///
1023    /// ```
1024    /// use mcp_execution_core::ServerConfig;
1025    /// use std::time::Duration;
1026    ///
1027    /// let config = ServerConfig::builder()
1028    ///     .command("docker".to_string())
1029    ///     .discover_timeout(Duration::from_secs(5))
1030    ///     .build().unwrap();
1031    ///
1032    /// assert_eq!(config.discover_timeout(), Duration::from_secs(5));
1033    /// ```
1034    #[must_use]
1035    pub const fn discover_timeout(mut self, timeout: Duration) -> Self {
1036        self.discover_timeout = timeout;
1037        self
1038    }
1039
1040    /// Builds and validates the `ServerConfig`.
1041    ///
1042    /// This is the only way to obtain a [`ServerConfig`] through the builder: beyond the
1043    /// structural checks (command/url presence), it also runs the full security validation
1044    /// performed by [`validate_server_config`] — shell metacharacters, forbidden environment
1045    /// variables, URL scheme, and header safety — before returning. A `ServerConfig` built
1046    /// through this method therefore cannot exist without having passed security
1047    /// validation. Unlike before #313, this is now also a type-level guarantee: every other
1048    /// way to obtain a `ServerConfig` (a struct literal, `serde_json::from_str`) is closed —
1049    /// see [`ServerConfig`]'s own "Security" section.
1050    ///
1051    /// # Errors
1052    ///
1053    /// Returns `Error::ValidationError` if:
1054    /// - Command is not set (or is empty) for stdio transport
1055    /// - URL is not set for HTTP/SSE transport
1056    ///
1057    /// Returns `Error::SecurityViolation` or `Error::ValidationError` for any
1058    /// reason documented on [`validate_server_config`] (forbidden shell
1059    /// metacharacters, forbidden environment variables, invalid URL scheme,
1060    /// unsafe headers, out-of-bounds timeouts, etc.).
1061    ///
1062    /// # Examples
1063    ///
1064    /// ```
1065    /// use mcp_execution_core::ServerConfig;
1066    ///
1067    /// let config = ServerConfig::builder()
1068    ///     .command("docker".to_string())
1069    ///     .build()
1070    ///     .unwrap();
1071    ///
1072    /// // Rejected at construction time — no separate validation step needed.
1073    /// let err = ServerConfig::builder()
1074    ///     .command("docker".to_string())
1075    ///     .arg("run; rm -rf /".to_string())
1076    ///     .build()
1077    ///     .unwrap_err();
1078    /// assert!(err.is_security_error());
1079    /// ```
1080    ///
1081    /// [`validate_server_config`]: fn.validate_server_config.html
1082    pub fn build(self) -> crate::Result<ServerConfig> {
1083        let config = self.build_structural()?;
1084        crate::validate_server_config(&config)?;
1085        Ok(config)
1086    }
1087
1088    /// Checks structural completeness (command/url presence) and assembles
1089    /// the `ServerConfig`, without running security validation.
1090    ///
1091    /// Kept private and separate from [`Self::build`] so the two concerns —
1092    /// "is this config structurally complete" and "is this config safe to
1093    /// spawn" — stay independently testable, while the public API only ever
1094    /// hands out a fully validated [`ServerConfig`].
1095    fn build_structural(self) -> crate::Result<ServerConfig> {
1096        let transport = match self.transport {
1097            TransportKind::Stdio => {
1098                let command = self.command.ok_or_else(|| crate::Error::ValidationError {
1099                    field: "command".to_string(),
1100                    reason: "command is required for stdio transport".to_string(),
1101                })?;
1102
1103                if command.trim().is_empty() {
1104                    return Err(crate::Error::ValidationError {
1105                        field: "command".to_string(),
1106                        reason: "command cannot be empty for stdio transport".to_string(),
1107                    });
1108                }
1109
1110                Transport::Stdio {
1111                    command,
1112                    args: self.args,
1113                    env: self.env,
1114                    cwd: self.cwd,
1115                }
1116            }
1117            TransportKind::Http => {
1118                let url = self.url.ok_or_else(|| crate::Error::ValidationError {
1119                    field: "url".to_string(),
1120                    reason: "url is required for HTTP transport".to_string(),
1121                })?;
1122
1123                Transport::Http {
1124                    url,
1125                    headers: self.headers,
1126                }
1127            }
1128            TransportKind::Sse => {
1129                let url = self.url.ok_or_else(|| crate::Error::ValidationError {
1130                    field: "url".to_string(),
1131                    reason: "url is required for SSE transport".to_string(),
1132                })?;
1133
1134                Transport::Sse {
1135                    url,
1136                    headers: self.headers,
1137                }
1138            }
1139        };
1140
1141        Ok(ServerConfig {
1142            transport,
1143            connect_timeout: self.connect_timeout,
1144            discover_timeout: self.discover_timeout,
1145        })
1146    }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152
1153    #[test]
1154    fn test_server_config_builder_minimal() {
1155        let config = ServerConfig::builder()
1156            .command("docker".to_string())
1157            .build()
1158            .unwrap();
1159
1160        assert_eq!(config.command(), Some("docker"));
1161        assert!(config.args().is_empty());
1162        assert!(config.env().is_empty());
1163        assert!(config.cwd().is_none());
1164    }
1165
1166    #[test]
1167    fn test_server_config_builder_with_args() {
1168        let config = ServerConfig::builder()
1169            .command("docker".to_string())
1170            .arg("run".to_string())
1171            .arg("--rm".to_string())
1172            .arg("mcp-server".to_string())
1173            .build()
1174            .unwrap();
1175
1176        assert_eq!(config.command(), Some("docker"));
1177        assert_eq!(config.args(), vec!["run", "--rm", "mcp-server"]);
1178    }
1179
1180    #[test]
1181    fn test_server_config_builder_with_args_vec() {
1182        let config = ServerConfig::builder()
1183            .command("docker".to_string())
1184            .args(vec!["run".to_string(), "--rm".to_string()])
1185            .build()
1186            .unwrap();
1187
1188        assert_eq!(config.args(), vec!["run", "--rm"]);
1189    }
1190
1191    #[test]
1192    fn test_server_config_builder_with_env() {
1193        let config = ServerConfig::builder()
1194            .command("mcp-server".to_string())
1195            .env("LOG_LEVEL".to_string(), "debug".to_string())
1196            .env("DEBUG".to_string(), "1".to_string())
1197            .build()
1198            .unwrap();
1199
1200        assert_eq!(config.env().len(), 2);
1201        assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
1202        assert_eq!(config.env().get("DEBUG"), Some(&"1".to_string()));
1203    }
1204
1205    #[test]
1206    fn test_server_config_builder_with_environment_map() {
1207        let mut env_map = HashMap::new();
1208        env_map.insert("VAR1".to_string(), "value1".to_string());
1209        env_map.insert("VAR2".to_string(), "value2".to_string());
1210
1211        let config = ServerConfig::builder()
1212            .command("mcp-server".to_string())
1213            .environment(env_map)
1214            .build()
1215            .unwrap();
1216
1217        assert_eq!(config.env().len(), 2);
1218    }
1219
1220    #[test]
1221    fn test_server_config_builder_with_cwd() {
1222        let config = ServerConfig::builder()
1223            .command("mcp-server".to_string())
1224            .cwd(PathBuf::from("/tmp"))
1225            .build()
1226            .unwrap();
1227
1228        assert_eq!(config.cwd(), Some(&PathBuf::from("/tmp")));
1229    }
1230
1231    #[test]
1232    fn test_server_config_builder_full() {
1233        let mut env_map = HashMap::new();
1234        env_map.insert("LOG_LEVEL".to_string(), "debug".to_string());
1235
1236        let config = ServerConfig::builder()
1237            .command("mcp-server".to_string())
1238            .args(vec!["--port".to_string(), "8080".to_string()])
1239            .environment(env_map)
1240            .cwd(PathBuf::from("/var/run"))
1241            .build()
1242            .unwrap();
1243
1244        assert_eq!(config.command(), Some("mcp-server"));
1245        assert_eq!(config.args().len(), 2);
1246        assert_eq!(config.env().len(), 1);
1247        assert_eq!(config.cwd(), Some(&PathBuf::from("/var/run")));
1248    }
1249
1250    #[test]
1251    #[should_panic(expected = "command")]
1252    fn test_server_config_builder_missing_command() {
1253        let _ = ServerConfig::builder().build().unwrap();
1254    }
1255
1256    #[test]
1257    fn test_server_config_builder_build_missing_command() {
1258        let result = ServerConfig::builder().build();
1259        assert!(result.is_err());
1260        assert!(result.unwrap_err().to_string().contains("command"));
1261    }
1262
1263    /// #177 — security validation must be folded into construction: a
1264    /// `ServerConfig` carrying a shell metacharacter can no longer be built
1265    /// at all, rather than only being caught by a separate manual call to
1266    /// `validate_server_config` downstream.
1267    #[test]
1268    fn test_build_rejects_shell_metacharacters_at_construction() {
1269        let result = ServerConfig::builder()
1270            .command("docker".to_string())
1271            .arg("run; rm -rf /".to_string())
1272            .build();
1273
1274        assert!(result.is_err());
1275        assert!(result.unwrap_err().is_security_error());
1276    }
1277
1278    /// #177 — same guarantee for forbidden environment variables.
1279    #[test]
1280    fn test_build_rejects_forbidden_env_var_at_construction() {
1281        let result = ServerConfig::builder()
1282            .command("docker".to_string())
1283            .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
1284            .build();
1285
1286        assert!(result.is_err());
1287        assert!(result.unwrap_err().is_security_error());
1288    }
1289
1290    #[test]
1291    fn test_server_config_accessors() {
1292        let config = ServerConfig::builder()
1293            .command("docker".to_string())
1294            .arg("run".to_string())
1295            .env("VAR".to_string(), "value".to_string())
1296            .cwd(PathBuf::from("/tmp"))
1297            .build()
1298            .unwrap();
1299
1300        assert_eq!(config.command(), Some("docker"));
1301        assert_eq!(config.args(), &["run".to_string()]);
1302        assert_eq!(config.env().len(), 1);
1303        assert_eq!(config.cwd(), Some(&PathBuf::from("/tmp")));
1304    }
1305
1306    #[test]
1307    fn test_server_config_serialize_deserialize() {
1308        let config = ServerConfig::builder()
1309            .command("mcp-server".to_string())
1310            .arg("--verbose".to_string())
1311            .env("DEBUG".to_string(), "1".to_string())
1312            .build()
1313            .unwrap();
1314
1315        let json = serde_json::to_string(&config).unwrap();
1316        let deserialized: ServerConfig = serde_json::from_str(&json).unwrap();
1317
1318        assert_eq!(config, deserialized);
1319    }
1320
1321    #[test]
1322    fn test_server_config_clone() {
1323        let config = ServerConfig::builder()
1324            .command("docker".to_string())
1325            .build()
1326            .unwrap();
1327
1328        let cloned = config.clone();
1329        assert_eq!(config, cloned);
1330    }
1331
1332    #[test]
1333    fn test_server_config_debug() {
1334        let config = ServerConfig::builder()
1335            .command("docker".to_string())
1336            .build()
1337            .unwrap();
1338
1339        let debug_str = format!("{config:?}");
1340        assert!(debug_str.contains("docker"));
1341    }
1342
1343    #[test]
1344    fn test_server_config_debug_redacts_header_values() {
1345        // headers are only populated for HTTP/SSE transport (see the
1346        // builder's `build`), so exercise them via `http_transport`.
1347        let config = ServerConfig::builder()
1348            .http_transport("https://api.example.com/mcp".to_string())
1349            .header(
1350                "Authorization".to_string(),
1351                "Bearer sk-secret-header-value".to_string(),
1352            )
1353            .build()
1354            .unwrap();
1355
1356        let debug_str = format!("{config:?}");
1357
1358        // The key is useful for debugging and is not secret.
1359        assert!(debug_str.contains("Authorization"));
1360        // The value must never appear.
1361        assert!(!debug_str.contains("sk-secret-header-value"));
1362    }
1363
1364    #[test]
1365    fn test_server_config_debug_redacts_env_values() {
1366        // env is only populated for stdio transport (see the builder's
1367        // `build`), so exercise it via the default stdio transport.
1368        let config = ServerConfig::builder()
1369            .command("docker".to_string())
1370            .env(
1371                "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
1372                "ghp_supersecretvalue".to_string(),
1373            )
1374            .build()
1375            .unwrap();
1376
1377        let debug_str = format!("{config:?}");
1378
1379        // The key is useful for debugging and is not secret.
1380        assert!(debug_str.contains("GITHUB_PERSONAL_ACCESS_TOKEN"));
1381        // The value must never appear.
1382        assert!(!debug_str.contains("ghp_supersecretvalue"));
1383    }
1384
1385    #[test]
1386    fn test_server_config_debug_redacts_args() {
1387        let secret = "sk-live-secret-arg-value";
1388        let config = ServerConfig::builder()
1389            .command("docker".to_string())
1390            .arg("--api-key".to_string())
1391            .arg(secret.to_string())
1392            .build()
1393            .unwrap();
1394
1395        let debug_str = format!("{config:?}");
1396        assert!(!debug_str.contains(secret));
1397    }
1398
1399    #[test]
1400    fn test_server_config_debug_redacts_url_userinfo_and_query() {
1401        let secret = "hunter2";
1402        let config = ServerConfig::builder()
1403            .http_transport(format!(
1404                "https://user:{secret}@api.example.com/mcp?token={secret}"
1405            ))
1406            .build()
1407            .unwrap();
1408
1409        let debug_str = format!("{config:?}");
1410        assert!(!debug_str.contains(secret));
1411        // Host/path stay readable.
1412        assert!(debug_str.contains("api.example.com/mcp"));
1413    }
1414
1415    #[test]
1416    fn test_server_config_builder_debug_redacts_args() {
1417        let secret = "sk-live-secret-arg-value";
1418        let builder = ServerConfig::builder()
1419            .command("docker".to_string())
1420            .arg("--api-key".to_string())
1421            .arg(secret.to_string());
1422
1423        let debug_str = format!("{builder:?}");
1424        assert!(!debug_str.contains(secret));
1425    }
1426
1427    #[test]
1428    fn test_server_config_builder_debug_redacts_url_userinfo_and_query() {
1429        let secret = "hunter2";
1430        let builder = ServerConfig::builder().url(format!(
1431            "https://user:{secret}@api.example.com/mcp?token={secret}"
1432        ));
1433
1434        let debug_str = format!("{builder:?}");
1435        assert!(!debug_str.contains(secret));
1436        assert!(debug_str.contains("api.example.com/mcp"));
1437    }
1438
1439    #[test]
1440    fn test_server_config_builder_debug_redacts_env_and_header_values() {
1441        // Unlike `ServerConfig::build()`, the builder itself doesn't drop
1442        // env/headers based on transport, so both can be populated at once
1443        // and inspected via `{:?}` before `build()` is ever called.
1444        let builder = ServerConfig::builder()
1445            .command("docker".to_string())
1446            .env(
1447                "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
1448                "ghp_supersecretvalue".to_string(),
1449            )
1450            .header(
1451                "Authorization".to_string(),
1452                "Bearer sk-secret-header-value".to_string(),
1453            );
1454
1455        let debug_str = format!("{builder:?}");
1456
1457        // Keys are useful for debugging and are not secret.
1458        assert!(debug_str.contains("GITHUB_PERSONAL_ACCESS_TOKEN"));
1459        assert!(debug_str.contains("Authorization"));
1460        // Values must never appear.
1461        assert!(!debug_str.contains("ghp_supersecretvalue"));
1462        assert!(!debug_str.contains("sk-secret-header-value"));
1463    }
1464
1465    #[test]
1466    fn test_server_config_serialize_still_contains_real_secret_values() {
1467        // Serialize/Deserialize must round-trip real values for config
1468        // persistence; only Debug formatting is redacted. Headers and env
1469        // are exercised separately since `build()` drops whichever one
1470        // doesn't match the config's transport.
1471        let http_config = ServerConfig::builder()
1472            .http_transport("https://api.example.com/mcp".to_string())
1473            .header(
1474                "Authorization".to_string(),
1475                "Bearer sk-secret-header-value".to_string(),
1476            )
1477            .build()
1478            .unwrap();
1479
1480        let http_json = serde_json::to_string(&http_config).unwrap();
1481        assert!(http_json.contains("sk-secret-header-value"));
1482
1483        let http_deserialized: ServerConfig = serde_json::from_str(&http_json).unwrap();
1484        assert_eq!(http_config, http_deserialized);
1485
1486        let stdio_config = ServerConfig::builder()
1487            .command("docker".to_string())
1488            .env(
1489                "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
1490                "ghp_supersecretvalue".to_string(),
1491            )
1492            .build()
1493            .unwrap();
1494
1495        let stdio_json = serde_json::to_string(&stdio_config).unwrap();
1496        assert!(stdio_json.contains("ghp_supersecretvalue"));
1497
1498        let stdio_deserialized: ServerConfig = serde_json::from_str(&stdio_json).unwrap();
1499        assert_eq!(stdio_config, stdio_deserialized);
1500    }
1501
1502    #[test]
1503    fn test_transport_kind_default() {
1504        assert_eq!(TransportKind::default(), TransportKind::Stdio);
1505    }
1506
1507    #[test]
1508    fn test_server_config_http_transport() {
1509        let config = ServerConfig::builder()
1510            .http_transport("https://api.example.com/mcp".to_string())
1511            .build()
1512            .unwrap();
1513
1514        assert!(matches!(config.transport(), Transport::Http { .. }));
1515        assert_eq!(config.url(), Some("https://api.example.com/mcp"));
1516        assert!(config.headers().is_empty());
1517        assert!(config.command().is_none());
1518        assert!(config.args().is_empty());
1519        assert!(config.env().is_empty());
1520        assert_eq!(config.cwd(), None);
1521    }
1522
1523    #[test]
1524    fn test_server_config_http_with_headers() {
1525        let config = ServerConfig::builder()
1526            .http_transport("https://api.example.com/mcp".to_string())
1527            .header("Authorization".to_string(), "Bearer token".to_string())
1528            .header("Content-Type".to_string(), "application/json".to_string())
1529            .build()
1530            .unwrap();
1531
1532        assert!(matches!(config.transport(), Transport::Http { .. }));
1533        assert_eq!(config.headers().len(), 2);
1534        assert_eq!(
1535            config.headers().get("Authorization"),
1536            Some(&"Bearer token".to_string())
1537        );
1538        assert_eq!(
1539            config.headers().get("Content-Type"),
1540            Some(&"application/json".to_string())
1541        );
1542    }
1543
1544    #[test]
1545    fn test_server_config_http_with_headers_map() {
1546        let mut headers = HashMap::new();
1547        headers.insert("Authorization".to_string(), "Bearer token".to_string());
1548
1549        let config = ServerConfig::builder()
1550            .http_transport("https://api.example.com/mcp".to_string())
1551            .headers(headers)
1552            .build()
1553            .unwrap();
1554
1555        assert_eq!(config.headers().len(), 1);
1556    }
1557
1558    /// This does not call `.http_transport(...)`, so the builder's transport kind stays at its
1559    /// `Stdio` default — it exercises `build_structural`'s "command is required" branch, not an
1560    /// HTTP-specific one. See `test_server_config_http_build_missing_url` below for the actual
1561    /// HTTP-missing-url branch.
1562    #[test]
1563    fn test_server_config_default_builder_build_missing_command() {
1564        let result = ServerConfig::builder().build();
1565        assert!(result.is_err());
1566        assert!(result.unwrap_err().to_string().contains("required"));
1567    }
1568
1569    /// `TransportKind::Http` is only reachable through the public builder API via
1570    /// `.http_transport(url)`, which always sets `url` in the same call — so this branch of
1571    /// `build_structural` (url missing while the transport kind is `Http`) can't be reached
1572    /// through any public builder method. Poke the private `transport` field directly, mirroring
1573    /// `test_server_config_sse_build_missing_url` below, so the branch has coverage at all.
1574    #[test]
1575    fn test_server_config_http_build_missing_url() {
1576        let mut builder = ServerConfig::builder();
1577        builder.transport = TransportKind::Http;
1578
1579        let result = builder.build();
1580        assert!(result.is_err());
1581        assert!(result.unwrap_err().to_string().contains("url is required"));
1582    }
1583
1584    #[test]
1585    fn test_server_config_http_accessors() {
1586        let config = ServerConfig::builder()
1587            .http_transport("https://api.example.com/mcp".to_string())
1588            .header("Auth".to_string(), "token".to_string())
1589            .build()
1590            .unwrap();
1591
1592        assert!(matches!(config.transport(), Transport::Http { .. }));
1593        assert_eq!(config.url(), Some("https://api.example.com/mcp"));
1594        assert_eq!(config.headers().len(), 1);
1595    }
1596
1597    #[test]
1598    fn test_server_config_stdio_default_transport() {
1599        let config = ServerConfig::builder()
1600            .command("docker".to_string())
1601            .build()
1602            .unwrap();
1603
1604        assert!(matches!(config.transport(), Transport::Stdio { .. }));
1605    }
1606
1607    #[test]
1608    fn test_server_config_sse_transport() {
1609        let config = ServerConfig::builder()
1610            .sse_transport("https://api.example.com/sse".to_string())
1611            .build()
1612            .unwrap();
1613
1614        assert!(matches!(config.transport(), Transport::Sse { .. }));
1615        assert_eq!(config.url(), Some("https://api.example.com/sse"));
1616        assert!(config.headers().is_empty());
1617        assert!(config.command().is_none());
1618        assert!(config.args().is_empty());
1619        assert!(config.env().is_empty());
1620        assert_eq!(config.cwd(), None);
1621    }
1622
1623    /// The `Debug` impl's `Transport::Sse` arm (see `impl fmt::Debug for ServerConfig` above)
1624    /// is otherwise never exercised — every other `Debug`-redaction test builds an `Http` or
1625    /// `Stdio` config.
1626    #[test]
1627    fn test_server_config_debug_sse() {
1628        let secret = "sk-secret-sse-header-value";
1629        let config = ServerConfig::builder()
1630            .sse_transport("https://api.example.com/sse".to_string())
1631            .header("Authorization".to_string(), secret.to_string())
1632            .build()
1633            .unwrap();
1634
1635        let debug_str = format!("{config:?}");
1636        assert!(debug_str.contains("api.example.com/sse"));
1637        assert!(debug_str.contains("Authorization"));
1638        assert!(!debug_str.contains(secret));
1639    }
1640
1641    /// Companion to `test_server_config_serialize_still_contains_real_secret_values`, which
1642    /// only exercises `Stdio`/`Http` round-tripping — `Sse` shares their `url`/`headers` shape
1643    /// but had no round-trip test of its own.
1644    #[test]
1645    fn test_server_config_sse_serialize_deserialize_round_trip() {
1646        let config = ServerConfig::builder()
1647            .sse_transport("https://api.example.com/sse".to_string())
1648            .header("Authorization".to_string(), "Bearer token".to_string())
1649            .build()
1650            .unwrap();
1651
1652        let json = serde_json::to_string(&config).unwrap();
1653        let deserialized: ServerConfig = serde_json::from_str(&json).unwrap();
1654        assert_eq!(config, deserialized);
1655    }
1656
1657    #[test]
1658    fn test_server_config_sse_with_headers() {
1659        let config = ServerConfig::builder()
1660            .sse_transport("https://api.example.com/sse".to_string())
1661            .header("Authorization".to_string(), "Bearer token".to_string())
1662            .header("X-Custom".to_string(), "value".to_string())
1663            .build()
1664            .unwrap();
1665
1666        assert!(matches!(config.transport(), Transport::Sse { .. }));
1667        assert_eq!(config.headers().len(), 2);
1668        assert_eq!(
1669            config.headers().get("Authorization"),
1670            Some(&"Bearer token".to_string())
1671        );
1672        assert_eq!(config.headers().get("X-Custom"), Some(&"value".to_string()));
1673    }
1674
1675    #[test]
1676    fn test_server_config_sse_build_missing_url() {
1677        let mut builder = ServerConfig::builder();
1678        builder.transport = TransportKind::Sse;
1679
1680        let result = builder.build();
1681        assert!(result.is_err());
1682        assert!(result.unwrap_err().to_string().contains("url is required"));
1683    }
1684
1685    #[test]
1686    fn test_transport_serialization_tags_variant() {
1687        let stdio = ServerConfig::builder()
1688            .command("docker".to_string())
1689            .build()
1690            .unwrap();
1691        let http = ServerConfig::builder()
1692            .http_transport("https://api.example.com/mcp".to_string())
1693            .build()
1694            .unwrap();
1695        let sse = ServerConfig::builder()
1696            .sse_transport("https://api.example.com/sse".to_string())
1697            .build()
1698            .unwrap();
1699
1700        assert!(
1701            serde_json::to_string(&stdio)
1702                .unwrap()
1703                .contains(r#""transport":"stdio""#)
1704        );
1705        assert!(
1706            serde_json::to_string(&http)
1707                .unwrap()
1708                .contains(r#""transport":"http""#)
1709        );
1710        assert!(
1711            serde_json::to_string(&sse)
1712                .unwrap()
1713                .contains(r#""transport":"sse""#)
1714        );
1715    }
1716
1717    #[test]
1718    fn test_transport_deserialization_from_tag() {
1719        let stdio: ServerConfig =
1720            serde_json::from_str(r#"{"transport":"stdio","command":"docker"}"#).unwrap();
1721        let http: ServerConfig =
1722            serde_json::from_str(r#"{"transport":"http","url":"https://api.example.com/mcp"}"#)
1723                .unwrap();
1724        let sse: ServerConfig =
1725            serde_json::from_str(r#"{"transport":"sse","url":"https://api.example.com/sse"}"#)
1726                .unwrap();
1727
1728        assert!(matches!(stdio.transport(), Transport::Stdio { .. }));
1729        assert!(matches!(http.transport(), Transport::Http { .. }));
1730        assert!(matches!(sse.transport(), Transport::Sse { .. }));
1731    }
1732
1733    /// #313 — the enum shape makes an incomplete Http config impossible to deserialize at
1734    /// all: `url` is a required (non-`#[serde(default)]`) field of `Transport::Http`, so a
1735    /// hand-edited `mcp.json` with `"transport": "http"` and no `url` key now fails at
1736    /// deserialization time rather than silently producing an incomplete `ServerConfig` that
1737    /// only `validate_server_config` would have caught downstream.
1738    #[test]
1739    fn test_deserialize_http_config_missing_url_is_rejected() {
1740        let result: std::result::Result<ServerConfig, _> =
1741            serde_json::from_str(r#"{"transport": "http"}"#);
1742        assert!(result.is_err());
1743    }
1744
1745    /// #313 — `Deserialize` itself now runs the same security validation `build()` does, so
1746    /// a hand-edited `mcp.json` carrying a shell metacharacter fails to deserialize at all.
1747    #[test]
1748    fn test_deserialize_rejects_shell_metacharacters() {
1749        let result: std::result::Result<ServerConfig, _> = serde_json::from_str(
1750            r#"{"transport":"stdio","command":"docker","args":["run; rm -rf /"]}"#,
1751        );
1752        assert!(result.is_err());
1753    }
1754
1755    /// #345 — `Transport` used to derive plain `Debug`, so `format!("{:?}", transport)` on a
1756    /// bare value (e.g. matched out of `ServerConfig::transport()`) leaked secrets even though
1757    /// `ServerConfig`'s own `Debug` impl already redacted the same fields.
1758    #[test]
1759    fn test_transport_debug_redacts_stdio_args_and_env() {
1760        let secret_arg = "sk-live-secret-arg-value";
1761        let secret_env = "ghp_supersecretvalue";
1762        let transport = Transport::Stdio {
1763            command: "docker".to_string(),
1764            args: vec!["--api-key".to_string(), secret_arg.to_string()],
1765            env: HashMap::from([(
1766                "GITHUB_PERSONAL_ACCESS_TOKEN".to_string(),
1767                secret_env.to_string(),
1768            )]),
1769            cwd: None,
1770        };
1771
1772        let debug_str = format!("{transport:?}");
1773        assert!(debug_str.contains("docker"));
1774        assert!(debug_str.contains("GITHUB_PERSONAL_ACCESS_TOKEN"));
1775        assert!(!debug_str.contains(secret_arg));
1776        assert!(!debug_str.contains(secret_env));
1777    }
1778
1779    #[test]
1780    fn test_transport_debug_redacts_http_url_and_headers() {
1781        let secret = "hunter2";
1782        let transport = Transport::Http {
1783            url: format!("https://user:{secret}@api.example.com/mcp?token={secret}"),
1784            headers: HashMap::from([("Authorization".to_string(), secret.to_string())]),
1785        };
1786
1787        let debug_str = format!("{transport:?}");
1788        assert!(debug_str.contains("api.example.com/mcp"));
1789        assert!(debug_str.contains("Authorization"));
1790        assert!(!debug_str.contains(secret));
1791    }
1792
1793    /// Companion to the `Http` case above — `Transport::Sse` shares the same `url`/`headers`
1794    /// shape but is a distinct match arm in the `Debug` impl, so it needs its own coverage.
1795    #[test]
1796    fn test_transport_debug_redacts_sse_url_and_headers() {
1797        let secret = "sk-secret-sse-header-value";
1798        let transport = Transport::Sse {
1799            url: "https://api.example.com/sse".to_string(),
1800            headers: HashMap::from([("Authorization".to_string(), secret.to_string())]),
1801        };
1802
1803        let debug_str = format!("{transport:?}");
1804        assert!(debug_str.contains("api.example.com/sse"));
1805        assert!(debug_str.contains("Authorization"));
1806        assert!(!debug_str.contains(secret));
1807    }
1808}