Skip to main content

thndrs_lib/core/mcp/
config.rs

1//! MCP configuration loading.
2//!
3//! MCP server definitions live in separate files from ordinary `thndrs`
4//! runtime config:
5//! - Global: `~/.thndrs/mcp.toml`
6//! - Project: `.thndrs/mcp.toml`
7//!
8//! Project server definitions override global definitions by server name.
9
10use 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/// Supported MCP transport types.
22#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
23#[serde(rename_all = "snake_case")]
24pub enum McpTransport {
25    /// Launch a local MCP server subprocess and communicate over stdin/stdout.
26    #[default]
27    Stdio,
28    /// Use MCP Streamable HTTP.
29    StreamableHttp,
30}
31
32/// Configuration for one MCP server.
33#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
34#[serde(default, deny_unknown_fields)]
35pub struct McpServerConfig {
36    /// Transport used to reach this server.
37    pub transport: McpTransport,
38    /// Executable command for stdio servers.
39    pub command: String,
40    /// Command-line arguments passed after [`McpServerConfig::command`].
41    pub args: Vec<String>,
42    /// Environment variables passed to stdio child processes.
43    pub env: BTreeMap<String, String>,
44    /// URL used by Streamable HTTP servers.
45    pub url: Option<String>,
46    /// Headers sent to Streamable HTTP servers.
47    pub headers: BTreeMap<String, String>,
48    /// Whether this server is discoverable and callable.
49    pub enabled: bool,
50    /// Timeout for startup and tool calls in seconds.
51    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
69/// MCP server map keyed by configured server name.
70pub type McpServersConfig = BTreeMap<String, McpServerConfig>;
71
72/// User-editable MCP configuration loaded from TOML.
73#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
74#[serde(default, deny_unknown_fields)]
75pub struct McpConfig {
76    /// Named MCP server definitions.
77    pub servers: McpServersConfig,
78}
79
80impl McpConfig {
81    /// Merge `other` over `self`, replacing servers with the same name.
82    pub fn merge(mut self, other: McpConfig) -> Self {
83        self.servers.extend(other.servers);
84        self
85    }
86}
87
88/// Fully resolved MCP configuration.
89#[derive(Clone, Debug)]
90pub struct EffectiveMcpConfig {
91    /// Final resolved server definitions after precedence and env expansion.
92    pub config: McpConfig,
93    /// Loaded MCP config file layers in precedence order.
94    pub layers: Vec<LoadedMcpConfigLayer>,
95    /// Non-fatal loading diagnostics.
96    pub diagnostics: Vec<String>,
97}
98
99/// A single loaded MCP config file layer.
100#[derive(Clone, Debug)]
101pub struct LoadedMcpConfigLayer {
102    pub source: ConfigSource,
103    /// Redacted path label safe for diagnostics and metadata.
104    pub display_path: Option<String>,
105    /// Lowercase hex SHA-256 of file bytes.
106    pub hash: Option<String>,
107}
108
109/// Validate an MCP server name accepted by `mcp__{server}__{tool}` namespacing.
110pub 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
124/// Load and merge MCP config layers.
125pub 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}