#![cfg(feature = "agents-mcp")]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::agent::manifest::{AgentManifest, McpServerConfig, McpTransport};
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct McpJson {
#[serde(default, rename = "mcpServers")]
pub mcp_servers: BTreeMap<String, McpServerEntry>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct McpServerEntry {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub transport: Option<String>,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
pub fn project_mcp_json_path(project_root: &Path) -> PathBuf {
project_root.join(".mcp.json")
}
pub fn from_json_str(buf: &str) -> anyhow::Result<McpJson> {
let trimmed = buf.trim();
if trimmed.is_empty() {
return Ok(McpJson::default());
}
serde_json::from_str(trimmed).map_err(|e| anyhow::anyhow!("invalid .mcp.json: {e}"))
}
pub fn read_from_path(path: &Path) -> anyhow::Result<McpJson> {
if !path.exists() {
return Ok(McpJson::default());
}
let buf = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?;
from_json_str(&buf).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
}
pub fn to_manifest_entries(parsed: &McpJson) -> Result<Vec<McpServerConfig>, Vec<String>> {
let mut out = Vec::with_capacity(parsed.mcp_servers.len());
let mut errs = Vec::new();
for (name, entry) in &parsed.mcp_servers {
match entry_to_config(name, entry) {
Ok(cfg) => out.push(cfg),
Err(e) => errs.push(e),
}
}
if errs.is_empty() {
Ok(out)
} else {
Err(errs)
}
}
fn entry_to_config(name: &str, entry: &McpServerEntry) -> Result<McpServerConfig, String> {
if name.is_empty() {
return Err("mcpServers entry has empty name".to_owned());
}
let transport = resolve_transport(name, entry)?;
let mut command = Vec::new();
if let Some(ref c) = entry.command {
command.push(c.clone());
command.extend(entry.args.iter().cloned());
}
Ok(McpServerConfig {
name: name.to_owned(),
transport,
command,
url: entry.url.clone(),
capabilities: entry.capabilities.clone(),
env: entry.env.clone(),
})
}
fn resolve_transport(name: &str, entry: &McpServerEntry) -> Result<McpTransport, String> {
if let Some(ref t) = entry.transport {
return match t.as_str() {
"stdio" => Ok(McpTransport::Stdio),
"sse" => Ok(McpTransport::Sse),
"websocket" | "ws" => Ok(McpTransport::WebSocket),
other => Err(format!(
"mcpServers[\"{name}\"]: unknown transport \"{other}\" (expected stdio|sse|websocket)"
)),
};
}
match (entry.command.is_some(), entry.url.is_some()) {
(true, _) => Ok(McpTransport::Stdio),
(false, true) => Ok(McpTransport::Sse),
(false, false) => Err(format!(
"mcpServers[\"{name}\"]: must specify either `command` (stdio) or `url` (sse/ws)"
)),
}
}
pub fn merge_into_manifest(
manifest: &mut AgentManifest,
parsed: &McpJson,
) -> Result<usize, Vec<String>> {
let entries = to_manifest_entries(parsed)?;
let existing: std::collections::HashSet<String> =
manifest.mcp_servers.iter().map(|s| s.name.clone()).collect();
let mut added = 0;
for cfg in entries {
if existing.contains(&cfg.name) {
continue; }
manifest.mcp_servers.push(cfg);
added += 1;
}
Ok(added)
}
pub fn load_and_merge(manifest: &mut AgentManifest, project_root: &Path) -> anyhow::Result<usize> {
let path = project_mcp_json_path(project_root);
let parsed = read_from_path(&path)?;
if parsed.mcp_servers.is_empty() {
return Ok(0);
}
merge_into_manifest(manifest, &parsed).map_err(|errs| anyhow::anyhow!(errs.join("; ")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, body: &str) {
if let Some(p) = path.parent() {
fs::create_dir_all(p).expect("mkdir");
}
fs::write(path, body).expect("write");
}
#[test]
fn parse_empty_yields_default() {
let p = from_json_str("").expect("empty ok");
assert!(p.mcp_servers.is_empty());
let p = from_json_str(" \n\t ").expect("whitespace ok");
assert!(p.mcp_servers.is_empty());
}
#[test]
fn parse_empty_object_yields_default() {
let p = from_json_str("{}").expect("empty obj ok");
assert!(p.mcp_servers.is_empty());
}
#[test]
fn parse_minimal_stdio_entry() {
let s = r#"{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"]
}
}
}"#;
let p = from_json_str(s).expect("parse");
let fs_entry = p.mcp_servers.get("filesystem").expect("entry");
assert_eq!(fs_entry.command.as_deref(), Some("npx"));
assert_eq!(fs_entry.args, vec!["-y", "@modelcontextprotocol/server-filesystem"]);
}
#[test]
fn parse_sse_entry() {
let s = r#"{
"mcpServers": {
"remote": {"url": "https://example.com/sse"}
}
}"#;
let p = from_json_str(s).expect("parse");
assert_eq!(p.mcp_servers["remote"].url.as_deref(), Some("https://example.com/sse"));
}
#[test]
fn parse_unknown_top_level_field_rejected() {
let s = r#"{"badKey": 1}"#;
let err = from_json_str(s).expect_err("must reject");
assert!(format!("{err}").contains("invalid .mcp.json"));
}
#[test]
fn parse_unknown_entry_field_rejected() {
let s = r#"{"mcpServers": {"x": {"comand": "npx"}}}"#;
let err = from_json_str(s).expect_err("must reject typo");
assert!(format!("{err}").contains("invalid .mcp.json"));
}
#[test]
fn parse_malformed_json_errs_loudly() {
let err = from_json_str("{not json").expect_err("must err");
assert!(format!("{err}").contains("invalid .mcp.json"));
}
#[test]
fn read_missing_path_returns_default() {
let p = std::env::temp_dir().join("does-not-exist-mcpjson.json");
let _ = std::fs::remove_file(&p);
let parsed = read_from_path(&p).expect("missing ok");
assert!(parsed.mcp_servers.is_empty());
}
#[test]
fn read_malformed_path_errs_loudly() {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path().join(".mcp.json");
write(&p, "{not json");
let err = read_from_path(&p).expect_err("must err");
let msg = format!("{err}");
assert!(msg.contains(".mcp.json"));
}
#[test]
fn resolve_transport_stdio_when_command_set() {
let e = McpServerEntry { command: Some("npx".into()), ..Default::default() };
let t = resolve_transport("x", &e).unwrap();
assert!(matches!(t, McpTransport::Stdio));
}
#[test]
fn resolve_transport_sse_when_url_set() {
let e = McpServerEntry { url: Some("https://x".into()), ..Default::default() };
let t = resolve_transport("x", &e).unwrap();
assert!(matches!(t, McpTransport::Sse));
}
#[test]
fn resolve_transport_explicit_overrides_heuristic() {
let e = McpServerEntry {
command: Some("npx".into()),
transport: Some("websocket".into()),
..Default::default()
};
let t = resolve_transport("x", &e).unwrap();
assert!(matches!(t, McpTransport::WebSocket));
}
#[test]
fn resolve_transport_unknown_explicit_errs() {
let e = McpServerEntry { transport: Some("magic".into()), ..Default::default() };
let err = resolve_transport("x", &e).unwrap_err();
assert!(err.contains("magic"));
}
#[test]
fn resolve_transport_neither_command_nor_url_errs() {
let e = McpServerEntry::default();
let err = resolve_transport("x", &e).unwrap_err();
assert!(err.contains("must specify"));
}
#[test]
fn to_manifest_entries_collects_errors() {
let s = r#"{
"mcpServers": {
"good": {"command": "npx", "args": ["-y"]},
"bad": {}
}
}"#;
let p = from_json_str(s).expect("parse");
let errs = to_manifest_entries(&p).expect_err("must err on bad entry");
assert_eq!(errs.len(), 1);
assert!(errs[0].contains("\"bad\""));
}
#[test]
fn to_manifest_entries_preserves_command_args() {
let s = r#"{
"mcpServers": {
"fs": {"command": "/usr/bin/server", "args": ["--root", "/tmp"]}
}
}"#;
let p = from_json_str(s).expect("parse");
let cfgs = to_manifest_entries(&p).expect("ok");
assert_eq!(cfgs.len(), 1);
assert_eq!(cfgs[0].command, vec!["/usr/bin/server", "--root", "/tmp"]);
}
#[test]
fn merge_adds_new_entries() {
let mut m = AgentManifest::default();
let s = r#"{"mcpServers": {"fs": {"command": "npx"}}}"#;
let parsed = from_json_str(s).expect("parse");
let added = merge_into_manifest(&mut m, &parsed).expect("ok");
assert_eq!(added, 1);
assert_eq!(m.mcp_servers.len(), 1);
assert_eq!(m.mcp_servers[0].name, "fs");
}
#[test]
fn parse_entry_env_is_collected() {
let s = r#"{
"mcpServers": {
"fs": {
"command": "npx",
"env": {"FS_ROOT": "/tmp", "FS_DEBUG": "1"}
}
}
}"#;
let p = from_json_str(s).expect("parse");
let entry = &p.mcp_servers["fs"];
assert_eq!(entry.env.len(), 2);
assert_eq!(entry.env.get("FS_ROOT").map(String::as_str), Some("/tmp"));
assert_eq!(entry.env.get("FS_DEBUG").map(String::as_str), Some("1"));
}
#[test]
fn entry_to_config_threads_env() {
let s = r#"{
"mcpServers": {
"fs": {
"command": "npx",
"env": {"FOO": "bar", "BAZ": "qux"}
}
}
}"#;
let p = from_json_str(s).expect("parse");
let cfgs = to_manifest_entries(&p).expect("ok");
assert_eq!(cfgs.len(), 1);
assert_eq!(cfgs[0].env.len(), 2);
assert_eq!(cfgs[0].env.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(cfgs[0].env.get("BAZ").map(String::as_str), Some("qux"));
}
#[test]
fn entry_to_config_preserves_empty_env() {
let s = r#"{"mcpServers": {"fs": {"command": "npx"}}}"#;
let p = from_json_str(s).expect("parse");
let cfgs = to_manifest_entries(&p).expect("ok");
assert!(cfgs[0].env.is_empty());
}
#[test]
fn merge_threads_env_to_manifest_servers() {
let mut m = AgentManifest::default();
let s = r#"{
"mcpServers": {
"fs": {
"command": "npx",
"env": {"NODE_ENV": "production"}
}
}
}"#;
let parsed = from_json_str(s).expect("parse");
merge_into_manifest(&mut m, &parsed).expect("ok");
assert_eq!(m.mcp_servers.len(), 1);
assert_eq!(m.mcp_servers[0].env.get("NODE_ENV").map(String::as_str), Some("production"));
}
#[test]
fn merge_manifest_wins_on_name_collision() {
let mut m = AgentManifest::default();
m.mcp_servers.push(McpServerConfig {
name: "fs".into(),
transport: McpTransport::Stdio,
command: vec!["manifest-cmd".into()],
url: None,
capabilities: vec![],
env: Default::default(),
});
let s = r#"{"mcpServers": {"fs": {"command": "json-cmd"}}}"#;
let parsed = from_json_str(s).expect("parse");
let added = merge_into_manifest(&mut m, &parsed).expect("ok");
assert_eq!(added, 0, "manifest must win over .mcp.json on name collision");
assert_eq!(m.mcp_servers.len(), 1);
assert_eq!(m.mcp_servers[0].command[0], "manifest-cmd");
}
#[test]
fn merge_with_no_entries_is_noop() {
let mut m = AgentManifest::default();
let parsed = McpJson::default();
let added = merge_into_manifest(&mut m, &parsed).expect("ok");
assert_eq!(added, 0);
assert!(m.mcp_servers.is_empty());
}
#[test]
fn load_and_merge_missing_file_is_noop() {
let dir = tempfile::tempdir().expect("tempdir");
let mut m = AgentManifest::default();
let added = load_and_merge(&mut m, dir.path()).expect("ok");
assert_eq!(added, 0);
assert!(m.mcp_servers.is_empty());
}
#[test]
fn load_and_merge_real_file() {
let dir = tempfile::tempdir().expect("tempdir");
let mcp = dir.path().join(".mcp.json");
write(&mcp, r#"{"mcpServers": {"fs": {"command": "npx", "args": ["-y", "fs-server"]}}}"#);
let mut m = AgentManifest::default();
let added = load_and_merge(&mut m, dir.path()).expect("ok");
assert_eq!(added, 1);
assert_eq!(m.mcp_servers[0].name, "fs");
assert_eq!(m.mcp_servers[0].command, vec!["npx", "-y", "fs-server"]);
}
#[test]
fn load_and_merge_malformed_file_errors() {
let dir = tempfile::tempdir().expect("tempdir");
let mcp = dir.path().join(".mcp.json");
write(&mcp, "{not json");
let mut m = AgentManifest::default();
let err = load_and_merge(&mut m, dir.path()).expect_err("must err");
let msg = format!("{err}");
assert!(msg.contains(".mcp.json"));
}
}