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