1use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16use crate::config::{ConfigError, ConfigSource};
17use crate::utils;
18
19const DEFAULT_TIMEOUT_SECS: u64 = 20;
20
21#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
23#[serde(rename_all = "snake_case")]
24pub enum McpTransport {
25 #[default]
27 Stdio,
28 StreamableHttp,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
34#[serde(default, deny_unknown_fields)]
35pub struct McpServerConfig {
36 pub transport: McpTransport,
38 pub command: String,
40 pub args: Vec<String>,
42 pub env: BTreeMap<String, String>,
44 pub url: Option<String>,
46 pub headers: BTreeMap<String, String>,
48 pub enabled: bool,
50 pub timeout_secs: u64,
52}
53
54impl Default for McpServerConfig {
55 fn default() -> Self {
56 Self {
57 transport: McpTransport::Stdio,
58 command: String::new(),
59 args: Vec::new(),
60 env: BTreeMap::new(),
61 url: None,
62 headers: BTreeMap::new(),
63 enabled: true,
64 timeout_secs: DEFAULT_TIMEOUT_SECS,
65 }
66 }
67}
68
69pub type McpServersConfig = BTreeMap<String, McpServerConfig>;
71
72#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
74#[serde(default, deny_unknown_fields)]
75pub struct McpConfig {
76 pub servers: McpServersConfig,
78}
79
80impl McpConfig {
81 pub fn merge(mut self, other: McpConfig) -> Self {
83 self.servers.extend(other.servers);
84 self
85 }
86}
87
88#[derive(Clone, Debug)]
90pub struct EffectiveMcpConfig {
91 pub config: McpConfig,
93 pub layers: Vec<LoadedMcpConfigLayer>,
95 pub diagnostics: Vec<String>,
97}
98
99#[derive(Clone, Debug)]
101pub struct LoadedMcpConfigLayer {
102 pub source: ConfigSource,
103 pub display_path: Option<String>,
105 pub hash: Option<String>,
107}
108
109pub fn validate_mcp_server_name(name: &str) -> Result<(), ConfigError> {
111 if name.is_empty()
112 || !name
113 .bytes()
114 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
115 {
116 return Err(ConfigError::InvalidConfig {
117 key: format!("mcp.servers.{name}"),
118 message: "name must match [A-Za-z0-9_-]+".to_string(),
119 });
120 }
121 Ok(())
122}
123
124pub fn load_effective_mcp(workspace: &Path, env_vars: &[(String, String)]) -> Result<EffectiveMcpConfig, ConfigError> {
126 let mut layers = Vec::new();
127 let mut merged = McpConfig::default();
128
129 if let Some(global_path) = global_mcp_config_path()
130 && global_path.is_file()
131 {
132 let (global_config, hash) = load_mcp_file(&global_path)?;
133 let display_path = mcp_global_path_display(&global_path);
134 layers.push(LoadedMcpConfigLayer {
135 source: ConfigSource::GlobalFile,
136 display_path: Some(display_path),
137 hash: Some(hash),
138 });
139 merged = merged.merge(global_config);
140 }
141
142 let project_path = project_mcp_config_path(workspace);
143 if project_path.is_file() {
144 let (project_config, hash) = load_mcp_file(&project_path)?;
145 let display_path = mcp_project_path_display(&project_path, workspace);
146 layers.push(LoadedMcpConfigLayer {
147 source: ConfigSource::ProjectFile,
148 display_path: Some(display_path),
149 hash: Some(hash),
150 });
151 merged = merged.merge(project_config);
152 }
153
154 let mut diagnostics = Vec::new();
155 expand_mcp_env(&mut merged, env_vars, &mut diagnostics);
156 validate_mcp_config(&merged)?;
157
158 Ok(EffectiveMcpConfig { config: merged, layers, diagnostics })
159}
160
161fn global_mcp_config_path() -> Option<PathBuf> {
162 utils::home_dir().map(|home| home.join(".thndrs").join("mcp.toml"))
163}
164
165fn project_mcp_config_path(workspace: &Path) -> PathBuf {
166 workspace.join(".thndrs").join("mcp.toml")
167}
168
169fn mcp_global_path_display(path: &Path) -> String {
170 if let Some(home) = utils::home_dir()
171 && let Ok(rel) = path.strip_prefix(&home)
172 {
173 return format!("~/{}", rel.display());
174 }
175 path.display().to_string()
176}
177
178fn mcp_project_path_display(path: &Path, workspace: &Path) -> String {
179 if let Ok(rel) = path.strip_prefix(workspace) {
180 return rel.display().to_string();
181 }
182 path.display().to_string()
183}
184
185fn load_mcp_file(path: &Path) -> Result<(McpConfig, String), ConfigError> {
186 let content = fs::read_to_string(path).map_err(|source| ConfigError::Read { path: path.to_path_buf(), source })?;
187 let config: McpConfig =
188 toml::from_str(&content).map_err(|source| ConfigError::Parse { path: path.to_path_buf(), source })?;
189 validate_mcp_config(&config)?;
190 let hash = sha256_hex(content.as_bytes());
191 Ok((config, hash))
192}
193
194fn validate_mcp_config(config: &McpConfig) -> Result<(), ConfigError> {
195 for (name, server) in &config.servers {
196 validate_mcp_server_name(name)?;
197 if server.timeout_secs == 0 {
198 return Err(ConfigError::InvalidConfig {
199 key: format!("mcp.servers.{name}.timeout_secs"),
200 message: "timeout_secs must be greater than 0".to_string(),
201 });
202 }
203 match server.transport {
204 McpTransport::Stdio if server.command.trim().is_empty() => {
205 return Err(ConfigError::InvalidConfig {
206 key: format!("mcp.servers.{name}.command"),
207 message: "command is required for stdio transport".to_string(),
208 });
209 }
210 McpTransport::StreamableHttp if server.url.as_ref().is_none_or(|url| url.trim().is_empty()) => {
211 return Err(ConfigError::InvalidConfig {
212 key: format!("mcp.servers.{name}.url"),
213 message: "url is required for streamable_http transport".to_string(),
214 });
215 }
216 _ => {}
217 }
218 }
219 Ok(())
220}
221
222fn expand_mcp_env(config: &mut McpConfig, env_vars: &[(String, String)], diagnostics: &mut Vec<String>) {
223 let env = env_vars.iter().cloned().collect::<BTreeMap<_, _>>();
224 let mut skipped = Vec::new();
225
226 for (name, server) in &mut config.servers {
227 let missing = expand_server_env(server, &env);
228 if !missing.is_empty() {
229 diagnostics.push(format!(
230 "mcp server `{name}` skipped: unresolved environment variable{} {}",
231 if missing.len() == 1 { "" } else { "s" },
232 missing.into_iter().collect::<Vec<_>>().join(", ")
233 ));
234 skipped.push(name.clone());
235 }
236 }
237
238 for name in skipped {
239 config.servers.remove(&name);
240 }
241}
242
243fn expand_server_env(server: &mut McpServerConfig, env: &BTreeMap<String, String>) -> BTreeSet<String> {
244 let mut missing = BTreeSet::new();
245 server.command = expand_value(&server.command, env, &mut missing);
246 server.args = server
247 .args
248 .iter()
249 .map(|value| expand_value(value, env, &mut missing))
250 .collect();
251 server.env = expand_map(&server.env, env, &mut missing);
252 server.url = server.url.as_ref().map(|value| expand_value(value, env, &mut missing));
253 server.headers = expand_map(&server.headers, env, &mut missing);
254 missing
255}
256
257fn expand_map(
258 values: &BTreeMap<String, String>, env: &BTreeMap<String, String>, missing: &mut BTreeSet<String>,
259) -> BTreeMap<String, String> {
260 values
261 .iter()
262 .map(|(key, value)| (key.clone(), expand_value(value, env, missing)))
263 .collect()
264}
265
266fn expand_value(value: &str, env: &BTreeMap<String, String>, missing: &mut BTreeSet<String>) -> String {
267 let mut expanded = String::new();
268 let mut rest = value;
269
270 while let Some(start) = rest.find("${") {
271 expanded.push_str(&rest[..start]);
272 let after_start = &rest[start + 2..];
273 let Some(end) = after_start.find('}') else {
274 expanded.push_str(&rest[start..]);
275 return expanded;
276 };
277
278 let name = &after_start[..end];
279 if let Some(replacement) = env.get(name) {
280 expanded.push_str(replacement);
281 } else {
282 missing.insert(name.to_string());
283 expanded.push_str(&rest[start..start + end + 3]);
284 }
285 rest = &after_start[end + 1..];
286 }
287
288 expanded.push_str(rest);
289 expanded
290}
291
292fn sha256_hex(bytes: &[u8]) -> String {
293 use sha2::{Digest, Sha256};
294
295 let mut hasher = Sha256::new();
296 hasher.update(bytes);
297 let result = hasher.finalize();
298 hex_encode(&result)
299}
300
301fn hex_encode(bytes: &[u8]) -> String {
302 let mut out = String::with_capacity(bytes.len() * 2);
303 for byte in bytes {
304 out.push_str(&format!("{byte:02x}"));
305 }
306 out
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn with_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
314 let _guard = crate::test_env::lock();
315 let old_home = std::env::var_os("HOME");
316
317 unsafe {
318 std::env::set_var("HOME", home);
319 }
320
321 let result = f();
322
323 unsafe {
324 if let Some(old_home) = old_home {
325 std::env::set_var("HOME", old_home);
326 } else {
327 std::env::remove_var("HOME");
328 }
329 }
330
331 result
332 }
333
334 #[test]
335 fn parses_stdio_server_config() {
336 let config: McpConfig = toml::from_str(
337 r#"
338 [servers.docs]
339 transport = "stdio"
340 command = "docs-mcp"
341 args = ["--workspace", "${THNDRS_WORKSPACE}"]
342 env = { TOKEN = "${DOCS_TOKEN}" }
343 enabled = false
344 timeout_secs = 15
345 "#,
346 )
347 .expect("mcp config parses");
348
349 let server = &config.servers["docs"];
350 assert_eq!(server.transport, McpTransport::Stdio);
351 assert_eq!(server.command, "docs-mcp");
352 assert_eq!(server.args, vec!["--workspace", "${THNDRS_WORKSPACE}"]);
353 assert_eq!(server.env["TOKEN"], "${DOCS_TOKEN}");
354 assert!(!server.enabled);
355 assert_eq!(server.timeout_secs, 15);
356 }
357
358 #[test]
359 fn parses_streamable_http_server_config() {
360 let config: McpConfig = toml::from_str(
361 r#"
362 [servers.web]
363 transport = "streamable_http"
364 url = "https://mcp.example.test"
365 headers = { Authorization = "Bearer ${MCP_TOKEN}" }
366 "#,
367 )
368 .expect("mcp config parses");
369
370 let server = &config.servers["web"];
371 assert_eq!(server.transport, McpTransport::StreamableHttp);
372 assert_eq!(server.url.as_deref(), Some("https://mcp.example.test"));
373 assert_eq!(server.headers["Authorization"], "Bearer ${MCP_TOKEN}");
374 assert_eq!(server.timeout_secs, DEFAULT_TIMEOUT_SECS);
375 }
376
377 #[test]
378 fn rejects_unknown_fields() {
379 let err = toml::from_str::<McpConfig>(
380 r#"
381 [servers.docs]
382 command = "docs-mcp"
383 prompt_injection = true
384 "#,
385 )
386 .expect_err("unknown fields rejected");
387 assert!(err.to_string().contains("unknown field"));
388 }
389
390 #[test]
391 fn rejects_invalid_server_names() {
392 let err = validate_mcp_server_name("bad/name").expect_err("invalid name rejected");
393 assert!(
394 matches!(err, ConfigError::InvalidConfig { key, message } if key == "mcp.servers.bad/name" && message.contains("[A-Za-z0-9_-]+"))
395 );
396 }
397
398 #[test]
399 fn requires_stdio_command() {
400 let config: McpConfig = toml::from_str(
401 r#"
402 [servers.docs]
403 transport = "stdio"
404 "#,
405 )
406 .expect("mcp config parses");
407
408 let err = validate_mcp_config(&config).expect_err("missing command rejected");
409 assert!(
410 matches!(err, ConfigError::InvalidConfig { key, message } if key == "mcp.servers.docs.command" && message.contains("stdio"))
411 );
412 }
413
414 #[test]
415 fn requires_http_url() {
416 let config: McpConfig = toml::from_str(
417 r#"
418 [servers.web]
419 transport = "streamable_http"
420 "#,
421 )
422 .expect("mcp config parses");
423
424 let err = validate_mcp_config(&config).expect_err("missing url rejected");
425 assert!(
426 matches!(err, ConfigError::InvalidConfig { key, message } if key == "mcp.servers.web.url" && message.contains("streamable_http"))
427 );
428 }
429
430 #[test]
431 fn project_servers_override_global_by_name() {
432 let tmp = tempfile::tempdir().unwrap();
433 let home = tmp.path().join("home");
434 fs::create_dir_all(home.join(".thndrs")).unwrap();
435 fs::write(
436 home.join(".thndrs").join("mcp.toml"),
437 r#"
438 [servers.shared]
439 command = "global"
440
441 [servers.global_only]
442 command = "global-only"
443 "#,
444 )
445 .unwrap();
446
447 let workspace = tmp.path().join("workspace");
448 fs::create_dir_all(workspace.join(".thndrs")).unwrap();
449 fs::write(
450 workspace.join(".thndrs").join("mcp.toml"),
451 r#"
452 [servers.shared]
453 command = "project"
454
455 [servers.project_only]
456 command = "project-only"
457 "#,
458 )
459 .unwrap();
460
461 let effective = with_home(&home, || load_effective_mcp(&workspace, &[]).unwrap());
462
463 assert_eq!(effective.config.servers["shared"].command, "project");
464 assert_eq!(effective.config.servers["global_only"].command, "global-only");
465 assert_eq!(effective.config.servers["project_only"].command, "project-only");
466 assert_eq!(effective.layers.len(), 2);
467 assert_eq!(effective.layers[0].display_path.as_deref(), Some("~/.thndrs/mcp.toml"));
468 assert_eq!(effective.layers[1].display_path.as_deref(), Some(".thndrs/mcp.toml"));
469 }
470
471 #[test]
472 fn expands_environment_values() {
473 let mut config: McpConfig = toml::from_str(
474 r#"
475 [servers.docs]
476 command = "${DOCS_BIN}"
477 args = ["--workspace", "${THNDRS_WORKSPACE}"]
478 env = { TOKEN = "${DOCS_TOKEN}" }
479 "#,
480 )
481 .expect("mcp config parses");
482 let mut diagnostics = Vec::new();
483
484 expand_mcp_env(
485 &mut config,
486 &[
487 ("DOCS_BIN".to_string(), "docs-mcp".to_string()),
488 ("THNDRS_WORKSPACE".to_string(), "/repo".to_string()),
489 ("DOCS_TOKEN".to_string(), "secret".to_string()),
490 ],
491 &mut diagnostics,
492 );
493
494 let server = &config.servers["docs"];
495 assert_eq!(server.command, "docs-mcp");
496 assert_eq!(server.args, vec!["--workspace", "/repo"]);
497 assert_eq!(server.env["TOKEN"], "secret");
498 assert!(diagnostics.is_empty());
499 }
500
501 #[test]
502 fn skips_servers_with_unresolved_environment_values() {
503 let mut config: McpConfig = toml::from_str(
504 r#"
505 [servers.docs]
506 command = "docs-mcp"
507 args = ["${MISSING_WORKSPACE}"]
508
509 [servers.ready]
510 command = "ready-mcp"
511 "#,
512 )
513 .expect("mcp config parses");
514 let mut diagnostics = Vec::new();
515
516 expand_mcp_env(&mut config, &[], &mut diagnostics);
517
518 assert!(!config.servers.contains_key("docs"));
519 assert!(config.servers.contains_key("ready"));
520 assert_eq!(
521 diagnostics,
522 vec!["mcp server `docs` skipped: unresolved environment variable MISSING_WORKSPACE"]
523 );
524 }
525
526 #[test]
527 fn loaded_layers_record_only_safe_file_metadata() {
528 let tmp = tempfile::tempdir().unwrap();
529 let home = tmp.path().join("home");
530 fs::create_dir_all(home.join(".thndrs")).unwrap();
531 let workspace = tmp.path().join("workspace");
532 fs::create_dir_all(workspace.join(".thndrs")).unwrap();
533 fs::write(
534 workspace.join(".thndrs").join("mcp.toml"),
535 r#"
536 [servers.web]
537 transport = "streamable_http"
538 url = "https://mcp.example.test"
539 env = { TOKEN = "env-secret" }
540 headers = { Authorization = "Bearer header-secret" }
541 "#,
542 )
543 .unwrap();
544
545 let effective = with_home(&home, || load_effective_mcp(&workspace, &[]).unwrap());
546
547 assert_eq!(effective.config.servers["web"].env["TOKEN"], "env-secret");
548 assert_eq!(
549 effective.config.servers["web"].headers["Authorization"],
550 "Bearer header-secret"
551 );
552 assert_eq!(effective.layers[0].source, ConfigSource::ProjectFile);
553 assert_eq!(effective.layers[0].display_path.as_deref(), Some(".thndrs/mcp.toml"));
554 assert!(effective.layers[0].hash.is_some());
555 }
556
557 #[test]
558 fn diagnostics_do_not_include_secret_values_for_unresolved_env() {
559 let mut config: McpConfig = toml::from_str(
560 r#"
561 [servers.web]
562 transport = "streamable_http"
563 url = "https://mcp.example.test"
564 headers = { Authorization = "Bearer ${MISSING_TOKEN}" }
565 "#,
566 )
567 .expect("mcp config parses");
568 let mut diagnostics = Vec::new();
569
570 expand_mcp_env(&mut config, &[], &mut diagnostics);
571
572 assert_eq!(
573 diagnostics,
574 vec!["mcp server `web` skipped: unresolved environment variable MISSING_TOKEN"]
575 );
576 }
577}