Skip to main content

tuff_core/
mcp.rs

1use std::path::Path;
2
3use crate::error::{Result, TuffError};
4
5pub fn validate_config(mcp_config_path: &Path) -> Result<()> {
6    read_config(mcp_config_path).map(|_| ())
7}
8
9pub fn register_tool(
10    mcp_config_path: &Path,
11    tool_id: &str,
12    command: &str,
13    args: &[String],
14) -> Result<()> {
15    let mut config = read_config(mcp_config_path)?;
16    let config_object = config
17        .as_object_mut()
18        .expect("read_config returns a JSON object");
19    let servers = config_object
20        .entry("mcpServers")
21        .or_insert_with(|| serde_json::json!({}))
22        .as_object_mut()
23        .expect("read_config validates the mcpServers object");
24
25    servers.insert(
26        tool_id.to_string(),
27        serde_json::json!({"command": command, "args": args}),
28    );
29
30    if let Some(parent) = mcp_config_path.parent() {
31        std::fs::create_dir_all(parent)?;
32    }
33    write_config(mcp_config_path, &config)
34}
35
36pub fn remove_tool(mcp_config_path: &Path, tool_id: &str) -> Result<()> {
37    if !mcp_config_path.exists() {
38        return Ok(());
39    }
40
41    let mut config = read_config(mcp_config_path)?;
42    let Some(servers) = config
43        .as_object_mut()
44        .and_then(|object| object.get_mut("mcpServers"))
45        .and_then(serde_json::Value::as_object_mut)
46    else {
47        return Ok(());
48    };
49    if servers.remove(tool_id).is_none() {
50        return Ok(());
51    }
52
53    write_config(mcp_config_path, &config)
54}
55
56fn read_config(mcp_config_path: &Path) -> Result<serde_json::Value> {
57    let config = if mcp_config_path.exists() {
58        let raw = std::fs::read_to_string(mcp_config_path)?;
59        if raw.trim().is_empty() {
60            serde_json::json!({})
61        } else {
62            serde_json::from_str(&raw).map_err(|error| {
63                TuffError::new(format!(
64                    "invalid MCP config at {}: {error}",
65                    mcp_config_path.display()
66                ))
67            })?
68        }
69    } else {
70        serde_json::json!({})
71    };
72
73    let object = config.as_object().ok_or_else(|| {
74        TuffError::new(format!(
75            "invalid MCP config at {}: root must be a JSON object",
76            mcp_config_path.display()
77        ))
78    })?;
79    if object
80        .get("mcpServers")
81        .is_some_and(|servers| !servers.is_object())
82    {
83        return Err(TuffError::new(format!(
84            "invalid MCP config at {}: field 'mcpServers' must be a JSON object",
85            mcp_config_path.display()
86        )));
87    }
88
89    Ok(config)
90}
91
92fn write_config(mcp_config_path: &Path, config: &serde_json::Value) -> Result<()> {
93    std::fs::write(
94        mcp_config_path,
95        serde_json::to_string_pretty(config)? + "\n",
96    )?;
97    Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn register_rejects_malformed_json_without_changing_it() {
106        let temp = tempfile::tempdir().expect("tempdir");
107        let path = temp.path().join("mcp.json");
108        let original = b"{ not-json\n";
109        std::fs::write(&path, original).expect("write config");
110
111        let error = register_tool(&path, "demo", "python", &[]).expect_err("invalid config");
112
113        assert!(error.to_string().contains("invalid MCP config"));
114        assert_eq!(std::fs::read(&path).expect("read config"), original);
115    }
116
117    #[test]
118    fn remove_rejects_malformed_json_without_changing_it() {
119        let temp = tempfile::tempdir().expect("tempdir");
120        let path = temp.path().join("mcp.json");
121        let original = b"[invalid";
122        std::fs::write(&path, original).expect("write config");
123
124        let error = remove_tool(&path, "demo").expect_err("invalid config");
125
126        assert!(error.to_string().contains("invalid MCP config"));
127        assert_eq!(std::fs::read(&path).expect("read config"), original);
128    }
129
130    #[test]
131    fn register_preserves_unrelated_fields() {
132        let temp = tempfile::tempdir().expect("tempdir");
133        let path = temp.path().join("mcp.json");
134        std::fs::write(
135            &path,
136            r#"{"custom":{"enabled":true},"mcpServers":{"existing":{"command":"node"}}}"#,
137        )
138        .expect("write config");
139
140        register_tool(&path, "demo", "python", &["server.py".to_string()]).expect("register tool");
141
142        let config: serde_json::Value =
143            serde_json::from_slice(&std::fs::read(path).expect("read config"))
144                .expect("parse config");
145        assert_eq!(config["custom"]["enabled"], true);
146        assert_eq!(config["mcpServers"]["existing"]["command"], "node");
147        assert_eq!(config["mcpServers"]["demo"]["command"], "python");
148    }
149
150    #[test]
151    fn validation_rejects_non_object_mcp_servers() {
152        let temp = tempfile::tempdir().expect("tempdir");
153        let path = temp.path().join("mcp.json");
154        std::fs::write(&path, r#"{"mcpServers":[]}"#).expect("write config");
155
156        let error = validate_config(&path).expect_err("invalid mcpServers");
157
158        assert!(
159            error
160                .to_string()
161                .contains("'mcpServers' must be a JSON object")
162        );
163    }
164}