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}