Skip to main content

batuta/agent/
mcp_json.rs

1//! Project-root `.mcp.json` loader (PMAT-CODE-MCP-JSON-LOADER-001).
2//!
3//! Mirrors Claude Code's `<project_root>/.mcp.json` shape:
4//!
5//! ```json
6//! {
7//!   "mcpServers": {
8//!     "filesystem": {
9//!       "command": "npx",
10//!       "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
11//!       "env": {}
12//!     }
13//!   }
14//! }
15//! ```
16//!
17//! When `apr code` starts, this loader reads the file (if present) and
18//! merges its servers into [`AgentManifest::mcp_servers`] before MCP
19//! discovery fires. Manifest-declared servers always win on name
20//! collision (CLI/manifest beats project-root config — same pattern as
21//! the settings-ladder). Missing file is a non-error; malformed JSON
22//! is a hard error so the operator notices.
23//!
24//! Pure-function design: the parser + merge live here as pure
25//! functions; the only caller-visible side effect is mutation of an
26//! AgentManifest passed by `&mut`.
27
28#![cfg(feature = "agents-mcp")]
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33use serde::Deserialize;
34
35use crate::agent::manifest::{AgentManifest, McpServerConfig, McpTransport};
36
37/// Raw `.mcp.json` shape — Claude Code's wire format.
38#[derive(Debug, Deserialize, Default)]
39#[serde(deny_unknown_fields)]
40pub struct McpJson {
41    /// Map of server-name → config. `mcpServers` matches Claude
42    /// Code's spelling exactly so an existing file copies in unchanged.
43    #[serde(default, rename = "mcpServers")]
44    pub mcp_servers: BTreeMap<String, McpServerEntry>,
45}
46
47/// One entry in the `mcpServers` map. Either `command` (stdio
48/// transport) or `url` (sse / websocket) must be present — enforced
49/// at merge time, not parse time, so missing-both produces a clear
50/// error message.
51#[derive(Debug, Deserialize, Default)]
52#[serde(deny_unknown_fields)]
53pub struct McpServerEntry {
54    /// stdio: command to launch (e.g. `"npx"` or `"/usr/local/bin/server"`).
55    #[serde(default)]
56    pub command: Option<String>,
57
58    /// stdio: argv tail (without command).
59    #[serde(default)]
60    pub args: Vec<String>,
61
62    /// SSE / WebSocket URL.
63    #[serde(default)]
64    pub url: Option<String>,
65
66    /// Optional explicit transport. Defaults to `stdio` if `command`
67    /// present, `sse` if `url` present.
68    #[serde(default)]
69    pub transport: Option<String>,
70
71    /// Capability allowlist; `["*"]` = all. Empty = all (Claude
72    /// default).
73    #[serde(default)]
74    pub capabilities: Vec<String>,
75
76    /// Process env (stdio only). Threaded through to subprocess spawn
77    /// via `McpServerConfig.env` → `StdioMcpTransport.env` →
78    /// `tokio::process::Command::env`. Empty map = inherit parent env
79    /// unchanged. PMAT-CODE-MCP-ENV-001 (CLOSED 2026-05-07).
80    #[serde(default)]
81    pub env: BTreeMap<String, String>,
82}
83
84/// Project-root `.mcp.json` path: `<project>/.mcp.json`.
85pub fn project_mcp_json_path(project_root: &Path) -> PathBuf {
86    project_root.join(".mcp.json")
87}
88
89/// Parse JSON text. Empty/whitespace-only text returns `Default`
90/// (matches the missing-file convention so a `.mcp.json` containing
91/// just `{}` or whitespace is a no-op rather than an error).
92pub fn from_json_str(buf: &str) -> anyhow::Result<McpJson> {
93    let trimmed = buf.trim();
94    if trimmed.is_empty() {
95        return Ok(McpJson::default());
96    }
97    serde_json::from_str(trimmed).map_err(|e| anyhow::anyhow!("invalid .mcp.json: {e}"))
98}
99
100/// Read `.mcp.json` from `path`. Missing file returns `Default`
101/// (non-error); other I/O failures and malformed JSON are hard errors
102/// so the operator notices instead of silently running on partial
103/// config (Poka-Yoke).
104pub fn read_from_path(path: &Path) -> anyhow::Result<McpJson> {
105    if !path.exists() {
106        return Ok(McpJson::default());
107    }
108    let buf = std::fs::read_to_string(path)
109        .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
110    from_json_str(&buf).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
111}
112
113/// Convert a parsed `.mcp.json` into [`McpServerConfig`] entries
114/// (the AgentManifest shape). Returns one error per malformed entry
115/// rather than the first; callers see all problems at once.
116pub fn to_manifest_entries(parsed: &McpJson) -> Result<Vec<McpServerConfig>, Vec<String>> {
117    let mut out = Vec::with_capacity(parsed.mcp_servers.len());
118    let mut errs = Vec::new();
119    for (name, entry) in &parsed.mcp_servers {
120        match entry_to_config(name, entry) {
121            Ok(cfg) => out.push(cfg),
122            Err(e) => errs.push(e),
123        }
124    }
125    if errs.is_empty() {
126        Ok(out)
127    } else {
128        Err(errs)
129    }
130}
131
132fn entry_to_config(name: &str, entry: &McpServerEntry) -> Result<McpServerConfig, String> {
133    if name.is_empty() {
134        return Err("mcpServers entry has empty name".to_owned());
135    }
136    let transport = resolve_transport(name, entry)?;
137    let mut command = Vec::new();
138    if let Some(ref c) = entry.command {
139        command.push(c.clone());
140        command.extend(entry.args.iter().cloned());
141    }
142    Ok(McpServerConfig {
143        name: name.to_owned(),
144        transport,
145        command,
146        url: entry.url.clone(),
147        capabilities: entry.capabilities.clone(),
148        // PMAT-CODE-MCP-ENV-001: threaded through (was previously parsed
149        // but discarded). Empty map = inherit parent env unchanged.
150        env: entry.env.clone(),
151    })
152}
153
154fn resolve_transport(name: &str, entry: &McpServerEntry) -> Result<McpTransport, String> {
155    if let Some(ref t) = entry.transport {
156        return match t.as_str() {
157            "stdio" => Ok(McpTransport::Stdio),
158            "sse" => Ok(McpTransport::Sse),
159            "websocket" | "ws" => Ok(McpTransport::WebSocket),
160            other => Err(format!(
161                "mcpServers[\"{name}\"]: unknown transport \"{other}\" (expected stdio|sse|websocket)"
162            )),
163        };
164    }
165    // Heuristic: command → stdio, url → sse, neither → error.
166    match (entry.command.is_some(), entry.url.is_some()) {
167        (true, _) => Ok(McpTransport::Stdio),
168        (false, true) => Ok(McpTransport::Sse),
169        (false, false) => Err(format!(
170            "mcpServers[\"{name}\"]: must specify either `command` (stdio) or `url` (sse/ws)"
171        )),
172    }
173}
174
175/// Merge `.mcp.json`-derived servers into `manifest.mcp_servers`.
176/// Manifest-declared servers (already present by name) always win —
177/// the project-root `.mcp.json` is treated as a default that the
178/// operator can override via the TOML manifest.
179///
180/// Returns the count of servers added (so the caller can log a
181/// one-line summary). Errors per malformed entry are returned via
182/// the `Err` arm; the manifest is left unchanged in that case.
183pub fn merge_into_manifest(
184    manifest: &mut AgentManifest,
185    parsed: &McpJson,
186) -> Result<usize, Vec<String>> {
187    let entries = to_manifest_entries(parsed)?;
188    let existing: std::collections::HashSet<String> =
189        manifest.mcp_servers.iter().map(|s| s.name.clone()).collect();
190    let mut added = 0;
191    for cfg in entries {
192        if existing.contains(&cfg.name) {
193            continue; // manifest wins
194        }
195        manifest.mcp_servers.push(cfg);
196        added += 1;
197    }
198    Ok(added)
199}
200
201/// One-shot: load `.mcp.json` from the project root (if present),
202/// merge into `manifest`. Errors propagate up. Returns the count of
203/// servers added.
204pub fn load_and_merge(manifest: &mut AgentManifest, project_root: &Path) -> anyhow::Result<usize> {
205    let path = project_mcp_json_path(project_root);
206    let parsed = read_from_path(&path)?;
207    if parsed.mcp_servers.is_empty() {
208        return Ok(0);
209    }
210    merge_into_manifest(manifest, &parsed).map_err(|errs| anyhow::anyhow!(errs.join("; ")))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::fs;
217
218    fn write(path: &Path, body: &str) {
219        if let Some(p) = path.parent() {
220            fs::create_dir_all(p).expect("mkdir");
221        }
222        fs::write(path, body).expect("write");
223    }
224
225    // ── from_json_str ──────────────────────────────────────────────
226
227    #[test]
228    fn parse_empty_yields_default() {
229        let p = from_json_str("").expect("empty ok");
230        assert!(p.mcp_servers.is_empty());
231        let p = from_json_str("   \n\t   ").expect("whitespace ok");
232        assert!(p.mcp_servers.is_empty());
233    }
234
235    #[test]
236    fn parse_empty_object_yields_default() {
237        let p = from_json_str("{}").expect("empty obj ok");
238        assert!(p.mcp_servers.is_empty());
239    }
240
241    #[test]
242    fn parse_minimal_stdio_entry() {
243        let s = r#"{
244            "mcpServers": {
245                "filesystem": {
246                    "command": "npx",
247                    "args": ["-y", "@modelcontextprotocol/server-filesystem"]
248                }
249            }
250        }"#;
251        let p = from_json_str(s).expect("parse");
252        let fs_entry = p.mcp_servers.get("filesystem").expect("entry");
253        assert_eq!(fs_entry.command.as_deref(), Some("npx"));
254        assert_eq!(fs_entry.args, vec!["-y", "@modelcontextprotocol/server-filesystem"]);
255    }
256
257    #[test]
258    fn parse_sse_entry() {
259        let s = r#"{
260            "mcpServers": {
261                "remote": {"url": "https://example.com/sse"}
262            }
263        }"#;
264        let p = from_json_str(s).expect("parse");
265        assert_eq!(p.mcp_servers["remote"].url.as_deref(), Some("https://example.com/sse"));
266    }
267
268    #[test]
269    fn parse_unknown_top_level_field_rejected() {
270        let s = r#"{"badKey": 1}"#;
271        let err = from_json_str(s).expect_err("must reject");
272        assert!(format!("{err}").contains("invalid .mcp.json"));
273    }
274
275    #[test]
276    fn parse_unknown_entry_field_rejected() {
277        // Poka-Yoke: typo in entry shouldn't silently no-op.
278        let s = r#"{"mcpServers": {"x": {"comand": "npx"}}}"#;
279        let err = from_json_str(s).expect_err("must reject typo");
280        assert!(format!("{err}").contains("invalid .mcp.json"));
281    }
282
283    #[test]
284    fn parse_malformed_json_errs_loudly() {
285        let err = from_json_str("{not json").expect_err("must err");
286        assert!(format!("{err}").contains("invalid .mcp.json"));
287    }
288
289    // ── read_from_path ─────────────────────────────────────────────
290
291    #[test]
292    fn read_missing_path_returns_default() {
293        let p = std::env::temp_dir().join("does-not-exist-mcpjson.json");
294        let _ = std::fs::remove_file(&p);
295        let parsed = read_from_path(&p).expect("missing ok");
296        assert!(parsed.mcp_servers.is_empty());
297    }
298
299    #[test]
300    fn read_malformed_path_errs_loudly() {
301        let dir = tempfile::tempdir().expect("tempdir");
302        let p = dir.path().join(".mcp.json");
303        write(&p, "{not json");
304        let err = read_from_path(&p).expect_err("must err");
305        let msg = format!("{err}");
306        assert!(msg.contains(".mcp.json"));
307    }
308
309    // ── resolve_transport ──────────────────────────────────────────
310
311    #[test]
312    fn resolve_transport_stdio_when_command_set() {
313        let e = McpServerEntry { command: Some("npx".into()), ..Default::default() };
314        let t = resolve_transport("x", &e).unwrap();
315        assert!(matches!(t, McpTransport::Stdio));
316    }
317
318    #[test]
319    fn resolve_transport_sse_when_url_set() {
320        let e = McpServerEntry { url: Some("https://x".into()), ..Default::default() };
321        let t = resolve_transport("x", &e).unwrap();
322        assert!(matches!(t, McpTransport::Sse));
323    }
324
325    #[test]
326    fn resolve_transport_explicit_overrides_heuristic() {
327        let e = McpServerEntry {
328            command: Some("npx".into()),
329            transport: Some("websocket".into()),
330            ..Default::default()
331        };
332        let t = resolve_transport("x", &e).unwrap();
333        assert!(matches!(t, McpTransport::WebSocket));
334    }
335
336    #[test]
337    fn resolve_transport_unknown_explicit_errs() {
338        let e = McpServerEntry { transport: Some("magic".into()), ..Default::default() };
339        let err = resolve_transport("x", &e).unwrap_err();
340        assert!(err.contains("magic"));
341    }
342
343    #[test]
344    fn resolve_transport_neither_command_nor_url_errs() {
345        let e = McpServerEntry::default();
346        let err = resolve_transport("x", &e).unwrap_err();
347        assert!(err.contains("must specify"));
348    }
349
350    // ── to_manifest_entries ────────────────────────────────────────
351
352    #[test]
353    fn to_manifest_entries_collects_errors() {
354        let s = r#"{
355            "mcpServers": {
356                "good": {"command": "npx", "args": ["-y"]},
357                "bad":  {}
358            }
359        }"#;
360        let p = from_json_str(s).expect("parse");
361        let errs = to_manifest_entries(&p).expect_err("must err on bad entry");
362        assert_eq!(errs.len(), 1);
363        assert!(errs[0].contains("\"bad\""));
364    }
365
366    #[test]
367    fn to_manifest_entries_preserves_command_args() {
368        let s = r#"{
369            "mcpServers": {
370                "fs": {"command": "/usr/bin/server", "args": ["--root", "/tmp"]}
371            }
372        }"#;
373        let p = from_json_str(s).expect("parse");
374        let cfgs = to_manifest_entries(&p).expect("ok");
375        assert_eq!(cfgs.len(), 1);
376        assert_eq!(cfgs[0].command, vec!["/usr/bin/server", "--root", "/tmp"]);
377    }
378
379    // ── merge_into_manifest ────────────────────────────────────────
380
381    #[test]
382    fn merge_adds_new_entries() {
383        let mut m = AgentManifest::default();
384        let s = r#"{"mcpServers": {"fs": {"command": "npx"}}}"#;
385        let parsed = from_json_str(s).expect("parse");
386        let added = merge_into_manifest(&mut m, &parsed).expect("ok");
387        assert_eq!(added, 1);
388        assert_eq!(m.mcp_servers.len(), 1);
389        assert_eq!(m.mcp_servers[0].name, "fs");
390    }
391
392    // ── PMAT-CODE-MCP-ENV-001: env field threading ────────────────
393
394    #[test]
395    fn parse_entry_env_is_collected() {
396        // Env was previously parsed but discarded. Verify the parser
397        // still reads the JSON shape Claude Code emits.
398        let s = r#"{
399            "mcpServers": {
400                "fs": {
401                    "command": "npx",
402                    "env": {"FS_ROOT": "/tmp", "FS_DEBUG": "1"}
403                }
404            }
405        }"#;
406        let p = from_json_str(s).expect("parse");
407        let entry = &p.mcp_servers["fs"];
408        assert_eq!(entry.env.len(), 2);
409        assert_eq!(entry.env.get("FS_ROOT").map(String::as_str), Some("/tmp"));
410        assert_eq!(entry.env.get("FS_DEBUG").map(String::as_str), Some("1"));
411    }
412
413    #[test]
414    fn entry_to_config_threads_env() {
415        // The fix: `env` MUST flow from McpServerEntry → McpServerConfig
416        // (was previously dropped on the floor at this conversion step).
417        let s = r#"{
418            "mcpServers": {
419                "fs": {
420                    "command": "npx",
421                    "env": {"FOO": "bar", "BAZ": "qux"}
422                }
423            }
424        }"#;
425        let p = from_json_str(s).expect("parse");
426        let cfgs = to_manifest_entries(&p).expect("ok");
427        assert_eq!(cfgs.len(), 1);
428        assert_eq!(cfgs[0].env.len(), 2);
429        assert_eq!(cfgs[0].env.get("FOO").map(String::as_str), Some("bar"));
430        assert_eq!(cfgs[0].env.get("BAZ").map(String::as_str), Some("qux"));
431    }
432
433    #[test]
434    fn entry_to_config_preserves_empty_env() {
435        // No env declared → empty map (not None, not error). Subprocess
436        // inherits parent env unchanged.
437        let s = r#"{"mcpServers": {"fs": {"command": "npx"}}}"#;
438        let p = from_json_str(s).expect("parse");
439        let cfgs = to_manifest_entries(&p).expect("ok");
440        assert!(cfgs[0].env.is_empty());
441    }
442
443    #[test]
444    fn merge_threads_env_to_manifest_servers() {
445        // End-to-end: .mcp.json env reaches AgentManifest.mcp_servers[].env.
446        let mut m = AgentManifest::default();
447        let s = r#"{
448            "mcpServers": {
449                "fs": {
450                    "command": "npx",
451                    "env": {"NODE_ENV": "production"}
452                }
453            }
454        }"#;
455        let parsed = from_json_str(s).expect("parse");
456        merge_into_manifest(&mut m, &parsed).expect("ok");
457        assert_eq!(m.mcp_servers.len(), 1);
458        assert_eq!(m.mcp_servers[0].env.get("NODE_ENV").map(String::as_str), Some("production"));
459    }
460
461    #[test]
462    fn merge_manifest_wins_on_name_collision() {
463        // Manifest already has "fs" — `.mcp.json` "fs" must not overwrite.
464        let mut m = AgentManifest::default();
465        m.mcp_servers.push(McpServerConfig {
466            name: "fs".into(),
467            transport: McpTransport::Stdio,
468            command: vec!["manifest-cmd".into()],
469            url: None,
470            capabilities: vec![],
471            env: Default::default(),
472        });
473        let s = r#"{"mcpServers": {"fs": {"command": "json-cmd"}}}"#;
474        let parsed = from_json_str(s).expect("parse");
475        let added = merge_into_manifest(&mut m, &parsed).expect("ok");
476        assert_eq!(added, 0, "manifest must win over .mcp.json on name collision");
477        assert_eq!(m.mcp_servers.len(), 1);
478        assert_eq!(m.mcp_servers[0].command[0], "manifest-cmd");
479    }
480
481    #[test]
482    fn merge_with_no_entries_is_noop() {
483        let mut m = AgentManifest::default();
484        let parsed = McpJson::default();
485        let added = merge_into_manifest(&mut m, &parsed).expect("ok");
486        assert_eq!(added, 0);
487        assert!(m.mcp_servers.is_empty());
488    }
489
490    // ── load_and_merge ─────────────────────────────────────────────
491
492    #[test]
493    fn load_and_merge_missing_file_is_noop() {
494        let dir = tempfile::tempdir().expect("tempdir");
495        let mut m = AgentManifest::default();
496        let added = load_and_merge(&mut m, dir.path()).expect("ok");
497        assert_eq!(added, 0);
498        assert!(m.mcp_servers.is_empty());
499    }
500
501    #[test]
502    fn load_and_merge_real_file() {
503        let dir = tempfile::tempdir().expect("tempdir");
504        let mcp = dir.path().join(".mcp.json");
505        write(&mcp, r#"{"mcpServers": {"fs": {"command": "npx", "args": ["-y", "fs-server"]}}}"#);
506        let mut m = AgentManifest::default();
507        let added = load_and_merge(&mut m, dir.path()).expect("ok");
508        assert_eq!(added, 1);
509        assert_eq!(m.mcp_servers[0].name, "fs");
510        assert_eq!(m.mcp_servers[0].command, vec!["npx", "-y", "fs-server"]);
511    }
512
513    #[test]
514    fn load_and_merge_malformed_file_errors() {
515        let dir = tempfile::tempdir().expect("tempdir");
516        let mcp = dir.path().join(".mcp.json");
517        write(&mcp, "{not json");
518        let mut m = AgentManifest::default();
519        let err = load_and_merge(&mut m, dir.path()).expect_err("must err");
520        let msg = format!("{err}");
521        assert!(msg.contains(".mcp.json"));
522    }
523}