agent_config/spec/mcp/
transport.rs1use std::collections::BTreeMap;
5
6use fluent_uri::Uri;
7
8use crate::error::AgentConfigError;
9
10#[derive(Debug, Clone)]
12#[non_exhaustive]
13pub enum McpTransport {
14 Stdio {
18 command: String,
20 args: Vec<String>,
22 env: BTreeMap<String, String>,
25 },
26 Http {
28 url: String,
30 headers: BTreeMap<String, String>,
32 },
33 Sse {
35 url: String,
37 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 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}