use anyhow::{Result, anyhow};
use std::collections::HashMap;
use std::time::Duration;
use super::client::McpClient;
use super::transport::StdioTransport;
pub struct ResolvedServer {
pub command: String,
pub package: String,
pub env_vars: Vec<(String, String)>, pub extra_args: Vec<String>,
}
struct RegistryEntry {
name: &'static str,
command: &'static str,
package: &'static str,
description: &'static str,
env_vars: &'static [(&'static str, &'static str)],
extra_args: &'static [&'static str],
}
const REGISTRY: &[RegistryEntry] = &[
RegistryEntry {
name: "context7",
command: "npx",
package: "@upstash/context7-mcp",
description: "Up-to-date library documentation and code examples",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "filesystem",
command: "npx",
package: "@modelcontextprotocol/server-filesystem",
description: "Secure file operations with configurable access",
env_vars: &[],
extra_args: &["."],
},
RegistryEntry {
name: "memory",
command: "npx",
package: "@modelcontextprotocol/server-memory",
description: "Persistent memory via knowledge graph",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "fetch",
command: "uvx",
package: "mcp-server-fetch",
description: "Web content fetching and conversion (PyPI, uvx)",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "git",
command: "uvx",
package: "mcp-server-git",
description: "Git repository tools — log, diff, status, blame (PyPI, uvx)",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "time",
command: "uvx",
package: "mcp-server-time",
description: "Time and timezone conversion (PyPI, uvx)",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "playwright",
command: "npx",
package: "@playwright/mcp",
description: "Browser automation and testing",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "notion",
command: "npx",
package: "@notionhq/notion-mcp-server",
description: "Notion workspace — pages, databases, tasks",
env_vars: &[("NOTION_API_KEY", "Notion API integration token")],
extra_args: &[],
},
RegistryEntry {
name: "slack",
command: "npx",
package: "@zencoderai/slack-mcp-server",
description: "Slack messaging and channel management (maintained by Zencoder; handoff from deprecated @modelcontextprotocol/server-slack)",
env_vars: &[
("SLACK_BOT_TOKEN", "Slack bot token (xoxb-...)"),
("SLACK_TEAM_ID", "Slack workspace/team ID"),
],
extra_args: &[],
},
RegistryEntry {
name: "postgres",
command: "uvx",
package: "postgres-mcp",
description: "PostgreSQL queries (community, crystaldba): RW access, EXPLAIN, index tuning, health checks",
env_vars: &[(
"DATABASE_URI",
"PostgreSQL connection string (e.g., postgresql://user:pass@localhost:5432/db)",
)],
extra_args: &[],
},
RegistryEntry {
name: "sequential-thinking",
command: "npx",
package: "@modelcontextprotocol/server-sequential-thinking",
description: "Dynamic problem-solving through thought sequences",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "brave-search",
command: "npx",
package: "@brave/brave-search-mcp-server",
description: "Brave Search (official, brave-maintained): web, local, image, video, news, AI summary",
env_vars: &[(
"BRAVE_API_KEY",
"Brave Search API key (https://brave.com/search/api/)",
)],
extra_args: &["--transport", "stdio"],
},
RegistryEntry {
name: "everything",
command: "npx",
package: "@modelcontextprotocol/server-everything",
description: "Reference/test server with all MCP features",
env_vars: &[],
extra_args: &[],
},
RegistryEntry {
name: "supabase",
command: "npx",
package: "@supabase/mcp-server-supabase",
description: "Supabase — database, auth, edge functions",
env_vars: &[
("SUPABASE_URL", "Supabase project URL"),
("SUPABASE_SERVICE_ROLE_KEY", "Supabase service role key"),
],
extra_args: &[],
},
RegistryEntry {
name: "perplexity",
command: "npx",
package: "perplexity-mcp",
description: "Perplexity AI search API",
env_vars: &[("PERPLEXITY_API_KEY", "Perplexity API key")],
extra_args: &[],
},
RegistryEntry {
name: "docker",
command: "npx",
package: "mcp-server-docker",
description: "Docker container management (community)",
env_vars: &[],
extra_args: &[],
},
];
fn lookup(name: &str) -> Option<&'static RegistryEntry> {
REGISTRY.iter().find(|e| e.name == name)
}
fn build_launch_args(command: &str, package: &str, extra_args: &[String]) -> Vec<String> {
let mut args = match command {
"npx" => vec!["-y".to_string(), package.to_string()],
_ => vec![package.to_string()], };
args.extend_from_slice(extra_args);
args
}
pub async fn validate_server(
command: &str,
package: &str,
extra_args: &[String],
env: &HashMap<String, String>,
) -> Result<Vec<String>> {
let args = build_launch_args(command, package, extra_args);
let transport = tokio::time::timeout(
Duration::from_secs(60),
StdioTransport::spawn(command, &args, env),
)
.await
.map_err(|_| {
anyhow!(
"Server startup timed out (60s). Is {} installed?",
match command {
"npx" => "Node.js/npx",
"uvx" => "uv/uvx",
other => other,
}
)
})?
.map_err(|e| anyhow!("Failed to spawn server: {}", e))?;
let mut client = McpClient::new(transport);
tokio::time::timeout(Duration::from_secs(60), async {
client.initialize().await?;
let tools = client.list_tools().await?;
let tool_names: Vec<String> = tools.iter().map(|t| t.name.clone()).collect();
client.shutdown().await;
Ok::<Vec<String>, anyhow::Error>(tool_names)
})
.await
.map_err(|_| anyhow!("Server initialization timed out (60s)"))?
}
async fn try_conventions(name: &str) -> Option<String> {
let patterns = [
format!("@{}/mcp-server", name),
format!("{}-mcp-server", name),
format!("@modelcontextprotocol/server-{}", name),
format!("{}-mcp", name),
];
for pattern in &patterns {
println!(" Trying {}...", pattern);
let empty_env = HashMap::new();
if validate_server("npx", pattern, &[], &empty_env)
.await
.is_ok()
{
return Some(pattern.clone());
}
}
None
}
async fn search_npm(name: &str) -> Result<Option<(String, String)>> {
let query = format!("{} mcp server", name);
let url = reqwest::Url::parse_with_params(
"https://registry.npmjs.org/-/v1/search",
&[("text", query.as_str()), ("size", "5")],
)
.map_err(|e| anyhow!("Failed to build npm search URL: {}", e))?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let response = client
.get(url)
.send()
.await
.map_err(|e| anyhow!("npm registry search failed (network unavailable?): {}", e))?;
if !response.status().is_success() {
return Err(anyhow!("npm registry returned HTTP {}", response.status()));
}
let body: serde_json::Value = response.json().await?;
let objects = body
.get("objects")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for obj in &objects {
let pkg_name = obj
.pointer("/package/name")
.and_then(|v| v.as_str())
.unwrap_or("");
let description = obj
.pointer("/package/description")
.and_then(|v| v.as_str())
.unwrap_or("");
let keywords = obj
.pointer("/package/keywords")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|k| k.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
let combined = format!("{} {} {}", pkg_name, description, keywords).to_lowercase();
if combined.contains("mcp") {
return Ok(Some((pkg_name.to_string(), description.to_string())));
}
}
Ok(None)
}
pub async fn resolve(name: &str) -> Result<ResolvedServer> {
if let Some(entry) = lookup(name) {
println!("Found: {} ({})", entry.package, entry.description);
return Ok(ResolvedServer {
command: entry.command.to_string(),
package: entry.package.to_string(),
env_vars: entry
.env_vars
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
extra_args: entry.extra_args.iter().map(|s| s.to_string()).collect(),
});
}
println!("Not in built-in registry, trying conventions...");
if let Some(package) = try_conventions(name).await {
println!("Found: {}", package);
return Ok(ResolvedServer {
command: "npx".to_string(),
package,
env_vars: Vec::new(),
extra_args: Vec::new(),
});
}
println!("Searching npm registry...");
match search_npm(name).await {
Ok(Some((package, description))) => {
println!("Found: {} — {}", package, description);
let empty_env = HashMap::new();
validate_server("npx", &package, &[], &empty_env)
.await
.map_err(|e| {
anyhow!(
"Found npm package '{}' but it failed validation: {}",
package,
e
)
})?;
Ok(ResolvedServer {
command: "npx".to_string(),
package,
env_vars: Vec::new(),
extra_args: Vec::new(),
})
},
Ok(None) => Err(anyhow!(
"Could not find MCP server '{}'\n\n\
You can add it manually in ~/.config/mermaid/config.toml:\n\
[mcp_servers.{}]\n\
command = \"npx\"\n\
args = [\"-y\", \"PACKAGE_NAME\"]",
name,
name
)),
Err(e) => Err(anyhow!(
"Convention-based lookup failed, and npm search also failed: {}\n\n\
You can add it manually in ~/.config/mermaid/config.toml:\n\
[mcp_servers.{}]\n\
command = \"npx\"\n\
args = [\"-y\", \"PACKAGE_NAME\"]",
e,
name
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_entries_are_well_formed() {
assert!(!REGISTRY.is_empty(), "registry must not be empty");
for entry in REGISTRY {
assert!(
matches!(entry.command, "npx" | "uvx"),
"entry {:?} has unsupported launcher {:?}",
entry.name,
entry.command
);
assert!(
!entry.package.is_empty(),
"entry {:?} has empty package",
entry.name
);
assert!(
!entry.name.is_empty(),
"registry entry has empty name (package: {:?})",
entry.package
);
}
}
#[test]
fn registry_does_not_reference_deprecated_modelcontextprotocol_packages() {
let deprecated = [
"@modelcontextprotocol/server-slack",
"@modelcontextprotocol/server-postgres",
"@modelcontextprotocol/server-brave-search",
"@modelcontextprotocol/server-github",
];
for entry in REGISTRY {
for pkg in &deprecated {
assert_ne!(
entry.package, *pkg,
"registry entry {:?} still references deprecated package {}",
entry.name, pkg
);
}
}
}
#[test]
fn lookup_resolves_replacement_packages() {
assert_eq!(
lookup("slack").unwrap().package,
"@zencoderai/slack-mcp-server"
);
assert_eq!(lookup("postgres").unwrap().package, "postgres-mcp");
assert_eq!(lookup("postgres").unwrap().command, "uvx");
assert_eq!(
lookup("brave-search").unwrap().package,
"@brave/brave-search-mcp-server"
);
assert_eq!(
lookup("brave-search").unwrap().extra_args,
&["--transport", "stdio"]
);
}
}