1use std::collections::HashMap;
2use std::sync::Arc;
3
4use reqwest::Url;
5use serde::{Deserialize, Serialize};
6
7use super::client::McpClient;
8use crate::types::tools::McpToolParam;
9
10const MCP_ALLOWED_HOSTS_ENV: &str = "AGENTIC_MCP_ALLOWED_HOSTS";
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(untagged, deny_unknown_fields)]
17pub enum McpServerEntry {
18 Http {
19 url: String,
20 #[serde(default, alias = "http_headers", skip_serializing_if = "Option::is_none")]
21 headers: Option<HashMap<String, String>>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 allowed_tools: Option<Vec<String>>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 require_approval: Option<String>,
26 },
27 Stdio {
28 command: String,
29 #[serde(default)]
30 args: Vec<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 env: Option<HashMap<String, String>>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 cwd: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 allowed_tools: Option<Vec<String>>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 require_approval: Option<String>,
39 },
40}
41
42impl McpServerEntry {
43 #[must_use]
44 pub fn allowed_tools(&self) -> Option<&[String]> {
45 match self {
46 Self::Http { allowed_tools, .. } | Self::Stdio { allowed_tools, .. } => allowed_tools.as_deref(),
47 }
48 }
49
50 #[must_use]
51 pub fn require_approval(&self) -> Option<&str> {
52 match self {
53 Self::Http { require_approval, .. } | Self::Stdio { require_approval, .. } => require_approval.as_deref(),
54 }
55 }
56}
57
58#[derive(Default)]
59pub struct McpClientPool {
60 clients: HashMap<String, Arc<McpClient>>,
61 connection_errors: HashMap<String, String>,
62}
63
64impl McpClientPool {
65 pub async fn from_params(params: &[McpToolParam]) -> Self {
66 Self::from_params_with_allowed_hosts(params, &allowed_hosts_from_env()).await
67 }
68
69 pub async fn from_params_with_allowed_hosts(params: &[McpToolParam], allowed_hosts: &[String]) -> Self {
70 let servers: HashMap<String, McpServerEntry> = params
71 .iter()
72 .filter_map(|param| server_entry_from_param(param, allowed_hosts))
73 .collect();
74 Self::from_config(servers).await
75 }
76
77 pub async fn from_config(servers: HashMap<String, McpServerEntry>) -> Self {
78 let mut clients = HashMap::with_capacity(servers.len());
79 let mut connection_errors = HashMap::new();
80
81 for (server_label, entry) in servers {
82 let result = match entry {
83 McpServerEntry::Http { url, headers, .. } => McpClient::connect(&url, headers).await,
84 McpServerEntry::Stdio {
85 command,
86 args,
87 env,
88 cwd,
89 ..
90 } => McpClient::connect_stdio(&command, &args, env.as_ref(), cwd.as_deref()).await,
91 };
92
93 match result {
94 Ok(client) => {
95 clients.insert(server_label, Arc::new(client));
96 }
97 Err(error) => {
98 let error_message = error.to_string();
99 tracing::warn!(
100 server_label = %server_label,
101 error = %error_message,
102 "failed to connect MCP server from config"
103 );
104 connection_errors.insert(server_label, error_message);
105 }
106 }
107 }
108
109 Self {
110 clients,
111 connection_errors,
112 }
113 }
114
115 #[must_use]
116 pub fn get(&self, server_label: &str) -> Option<&Arc<McpClient>> {
117 self.clients.get(server_label)
118 }
119
120 #[must_use]
121 pub fn connection_error(&self, server_label: &str) -> Option<&str> {
122 self.connection_errors.get(server_label).map(String::as_str)
123 }
124}
125
126fn server_entry_from_param(param: &McpToolParam, allowed_hosts: &[String]) -> Option<(String, McpServerEntry)> {
127 let Some(server_label) = clean_string(Some(¶m.server_label)) else {
128 tracing::debug!("MCP tool param has no server_label");
129 return None;
130 };
131
132 if let Some(url) = clean_string(param.server_url.as_deref()) {
133 let url = match validate_request_server_url_with_allowed_hosts(&url, allowed_hosts) {
134 Ok(url) => url,
135 Err(reason) => {
136 tracing::warn!(server_label, url, reason, "MCP tool param server_url rejected");
137 return None;
138 }
139 };
140
141 return Some((
142 server_label,
143 McpServerEntry::Http {
144 url,
145 headers: request_headers(param),
146 allowed_tools: None,
147 require_approval: None,
148 },
149 ));
150 }
151
152 tracing::warn!(server_label, "MCP tool param has no server_url");
153 None
154}
155
156fn request_headers(param: &McpToolParam) -> Option<HashMap<String, String>> {
157 let mut headers = param.headers.clone().unwrap_or_default();
158 if let Some(authorization) = clean_string(param.authorization.as_deref()) {
159 headers.insert("Authorization".to_owned(), format!("Bearer {authorization}"));
160 }
161 (!headers.is_empty()).then_some(headers)
162}
163
164fn validate_request_server_url_with_allowed_hosts(value: &str, allowed_hosts: &[String]) -> Result<String, String> {
165 let url = Url::parse(value).map_err(|error| format!("invalid URL: {error}"))?;
166 match url.scheme() {
167 "http" | "https" => {}
168 _ => return Err("URL scheme must be http or https".to_owned()),
169 }
170
171 if !url.username().is_empty() || url.password().is_some() {
172 return Err("URL must not include credentials".to_owned());
173 }
174
175 let host = url.host().ok_or_else(|| "URL must include a host".to_owned())?;
176 if is_allowed_request_host(&host, allowed_hosts) {
177 return Ok(value.to_owned());
178 }
179
180 Err(format!(
181 "MCP server_url host is not allowed; set {MCP_ALLOWED_HOSTS_ENV} to allow it"
182 ))
183}
184
185fn is_allowed_request_host(host: &url::Host<&str>, allowed_hosts: &[String]) -> bool {
186 match host {
187 url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost") || host_allowed(host, allowed_hosts),
188 url::Host::Ipv4(address) => address.is_loopback() || host_allowed(&address.to_string(), allowed_hosts),
189 url::Host::Ipv6(address) => address.is_loopback() || host_allowed(&address.to_string(), allowed_hosts),
190 }
191}
192
193fn host_allowed(host: &str, allowed_hosts: &[String]) -> bool {
194 allowed_hosts
195 .iter()
196 .any(|allowed_host| allowed_host.eq_ignore_ascii_case(host))
197}
198
199pub(crate) fn allowed_hosts_from_env() -> Vec<String> {
200 parse_allowed_hosts(&std::env::var(MCP_ALLOWED_HOSTS_ENV).unwrap_or_default())
201}
202
203fn parse_allowed_hosts(value: &str) -> Vec<String> {
204 value
205 .split(',')
206 .map(str::trim)
207 .filter(|host| !host.is_empty())
208 .map(str::to_owned)
209 .collect()
210}
211
212fn clean_string(value: Option<&str>) -> Option<String> {
213 value
214 .map(str::trim)
215 .filter(|value| !value.is_empty())
216 .map(str::to_owned)
217}
218
219#[cfg(test)]
220mod tests {
221 use super::{
222 McpServerEntry, parse_allowed_hosts, server_entry_from_param, validate_request_server_url_with_allowed_hosts,
223 };
224 use crate::types::tools::McpToolParam;
225
226 #[test]
227 fn mcp_server_entry_deserializes_http_config() {
228 let entry = serde_json::from_value::<McpServerEntry>(serde_json::json!({
229 "url": "http://localhost:9000",
230 "headers": {"Authorization": "Bearer token"},
231 "allowed_tools": ["say_hello", "sum"],
232 "require_approval": "never"
233 }))
234 .unwrap();
235
236 match entry {
237 McpServerEntry::Http {
238 url,
239 headers,
240 allowed_tools,
241 require_approval,
242 } => {
243 assert_eq!(url, "http://localhost:9000");
244 assert_eq!(headers.unwrap()["Authorization"], "Bearer token");
245 assert_eq!(allowed_tools.unwrap(), ["say_hello", "sum"]);
246 assert_eq!(require_approval.as_deref(), Some("never"));
247 }
248 McpServerEntry::Stdio { .. } => panic!("expected HTTP MCP config"),
249 }
250 }
251
252 #[test]
253 fn mcp_server_entry_deserializes_stdio_config() {
254 let entry = serde_json::from_value::<McpServerEntry>(serde_json::json!({
255 "command": "python3",
256 "args": ["/tmp/server.py"],
257 "env": {"TOKEN": "secret"},
258 "cwd": "/tmp"
259 }))
260 .unwrap();
261
262 match entry {
263 McpServerEntry::Stdio {
264 command,
265 args,
266 env,
267 cwd,
268 allowed_tools,
269 require_approval,
270 } => {
271 assert_eq!(command, "python3");
272 assert_eq!(args, vec!["/tmp/server.py".to_owned()]);
273 assert_eq!(env.unwrap()["TOKEN"], "secret");
274 assert_eq!(cwd.as_deref(), Some("/tmp"));
275 assert!(allowed_tools.is_none());
276 assert!(require_approval.is_none());
277 }
278 McpServerEntry::Http { .. } => panic!("expected stdio MCP config"),
279 }
280 }
281
282 #[test]
283 fn request_server_url_allows_loopback_http() {
284 let url = validate_request_server_url_with_allowed_hosts("http://127.0.0.1:8000/mcp", &[]).unwrap();
285 assert_eq!(url, "http://127.0.0.1:8000/mcp");
286 }
287
288 #[test]
289 fn request_server_url_allows_ipv6_loopback_http() {
290 let url = validate_request_server_url_with_allowed_hosts("http://[::1]:8000/mcp", &[]).unwrap();
291 assert_eq!(url, "http://[::1]:8000/mcp");
292 }
293
294 #[test]
295 fn request_server_url_rejects_unallowlisted_host() {
296 let error = validate_request_server_url_with_allowed_hosts("http://169.254.169.254/mcp", &[]).unwrap_err();
297 assert!(error.contains("not allowed"));
298 }
299
300 #[test]
301 fn request_server_url_uses_supplied_allowlist() {
302 assert!(
303 validate_request_server_url_with_allowed_hosts(
304 "https://mcp.example.com/mcp",
305 &["mcp.example.com".to_owned()]
306 )
307 .is_ok()
308 );
309 assert!(validate_request_server_url_with_allowed_hosts("https://mcp.example.com/mcp", &[]).is_err());
310 }
311
312 #[test]
313 fn request_params_ignore_stdio_fields_without_configuring_transport() {
314 let param = serde_json::from_value::<McpToolParam>(serde_json::json!({
315 "server_label": "repo",
316 "command": "python3",
317 "args": ["/tmp/server.py"]
318 }))
319 .unwrap();
320
321 assert!(server_entry_from_param(¶m, &[]).is_none());
322 }
323
324 #[test]
325 fn allowed_host_parser_trims_and_discards_empty_entries() {
326 assert_eq!(
327 parse_allowed_hosts(" Example.COM, ,api.test "),
328 vec!["Example.COM", "api.test"]
329 );
330 }
331}