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