use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::Path;
use serde::Deserialize;
use crate::types::{
ConfigWarning, McpConfig, McpRemoteServerConfig, McpServerConfig, McpStdioServerConfig,
};
pub(crate) const MCP_JSON_RELATIVE: &str = ".mcp.json";
pub(crate) fn merge_repo_mcp_json(
repo_root: Option<&Path>,
config: &mut McpConfig,
) -> Result<Vec<ConfigWarning>, String> {
let Some(root) = repo_root else {
return Ok(Vec::new());
};
let path = root.join(MCP_JSON_RELATIVE);
let raw = match fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(format!("failed to read {}: {err}", path.display())),
};
let servers = parse_mcp_json(&path, &raw)?;
Ok(merge_servers(&path, config, servers))
}
fn merge_servers(
path: &Path,
config: &mut McpConfig,
servers: BTreeMap<String, McpServerConfig>,
) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
for (name, server) in servers {
if config.servers.contains_key(&name) {
warnings.push(ConfigWarning::McpJsonOverridden {
path: path.to_path_buf(),
server: name,
});
continue;
}
config.servers.insert(name.clone(), server);
if !config.enabled_servers.contains(&name) {
config.enabled_servers.push(name);
}
}
warnings
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct McpJsonFile {
#[serde(rename = "mcpServers")]
mcp_servers: BTreeMap<String, McpJsonServer>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct McpJsonServer {
#[serde(rename = "type")]
kind: Option<String>,
command: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<String, String>,
url: Option<String>,
#[serde(default)]
headers: BTreeMap<String, String>,
}
pub(crate) fn parse_mcp_json(
path: &Path,
raw: &str,
) -> Result<BTreeMap<String, McpServerConfig>, String> {
let file: McpJsonFile = serde_json::from_str(raw)
.map_err(|err| format!("invalid .mcp.json at {}: {err}", path.display()))?;
file.mcp_servers
.into_iter()
.map(|(name, server)| resolve_server(path, &name, server).map(|cfg| (name, cfg)))
.collect()
}
fn resolve_server(
path: &Path,
name: &str,
server: McpJsonServer,
) -> Result<McpServerConfig, String> {
let McpJsonServer {
kind,
command,
args,
env,
url,
headers,
} = server;
let kind = kind.as_deref().map(str::to_ascii_lowercase);
match kind.as_deref() {
Some("stdio") => build_stdio(path, name, command, args, env, url, headers),
Some("http") => build_remote(path, name, false, command, url, headers),
Some("sse") => build_remote(path, name, true, command, url, headers),
Some(other) => Err(format!(
"mcpServers.{name}.type `{other}` is not one of stdio/http/sse at {}",
path.display()
)),
None => {
if command.is_some() {
build_stdio(path, name, command, args, env, url, headers)
} else if url.is_some() {
build_remote(path, name, false, command, url, headers)
} else {
Err(format!(
"mcpServers.{name} needs either `command` (stdio) or `url` (http/sse) at {}",
path.display()
))
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_stdio(
path: &Path,
name: &str,
command: Option<String>,
args: Vec<String>,
env: BTreeMap<String, String>,
url: Option<String>,
headers: BTreeMap<String, String>,
) -> Result<McpServerConfig, String> {
let Some(command) = command else {
return Err(format!(
"mcpServers.{name} stdio transport requires `command` at {}",
path.display()
));
};
if url.is_some() {
return Err(format!(
"mcpServers.{name} stdio transport must not set `url` at {}",
path.display()
));
}
if !headers.is_empty() {
return Err(format!(
"mcpServers.{name} stdio transport must not set `headers` at {}",
path.display()
));
}
Ok(McpServerConfig::Stdio(McpStdioServerConfig {
command,
args,
env,
}))
}
fn build_remote(
path: &Path,
name: &str,
sse: bool,
command: Option<String>,
url: Option<String>,
headers: BTreeMap<String, String>,
) -> Result<McpServerConfig, String> {
let transport = if sse { "sse" } else { "http" };
let Some(url) = url else {
return Err(format!(
"mcpServers.{name} {transport} transport requires `url` at {}",
path.display()
));
};
if command.is_some() {
return Err(format!(
"mcpServers.{name} {transport} transport must not set `command` at {}",
path.display()
));
}
let config = McpRemoteServerConfig { url, headers };
Ok(if sse {
McpServerConfig::Sse(config)
} else {
McpServerConfig::Http(config)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn p() -> &'static Path {
Path::new("/repo/.mcp.json")
}
#[test]
fn infers_stdio_from_command() {
let raw = r#"{ "mcpServers": { "fs": { "command": "npx", "args": ["-y", "x"], "env": { "R": "1" } } } }"#;
let out = parse_mcp_json(p(), raw).expect("parse");
match out.get("fs").expect("fs") {
McpServerConfig::Stdio(s) => {
assert_eq!(s.command, "npx");
assert_eq!(s.args, vec!["-y".to_string(), "x".to_string()]);
assert_eq!(s.env.get("R").map(String::as_str), Some("1"));
}
other => panic!("expected stdio, got {other:?}"),
}
}
#[test]
fn infers_http_from_url() {
let raw =
r#"{ "mcpServers": { "docs": { "url": "https://x/mcp", "headers": { "k": "v" } } } }"#;
let out = parse_mcp_json(p(), raw).expect("parse");
match out.get("docs").expect("docs") {
McpServerConfig::Http(r) => {
assert_eq!(r.url, "https://x/mcp");
assert_eq!(r.headers.get("k").map(String::as_str), Some("v"));
}
other => panic!("expected http, got {other:?}"),
}
}
#[test]
fn explicit_sse_type() {
let raw = r#"{ "mcpServers": { "s": { "type": "sse", "url": "https://x/sse" } } }"#;
let out = parse_mcp_json(p(), raw).expect("parse");
assert!(matches!(out.get("s"), Some(McpServerConfig::Sse(_))));
}
#[test]
fn stdio_with_url_is_rejected() {
let raw = r#"{ "mcpServers": { "bad": { "command": "x", "url": "https://y" } } }"#;
let err = parse_mcp_json(p(), raw).expect_err("should reject");
assert!(err.contains("must not set `url`"), "{err}");
}
#[test]
fn missing_command_and_url_is_rejected() {
let raw = r#"{ "mcpServers": { "bad": { "env": { "A": "B" } } } }"#;
let err = parse_mcp_json(p(), raw).expect_err("should reject");
assert!(err.contains("needs either `command`"), "{err}");
}
#[test]
fn unknown_field_is_rejected() {
let raw = r#"{ "mcpServers": { "x": { "command": "c", "bogus": 1 } } }"#;
let err = parse_mcp_json(p(), raw).expect_err("should reject");
assert!(err.contains("invalid .mcp.json"), "{err}");
}
}