1#![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#[derive(Debug, Deserialize, Default)]
39#[serde(deny_unknown_fields)]
40pub struct McpJson {
41 #[serde(default, rename = "mcpServers")]
44 pub mcp_servers: BTreeMap<String, McpServerEntry>,
45}
46
47#[derive(Debug, Deserialize, Default)]
52#[serde(deny_unknown_fields)]
53pub struct McpServerEntry {
54 #[serde(default)]
56 pub command: Option<String>,
57
58 #[serde(default)]
60 pub args: Vec<String>,
61
62 #[serde(default)]
64 pub url: Option<String>,
65
66 #[serde(default)]
69 pub transport: Option<String>,
70
71 #[serde(default)]
74 pub capabilities: Vec<String>,
75
76 #[serde(default)]
81 pub env: BTreeMap<String, String>,
82}
83
84pub fn project_mcp_json_path(project_root: &Path) -> PathBuf {
86 project_root.join(".mcp.json")
87}
88
89pub 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
100pub 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
113pub 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 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 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
175pub 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; }
195 manifest.mcp_servers.push(cfg);
196 added += 1;
197 }
198 Ok(added)
199}
200
201pub 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 #[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 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 #[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 #[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 #[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 #[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 #[test]
395 fn parse_entry_env_is_collected() {
396 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 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 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 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 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 #[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}