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 use crate::tools::policy::MCP_PREFIX as PREFIX;
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    /// Literal variables it is started with (an extension's `mcp.json`).
39    pub env: Vec<(String, String)>,
40    /// Where it starts, when not the worktree.
41    pub cwd: Option<std::path::PathBuf>,
42    /// A folder of its own it may write (an extension's `${PLUGIN_DATA}`).
43    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/// Whether `name` may name an MCP server: lowercase letters, digits and
57/// single `_` inside it (a tool reference's component), not fs, proc or git.
58#[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
70/// Reads `[mcp.<name>]` sections.
71pub 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/// A tool as offered to the model (journaled with each attempt).
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct McpTool {
109    /// `mcp__<server>__<tool>`.
110    pub name: String,
111    /// The server.
112    pub server: String,
113    /// The tool's own name on the server.
114    pub tool: String,
115    /// What the server says it does.
116    pub description: String,
117    /// Its input schema, in the strict-calling subset.
118    pub schema: Value,
119    /// `allow`, `approval` or `deny`.
120    pub decision: String,
121    /// The argument fields the model authors (`/<property>`), for the grant.
122    pub fields: Vec<String>,
123    /// agentplane's digest over the tool's name, description, schema and
124    /// annotations as discovered: what a person reviewed.
125    pub digest: String,
126}
127
128/// Running servers and their tools.
129#[derive(Debug, Default)]
130pub struct Connected {
131    /// The tools offered.
132    pub tools: Vec<McpTool>,
133    /// One tool client per server, by server name.
134    pub clients: Vec<(String, Arc<dyn ToolClient>)>,
135    /// What could not be used, and why.
136    pub notices: Vec<String>,
137}
138
139/// The model-facing name, if it is a valid tool name (≤ 64 of `[A-Za-z0-9_-]`).
140fn 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
149/// Starts every configured server in `cwd` and lists its tools; a server
150/// that fails is reported, not fatal.
151pub 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    // Servers run confined like every other command.
182    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    // agentplane runs the lifecycle (discover first, legacy fallback).
197    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    // Each tool's description, schema and a digest over all of it.
205    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        // The server's schema narrowed by agentplane: objects closed, nulls
222        // and bounds untouched, composed schemas left as written (closing
223        // those can admit more). One still not strict-ready is sent
224        // non-strict.
225        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
261/// Where the digests of reviewed tools are kept, in the project's state directory.
262pub 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
278/// Holds allowed tools to what a person reviewed: the first time an
279/// allowed tool is seen its digest is recorded; when a server later changes
280/// it — description, schema or annotations — the tool needs approval again
281/// and a notice says so. `approved` are the tools a person approved for
282/// this task since (`speccoder task approve`), which re-reviews them.
283pub 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
308/// Records that a person approved a call of `tool` (re-reviewed as it is now).
309pub 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/// A server's client whose results are masked ([`crate::secrets`]): a
319/// value copied from the person's secret files never reaches the model, nor
320/// the journal, through an MCP tool either.
321#[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}