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}