Skip to main content

mcp_proxy/
mcp_json.rs

1//! Support for `.mcp.json` / `mcp.json` config format.
2//!
3//! The `.mcp.json` format is the standard MCP client config used by Claude
4//! Desktop, VS Code, Claude Code, and other MCP clients. This module parses
5//! that format and converts entries into [`BackendConfig`] values that the
6//! proxy can use.
7//!
8//! # Format
9//!
10//! ```json
11//! {
12//!   "mcpServers": {
13//!     "github": {
14//!       "command": "npx",
15//!       "args": ["-y", "@modelcontextprotocol/server-github"],
16//!       "env": { "GITHUB_TOKEN": "..." }
17//!     },
18//!     "api": {
19//!       "url": "http://localhost:8080"
20//!     }
21//!   }
22//! }
23//! ```
24
25use std::collections::HashMap;
26use std::path::Path;
27
28use anyhow::{Context, Result};
29use serde::Deserialize;
30
31use crate::config::{BackendConfig, TransportType};
32
33/// Top-level `.mcp.json` structure.
34#[derive(Debug, Deserialize)]
35pub struct McpJsonConfig {
36    /// Map of server name to server configuration.
37    #[serde(rename = "mcpServers")]
38    pub mcp_servers: HashMap<String, McpJsonServer>,
39}
40
41/// A single MCP server entry in `.mcp.json`.
42#[derive(Debug, Deserialize)]
43pub struct McpJsonServer {
44    /// Command to run (stdio transport).
45    pub command: Option<String>,
46    /// Arguments for the command.
47    #[serde(default)]
48    pub args: Vec<String>,
49    /// Environment variables for the subprocess.
50    #[serde(default)]
51    pub env: HashMap<String, String>,
52    /// URL for HTTP transport.
53    pub url: Option<String>,
54}
55
56impl McpJsonConfig {
57    /// Load and parse a `.mcp.json` file.
58    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    /// Parse a `.mcp.json` string.
65    pub fn parse(json: &str) -> Result<Self> {
66        serde_json::from_str(json).context("parsing .mcp.json")
67    }
68
69    /// Convert all server entries into [`BackendConfig`] values.
70    ///
71    /// Server names become backend names. Entries with `command` become stdio
72    /// backends; entries with `url` become HTTP backends.
73    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        // Sort by name for deterministic ordering
82        backends.sort_by(|a, b| a.name.cmp(&b.name));
83        Ok(backends)
84    }
85}
86
87/// Convert a single `.mcp.json` server entry to a `BackendConfig`.
88fn 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        // Sorted by name
207        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}