1use std::collections::HashMap;
26use std::path::Path;
27
28use anyhow::{Context, Result};
29use serde::Deserialize;
30
31use crate::config::{BackendConfig, TransportType};
32
33#[derive(Debug, Deserialize)]
35pub struct McpJsonConfig {
36 #[serde(rename = "mcpServers")]
38 pub mcp_servers: HashMap<String, McpJsonServer>,
39}
40
41#[derive(Debug, Deserialize)]
43pub struct McpJsonServer {
44 pub command: Option<String>,
46 #[serde(default)]
48 pub args: Vec<String>,
49 #[serde(default)]
51 pub env: HashMap<String, String>,
52 pub url: Option<String>,
54}
55
56impl McpJsonConfig {
57 pub fn load(path: &Path) -> Result<Self> {
59 let content =
60 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
61 Self::parse(&content)
62 }
63
64 pub fn parse(json: &str) -> Result<Self> {
66 serde_json::from_str(json).context("parsing .mcp.json")
67 }
68
69 pub fn into_backends(self) -> Result<Vec<BackendConfig>> {
74 let mut backends = Vec::new();
75
76 for (name, server) in self.mcp_servers {
77 let backend = server_to_backend(name, server)?;
78 backends.push(backend);
79 }
80
81 backends.sort_by(|a, b| a.name.cmp(&b.name));
83 Ok(backends)
84 }
85}
86
87fn server_to_backend(name: String, server: McpJsonServer) -> Result<BackendConfig> {
89 let (transport, command, url) = if let Some(command) = server.command {
90 (TransportType::Stdio, Some(command), None)
91 } else if let Some(url) = server.url {
92 (TransportType::Http, None, Some(url))
93 } else {
94 anyhow::bail!(
95 "server '{}': must have either 'command' (stdio) or 'url' (http)",
96 name
97 );
98 };
99
100 Ok(BackendConfig {
101 name,
102 transport,
103 command,
104 args: server.args,
105 url,
106 env: server.env,
107 bearer_token: None,
108 forward_auth: false,
109 timeout: None,
110 circuit_breaker: None,
111 rate_limit: None,
112 concurrency: None,
113 retry: None,
114 outlier_detection: None,
115 hedging: None,
116 cache: None,
117 default_args: serde_json::Map::new(),
118 inject_args: Vec::new(),
119 param_overrides: Vec::new(),
120 expose_tools: Vec::new(),
121 hide_tools: Vec::new(),
122 expose_resources: Vec::new(),
123 hide_resources: Vec::new(),
124 expose_prompts: Vec::new(),
125 hide_prompts: Vec::new(),
126 hide_destructive: false,
127 read_only_only: false,
128 failover_for: None,
129 priority: 0,
130 canary_of: None,
131 weight: 100,
132 aliases: Vec::new(),
133 mirror_of: None,
134 mirror_percent: 100,
135 })
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn test_parse_stdio_server() {
144 let json = r#"{
145 "mcpServers": {
146 "github": {
147 "command": "npx",
148 "args": ["-y", "@modelcontextprotocol/server-github"],
149 "env": { "GITHUB_TOKEN": "secret" }
150 }
151 }
152 }"#;
153
154 let config = McpJsonConfig::parse(json).unwrap();
155 let backends = config.into_backends().unwrap();
156 assert_eq!(backends.len(), 1);
157 assert_eq!(backends[0].name, "github");
158 assert!(matches!(backends[0].transport, TransportType::Stdio));
159 assert_eq!(backends[0].command.as_deref(), Some("npx"));
160 assert_eq!(
161 backends[0].args,
162 vec!["-y", "@modelcontextprotocol/server-github"]
163 );
164 assert_eq!(backends[0].env.get("GITHUB_TOKEN").unwrap(), "secret");
165 }
166
167 #[test]
168 fn test_parse_http_server() {
169 let json = r#"{
170 "mcpServers": {
171 "api": {
172 "url": "http://localhost:8080"
173 }
174 }
175 }"#;
176
177 let config = McpJsonConfig::parse(json).unwrap();
178 let backends = config.into_backends().unwrap();
179 assert_eq!(backends.len(), 1);
180 assert_eq!(backends[0].name, "api");
181 assert!(matches!(backends[0].transport, TransportType::Http));
182 assert_eq!(backends[0].url.as_deref(), Some("http://localhost:8080"));
183 }
184
185 #[test]
186 fn test_parse_multiple_servers() {
187 let json = r#"{
188 "mcpServers": {
189 "github": {
190 "command": "npx",
191 "args": ["-y", "@modelcontextprotocol/server-github"]
192 },
193 "filesystem": {
194 "command": "npx",
195 "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home"]
196 },
197 "api": {
198 "url": "http://localhost:8080"
199 }
200 }
201 }"#;
202
203 let config = McpJsonConfig::parse(json).unwrap();
204 let backends = config.into_backends().unwrap();
205 assert_eq!(backends.len(), 3);
206 assert_eq!(backends[0].name, "api");
208 assert_eq!(backends[1].name, "filesystem");
209 assert_eq!(backends[2].name, "github");
210 }
211
212 #[test]
213 fn test_rejects_server_without_command_or_url() {
214 let json = r#"{
215 "mcpServers": {
216 "bad": {
217 "args": ["--help"]
218 }
219 }
220 }"#;
221
222 let config = McpJsonConfig::parse(json).unwrap();
223 let err = config.into_backends().unwrap_err();
224 assert!(err.to_string().contains("command"));
225 }
226
227 #[test]
228 fn test_empty_servers() {
229 let json = r#"{ "mcpServers": {} }"#;
230 let config = McpJsonConfig::parse(json).unwrap();
231 let backends = config.into_backends().unwrap();
232 assert!(backends.is_empty());
233 }
234
235 #[test]
236 fn test_default_env_and_args() {
237 let json = r#"{
238 "mcpServers": {
239 "simple": {
240 "command": "echo"
241 }
242 }
243 }"#;
244
245 let config = McpJsonConfig::parse(json).unwrap();
246 let backends = config.into_backends().unwrap();
247 assert!(backends[0].args.is_empty());
248 assert!(backends[0].env.is_empty());
249 }
250}