1use std::path::Path;
13use std::sync::Arc;
14use std::time::Duration;
15
16use agentplane::tools::rmcp;
17use agentplane::tools::{Destination, McpClient, ToolClient};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21pub use crate::tools::policy::MCP_PREFIX as PREFIX;
23
24const STARTUP: Duration = Duration::from_secs(30);
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ServerConfig {
30 pub name: String,
32 pub command: Vec<String>,
34 pub allow: Vec<String>,
36 pub deny: Vec<String>,
38}
39
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct RawServer {
43 command: Vec<String>,
44 #[serde(default)]
45 allow: Vec<String>,
46 #[serde(default)]
47 deny: Vec<String>,
48}
49
50pub fn parse(text: &str) -> Result<Vec<ServerConfig>, String> {
52 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
53 let Some(mcp) = table.get("mcp") else {
54 return Ok(Vec::new());
55 };
56 let mcp = mcp
57 .as_table()
58 .ok_or_else(|| "`mcp` must be a table of [mcp.<name>] tables".to_owned())?;
59 mcp.iter()
60 .map(|(name, v)| {
61 let valid = !name.is_empty()
62 && name
63 .chars()
64 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
65 && !matches!(name.as_str(), "fs" | "proc" | "git");
66 if !valid {
67 return Err(format!(
68 "[mcp.{name}]: a server name is lowercase letters, digits and `-`, and not fs, proc or git"
69 ));
70 }
71 let raw: RawServer = v
72 .clone()
73 .try_into()
74 .map_err(|e| format!("[mcp.{name}]: {e}"))?;
75 if raw.command.first().is_none_or(String::is_empty) {
76 return Err(format!("[mcp.{name}]: `command` must name a program"));
77 }
78 Ok(ServerConfig {
79 name: name.clone(),
80 command: raw.command,
81 allow: raw.allow,
82 deny: raw.deny,
83 })
84 })
85 .collect()
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct McpTool {
91 pub name: String,
93 pub server: String,
95 pub tool: String,
97 pub description: String,
99 pub schema: Value,
101 pub decision: String,
103 pub fields: Vec<String>,
105 pub digest: String,
108}
109
110#[derive(Debug, Default)]
112pub struct Connected {
113 pub tools: Vec<McpTool>,
115 pub clients: Vec<(String, Arc<dyn ToolClient>)>,
117 pub notices: Vec<String>,
119}
120
121fn model_name(server: &str, tool: &str) -> Option<String> {
123 let name = format!("{PREFIX}{}__{tool}", server.replace('-', "_"));
124 (name.len() <= 64
125 && name
126 .chars()
127 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
128 .then_some(name)
129}
130
131pub async fn connect(
134 servers: &[ServerConfig],
135 cwd: &Path,
136 runner: &crate::sandbox::Runner,
137) -> Connected {
138 let mut out = Connected::default();
139 for s in servers {
140 match connect_one(s, cwd, runner).await {
141 Ok((client, tools, notices)) => {
142 out.clients.push((s.name.clone(), client));
143 out.tools.extend(tools);
144 out.notices.extend(notices);
145 }
146 Err(e) => out
147 .notices
148 .push(format!("MCP server `{}` is not used: {e}", s.name)),
149 }
150 }
151 out
152}
153
154async fn connect_one(
155 s: &ServerConfig,
156 cwd: &Path,
157 runner: &crate::sandbox::Runner,
158) -> Result<(Arc<dyn ToolClient>, Vec<McpTool>, Vec<String>), String> {
159 let (argv, tmp) = runner.wrap(&s.command)?;
161 let mut command = tokio::process::Command::new(&argv[0]);
162 command
163 .args(&argv[1..])
164 .current_dir(cwd)
165 .env_clear()
166 .envs(crate::verify::process::scrubbed_env(std::env::vars()))
167 .kill_on_drop(true);
168 if let Some(t) = tmp {
169 command.env("TMPDIR", &t).env("TMP", &t).env("TEMP", &t);
170 }
171 let transport = rmcp::transport::TokioChildProcess::new(command)
172 .map_err(|e| format!("cannot start `{}`: {e}", s.command.join(" ")))?;
173 let client = tokio::time::timeout(
175 STARTUP,
176 McpClient::connect(s.name.clone(), transport, Destination::Local),
177 )
178 .await
179 .map_err(|_| "it did not initialise within 30 s".to_owned())?
180 .map_err(|e| format!("it did not initialise: {e}"))?;
181 let listed = tokio::time::timeout(STARTUP, client.discover())
183 .await
184 .map_err(|_| "it did not list its tools within 30 s".to_owned())?
185 .map_err(|e| format!("tools/list failed: {e}"))?;
186
187 let mut tools = Vec::new();
188 let mut notices = Vec::new();
189 for t in listed {
190 let tool = t.id.tool.clone();
191 let Some(name) = model_name(&s.name, &tool) else {
192 notices.push(format!(
193 "MCP tool `{}/{tool}` skipped: its name cannot be a tool name",
194 s.name
195 ));
196 continue;
197 };
198 let schema = agentplane::tools::closed_schema(&t.input_schema);
203 if let Some(problem) = agentplane::model::strict_schema_problem(&schema) {
204 notices.push(format!(
205 "MCP tool `{}/{tool}` is sent without strict decoding: {problem}",
206 s.name
207 ));
208 }
209 let decision = if s.deny.contains(&tool) {
210 "deny"
211 } else if s.allow.contains(&tool) {
212 "allow"
213 } else {
214 "approval"
215 };
216 let fields = schema["properties"]
217 .as_object()
218 .map(|p| p.keys().map(|k| format!("/{k}")).collect())
219 .unwrap_or_default();
220 tools.push(McpTool {
221 name,
222 server: s.name.clone(),
223 tool,
224 description: if t.description.is_empty() {
225 "(no description)".to_owned()
226 } else {
227 t.description.clone()
228 },
229 schema,
230 decision: decision.to_owned(),
231 fields,
232 digest: t.digest.to_hex(),
233 });
234 }
235 Ok((Arc::new(Masking(Arc::new(client))), tools, notices))
236}
237
238pub const REVIEWED: &str = "mcp-reviewed.json";
240
241fn load_reviewed(state: &Path) -> std::collections::BTreeMap<String, String> {
242 std::fs::read_to_string(state.join(REVIEWED))
243 .ok()
244 .and_then(|t| serde_json::from_str(&t).ok())
245 .unwrap_or_default()
246}
247
248fn save_reviewed(state: &Path, map: &std::collections::BTreeMap<String, String>) {
249 let _ = std::fs::create_dir_all(state);
250 if let Ok(text) = serde_json::to_string_pretty(map) {
251 let _ = std::fs::write(state.join(REVIEWED), text);
252 }
253}
254
255pub fn pin_reviewed(state: &Path, tools: &mut [McpTool], approved: &[String]) -> Vec<String> {
261 let mut map = load_reviewed(state);
262 let mut notices = Vec::new();
263 for t in tools.iter_mut().filter(|t| t.decision == "allow") {
264 let key = format!("{}/{}", t.server, t.tool);
265 match map.get(&key) {
266 None => {
267 map.insert(key, t.digest.clone());
268 }
269 Some(seen) if *seen == t.digest => {}
270 Some(_) if approved.contains(&t.name) => {
271 map.insert(key, t.digest.clone());
272 }
273 Some(_) => {
274 t.decision = "approval".to_owned();
275 notices.push(format!(
276 "MCP tool `{key}` changed since it was allowed (description, schema or annotations); each call needs approval until a person approves one"
277 ));
278 }
279 }
280 }
281 save_reviewed(state, &map);
282 notices
283}
284
285pub fn mark_reviewed(state: &Path, tools: &[McpTool], name: &str) {
287 let Some(t) = tools.iter().find(|t| t.name == name) else {
288 return;
289 };
290 let mut map = load_reviewed(state);
291 map.insert(format!("{}/{}", t.server, t.tool), t.digest.clone());
292 save_reviewed(state, &map);
293}
294
295#[derive(Debug)]
299struct Masking(Arc<dyn ToolClient>);
300
301#[async_trait::async_trait]
302impl ToolClient for Masking {
303 async fn call(
304 &self,
305 tool: &agentplane::tools::ToolId,
306 arguments: &Value,
307 provenance: Option<&agentplane::core::Provenance>,
308 ) -> Result<Value, agentplane::tools::ToolError> {
309 let mut v = self.0.call(tool, arguments, provenance).await?;
310 crate::secrets::mask_json(&mut v);
311 Ok(v)
312 }
313
314 fn destination(&self, tool: &agentplane::tools::ToolId) -> Destination {
315 self.0.destination(tool)
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn a_servers_result_is_masked() {
325 #[derive(Debug)]
326 struct Echo;
327 #[async_trait::async_trait]
328 impl ToolClient for Echo {
329 async fn call(
330 &self,
331 _: &agentplane::tools::ToolId,
332 arguments: &Value,
333 _: Option<&agentplane::core::Provenance>,
334 ) -> Result<Value, agentplane::tools::ToolError> {
335 Ok(arguments.clone())
336 }
337 fn destination(&self, _: &agentplane::tools::ToolId) -> Destination {
338 Destination::Local
339 }
340 }
341 crate::secrets::register("MCP_SECRET", "mcp-masked-Lr5 value");
342 let tool = agentplane::tools::ToolId {
343 server: "s".to_owned(),
344 tool: "t".to_owned(),
345 };
346 let v = tokio::runtime::Builder::new_current_thread()
347 .build()
348 .unwrap()
349 .block_on(Masking(Arc::new(Echo)).call(
350 &tool,
351 &serde_json::json!({"text": "it is mcp-masked-Lr5 value"}),
352 None,
353 ))
354 .unwrap();
355 assert_eq!(v["text"], "it is [redacted:MCP_SECRET]");
356 }
357
358 #[test]
359 fn servers_parse_and_bad_names_are_refused() {
360 let s =
361 parse("[mcp.notes]\ncommand = [\"python3\", \"notes.py\"]\nallow = [\"list_notes\"]\n")
362 .unwrap_or_else(|e| panic!("{e}"));
363 assert_eq!(s[0].name, "notes");
364 assert_eq!(s[0].allow, ["list_notes"]);
365 assert!(parse("[mcp.fs]\ncommand = [\"x\"]\n").is_err());
366 assert!(parse("[mcp.Bad]\ncommand = [\"x\"]\n").is_err());
367 assert!(parse("[mcp.x]\ncommand = []\n").is_err());
368 assert!(parse("").unwrap_or_default().is_empty());
369 assert_eq!(
370 model_name("my-db", "query").as_deref(),
371 Some("mcp__my_db__query")
372 );
373 assert!(model_name("x", &"y".repeat(70)).is_none());
374 }
375}