use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::{Mutex as AsyncMutex, MutexGuard};
use crate::mcp::McpPool;
use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
use crate::utils::write_atomic;
#[derive(Deserialize)]
struct RegistryResponse {
servers: Vec<RegistryServerEntry>,
metadata: Option<RegistryMetadata>,
}
#[derive(Deserialize)]
struct RegistryServerEntry {
server: RegistryServer,
#[serde(rename = "_meta", default)]
meta: Option<RegistryResponseMeta>,
}
impl RegistryServerEntry {
fn lifecycle_status(&self) -> Option<&str> {
self.meta
.as_ref()
.and_then(|m| m.official.as_ref())
.and_then(|o| o.status.as_deref())
}
}
#[derive(Deserialize)]
struct RegistryResponseMeta {
#[serde(rename = "io.modelcontextprotocol.registry/official", default)]
official: Option<RegistryOfficialMeta>,
}
#[derive(Deserialize)]
struct RegistryOfficialMeta {
#[serde(default)]
status: Option<String>,
}
#[derive(Deserialize)]
struct RegistryServer {
name: String,
description: String,
#[serde(default)]
packages: Option<Vec<RegistryPackage>>,
}
#[derive(Deserialize)]
struct RegistryPackage {
#[serde(rename = "registryType")]
registry_type: String,
identifier: String,
#[serde(default)]
version: Option<String>,
#[serde(rename = "runtimeHint", default)]
runtime_hint: Option<String>,
#[serde(deserialize_with = "deserialize_transport", default)]
transport: Option<String>,
#[serde(default)]
#[serde(rename = "packageArguments")]
package_arguments: Vec<RegistryArg>,
#[serde(
rename = "runtimeArguments",
deserialize_with = "deserialize_runtime_arguments",
default
)]
runtime_arguments: Vec<String>,
#[serde(rename = "environmentVariables", default)]
environment_variables: Value,
}
impl RegistryPackage {
fn declares_environment_variables(&self) -> bool {
match &self.environment_variables {
Value::Null => false,
Value::Array(values) => !values.is_empty(),
Value::Object(values) => !values.is_empty(),
_ => true,
}
}
}
fn deserialize_transport<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrString {
Bare(String),
Wrapped {
#[serde(rename = "type")]
r#type: String,
},
}
let opt: Option<OneOrString> = Option::deserialize(deserializer)?;
Ok(opt.map(|v| match v {
OneOrString::Bare(s) => s,
OneOrString::Wrapped { r#type } => r#type,
}))
}
fn deserialize_runtime_arguments<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrArg {
Bare(String),
Wrapped {
#[serde(default)]
value: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
default: Option<String>,
},
}
let raw: Vec<StringOrArg> = Vec::deserialize(deserializer)?;
let mut args = Vec::new();
for arg in raw {
match arg {
StringOrArg::Bare(value) => args.push(value),
StringOrArg::Wrapped {
value: Some(value), ..
} => args.push(value),
StringOrArg::Wrapped { name, default, .. } => {
if let Some(name) = name {
args.push(name);
}
if let Some(default) = default {
args.push(default);
}
}
}
}
Ok(args)
}
fn default_runtime_hint(registry_type: &str) -> Option<&'static str> {
match registry_type {
"npm" => Some("npx"),
"pypi" => Some("uvx"),
_ => None,
}
}
#[derive(Deserialize)]
struct RegistryArg {
#[serde(default)]
name: Option<String>,
description: Option<String>,
#[serde(rename = "isRequired", default)]
is_required: bool,
#[serde(rename = "type", default)]
kind: Option<String>,
#[serde(default)]
value: Option<String>,
default: Option<String>,
}
#[derive(Deserialize)]
struct RegistryMetadata {
#[serde(rename = "nextCursor")]
next_cursor: Option<String>,
}
pub const MCP_REGISTRY_CACHE_VERSION: u32 = 6;
#[derive(Serialize, Deserialize, Clone)]
pub struct McpRegistryIndex {
pub version: u32,
pub count: usize,
pub servers: Vec<McpRegistryServerEntry>,
#[serde(default)]
pub synced_at: Option<DateTime<Utc>>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DigestEntry {
pub name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_args: Vec<McpRegistryArgEntry>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct McpRegistryServerEntry {
pub name: String,
pub description: String,
pub launch: McpLaunchSpec,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct McpLaunchSpec {
pub run_command: String,
pub required_args: Vec<McpRegistryArgEntry>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct McpRegistryArgEntry {
pub name: String,
pub kind: Option<String>,
pub description: Option<String>,
pub default: Option<String>,
}
pub struct McpSyncRegistry {
cache_path_override: Option<PathBuf>,
}
impl McpSyncRegistry {
pub fn new() -> Self {
Self {
cache_path_override: None,
}
}
#[cfg(test)]
pub fn with_cache_path(path: PathBuf) -> Self {
Self {
cache_path_override: Some(path),
}
}
fn cache_path(&self) -> Result<PathBuf, ToolError> {
match &self.cache_path_override {
Some(path) => Ok(path.clone()),
None => dirs::home_dir()
.ok_or_else(|| ToolError::execution_failed("Cannot determine home directory"))
.map(|h| h.join(".codewhale").join("mcp-index.json")),
}
}
}
const REGISTRY_API: &str = "https://registry.modelcontextprotocol.io/v0.1/servers";
const PER_PAGE: usize = 100;
const REQUEST_TIMEOUT_SECS: u64 = 30;
const PAGE_PACING_MS: u64 = 500;
const INCREMENTAL_INTERVAL_SECS: i64 = 24 * 60 * 60;
const FULL_RESYNC_INTERVAL_SECS: i64 = 30 * 24 * 60 * 60;
const USER_AGENT: &str = concat!(
"Mozilla/5.0 (compatible; codewhale/",
env!("CARGO_PKG_VERSION"),
"; +https://github.com/Hmbown/CodeWhale)"
);
const MAX_SYNC_ATTEMPTS: usize = 3;
const REGISTRY_CONNECT_TIMEOUT_SECS: u64 = 60;
fn cache_path() -> Result<PathBuf, ToolError> {
dirs::home_dir()
.ok_or_else(|| ToolError::execution_failed("Cannot determine home directory"))
.map(|h| h.join(".codewhale").join("mcp-index.json"))
}
fn read_cache(path: &Path) -> Option<McpRegistryIndex> {
let data = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&data).ok()
}
fn cache_is_fresh(cache: &McpRegistryIndex, now: DateTime<Utc>) -> bool {
if cache.version != MCP_REGISTRY_CACHE_VERSION {
return false;
}
cache.synced_at.is_some_and(|synced| {
now.signed_duration_since(synced).num_seconds() < INCREMENTAL_INTERVAL_SECS
})
}
fn viable_entries(entries: Vec<RegistryServerEntry>) -> Vec<McpRegistryServerEntry> {
entries
.into_iter()
.filter(|entry| {
!matches!(
entry.lifecycle_status(),
Some("deleted") | Some("deprecated")
)
})
.filter_map(|entry| server_to_entry(entry.server))
.collect()
}
fn build_run_command(
runtime_hint: &str,
identifier: &str,
version: &str,
runtime_arguments: &[String],
package_arguments: &[RegistryArg],
) -> String {
let runtime = runtime_hint;
let mut normalized_runtime_arguments = runtime_arguments.to_vec();
if runtime_hint == "npx"
&& !normalized_runtime_arguments
.iter()
.any(|argument| matches!(argument.as_str(), "-y" | "--yes"))
{
normalized_runtime_arguments.insert(0, "-y".to_string());
}
let mid = normalized_runtime_arguments
.iter()
.map(|argument| shell_words::quote(argument))
.collect::<Vec<_>>()
.join(" ");
let mid_with_space = if mid.is_empty() {
String::new()
} else {
format!("{mid} ")
};
let (sep, tail) = match runtime_hint {
"npx" => ("@", version.to_string()),
"uvx" => ("==", version.to_string()),
_ => return String::new(),
};
let fixed: Vec<String> = package_arguments
.iter()
.filter(|a| !a.is_required)
.filter(|a| a.name.is_none())
.filter_map(|a| a.value.as_deref().or(a.default.as_deref()))
.map(|value| shell_words::quote(value).into_owned())
.collect();
let fixed_str = if fixed.is_empty() {
String::new()
} else {
format!(" {}", fixed.join(" "))
};
let package_spec = format!("{identifier}{sep}{tail}");
let package = shell_words::quote(&package_spec);
format!("{runtime} {mid_with_space}{package}{fixed_str} <ARGS>")
}
fn build_launch_spec(
runtime_hint: &str,
identifier: &str,
version: &str,
pkg: &RegistryPackage,
) -> McpLaunchSpec {
McpLaunchSpec {
run_command: build_run_command(
runtime_hint,
identifier,
version,
&pkg.runtime_arguments,
&pkg.package_arguments,
),
required_args: pkg
.package_arguments
.iter()
.filter(|a| a.is_required)
.enumerate()
.map(|(index, a)| McpRegistryArgEntry {
name: a
.name
.clone()
.or_else(|| a.value.clone())
.unwrap_or_else(|| format!("arg_{}", index + 1)),
kind: a.kind.clone(),
description: a.description.clone(),
default: a.default.clone(),
})
.collect(),
}
}
fn server_to_entry(server: RegistryServer) -> Option<McpRegistryServerEntry> {
let first_pkg = server
.packages
.unwrap_or_default()
.into_iter()
.filter(|p| p.transport.as_deref() == Some("stdio"))
.filter(|p| !p.declares_environment_variables())
.filter(|p| matches!(p.registry_type.as_str(), "npm" | "pypi"))
.find_map(|p| {
let expected_hint = default_runtime_hint(&p.registry_type)?;
let hint = p
.runtime_hint
.clone()
.unwrap_or_else(|| expected_hint.to_string());
if hint != expected_hint {
return None;
}
let version = p.version.clone()?;
Some((p, hint, version))
});
let (pkg, hint, version) = first_pkg?;
Some(McpRegistryServerEntry {
name: server.name,
description: server.description,
launch: build_launch_spec(&hint, &pkg.identifier, &version, &pkg),
})
}
const REGISTRY_FIRST_PROMPT: &str = concat!(
"REGISTRY-FIRST POLICY: Compare the user's full task ",
"against every server name and description. Treat a server as a match ",
"when it plausibly covers the task's core specialized capability; ",
"wording need not be exact. If any plausible match exists, you must ",
"call start_registry_mcp_server with its exact name and inspect its ",
"tools before using shell commands, local programs, custom code, or a ",
"manual implementation. A familiar local alternative is not a reason ",
"to skip it. Fall back only when every catalog entry is clearly ",
"irrelevant or the matching server fails to start.",
);
#[derive(Serialize)]
struct RegistryCatalogResult {
instruction: &'static str,
count: usize,
servers: Vec<DigestEntry>,
}
fn catalog_from_cache(cache: &McpRegistryIndex) -> RegistryCatalogResult {
let servers = cache
.servers
.iter()
.map(|server| DigestEntry {
name: server.name.clone(),
description: server.description.clone(),
required_args: server.launch.required_args.clone(),
})
.collect::<Vec<_>>();
RegistryCatalogResult {
instruction: REGISTRY_FIRST_PROMPT,
count: servers.len(),
servers,
}
}
async fn load_registry_catalog(path: &Path) -> Result<RegistryCatalogResult, ToolError> {
let existing = read_cache(path);
let fresh = existing
.as_ref()
.is_some_and(|cache| cache_is_fresh(cache, Utc::now()));
if !fresh {
spawn_background_sync(path);
}
Ok(catalog_for_snapshot(existing))
}
fn catalog_for_snapshot(existing: Option<McpRegistryIndex>) -> RegistryCatalogResult {
match existing {
Some(cache) => catalog_from_cache(&cache),
None => RegistryCatalogResult {
instruction: "",
count: 0,
servers: Vec::new(),
},
}
}
fn try_acquire_sync_permit() -> Option<MutexGuard<'static, ()>> {
static SYNC_GUARD: OnceLock<AsyncMutex<()>> = OnceLock::new();
SYNC_GUARD
.get_or_init(|| AsyncMutex::new(()))
.try_lock()
.ok()
}
fn spawn_background_sync(path: &Path) -> bool {
let Some(permit) = try_acquire_sync_permit() else {
return false;
};
let path = path.to_path_buf();
tokio::spawn(async move {
if let Err(error) = sync_once(&path).await {
tracing::warn!("background Registry sync failed: {error}");
}
drop(permit);
});
true
}
async fn fetch_registry_entries(
client: &reqwest::Client,
updated_since: Option<DateTime<Utc>>,
) -> Result<Vec<RegistryServerEntry>, ToolError> {
let mut all_entries: Vec<RegistryServerEntry> = Vec::new();
let mut cursor: Option<String> = None;
loop {
let mut url = format!("{REGISTRY_API}?version=latest&limit={PER_PAGE}");
if let Some(since) = updated_since {
url.push_str(&format!(
"&updated_since={}",
urlencoding::encode(&since.to_rfc3339())
));
}
if let Some(ref c) = cursor {
url.push_str(&format!("&cursor={}", urlencoding::encode(c)));
}
let resp = client
.get(&url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| ToolError::execution_failed(format!("Registry API: {e}")))?;
let text = resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Registry body: {e}")))?;
let body: RegistryResponse = serde_json::from_str(&text)
.map_err(|e| ToolError::execution_failed(format!("Registry JSON parse: {e}")))?;
all_entries.extend(body.servers);
cursor = body.metadata.and_then(|m| m.next_cursor);
if cursor.is_none() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(PAGE_PACING_MS)).await;
}
Ok(all_entries)
}
#[derive(Debug, PartialEq)]
enum SyncStrategy {
Full,
Incremental { since: DateTime<Utc> },
}
fn sync_strategy(cache: Option<&McpRegistryIndex>, now: DateTime<Utc>) -> SyncStrategy {
match cache {
None => SyncStrategy::Full,
Some(cache) if cache.version != MCP_REGISTRY_CACHE_VERSION => SyncStrategy::Full,
Some(cache) => match cache.synced_at {
None => SyncStrategy::Full,
Some(synced)
if now.signed_duration_since(synced).num_seconds() >= FULL_RESYNC_INTERVAL_SECS =>
{
SyncStrategy::Full
}
Some(synced) => SyncStrategy::Incremental { since: synced },
},
}
}
fn merge_incremental_entries(
base: &[McpRegistryServerEntry],
entries: Vec<RegistryServerEntry>,
) -> Vec<McpRegistryServerEntry> {
let mut merged: HashMap<String, McpRegistryServerEntry> = base
.iter()
.map(|entry| (entry.name.clone(), entry.clone()))
.collect();
for entry in entries {
let name = entry.server.name.clone();
if matches!(
entry.lifecycle_status(),
Some("deleted") | Some("deprecated")
) {
merged.remove(&name);
continue;
}
match server_to_entry(entry.server) {
Some(updated) => {
merged.insert(name, updated);
}
None => {
merged.remove(&name);
}
}
}
merged.into_values().collect()
}
async fn sync_once(path: &Path) -> Result<(), ToolError> {
let _ = rustls::crypto::ring::default_provider().install_default();
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()
.map_err(|e| ToolError::execution_failed(format!("HTTP client: {e}")))?;
let now = Utc::now();
let cached = read_cache(path);
let strategy = sync_strategy(cached.as_ref(), now);
let updated_since = match strategy {
SyncStrategy::Full => None,
SyncStrategy::Incremental { since } => Some(since),
};
let mut last_error: Option<ToolError> = None;
let mut servers: Option<Vec<McpRegistryServerEntry>> = None;
for _attempt in 0..MAX_SYNC_ATTEMPTS {
match fetch_registry_entries(&client, updated_since).await {
Ok(entries) => {
servers = Some(match strategy {
SyncStrategy::Full => viable_entries(entries),
SyncStrategy::Incremental { .. } => merge_incremental_entries(
&cached.expect("incremental needs a cache").servers,
entries,
),
});
break;
}
Err(error) => last_error = Some(error),
}
}
let servers = servers.ok_or_else(|| {
last_error.unwrap_or_else(|| ToolError::execution_failed("Registry sync failed"))
})?;
let index = McpRegistryIndex {
version: MCP_REGISTRY_CACHE_VERSION,
count: servers.len(),
servers,
synced_at: Some(now),
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ToolError::execution_failed(format!("Create cache dir: {e}")))?;
}
let json_str = serde_json::to_string_pretty(&index)
.map_err(|e| ToolError::execution_failed(format!("Serialize: {e}")))?;
write_atomic(path, json_str.as_bytes())
.map_err(|e| ToolError::execution_failed(format!("Write cache: {e}")))?;
Ok(())
}
#[async_trait::async_trait]
impl ToolSpec for McpSyncRegistry {
fn name(&self) -> &str {
"registry_sync"
}
fn description(&self) -> &str {
"List installable local MCP servers for Registry-first capability \
selection. Compare their names and descriptions against the user's \
full task context before using shell commands, local programs, custom \
code, or manually reproducing a specialized integration. \
The index contains only stdio packages \
that declare no environment variables or API keys. If any server \
plausibly covers the task's core specialized capability, call \
start_registry_mcp_server with its exact name and inspect its tools \
before choosing a local alternative; do not run its package command \
through exec_shell."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::Network]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
fn supports_parallel(&self) -> bool {
true
}
async fn execute(&self, _input: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> {
let path = self.cache_path()?;
let result = load_registry_catalog(&path).await?;
let json = serde_json::to_string(&result)
.map_err(|e| ToolError::execution_failed(format!("Serialize: {e}")))?;
Ok(ToolResult::success(json))
}
}
pub struct StartRegistryMcpServer {
pool: Arc<AsyncMutex<McpPool>>,
}
impl StartRegistryMcpServer {
pub fn new(pool: Arc<AsyncMutex<McpPool>>) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl ToolSpec for StartRegistryMcpServer {
fn name(&self) -> &str {
"start_registry_mcp_server"
}
fn description(&self) -> &str {
"Install and start a local stdio MCP server previously returned by \
registry_sync. Only Registry packages that declare no environment \
variables are eligible. Pass the exact registry_name and, when the \
discovery result lists required_args, provide their values in the \
structured arguments object. The connected server's complete tool \
schemas become callable in the same turn."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"registry_name": {
"type": "string",
"description": "Exact server name returned by registry_sync"
},
"arguments": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Values keyed by required_args[].name; omit when none are required"
}
},
"required": ["registry_name"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::Network, ToolCapability::ExecutesCode]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolResult, ToolError> {
let registry_name = input
.get("registry_name")
.and_then(Value::as_str)
.ok_or_else(|| ToolError::invalid_input("missing required field: registry_name"))?;
let supplied: HashMap<String, String> = match input.get("arguments") {
Some(value) => serde_json::from_value(value.clone())
.map_err(|error| ToolError::invalid_input(format!("invalid arguments: {error}")))?,
None => HashMap::new(),
};
let path = cache_path()?;
let cache = read_cache(&path).ok_or_else(|| {
ToolError::execution_failed(format!(
"no current Registry cache at {}; run registry_sync first",
path.display()
))
})?;
if cache.version != MCP_REGISTRY_CACHE_VERSION {
return Err(ToolError::execution_failed(format!(
"cache at {} is from an older schema version; run registry_sync first",
path.display()
)));
}
let entry = cache
.servers
.iter()
.find(|server| server.name == registry_name)
.ok_or_else(|| ToolError::invalid_input("registry_name is not present in the cache"))?;
let expected: HashSet<&str> = entry
.launch
.required_args
.iter()
.map(|arg| arg.name.as_str())
.collect();
if let Some(unknown) = supplied
.keys()
.find(|name| !expected.contains(name.as_str()))
{
return Err(ToolError::invalid_input(format!(
"unknown argument '{unknown}' for {registry_name}"
)));
}
let mut rendered_args = Vec::new();
for argument in &entry.launch.required_args {
let value = supplied
.get(&argument.name)
.cloned()
.or_else(|| argument.default.clone())
.ok_or_else(|| {
ToolError::invalid_input(format!(
"missing required argument '{}' for {registry_name}",
argument.name
))
})?;
if matches!(argument.kind.as_deref(), Some("named")) && !argument.name.is_empty() {
rendered_args.push(shell_words::quote(&argument.name).into_owned());
}
rendered_args.push(shell_words::quote(&value).into_owned());
}
let command = entry
.launch
.run_command
.replace("<ARGS>", &rendered_args.join(" "));
let delegated = json!({
"server": command.trim(),
"name": registry_name,
"connect_timeout": REGISTRY_CONNECT_TIMEOUT_SECS,
});
crate::tools::runtime_mcp::StartRuntimeMcpServer::new(Arc::clone(&self.pool))
.execute(delegated, ctx)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_no_packages_filtered() {
let server = RegistryServer {
name: "test".into(),
description: "desc".into(),
packages: None,
};
assert!(server_to_entry(server).is_none());
}
#[test]
fn server_remote_only_filtered() {
let server = RegistryServer {
name: "test".into(),
description: "desc".into(),
packages: Some(vec![RegistryPackage {
registry_type: "npm".into(),
identifier: "@test/pkg".into(),
version: Some("1.0.0".into()),
runtime_hint: Some("npx".into()),
transport: Some("streamable-http".into()),
package_arguments: vec![],
runtime_arguments: vec![],
environment_variables: Value::Null,
}]),
};
assert!(server_to_entry(server).is_none());
}
#[test]
fn server_stdio_kept() {
let server = RegistryServer {
name: "test".into(),
description: "desc".into(),
packages: Some(vec![RegistryPackage {
registry_type: "npm".into(),
identifier: "@test/pkg".into(),
version: Some("1.0.0".into()),
runtime_hint: Some("npx".into()),
transport: Some("stdio".into()),
package_arguments: vec![],
runtime_arguments: vec!["-y".into()],
environment_variables: Value::Null,
}]),
};
let entry = server_to_entry(server).unwrap();
assert_eq!(entry.launch.run_command, "npx -y @test/pkg@1.0.0 <ARGS>");
}
#[test]
fn server_declaring_environment_variables_is_filtered() {
let server = RegistryServer {
name: "needs-secret".into(),
description: "requires an API key".into(),
packages: Some(vec![RegistryPackage {
registry_type: "npm".into(),
identifier: "@test/secret-server".into(),
version: Some("1.0.0".into()),
runtime_hint: Some("npx".into()),
transport: Some("stdio".into()),
package_arguments: vec![],
runtime_arguments: vec!["-y".into()],
environment_variables: json!([{ "name": "API_KEY", "isRequired": true }]),
}]),
};
assert!(server_to_entry(server).is_none());
}
#[test]
fn fixed_positional_package_arguments_render_into_run_command() {
let server = RegistryServer {
name: "io.github.adewale/agentic-mermaid".into(),
description: "Render Mermaid diagrams through MCP.".into(),
packages: Some(vec![RegistryPackage {
registry_type: "npm".into(),
identifier: "agentic-mermaid".into(),
version: Some("0.1.2".into()),
runtime_hint: Some("npx".into()),
transport: Some("stdio".into()),
package_arguments: vec![RegistryArg {
name: None,
description: None,
is_required: false,
kind: Some("positional".into()),
value: Some("mcp".into()),
default: None,
}],
runtime_arguments: vec!["-y".into()],
environment_variables: Value::Null,
}]),
};
let entry = server_to_entry(server).unwrap();
assert_eq!(
entry.launch.run_command,
"npx -y agentic-mermaid@0.1.2 mcp <ARGS>"
);
assert!(entry.launch.required_args.is_empty());
}
#[test]
fn placeholder_positional_package_argument_stays_in_required_args() {
let server = RegistryServer {
name: "test".into(),
description: "desc".into(),
packages: Some(vec![RegistryPackage {
registry_type: "npm".into(),
identifier: "@test/fs".into(),
version: Some("1.0.0".into()),
runtime_hint: Some("npx".into()),
transport: Some("stdio".into()),
package_arguments: vec![RegistryArg {
name: None,
description: Some("Directory to expose".into()),
is_required: true,
kind: Some("positional".into()),
value: None,
default: None,
}],
runtime_arguments: vec!["-y".into()],
environment_variables: Value::Null,
}]),
};
let entry = server_to_entry(server).unwrap();
assert_eq!(entry.launch.run_command, "npx -y @test/fs@1.0.0 <ARGS>");
assert_eq!(entry.launch.required_args.len(), 1);
}
#[test]
fn fixed_argument_with_spaces_preserves_one_process_argument() {
let command = build_run_command(
"npx",
"@test/fs",
"1.0.0",
&[],
&[RegistryArg {
name: None,
description: None,
is_required: false,
kind: Some("positional".into()),
value: Some("/tmp/a folder".into()),
default: None,
}],
);
let parsed = shell_words::split(command.replace("<ARGS>", "").trim()).unwrap();
assert_eq!(parsed.last().map(String::as_str), Some("/tmp/a folder"));
}
#[test]
fn server_without_explicit_stdio_transport_is_filtered() {
let server = RegistryServer {
name: "test".into(),
description: "desc".into(),
packages: Some(vec![RegistryPackage {
registry_type: "npm".into(),
identifier: "@test/pkg".into(),
version: Some("1.0.0".into()),
runtime_hint: Some("npx".into()),
transport: None,
package_arguments: vec![],
runtime_arguments: vec![],
environment_variables: Value::Null,
}]),
};
assert!(server_to_entry(server).is_none());
}
#[test]
fn unsupported_registry_runtime_is_not_advertised() {
let server = RegistryServer {
name: "container-only".into(),
description: "OCI stdio server".into(),
packages: Some(vec![RegistryPackage {
registry_type: "oci".into(),
identifier: "docker.io/example/server:1.0.0".into(),
version: None,
runtime_hint: Some("docker".into()),
transport: Some("stdio".into()),
package_arguments: vec![],
runtime_arguments: vec![],
environment_variables: Value::Null,
}]),
};
assert!(server_to_entry(server).is_none());
}
#[test]
fn registry_type_and_runtime_must_match() {
let server = RegistryServer {
name: "mismatched".into(),
description: "invalid npm runner".into(),
packages: Some(vec![RegistryPackage {
registry_type: "npm".into(),
identifier: "example".into(),
version: Some("1.0.0".into()),
runtime_hint: Some("uvx".into()),
transport: Some("stdio".into()),
package_arguments: vec![],
runtime_arguments: vec![],
environment_variables: Value::Null,
}]),
};
assert!(server_to_entry(server).is_none());
}
#[tokio::test]
#[ignore = "requires network access to the public MCP Registry; \
run with `cargo test -- --ignored`"]
async fn execute_writes_cache_file_and_returns_summary() {
use crate::tools::spec::ToolContext;
let tmp = tempfile::tempdir().expect("tempdir");
let tmp_path = tmp.keep();
let cache_path = tmp_path.join(".codewhale").join("mcp-index.json");
let ctx = ToolContext::new(tmp_path.clone());
let input = json!({});
let result = McpSyncRegistry::with_cache_path(cache_path.clone())
.execute(input, &ctx)
.await
.expect("execute() should not error against the live Registry");
assert!(
result.success,
"execute returned non-success: content={}",
result.content
);
let first_payload: serde_json::Value =
serde_json::from_str(&result.content).expect("result content must parse");
assert_eq!(first_payload["count"], 0, "no cache ⇒ empty catalog");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(600);
let cache = loop {
if let Ok(raw) = std::fs::read_to_string(&cache_path)
&& let Ok(cache) = serde_json::from_str::<McpRegistryIndex>(&raw)
{
break cache;
}
assert!(
std::time::Instant::now() < deadline,
"cache file did not appear at {:?} within 600s",
cache_path
);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
};
assert!(
cache.count > 0,
"expected at least 1 stdio server, got count={}",
cache.count
);
assert_eq!(
cache.servers.len(),
cache.count,
"cache.count must equal cache.servers.len()"
);
for entry in &cache.servers {
assert!(!entry.name.is_empty(), "server entry has empty name");
assert!(
entry.launch.run_command.ends_with("<ARGS>"),
"kept entry {} run_command should end with <ARGS>; got: {}",
entry.name,
entry.launch.run_command
);
}
let result = McpSyncRegistry::with_cache_path(cache_path.clone())
.execute(json!({}), &ctx)
.await
.expect("execute() should succeed once the cache is fresh");
let payload: serde_json::Value =
serde_json::from_str(&result.content).expect("result content must parse");
assert_eq!(payload["count"].as_u64(), Some(cache.count as u64));
assert_eq!(
payload["servers"].as_array().map(Vec::len),
Some(cache.count)
);
}
fn make_test_cache() -> McpRegistryIndex {
let server = McpRegistryServerEntry {
name: "io.modelcontextprotocol/filesystem".into(),
description: "Read/write local files with sandboxed paths".into(),
launch: McpLaunchSpec {
run_command: "npx -y @modelcontextprotocol/server-filesystem@1.0.0 <ARGS>".into(),
required_args: vec![],
},
};
McpRegistryIndex {
version: MCP_REGISTRY_CACHE_VERSION,
count: 1,
servers: vec![server],
synced_at: Some(Utc::now()),
}
}
#[test]
fn catalog_exposes_every_server_for_model_selection() {
let cache = make_test_cache();
let catalog = catalog_from_cache(&cache);
assert_eq!(catalog.count, 1);
assert_eq!(catalog.servers.len(), 1);
assert_eq!(
catalog.servers[0].name,
"io.modelcontextprotocol/filesystem"
);
assert_eq!(
catalog.servers[0].description,
"Read/write local files with sandboxed paths"
);
}
fn parse_server_entry(server_json: Value, status: Option<&str>) -> RegistryServerEntry {
let mut response = json!({ "server": server_json });
if let Some(status) = status {
response["_meta"] =
json!({ "io.modelcontextprotocol.registry/official": { "status": status } });
}
serde_json::from_value(response).expect("ServerResponse must deserialize")
}
#[test]
fn viable_entries_keeps_only_active_launchable_servers() {
let launchable = |name: &str, status: Option<&str>| {
parse_server_entry(
json!({
"name": name,
"description": "d",
"packages": [{
"registryType": "npm",
"identifier": "@test/pkg",
"version": "1.0.0",
"runtimeHint": "npx",
"transport": "stdio",
"packageArguments": [],
"runtimeArguments": [],
"environmentVariables": null
}]
}),
status,
)
};
let entries = vec![
launchable("a/active", Some("active")),
launchable("b/deprecated", Some("deprecated")),
launchable("c/deleted", Some("deleted")),
launchable("d/no-meta", None),
parse_server_entry(json!({ "name": "e/no-pkg", "description": "d" }), None),
];
let viable = viable_entries(entries);
let names: Vec<&str> = viable.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["a/active", "d/no-meta"]);
}
fn cache_entry(name: &str, description: &str) -> McpRegistryServerEntry {
McpRegistryServerEntry {
name: name.into(),
description: description.into(),
launch: McpLaunchSpec {
run_command: "npx pkg@1.0.0 <ARGS>".into(),
required_args: vec![],
},
}
}
#[test]
fn catalog_is_not_programmatically_filtered_or_bounded() {
let servers = (0..12)
.map(|index| cache_entry(&format!("example/file-{index}"), "file server"))
.collect::<Vec<_>>();
let cache = McpRegistryIndex {
version: MCP_REGISTRY_CACHE_VERSION,
count: servers.len(),
servers,
synced_at: Some(Utc::now()),
};
let result = catalog_from_cache(&cache);
assert_eq!(result.count, 12);
assert_eq!(result.servers.len(), 12);
}
#[test]
fn cache_is_fresh_honors_ttl_version_and_missing_timestamp() {
let now = Utc::now();
let base = make_test_cache();
let mut fresh = base.clone();
fresh.synced_at = Some(now - chrono::Duration::minutes(30));
assert!(cache_is_fresh(&fresh, now));
let mut expired = base.clone();
expired.synced_at = Some(now - chrono::Duration::seconds(INCREMENTAL_INTERVAL_SECS + 1));
assert!(!cache_is_fresh(&expired, now));
let mut legacy = base.clone();
legacy.synced_at = None;
assert!(!cache_is_fresh(&legacy, now));
let mut old_schema = base.clone();
old_schema.version = MCP_REGISTRY_CACHE_VERSION - 1;
old_schema.synced_at = Some(now);
assert!(!cache_is_fresh(&old_schema, now));
let mut future = base.clone();
future.synced_at = Some(now + chrono::Duration::hours(2));
assert!(cache_is_fresh(&future, now));
}
#[test]
fn catalog_for_snapshot_returns_empty_catalog_when_no_cache() {
let result = catalog_for_snapshot(None);
assert_eq!(result.count, 0);
assert!(result.servers.is_empty());
}
#[test]
fn catalog_for_snapshot_serves_cached_entries_without_flags() {
let result = catalog_for_snapshot(Some(make_test_cache()));
assert_eq!(result.count, 1);
}
#[test]
fn catalog_for_snapshot_serves_old_snapshot_as_is() {
let mut cache = make_test_cache();
cache.synced_at = Some(Utc::now() - chrono::Duration::days(31));
let result = catalog_for_snapshot(Some(cache));
assert_eq!(result.count, 1);
}
#[test]
fn sync_strategy_decides_full_vs_incremental() {
let now = Utc::now();
assert_eq!(sync_strategy(None, now), SyncStrategy::Full);
let mut old = make_test_cache();
old.version = MCP_REGISTRY_CACHE_VERSION - 1;
assert_eq!(sync_strategy(Some(&old), now), SyncStrategy::Full);
let mut no_ts = make_test_cache();
no_ts.synced_at = None;
assert_eq!(sync_strategy(Some(&no_ts), now), SyncStrategy::Full);
let mut ancient = make_test_cache();
ancient.synced_at = Some(now - chrono::Duration::days(31));
assert_eq!(sync_strategy(Some(&ancient), now), SyncStrategy::Full);
let mut stale = make_test_cache();
stale.synced_at = Some(now - chrono::Duration::days(2));
assert_eq!(
sync_strategy(Some(&stale), now),
SyncStrategy::Incremental {
since: stale.synced_at.expect("set above")
}
);
}
fn delta_entry(name: &str, status: Option<&str>) -> RegistryServerEntry {
parse_server_entry(
json!({
"name": name,
"description": "d",
"packages": [{
"registryType": "npm",
"identifier": "@test/pkg",
"version": "2.0.0",
"runtimeHint": "npx",
"transport": "stdio",
"packageArguments": [],
"runtimeArguments": [],
"environmentVariables": null
}]
}),
status,
)
}
#[test]
fn merge_incremental_entries_updates_inserts_and_removes() {
let base = vec![
cache_entry("a/unchanged", "unchanged"),
cache_entry("b/updated", "old description"),
cache_entry("c/deleted", "will be removed"),
cache_entry("d/deprecated", "will be removed"),
cache_entry("e/unlaunchable", "will be removed"),
];
let entries = vec![
delta_entry("b/updated", Some("active")),
delta_entry("f/new", Some("active")),
delta_entry("c/deleted", Some("deleted")),
delta_entry("d/deprecated", Some("deprecated")),
parse_server_entry(
json!({
"name": "e/unlaunchable",
"description": "d",
"packages": [{
"registryType": "npm",
"identifier": "@test/pkg",
"version": "2.0.0",
"runtimeHint": "uvx",
"transport": "stdio",
"packageArguments": [],
"runtimeArguments": [],
"environmentVariables": null
}]
}),
Some("active"),
),
];
let merged = merge_incremental_entries(&base, entries);
let by_name: HashMap<&str, &McpRegistryServerEntry> = merged
.iter()
.map(|entry| (entry.name.as_str(), entry))
.collect();
assert_eq!(merged.len(), 3);
assert!(
by_name.contains_key("a/unchanged"),
"entries absent from the delta must keep their cached copy"
);
assert!(
by_name.contains_key("f/new"),
"a new server in the delta must be inserted"
);
assert_eq!(
by_name["b/updated"].description, "d",
"an updated entry must replace the cached copy"
);
assert!(!by_name.contains_key("c/deleted"));
assert!(!by_name.contains_key("d/deprecated"));
assert!(
!by_name.contains_key("e/unlaunchable"),
"an entry that lost its launchable package must be dropped"
);
}
#[tokio::test]
async fn background_sync_permit_is_exclusive_until_released() {
let first = try_acquire_sync_permit();
assert!(first.is_some(), "first acquisition must succeed");
assert!(
try_acquire_sync_permit().is_none(),
"second acquisition must fail while the first is held"
);
drop(first);
let again = try_acquire_sync_permit();
assert!(again.is_some(), "permit must be reusable after release");
drop(again);
}
#[tokio::test]
async fn fresh_cache_serves_catalog_without_network() {
use crate::tools::spec::ToolContext;
let tmp = tempfile::tempdir().expect("tempdir");
let cache_dir = tmp.path().join(".codewhale");
std::fs::create_dir_all(&cache_dir).expect("create cache dir");
let cache_file = cache_dir.join("mcp-index.json");
let index = make_test_cache();
std::fs::write(
&cache_file,
serde_json::to_string_pretty(&index).expect("serialize fixture"),
)
.expect("write fixture cache");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = McpSyncRegistry::with_cache_path(cache_file)
.execute(json!({}), &ctx)
.await
.expect("fresh cache must serve without network");
let payload: serde_json::Value =
serde_json::from_str(&result.content).expect("result content must parse");
assert_eq!(payload["count"], 1, "fixture catalog must be served as-is");
assert_eq!(
payload["servers"][0]["name"], "io.modelcontextprotocol/filesystem",
"cache-first path must return the cached entry, not a live sync"
);
}
#[tokio::test]
#[ignore = "manual smoke run; prints the tool result to stdout \
(requires network access to registry.modelcontextprotocol.io)"]
#[allow(clippy::print_stderr)]
async fn execute_and_print_catalog_for_manual_inspection() {
use crate::tools::spec::ToolContext;
let tmp = tempfile::tempdir().expect("tempdir");
let tmp_path = tmp.keep();
let cache_path = tmp_path.join(".codewhale").join("mcp-index.json");
let ctx = ToolContext::new(tmp_path.clone());
let input = json!({});
let result = McpSyncRegistry::with_cache_path(cache_path.clone())
.execute(input, &ctx)
.await
.expect("execute() should not error against the live Registry");
eprintln!("cold-start payload (background download flagged):");
eprintln!("{}", result.content);
eprintln!("waiting for the background download to land...");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(600);
while !cache_path.exists() {
assert!(
std::time::Instant::now() < deadline,
"cache file did not appear at {:?} within 600s",
cache_path
);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
let result = McpSyncRegistry::with_cache_path(cache_path.clone())
.execute(json!({}), &ctx)
.await
.expect("execute() should succeed once the cache is fresh");
eprintln!("\n=== registry_sync output ===");
eprintln!("tool: registry_sync");
eprintln!(
"status: {}",
if result.success { "ok" } else { "fail" }
);
eprintln!("cache_path: {}", cache_path.display());
eprintln!("cache_exists: {}", cache_path.exists());
if cache_path.exists() {
match std::fs::metadata(&cache_path) {
Ok(meta) => eprintln!("cache_size_bytes: {}", meta.len()),
Err(e) => eprintln!("cache_stat_error: {e}"),
}
}
eprintln!("--- catalog payload (what the model sees) ---");
eprintln!("{}", result.content);
eprintln!("=== end ===\n");
}
}