Skip to main content

agent_config/spec/mcp/
transport.rs

1//! `McpTransport` enum, transport-shape validators, and secret-detection
2//! helpers for project-local inline-secret policy.
3
4use std::collections::BTreeMap;
5
6use fluent_uri::Uri;
7
8use crate::error::AgentConfigError;
9
10/// How an MCP server is reached.
11#[derive(Debug, Clone)]
12#[non_exhaustive]
13pub enum McpTransport {
14    /// Local subprocess launched via stdio (most common). The harness spawns
15    /// `command` with `args`, inheriting `env` overrides on top of the harness
16    /// environment.
17    Stdio {
18        /// Executable name or absolute path.
19        command: String,
20        /// Arguments passed to the command.
21        args: Vec<String>,
22        /// Environment variables set when launching the command. `BTreeMap`
23        /// for stable serialization order.
24        env: BTreeMap<String, String>,
25    },
26    /// HTTP endpoint (Cursor and Claude support; many harnesses do not).
27    Http {
28        /// Server URL.
29        url: String,
30        /// Additional headers (e.g. `Authorization`).
31        headers: BTreeMap<String, String>,
32    },
33    /// Server-sent-events endpoint.
34    Sse {
35        /// Server URL.
36        url: String,
37        /// Additional headers (e.g. `Authorization`).
38        headers: BTreeMap<String, String>,
39    },
40}
41
42pub(super) fn validate_transport(transport: &McpTransport) -> Result<(), AgentConfigError> {
43    match transport {
44        McpTransport::Stdio { command, args, env } => {
45            if command.trim().is_empty() {
46                return Err(invalid_mcp_spec("stdio MCP command must not be empty"));
47            }
48            validate_no_control_chars("stdio MCP command", command)?;
49            for arg in args {
50                validate_no_control_chars("stdio MCP argument", arg)?;
51            }
52            for (name, value) in env {
53                validate_env_name(name)?;
54                validate_value("stdio MCP environment value", value)?;
55            }
56        }
57        McpTransport::Http { url, headers } => {
58            validate_remote_transport("HTTP", url, headers)?;
59        }
60        McpTransport::Sse { url, headers } => {
61            validate_remote_transport("SSE", url, headers)?;
62        }
63    }
64    Ok(())
65}
66
67fn validate_remote_transport(
68    kind: &str,
69    url: &str,
70    headers: &BTreeMap<String, String>,
71) -> Result<(), AgentConfigError> {
72    validate_http_url(kind, url)?;
73    for (name, value) in headers {
74        validate_header_name(name)?;
75        validate_value("MCP header value", value)?;
76    }
77    Ok(())
78}
79
80fn validate_http_url(kind: &str, url: &str) -> Result<(), AgentConfigError> {
81    if url.chars().any(char::is_control) {
82        return Err(invalid_mcp_spec(format!(
83            "{kind} MCP URL must not contain control characters"
84        )));
85    }
86
87    let parsed =
88        Uri::parse(url).map_err(|e| invalid_mcp_spec(format!("{kind} MCP URL is invalid: {e}")))?;
89    let scheme = parsed.scheme().as_str();
90    if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
91        return Err(invalid_mcp_spec(format!(
92            "{kind} MCP URL must use http or https"
93        )));
94    }
95    let Some(authority) = parsed.authority() else {
96        return Err(invalid_mcp_spec(format!(
97            "{kind} MCP URL must include a host"
98        )));
99    };
100    if authority.host().is_empty() {
101        return Err(invalid_mcp_spec(format!(
102            "{kind} MCP URL must include a host"
103        )));
104    }
105    Ok(())
106}
107
108fn validate_env_name(name: &str) -> Result<(), AgentConfigError> {
109    if name.is_empty() {
110        return Err(invalid_mcp_spec(
111            "MCP environment variable name must not be empty",
112        ));
113    }
114    if name.contains('=') {
115        return Err(invalid_mcp_spec(
116            "MCP environment variable name must not contain '='",
117        ));
118    }
119    validate_no_control_chars("MCP environment variable name", name)
120}
121
122fn validate_header_name(name: &str) -> Result<(), AgentConfigError> {
123    if name.is_empty() {
124        return Err(invalid_mcp_spec("MCP header name must not be empty"));
125    }
126    if !name.chars().all(is_header_token_char) {
127        return Err(invalid_mcp_spec(
128            "MCP header name must contain only HTTP token characters",
129        ));
130    }
131    Ok(())
132}
133
134fn validate_value(kind: &str, value: &str) -> Result<(), AgentConfigError> {
135    validate_no_control_chars(kind, value)
136}
137
138fn validate_no_control_chars(kind: &str, value: &str) -> Result<(), AgentConfigError> {
139    if value.chars().any(char::is_control) {
140        return Err(invalid_mcp_spec(format!(
141            "{kind} must not contain control characters"
142        )));
143    }
144    Ok(())
145}
146
147fn is_header_token_char(c: char) -> bool {
148    matches!(
149        c,
150        'A'..='Z'
151            | 'a'..='z'
152            | '0'..='9'
153            | '!'
154            | '#'
155            | '$'
156            | '%'
157            | '&'
158            | '\''
159            | '*'
160            | '+'
161            | '-'
162            | '.'
163            | '^'
164            | '_'
165            | '`'
166            | '|'
167            | '~'
168    )
169}
170
171fn invalid_mcp_spec(message: impl Into<String>) -> AgentConfigError {
172    AgentConfigError::Other(anyhow::anyhow!(message.into()))
173}
174
175pub(super) fn is_inline_secret_env_value(name: &str, value: &str) -> bool {
176    likely_secret_env_name(name) && !value.trim().is_empty() && !is_placeholder_value(value)
177}
178
179pub(super) fn is_inline_secret_header_value(name: &str, value: &str) -> bool {
180    likely_secret_header_name(name) && !value.trim().is_empty() && !is_placeholder_value(value)
181}
182
183fn likely_secret_env_name(name: &str) -> bool {
184    let upper = name.to_ascii_uppercase();
185    [
186        "TOKEN",
187        "SECRET",
188        "KEY",
189        "PASSWORD",
190        "AUTH",
191        "BEARER",
192        "CREDENTIAL",
193    ]
194    .iter()
195    .any(|keyword| upper.contains(keyword))
196}
197
198fn likely_secret_header_name(name: &str) -> bool {
199    // Reuse env-name keywords (TOKEN/SECRET/KEY/PASSWORD/AUTH/BEARER/CREDENTIAL),
200    // which already match `Authorization`, `Proxy-Authorization`, `X-API-Key`,
201    // `X-Auth-Token`, etc. Add `COOKIE` since session cookies do not match the
202    // env-style keyword set.
203    if likely_secret_env_name(name) {
204        return true;
205    }
206    name.to_ascii_uppercase().contains("COOKIE")
207}
208
209fn is_placeholder_value(value: &str) -> bool {
210    let trimmed = value.trim();
211    if trimmed.starts_with("${") && trimmed.ends_with('}') && trimmed.len() > 3 {
212        return true;
213    }
214    trimmed
215        .strip_prefix('$')
216        .is_some_and(|name| !name.is_empty() && name.chars().all(is_env_name_char))
217}
218
219fn is_env_name_char(c: char) -> bool {
220    c.is_ascii_alphanumeric() || c == '_'
221}