use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Deserialize)]
pub struct McpServerEntry {
pub url: Option<String>,
pub command: Option<String>,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredMcpServer {
pub name: String,
pub url: String,
pub source: String,
}
pub fn discover_mcp_servers(workspace: Option<&Path>) -> Vec<DiscoveredMcpServer> {
let mut servers = Vec::new();
let global_path: Option<PathBuf> =
dirs::home_dir().map(|h| h.join(".mcp").join("servers.json"));
if let Some(ref path) = global_path {
if let Some(discovered) = load_mcp_config(path, "global") {
servers.extend(discovered);
}
}
if let Some(ws) = workspace {
let project_path = ws.join(".mcp.json");
if let Some(discovered) = load_mcp_config(&project_path, "project") {
servers.extend(discovered);
}
}
if !servers.is_empty() {
info!(count = servers.len(), "Discovered MCP servers");
}
servers
}
pub(crate) fn load_mcp_config(path: &Path, source: &str) -> Option<Vec<DiscoveredMcpServer>> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return None, };
let entries = parse_mcp_config_json(&content, path)?;
let mut result = Vec::new();
for (name, entry) in entries {
match entry.url {
Some(url) => {
result.push(DiscoveredMcpServer {
name,
url,
source: source.to_string(),
});
}
None => {
debug!(
server = %name,
path = %path.display(),
"Skipping stdio-only MCP server (no url field)"
);
}
}
}
Some(result)
}
fn parse_mcp_config_json(content: &str, path: &Path) -> Option<HashMap<String, McpServerEntry>> {
#[derive(Deserialize)]
struct McpConfigWrapper {
#[serde(alias = "mcpServers", alias = "servers")]
servers: Option<HashMap<String, McpServerEntry>>,
}
if let Ok(wrapper) = serde_json::from_str::<McpConfigWrapper>(content) {
if let Some(servers) = wrapper.servers {
return Some(servers);
}
}
match serde_json::from_str::<HashMap<String, McpServerEntry>>(content) {
Ok(map) => Some(map),
Err(err) => {
warn!(
path = %path.display(),
error = %err,
"Failed to parse MCP config; skipping"
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discover_no_files_returns_empty() {
let servers = discover_mcp_servers(Some(Path::new("/nonexistent/path")));
let _ = servers;
}
#[test]
fn test_discover_project_file() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(".mcp.json");
std::fs::write(
&config_path,
r#"{"servers": {"db": {"url": "http://localhost:4000"}}}"#,
)
.unwrap();
let servers = discover_mcp_servers(Some(dir.path()));
let project_servers: Vec<_> = servers.iter().filter(|s| s.source == "project").collect();
assert_eq!(project_servers.len(), 1);
assert_eq!(project_servers[0].name, "db");
assert_eq!(project_servers[0].url, "http://localhost:4000");
}
#[test]
fn test_load_mcp_config_missing_file() {
let result = load_mcp_config(Path::new("/nonexistent/servers.json"), "global");
assert!(result.is_none(), "Missing file should return None");
}
#[test]
fn test_load_mcp_config_claude_desktop_format() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("servers.json");
std::fs::write(
&path,
r#"{
"mcpServers": {
"github": { "url": "http://localhost:3000" },
"stdio-only": { "command": "node", "args": ["server.js"] }
}
}"#,
)
.unwrap();
let servers = load_mcp_config(&path, "test").unwrap();
assert_eq!(
servers.len(),
1,
"Only the server with url should be included"
);
assert_eq!(servers[0].name, "github");
assert_eq!(servers[0].url, "http://localhost:3000");
assert_eq!(servers[0].source, "test");
}
#[test]
fn test_load_mcp_config_servers_key_format() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mcp.json");
std::fs::write(
&path,
r#"{"servers": {"db": {"url": "http://localhost:4000"}}}"#,
)
.unwrap();
let servers = load_mcp_config(&path, "project").unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "db");
assert_eq!(servers[0].source, "project");
}
#[test]
fn test_load_mcp_config_multiple_http_servers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("servers.json");
std::fs::write(
&path,
r#"{
"mcpServers": {
"svc-a": { "url": "http://localhost:3001" },
"svc-b": { "url": "http://localhost:3002" },
"svc-c": { "url": "http://localhost:3003" }
}
}"#,
)
.unwrap();
let servers = load_mcp_config(&path, "global").unwrap();
assert_eq!(servers.len(), 3);
}
#[test]
fn test_load_mcp_config_all_stdio_returns_empty_vec() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("servers.json");
std::fs::write(
&path,
r#"{"mcpServers": {"only-stdio": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}}}"#,
)
.unwrap();
let servers = load_mcp_config(&path, "global").unwrap();
assert!(servers.is_empty(), "No URL entries → empty vec, not None");
}
#[test]
fn test_load_mcp_config_invalid_json_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "not valid json {{{").unwrap();
assert!(
load_mcp_config(&path, "test").is_none(),
"Invalid JSON should return None"
);
}
#[test]
fn test_load_mcp_config_empty_object_returns_some_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.json");
std::fs::write(&path, "{}").unwrap();
let result = load_mcp_config(&path, "test");
let _ = result;
}
#[test]
fn test_load_mcp_config_with_env_field() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("servers.json");
std::fs::write(
&path,
r#"{
"mcpServers": {
"api-server": {
"url": "http://localhost:5000",
"env": { "API_KEY": "secret" }
}
}
}"#,
)
.unwrap();
let servers = load_mcp_config(&path, "global").unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:5000");
}
#[test]
fn test_discovered_server_fields() {
let s = DiscoveredMcpServer {
name: "my-server".to_string(),
url: "http://localhost:9000".to_string(),
source: "global".to_string(),
};
assert_eq!(s.name, "my-server");
assert_eq!(s.url, "http://localhost:9000");
assert_eq!(s.source, "global");
}
#[test]
fn test_discovered_server_equality() {
let a = DiscoveredMcpServer {
name: "svc".to_string(),
url: "http://localhost:1234".to_string(),
source: "project".to_string(),
};
let b = a.clone();
assert_eq!(a, b);
}
}