Skip to main content

speccoder_runtime/
mcp.rs

1//! MCP servers' tools, under speccoder's policy.
2//!
3//! `[mcp.<name>]` in `speccoder.toml` names a stdio server (`command`) and,
4//! optionally, tools to `allow` without approval or to `deny`. For each run
5//! speccoder starts the servers in the worktree with a scrubbed environment,
6//! lists their tools and offers them to the model as `mcp__<server>__<tool>`
7//! with strict schemas. speccoder cannot know what an MCP tool does, so a
8//! call needs approval unless allowed — and whatever it writes in the
9//! worktree is still seen by the diff, the integrity audit and the verifier.
10//! The discovered tools ride in the attempt's journaled input.
11
12use 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
21/// The prefix of an MCP tool's name as the model sees it.
22pub const PREFIX: &str = "mcp__";
23
24/// How long a server may take to start and list its tools.
25const STARTUP: Duration = Duration::from_secs(30);
26
27/// One configured server.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ServerConfig {
30    /// `[mcp.<name>]`; the tool server name in agentplane.
31    pub name: String,
32    /// Program and arguments, run without a shell in the worktree.
33    pub command: Vec<String>,
34    /// Tools called without approval.
35    pub allow: Vec<String>,
36    /// Tools never called.
37    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
50/// Reads `[mcp.<name>]` sections.
51pub 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/// A tool as offered to the model (journaled with each attempt).
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct McpTool {
91    /// `mcp__<server>__<tool>`.
92    pub name: String,
93    /// The server.
94    pub server: String,
95    /// The tool's own name on the server.
96    pub tool: String,
97    /// What the server says it does.
98    pub description: String,
99    /// Its input schema, in the strict-calling subset.
100    pub schema: Value,
101    /// `allow`, `approval` or `deny`.
102    pub decision: String,
103    /// The argument fields the model authors (`/<property>`), for the grant.
104    pub fields: Vec<String>,
105    /// agentplane's digest over the tool's name, description, schema and
106    /// annotations as discovered: what a person reviewed.
107    pub digest: String,
108}
109
110/// Running servers and their tools.
111#[derive(Debug, Default)]
112pub struct Connected {
113    /// The tools offered.
114    pub tools: Vec<McpTool>,
115    /// One tool client per server, by server name.
116    pub clients: Vec<(String, Arc<dyn ToolClient>)>,
117    /// What could not be used, and why.
118    pub notices: Vec<String>,
119}
120
121/// The model-facing name, if it is a valid tool name (≤ 64 of `[A-Za-z0-9_-]`).
122fn 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
131/// Starts every configured server in `cwd` and lists its tools; a server
132/// that fails is reported, not fatal.
133pub 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    // Servers run confined like every other command.
160    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    // agentplane runs the lifecycle (discover first, legacy fallback).
174    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    // Each tool's description, schema and a digest over all of it.
182    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        // The server's schema narrowed by agentplane: objects closed, nulls
199        // and bounds untouched, composed schemas left as written (closing
200        // those can admit more). One still not strict-ready is sent
201        // non-strict.
202        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(client), tools, notices))
236}
237
238/// Where the digests of reviewed tools are kept, in the project's state directory.
239pub 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
255/// Holds allowed tools to what a person reviewed: the first time an
256/// allowed tool is seen its digest is recorded; when a server later changes
257/// it — description, schema or annotations — the tool needs approval again
258/// and a notice says so. `approved` are the tools a person approved for
259/// this task since (`speccoder task approve`), which re-reviews them.
260pub 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
285/// Records that a person approved a call of `tool` (re-reviewed as it is now).
286pub 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#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn servers_parse_and_bad_names_are_refused() {
301        let s =
302            parse("[mcp.notes]\ncommand = [\"python3\", \"notes.py\"]\nallow = [\"list_notes\"]\n")
303                .unwrap_or_else(|e| panic!("{e}"));
304        assert_eq!(s[0].name, "notes");
305        assert_eq!(s[0].allow, ["list_notes"]);
306        assert!(parse("[mcp.fs]\ncommand = [\"x\"]\n").is_err());
307        assert!(parse("[mcp.Bad]\ncommand = [\"x\"]\n").is_err());
308        assert!(parse("[mcp.x]\ncommand = []\n").is_err());
309        assert!(parse("").unwrap_or_default().is_empty());
310        assert_eq!(
311            model_name("my-db", "query").as_deref(),
312            Some("mcp__my_db__query")
313        );
314        assert!(model_name("x", &"y".repeat(70)).is_none());
315    }
316}