use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use agentplane::tools::rmcp;
use agentplane::tools::{Destination, McpClient, ToolClient};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use crate::tools::policy::MCP_PREFIX as PREFIX;
const STARTUP: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerConfig {
pub name: String,
pub command: Vec<String>,
pub allow: Vec<String>,
pub deny: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawServer {
command: Vec<String>,
#[serde(default)]
allow: Vec<String>,
#[serde(default)]
deny: Vec<String>,
}
pub fn parse(text: &str) -> Result<Vec<ServerConfig>, String> {
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let Some(mcp) = table.get("mcp") else {
return Ok(Vec::new());
};
let mcp = mcp
.as_table()
.ok_or_else(|| "`mcp` must be a table of [mcp.<name>] tables".to_owned())?;
mcp.iter()
.map(|(name, v)| {
let valid = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !matches!(name.as_str(), "fs" | "proc" | "git");
if !valid {
return Err(format!(
"[mcp.{name}]: a server name is lowercase letters, digits and `-`, and not fs, proc or git"
));
}
let raw: RawServer = v
.clone()
.try_into()
.map_err(|e| format!("[mcp.{name}]: {e}"))?;
if raw.command.first().is_none_or(String::is_empty) {
return Err(format!("[mcp.{name}]: `command` must name a program"));
}
Ok(ServerConfig {
name: name.clone(),
command: raw.command,
allow: raw.allow,
deny: raw.deny,
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpTool {
pub name: String,
pub server: String,
pub tool: String,
pub description: String,
pub schema: Value,
pub decision: String,
pub fields: Vec<String>,
pub digest: String,
}
#[derive(Debug, Default)]
pub struct Connected {
pub tools: Vec<McpTool>,
pub clients: Vec<(String, Arc<dyn ToolClient>)>,
pub notices: Vec<String>,
}
fn model_name(server: &str, tool: &str) -> Option<String> {
let name = format!("{PREFIX}{}__{tool}", server.replace('-', "_"));
(name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
.then_some(name)
}
pub async fn connect(
servers: &[ServerConfig],
cwd: &Path,
runner: &crate::sandbox::Runner,
) -> Connected {
let mut out = Connected::default();
for s in servers {
match connect_one(s, cwd, runner).await {
Ok((client, tools, notices)) => {
out.clients.push((s.name.clone(), client));
out.tools.extend(tools);
out.notices.extend(notices);
}
Err(e) => out
.notices
.push(format!("MCP server `{}` is not used: {e}", s.name)),
}
}
out
}
async fn connect_one(
s: &ServerConfig,
cwd: &Path,
runner: &crate::sandbox::Runner,
) -> Result<(Arc<dyn ToolClient>, Vec<McpTool>, Vec<String>), String> {
let (argv, tmp) = runner.wrap(&s.command)?;
let mut command = tokio::process::Command::new(&argv[0]);
command
.args(&argv[1..])
.current_dir(cwd)
.env_clear()
.envs(crate::verify::process::scrubbed_env(std::env::vars()))
.kill_on_drop(true);
if let Some(t) = tmp {
command.env("TMPDIR", &t).env("TMP", &t).env("TEMP", &t);
}
let transport = rmcp::transport::TokioChildProcess::new(command)
.map_err(|e| format!("cannot start `{}`: {e}", s.command.join(" ")))?;
let client = tokio::time::timeout(
STARTUP,
McpClient::connect(s.name.clone(), transport, Destination::Local),
)
.await
.map_err(|_| "it did not initialise within 30 s".to_owned())?
.map_err(|e| format!("it did not initialise: {e}"))?;
let listed = tokio::time::timeout(STARTUP, client.discover())
.await
.map_err(|_| "it did not list its tools within 30 s".to_owned())?
.map_err(|e| format!("tools/list failed: {e}"))?;
let mut tools = Vec::new();
let mut notices = Vec::new();
for t in listed {
let tool = t.id.tool.clone();
let Some(name) = model_name(&s.name, &tool) else {
notices.push(format!(
"MCP tool `{}/{tool}` skipped: its name cannot be a tool name",
s.name
));
continue;
};
let schema = agentplane::tools::closed_schema(&t.input_schema);
if let Some(problem) = agentplane::model::strict_schema_problem(&schema) {
notices.push(format!(
"MCP tool `{}/{tool}` is sent without strict decoding: {problem}",
s.name
));
}
let decision = if s.deny.contains(&tool) {
"deny"
} else if s.allow.contains(&tool) {
"allow"
} else {
"approval"
};
let fields = schema["properties"]
.as_object()
.map(|p| p.keys().map(|k| format!("/{k}")).collect())
.unwrap_or_default();
tools.push(McpTool {
name,
server: s.name.clone(),
tool,
description: if t.description.is_empty() {
"(no description)".to_owned()
} else {
t.description.clone()
},
schema,
decision: decision.to_owned(),
fields,
digest: t.digest.to_hex(),
});
}
Ok((Arc::new(client), tools, notices))
}
pub const REVIEWED: &str = "mcp-reviewed.json";
fn load_reviewed(state: &Path) -> std::collections::BTreeMap<String, String> {
std::fs::read_to_string(state.join(REVIEWED))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn save_reviewed(state: &Path, map: &std::collections::BTreeMap<String, String>) {
let _ = std::fs::create_dir_all(state);
if let Ok(text) = serde_json::to_string_pretty(map) {
let _ = std::fs::write(state.join(REVIEWED), text);
}
}
pub fn pin_reviewed(state: &Path, tools: &mut [McpTool], approved: &[String]) -> Vec<String> {
let mut map = load_reviewed(state);
let mut notices = Vec::new();
for t in tools.iter_mut().filter(|t| t.decision == "allow") {
let key = format!("{}/{}", t.server, t.tool);
match map.get(&key) {
None => {
map.insert(key, t.digest.clone());
}
Some(seen) if *seen == t.digest => {}
Some(_) if approved.contains(&t.name) => {
map.insert(key, t.digest.clone());
}
Some(_) => {
t.decision = "approval".to_owned();
notices.push(format!(
"MCP tool `{key}` changed since it was allowed (description, schema or annotations); each call needs approval until a person approves one"
));
}
}
}
save_reviewed(state, &map);
notices
}
pub fn mark_reviewed(state: &Path, tools: &[McpTool], name: &str) {
let Some(t) = tools.iter().find(|t| t.name == name) else {
return;
};
let mut map = load_reviewed(state);
map.insert(format!("{}/{}", t.server, t.tool), t.digest.clone());
save_reviewed(state, &map);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn servers_parse_and_bad_names_are_refused() {
let s =
parse("[mcp.notes]\ncommand = [\"python3\", \"notes.py\"]\nallow = [\"list_notes\"]\n")
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(s[0].name, "notes");
assert_eq!(s[0].allow, ["list_notes"]);
assert!(parse("[mcp.fs]\ncommand = [\"x\"]\n").is_err());
assert!(parse("[mcp.Bad]\ncommand = [\"x\"]\n").is_err());
assert!(parse("[mcp.x]\ncommand = []\n").is_err());
assert!(parse("").unwrap_or_default().is_empty());
assert_eq!(
model_name("my-db", "query").as_deref(),
Some("mcp__my_db__query")
);
assert!(model_name("x", &"y".repeat(70)).is_none());
}
}