use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::config::{BackendConfig, TransportType};
#[derive(Debug, Deserialize)]
pub struct McpJsonConfig {
#[serde(rename = "mcpServers")]
pub mcp_servers: HashMap<String, McpJsonServer>,
}
#[derive(Debug, Deserialize)]
pub struct McpJsonServer {
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub url: Option<String>,
}
impl McpJsonConfig {
pub fn load(path: &Path) -> Result<Self> {
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Self::parse(&content)
}
pub fn parse(json: &str) -> Result<Self> {
serde_json::from_str(json).context("parsing .mcp.json")
}
pub fn into_backends(self) -> Result<Vec<BackendConfig>> {
let mut backends = Vec::new();
for (name, server) in self.mcp_servers {
let backend = server_to_backend(name, server)?;
backends.push(backend);
}
backends.sort_by(|a, b| a.name.cmp(&b.name));
Ok(backends)
}
}
fn server_to_backend(name: String, server: McpJsonServer) -> Result<BackendConfig> {
let (transport, command, url) = if let Some(command) = server.command {
(TransportType::Stdio, Some(command), None)
} else if let Some(url) = server.url {
(TransportType::Http, None, Some(url))
} else {
anyhow::bail!(
"server '{}': must have either 'command' (stdio) or 'url' (http)",
name
);
};
Ok(BackendConfig {
name,
transport,
command,
args: server.args,
url,
env: server.env,
bearer_token: None,
forward_auth: false,
timeout: None,
circuit_breaker: None,
rate_limit: None,
concurrency: None,
retry: None,
outlier_detection: None,
hedging: None,
cache: None,
default_args: serde_json::Map::new(),
inject_args: Vec::new(),
param_overrides: Vec::new(),
expose_tools: Vec::new(),
hide_tools: Vec::new(),
expose_resources: Vec::new(),
hide_resources: Vec::new(),
expose_prompts: Vec::new(),
hide_prompts: Vec::new(),
hide_destructive: false,
read_only_only: false,
failover_for: None,
priority: 0,
canary_of: None,
weight: 100,
aliases: Vec::new(),
mirror_of: None,
mirror_percent: 100,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_stdio_server() {
let json = r#"{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_TOKEN": "secret" }
}
}
}"#;
let config = McpJsonConfig::parse(json).unwrap();
let backends = config.into_backends().unwrap();
assert_eq!(backends.len(), 1);
assert_eq!(backends[0].name, "github");
assert!(matches!(backends[0].transport, TransportType::Stdio));
assert_eq!(backends[0].command.as_deref(), Some("npx"));
assert_eq!(
backends[0].args,
vec!["-y", "@modelcontextprotocol/server-github"]
);
assert_eq!(backends[0].env.get("GITHUB_TOKEN").unwrap(), "secret");
}
#[test]
fn test_parse_http_server() {
let json = r#"{
"mcpServers": {
"api": {
"url": "http://localhost:8080"
}
}
}"#;
let config = McpJsonConfig::parse(json).unwrap();
let backends = config.into_backends().unwrap();
assert_eq!(backends.len(), 1);
assert_eq!(backends[0].name, "api");
assert!(matches!(backends[0].transport, TransportType::Http));
assert_eq!(backends[0].url.as_deref(), Some("http://localhost:8080"));
}
#[test]
fn test_parse_multiple_servers() {
let json = r#"{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"]
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home"]
},
"api": {
"url": "http://localhost:8080"
}
}
}"#;
let config = McpJsonConfig::parse(json).unwrap();
let backends = config.into_backends().unwrap();
assert_eq!(backends.len(), 3);
assert_eq!(backends[0].name, "api");
assert_eq!(backends[1].name, "filesystem");
assert_eq!(backends[2].name, "github");
}
#[test]
fn test_rejects_server_without_command_or_url() {
let json = r#"{
"mcpServers": {
"bad": {
"args": ["--help"]
}
}
}"#;
let config = McpJsonConfig::parse(json).unwrap();
let err = config.into_backends().unwrap_err();
assert!(err.to_string().contains("command"));
}
#[test]
fn test_empty_servers() {
let json = r#"{ "mcpServers": {} }"#;
let config = McpJsonConfig::parse(json).unwrap();
let backends = config.into_backends().unwrap();
assert!(backends.is_empty());
}
#[test]
fn test_default_env_and_args() {
let json = r#"{
"mcpServers": {
"simple": {
"command": "echo"
}
}
}"#;
let config = McpJsonConfig::parse(json).unwrap();
let backends = config.into_backends().unwrap();
assert!(backends[0].args.is_empty());
assert!(backends[0].env.is_empty());
}
}