| 1 | //! MCP Registry sync tool. |
| 2 | //! |
| 3 | //! Provides `McpSyncRegistry` — fetches the MCP Registry index, |
| 4 | //! filters for stdio-type servers, caches locally, and returns a summary. |
| 5 | //! The cache is deliberately simple: every sync pulls the complete |
| 6 | //! paginated listing and atomically replaces the previous `mcp-index.json` |
| 7 | //! (no TTL freshness bookkeeping, no merge, no eviction). The on-disk file |
| 8 | //! is the launch-metadata store for `start_registry_mcp_server`, and a |
| 9 | //! failed sync leaves the previous snapshot untouched. |
| 10 | //! |
| 11 | //! Upstream contract (MCP Registry, preview — breaking changes possible): |
| 12 | //! * List operation `GET /v0.1/servers` (cursor / limit / search / version |
| 13 | //! / include_deleted params): |
| 14 | //! <https://registry.modelcontextprotocol.io/docs#/operations/list-servers-v0.1> |
| 15 | //! OpenAPI source: <https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml> |
| 16 | //! * Aggregator integration guide (pagination format, server status |
| 17 | //! lifecycle): |
| 18 | //! <https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/registry-aggregators.mdx> |
| 19 | |
| 20 | use std::collections::{HashMap, HashSet}; |
| 21 | use std::path::{Path, PathBuf}; |
| 22 | use std::sync::Arc; |
| 23 | |
| 24 | use anyhow::Result; |
| 25 | use serde::{Deserialize, Serialize}; |
| 26 | use serde_json::{Value, json}; |
| 27 | use tokio::sync::Mutex as AsyncMutex; |
| 28 | |
| 29 | use crate::mcp::McpPool; |
| 30 | use crate::tools::spec::{ |
| 31 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 32 | }; |
| 33 | use crate::utils::write_atomic; |
| 34 | |
| 35 | // === Registry API response types === |
| 36 | |
| 37 | #[derive(Deserialize)] |
| 38 | struct RegistryResponse { |
| 39 | servers: Vec<RegistryServerEntry>, |
| 40 | metadata: Option<RegistryMetadata>, |
| 41 | } |
| 42 | |
| 43 | #[derive(Deserialize)] |
| 44 | struct RegistryServerEntry { |
| 45 | server: RegistryServer, |
| 46 | // Registry-managed metadata. Carries the lifecycle `status` under the |
| 47 | // official extension key (see `RegistryOfficialMeta`); the |
| 48 | // publisher-provided subkey is deliberately not declared. |
| 49 | #[serde(rename = "_meta", default)] |
| 50 | meta: Option<RegistryResponseMeta>, |
| 51 | } |
| 52 | |
| 53 | impl RegistryServerEntry { |
| 54 | /// Lifecycle status reported by the official registry extension. |
| 55 | /// `"active"` (or an absent extension) keeps the entry; `"deprecated"` |
| 56 | /// and `"deleted"` retire it — the aggregator guide recommends dropping |
| 57 | /// `deleted` entries (moderation takedowns: spam/malware/illegal) from |
| 58 | /// downstream indexes, and we treat `deprecated` the same so the model |
| 59 | /// is only offered servers the publisher still stands behind. |
| 60 | /// <https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/registry-aggregators.mdx> |
| 61 | fn lifecycle_status(&self) -> Option<&str> { |
| 62 | self.meta |
| 63 | .as_ref() |
| 64 | .and_then(|m| m.official.as_ref()) |
| 65 | .and_then(|o| o.status.as_deref()) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | #[derive(Deserialize)] |
| 70 | struct RegistryResponseMeta { |
| 71 | #[serde(rename = "io.modelcontextprotocol.registry/official", default)] |
| 72 | official: Option<RegistryOfficialMeta>, |
| 73 | } |
| 74 | |
| 75 | /// `status` is required upstream (enum `active | deprecated | deleted`); |
| 76 | /// kept Optional here so a missing extension never fails a page parse. |
| 77 | #[derive(Deserialize)] |
| 78 | struct RegistryOfficialMeta { |
| 79 | #[serde(default)] |
| 80 | status: Option<String>, |
| 81 | } |
| 82 | |
| 83 | #[derive(Deserialize)] |
| 84 | struct RegistryServer { |
| 85 | name: String, |
| 86 | description: String, |
| 87 | // `title`, `version`, `repository` are deliberately not declared — |
| 88 | // the cache no longer carries them (see `McpRegistryServerEntry`) |
| 89 | // and serde silently drops any extra fields, so we don't pay to |
| 90 | // validate or store data we'd immediately throw away. All three are |
| 91 | // optional in the upstream 2025-12 schema. |
| 92 | #[serde(default)] |
| 93 | packages: Option<Vec<RegistryPackage>>, |
| 94 | } |
| 95 | |
| 96 | #[derive(Deserialize)] |
| 97 | struct RegistryPackage { |
| 98 | #[serde(rename = "registryType")] |
| 99 | registry_type: String, |
| 100 | identifier: String, |
| 101 | // The upstream OCI entries (e.g. docker.io/foo/bar:1.2.3) omit the |
| 102 | // top-level `version` field because the tag is the version. Mirror that |
| 103 | // — Optional, with a fallback that parses the trailing `:tag` from the |
| 104 | // identifier when missing. |
| 105 | #[serde(default)] |
| 106 | version: Option<String>, |
| 107 | // The upstream 2025-12 schema dropped `runtimeHint` for nearly every |
| 108 | // entry (35/36 in the first page omit it; the runner is implied by |
| 109 | // `registryType`). Keep it Optional and fall back to a registry-type |
| 110 | // table when absent. |
| 111 | #[serde(rename = "runtimeHint", default)] |
| 112 | runtime_hint: Option<String>, |
| 113 | // The upstream schema now models `transport` as an object: |
| 114 | // `{"type": "stdio"}`. Older docs showed a bare string. Accept both |
| 115 | // so a future flip-back doesn't break us. |
| 116 | #[serde(deserialize_with = "deserialize_transport", default)] |
| 117 | transport: Option<String>, |
| 118 | #[serde(default)] |
| 119 | #[serde(rename = "packageArguments")] |
| 120 | package_arguments: Vec<RegistryArg>, |
| 121 | #[serde( |
| 122 | rename = "runtimeArguments", |
| 123 | deserialize_with = "deserialize_runtime_arguments", |
| 124 | default |
| 125 | )] |
| 126 | runtime_arguments: Vec<String>, |
| 127 | /// Registry-provided environment requirements are intentionally kept |
| 128 | /// transient. Runtime-discovered servers have no configuration channel |
| 129 | /// for secrets/API keys, so any package declaring environment variables |
| 130 | /// is ineligible and never reaches the on-disk cache. |
| 131 | #[serde(rename = "environmentVariables", default)] |
| 132 | environment_variables: Value, |
| 133 | } |
| 134 | |
| 135 | impl RegistryPackage { |
| 136 | fn declares_environment_variables(&self) -> bool { |
| 137 | match &self.environment_variables { |
| 138 | Value::Null => false, |
| 139 | Value::Array(values) => !values.is_empty(), |
| 140 | Value::Object(values) => !values.is_empty(), |
| 141 | // Fail closed if a future Registry schema uses an unexpected shape. |
| 142 | _ => true, |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | /// Deserialize `transport` as either a bare string (`"stdio"`) or an object |
| 148 | /// (`{"type": "stdio"}`). The MCP Registry 2025-12 schema ships the object |
| 149 | /// shape; older/draft docs showed the bare string. We accept both. |
| 150 | fn deserialize_transport<'de, D>(deserializer: D) -> Result<Option<String>, D::Error> |
| 151 | where |
| 152 | D: serde::Deserializer<'de>, |
| 153 | { |
| 154 | #[derive(Deserialize)] |
| 155 | #[serde(untagged)] |
| 156 | enum OneOrString { |
| 157 | Bare(String), |
| 158 | Wrapped { |
| 159 | #[serde(rename = "type")] |
| 160 | r#type: String, |
| 161 | }, |
| 162 | } |
| 163 | |
| 164 | let opt: Option<OneOrString> = Option::deserialize(deserializer)?; |
| 165 | Ok(opt.map(|v| match v { |
| 166 | OneOrString::Bare(s) => s, |
| 167 | OneOrString::Wrapped { r#type } => r#type, |
| 168 | })) |
| 169 | } |
| 170 | |
| 171 | /// Deserialize `runtimeArguments` as either `Vec<String>` (old schema) |
| 172 | /// or `Vec<{value, name, default, type, ...}>` (2025-12 schema). In the |
| 173 | /// object case we derive a string value from the available fields: |
| 174 | /// - Named args (`type: "named"`): `"{name} {default}"` or just `name` |
| 175 | /// - Positional args (`type: "positional"`): `default` |
| 176 | /// - Legacy objects with `value`: use `value` directly |
| 177 | fn deserialize_runtime_arguments<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error> |
| 178 | where |
| 179 | D: serde::Deserializer<'de>, |
| 180 | { |
| 181 | #[derive(Deserialize)] |
| 182 | #[serde(untagged)] |
| 183 | enum StringOrArg { |
| 184 | Bare(String), |
| 185 | Wrapped { |
| 186 | #[serde(default)] |
| 187 | value: Option<String>, |
| 188 | #[serde(default)] |
| 189 | name: Option<String>, |
| 190 | #[serde(default)] |
| 191 | default: Option<String>, |
| 192 | }, |
| 193 | } |
| 194 | |
| 195 | let raw: Vec<StringOrArg> = Vec::deserialize(deserializer)?; |
| 196 | let mut args = Vec::new(); |
| 197 | for arg in raw { |
| 198 | match arg { |
| 199 | StringOrArg::Bare(value) => args.push(value), |
| 200 | StringOrArg::Wrapped { |
| 201 | value: Some(value), .. |
| 202 | } => args.push(value), |
| 203 | StringOrArg::Wrapped { name, default, .. } => { |
| 204 | if let Some(name) = name { |
| 205 | args.push(name); |
| 206 | } |
| 207 | if let Some(default) = default { |
| 208 | args.push(default); |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | } |
| 213 | Ok(args) |
| 214 | } |
| 215 | |
| 216 | /// Derive a runtime hint from `registryType` when the upstream omits one. |
| 217 | /// Kept small on purpose: only the runtimes we know how to launch. |
| 218 | fn default_runtime_hint(registry_type: &str) -> Option<&'static str> { |
| 219 | match registry_type { |
| 220 | "npm" => Some("npx"), |
| 221 | "pypi" => Some("uvx"), |
| 222 | _ => None, |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | #[derive(Deserialize)] |
| 227 | struct RegistryArg { |
| 228 | // Positional arguments ship without a name — only `value` and |
| 229 | // `type`. Named arguments (`{"name": "--foo", "value": "bar", ...}`) |
| 230 | // carry it. Accept both: when missing, downstream code uses `value` |
| 231 | // as the arg name. |
| 232 | #[serde(default)] |
| 233 | name: Option<String>, |
| 234 | description: Option<String>, |
| 235 | // Upstream allows omitting `isRequired`; default false per spec. |
| 236 | #[serde(rename = "isRequired", default)] |
| 237 | is_required: bool, |
| 238 | // Upstream `type` discriminator (`"positional"` / `"named"`). Drives |
| 239 | // cmd-format decisions downstream. Renamed because `type` is a |
| 240 | // reserved word in Rust. |
| 241 | #[serde(rename = "type", default)] |
| 242 | kind: Option<String>, |
| 243 | #[serde(default)] |
| 244 | value: Option<String>, |
| 245 | default: Option<String>, |
| 246 | // `format` dropped — never read by any consumer. |
| 247 | } |
| 248 | |
| 249 | // === Cached index types === |
| 250 | |
| 251 | #[derive(Deserialize)] |
| 252 | struct RegistryMetadata { |
| 253 | #[serde(rename = "nextCursor")] |
| 254 | next_cursor: Option<String>, |
| 255 | } |
| 256 | |
| 257 | // === Cached index types === |
| 258 | // |
| 259 | // The cache file (`~/.codewhale/mcp-index.json`) is the on-disk source of |
| 260 | // truth for Registry-discovered local MCP launch metadata. |
| 261 | |
| 262 | /// Bumped whenever the cache shape changes. Lets the loader detect an old |
| 263 | /// cache file and trigger a full resync instead of failing to deserialize. |
| 264 | pub const MCP_REGISTRY_CACHE_VERSION: u32 = 6; |
| 265 | |
| 266 | #[derive(Serialize, Deserialize)] |
| 267 | pub struct McpRegistryIndex { |
| 268 | pub version: u32, |
| 269 | pub count: usize, |
| 270 | pub servers: Vec<McpRegistryServerEntry>, |
| 271 | } |
| 272 | |
| 273 | /// One Registry catalog entry exposed to the model for contextual selection. |
| 274 | #[derive(Serialize, Deserialize, Clone)] |
| 275 | pub struct DigestEntry { |
| 276 | pub name: String, |
| 277 | pub description: String, |
| 278 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 279 | pub required_args: Vec<McpRegistryArgEntry>, |
| 280 | } |
| 281 | |
| 282 | /// One cached server entry used by discovery and structured startup. |
| 283 | /// |
| 284 | /// Everything else that the upstream Registry ships (`title`, repository, |
| 285 | /// `packages[]`, optional named args, |
| 286 | /// runtime_arguments at package level) is dropped. Fixed positional |
| 287 | /// `packageArguments` (e.g. an `mcp` subcommand) are folded into |
| 288 | /// `run_command` at render time rather than kept as fields. |
| 289 | #[derive(Serialize, Deserialize, Clone)] |
| 290 | pub struct McpRegistryServerEntry { |
| 291 | pub name: String, |
| 292 | pub description: String, |
| 293 | pub launch: McpLaunchSpec, |
| 294 | } |
| 295 | |
| 296 | /// Host-owned launch data for one zero-environment stdio server. |
| 297 | #[derive(Serialize, Deserialize, Clone)] |
| 298 | pub struct McpLaunchSpec { |
| 299 | /// Template for the run command. The literal substring `<ARGS>` is |
| 300 | /// replaced by host-rendered structured argument values. |
| 301 | pub run_command: String, |
| 302 | pub required_args: Vec<McpRegistryArgEntry>, |
| 303 | } |
| 304 | |
| 305 | /// One CLI argument required at install time. `is_required` was dropped |
| 306 | /// because the cache only stores required args (others are filtered out |
| 307 | /// during sync). `kind` carries the upstream `type` discriminator |
| 308 | /// (`"positional"` vs `"named"`) so the cmd builder can decide whether |
| 309 | /// to emit `--name value` or just `value`. |
| 310 | #[derive(Serialize, Deserialize, Clone)] |
| 311 | pub struct McpRegistryArgEntry { |
| 312 | pub name: String, |
| 313 | pub kind: Option<String>, |
| 314 | pub description: Option<String>, |
| 315 | pub default: Option<String>, |
| 316 | } |
| 317 | |
| 318 | // === Tool implementation === |
| 319 | |
| 320 | pub struct McpSyncRegistry; |
| 321 | |
| 322 | const REGISTRY_API: &str = "https://registry.modelcontextprotocol.io/v0.1/servers"; |
| 323 | const PER_PAGE: usize = 100; |
| 324 | const REQUEST_TIMEOUT_SECS: u64 = 30; |
| 325 | /// Bounded retries for one sync. The on-disk cache only changes at the |
| 326 | /// final atomic replace, so a failed fetch (HTTP/parse error) never |
| 327 | /// mutates state and retrying is side-effect free. |
| 328 | const MAX_SYNC_ATTEMPTS: usize = 3; |
| 329 | /// Connect budget for Registry-launched servers. The 10s global default is |
| 330 | /// meant for pre-installed servers; Registry packages are typically fetched |
| 331 | /// on first launch via npx/uvx, which routinely exceeds it. |
| 332 | const REGISTRY_CONNECT_TIMEOUT_SECS: u64 = 60; |
| 333 | |
| 334 | fn cache_path() -> Result<PathBuf, ToolError> { |
| 335 | dirs::home_dir() |
| 336 | .ok_or_else(|| ToolError::execution_failed("Cannot determine home directory")) |
| 337 | .map(|h| h.join(".codewhale").join("mcp-index.json")) |
| 338 | } |
| 339 | |
| 340 | fn read_cache(path: &PathBuf) -> Option<McpRegistryIndex> { |
| 341 | let data = std::fs::read_to_string(path).ok()?; |
| 342 | serde_json::from_str(&data).ok() |
| 343 | } |
| 344 | |
| 345 | /// Convert one fetched listing into launchable cache entries. Servers the |
| 346 | /// upstream marks `deleted`/`deprecated` are dropped (the aggregator guide |
| 347 | /// recommends removing `deleted` entries — moderation takedowns — from |
| 348 | /// downstream indexes), as is anything the structured launcher cannot run. |
| 349 | /// The cache is a full snapshot every sync, so this filtering is the whole |
| 350 | /// story: retired servers simply never enter the fresh index. |
| 351 | /// Status lives in registry-managed `_meta` |
| 352 | /// (`ServerResponse._meta["io.modelcontextprotocol.registry/official"] |
| 353 | /// .status`, enum `active | deprecated | deleted`). |
| 354 | /// <https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml> |
| 355 | fn viable_entries(entries: Vec<RegistryServerEntry>) -> Vec<McpRegistryServerEntry> { |
| 356 | entries |
| 357 | .into_iter() |
| 358 | .filter(|entry| { |
| 359 | !matches!( |
| 360 | entry.lifecycle_status(), |
| 361 | Some("deleted") | Some("deprecated") |
| 362 | ) |
| 363 | }) |
| 364 | .filter_map(|entry| server_to_entry(entry.server)) |
| 365 | .collect() |
| 366 | } |
| 367 | |
| 368 | /// Render the run command template. Positional `packageArguments` with a |
| 369 | /// fixed literal (or defaulted) value are part of the invocation itself — |
| 370 | /// e.g. the `mcp` subcommand in `npx -y agentic-mermaid@0.1.2 mcp` — so |
| 371 | /// they are folded into the command right after the package spec. |
| 372 | /// `<ARGS>` is the splice point for everything user-supplied: positional |
| 373 | /// placeholders plus each `required_args` entry, rendered as named or |
| 374 | /// positional arguments by the structured Registry launcher. |
| 375 | fn build_run_command( |
| 376 | runtime_hint: &str, |
| 377 | identifier: &str, |
| 378 | version: &str, |
| 379 | runtime_arguments: &[String], |
| 380 | package_arguments: &[RegistryArg], |
| 381 | ) -> String { |
| 382 | let runtime = runtime_hint; |
| 383 | let mut normalized_runtime_arguments = runtime_arguments.to_vec(); |
| 384 | if runtime_hint == "npx" |
| 385 | && !normalized_runtime_arguments |
| 386 | .iter() |
| 387 | .any(|argument| matches!(argument.as_str(), "-y" | "--yes")) |
| 388 | { |
| 389 | normalized_runtime_arguments.insert(0, "-y".to_string()); |
| 390 | } |
| 391 | let mid = normalized_runtime_arguments |
| 392 | .iter() |
| 393 | .map(|argument| shell_words::quote(argument)) |
| 394 | .collect::<Vec<_>>() |
| 395 | .join(" "); |
| 396 | let mid_with_space = if mid.is_empty() { |
| 397 | String::new() |
| 398 | } else { |
| 399 | format!("{mid} ") |
| 400 | }; |
| 401 | let (sep, tail) = match runtime_hint { |
| 402 | "npx" => ("@", version.to_string()), |
| 403 | "uvx" => ("==", version.to_string()), |
| 404 | _ => return String::new(), |
| 405 | }; |
| 406 | // Upstream positional packageArguments ship without a `name`; named |
| 407 | // args always carry one. A nameless arg with a literal `value` (or a |
| 408 | // `default`) is a fixed token of the invocation, not user input — |
| 409 | // dropping it renders a command that cannot start the server (the |
| 410 | // agentic-mermaid `mcp` subcommand bug). |
| 411 | let fixed: Vec<String> = package_arguments |
| 412 | .iter() |
| 413 | .filter(|a| !a.is_required) |
| 414 | .filter(|a| a.name.is_none()) |
| 415 | .filter_map(|a| a.value.as_deref().or(a.default.as_deref())) |
| 416 | .map(|value| shell_words::quote(value).into_owned()) |
| 417 | .collect(); |
| 418 | let fixed_str = if fixed.is_empty() { |
| 419 | String::new() |
| 420 | } else { |
| 421 | format!(" {}", fixed.join(" ")) |
| 422 | }; |
| 423 | let package_spec = format!("{identifier}{sep}{tail}"); |
| 424 | let package = shell_words::quote(&package_spec); |
| 425 | format!("{runtime} {mid_with_space}{package}{fixed_str} <ARGS>") |
| 426 | } |
| 427 | |
| 428 | fn build_launch_spec( |
| 429 | runtime_hint: &str, |
| 430 | identifier: &str, |
| 431 | version: &str, |
| 432 | pkg: &RegistryPackage, |
| 433 | ) -> McpLaunchSpec { |
| 434 | McpLaunchSpec { |
| 435 | run_command: build_run_command( |
| 436 | runtime_hint, |
| 437 | identifier, |
| 438 | version, |
| 439 | &pkg.runtime_arguments, |
| 440 | &pkg.package_arguments, |
| 441 | ), |
| 442 | required_args: pkg |
| 443 | .package_arguments |
| 444 | .iter() |
| 445 | .filter(|a| a.is_required) |
| 446 | .enumerate() |
| 447 | .map(|(index, a)| McpRegistryArgEntry { |
| 448 | // Positional args omit `name` upstream; fall back to the |
| 449 | // value so the cache still carries something the cmd |
| 450 | // builder can render. |
| 451 | name: a |
| 452 | .name |
| 453 | .clone() |
| 454 | .or_else(|| a.value.clone()) |
| 455 | .unwrap_or_else(|| format!("arg_{}", index + 1)), |
| 456 | kind: a.kind.clone(), |
| 457 | description: a.description.clone(), |
| 458 | default: a.default.clone(), |
| 459 | }) |
| 460 | .collect(), |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | fn server_to_entry(server: RegistryServer) -> Option<McpRegistryServerEntry> { |
| 465 | // We only need the FIRST viable stdio package per server for |
| 466 | // launch metadata; everything beyond it would just duplicate |
| 467 | // info. Filter to stdio, resolve runtime_hint + version, and stop |
| 468 | // at the first hit. |
| 469 | let first_pkg = server |
| 470 | .packages |
| 471 | .unwrap_or_default() |
| 472 | .into_iter() |
| 473 | .filter(|p| p.transport.as_deref() == Some("stdio")) |
| 474 | .filter(|p| !p.declares_environment_variables()) |
| 475 | // The automatic launcher currently has deterministic install/run |
| 476 | // semantics for package-manager-backed npm and PyPI entries only. |
| 477 | .filter(|p| matches!(p.registry_type.as_str(), "npm" | "pypi")) |
| 478 | .find_map(|p| { |
| 479 | let expected_hint = default_runtime_hint(&p.registry_type)?; |
| 480 | let hint = p |
| 481 | .runtime_hint |
| 482 | .clone() |
| 483 | .unwrap_or_else(|| expected_hint.to_string()); |
| 484 | if hint != expected_hint { |
| 485 | return None; |
| 486 | } |
| 487 | let version = p.version.clone()?; |
| 488 | Some((p, hint, version)) |
| 489 | }); |
| 490 | |
| 491 | let (pkg, hint, version) = first_pkg?; |
| 492 | |
| 493 | Some(McpRegistryServerEntry { |
| 494 | name: server.name, |
| 495 | description: server.description, |
| 496 | launch: build_launch_spec(&hint, &pkg.identifier, &version, &pkg), |
| 497 | }) |
| 498 | } |
| 499 | |
| 500 | #[derive(Serialize)] |
| 501 | struct RegistryCatalogResult { |
| 502 | instruction: &'static str, |
| 503 | count: usize, |
| 504 | servers: Vec<DigestEntry>, |
| 505 | } |
| 506 | |
| 507 | fn catalog_from_cache(cache: &McpRegistryIndex) -> RegistryCatalogResult { |
| 508 | let servers = cache |
| 509 | .servers |
| 510 | .iter() |
| 511 | .map(|server| DigestEntry { |
| 512 | name: server.name.clone(), |
| 513 | description: server.description.clone(), |
| 514 | required_args: server.launch.required_args.clone(), |
| 515 | }) |
| 516 | .collect::<Vec<_>>(); |
| 517 | RegistryCatalogResult { |
| 518 | instruction: "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.", |
| 519 | count: servers.len(), |
| 520 | servers, |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | async fn load_registry_catalog() -> Result<RegistryCatalogResult, ToolError> { |
| 525 | let path = cache_path()?; |
| 526 | sync_once(&path).await?; |
| 527 | let cache = read_cache(&path).ok_or_else(|| { |
| 528 | ToolError::execution_failed(format!( |
| 529 | "Registry cache was not written at {}", |
| 530 | path.display() |
| 531 | )) |
| 532 | })?; |
| 533 | Ok(catalog_from_cache(&cache)) |
| 534 | } |
| 535 | |
| 536 | /// Paginate the full registry listing. Continues from each page's cursor, |
| 537 | /// appending entries, until the upstream reports no more pages — a complete |
| 538 | /// listing is the only shape ever cached. Never writes anything; callers |
| 539 | /// may retry freely because the on-disk cache only changes at the final |
| 540 | /// atomic replace. |
| 541 | async fn fetch_registry_entries( |
| 542 | client: &reqwest::Client, |
| 543 | ) -> Result<Vec<RegistryServerEntry>, ToolError> { |
| 544 | let mut all_entries: Vec<RegistryServerEntry> = Vec::new(); |
| 545 | let mut cursor: Option<String> = None; |
| 546 | loop { |
| 547 | let mut url = format!("{REGISTRY_API}?version=latest&limit={PER_PAGE}"); |
| 548 | if let Some(ref c) = cursor { |
| 549 | url.push_str(&format!("&cursor={}", urlencoding::encode(c))); |
| 550 | } |
| 551 | let resp = client |
| 552 | .get(&url) |
| 553 | .send() |
| 554 | .await |
| 555 | .and_then(reqwest::Response::error_for_status) |
| 556 | .map_err(|e| ToolError::execution_failed(format!("Registry API: {e}")))?; |
| 557 | let text = resp |
| 558 | .text() |
| 559 | .await |
| 560 | .map_err(|e| ToolError::execution_failed(format!("Registry body: {e}")))?; |
| 561 | let body: RegistryResponse = serde_json::from_str(&text) |
| 562 | .map_err(|e| ToolError::execution_failed(format!("Registry JSON parse: {e}")))?; |
| 563 | all_entries.extend(body.servers); |
| 564 | cursor = body.metadata.and_then(|m| m.next_cursor); |
| 565 | if cursor.is_none() { |
| 566 | break; |
| 567 | } |
| 568 | } |
| 569 | Ok(all_entries) |
| 570 | } |
| 571 | |
| 572 | /// Pull the full registry listing, convert it to a fresh snapshot, and |
| 573 | /// atomically replace the cache file. The fetch is retried (bounded) on |
| 574 | /// failure; the old cache only changes at the atomic replace, so a failed |
| 575 | /// sync leaves the previous snapshot untouched. Returns `Err` only after |
| 576 | /// the retries are exhausted. |
| 577 | async fn sync_once(path: &Path) -> Result<(), ToolError> { |
| 578 | // rustls default-provider install pattern (matches `client.rs`). |
| 579 | let _ = rustls::crypto::ring::default_provider().install_default(); |
| 580 | let client = reqwest::Client::builder() |
| 581 | .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS)) |
| 582 | .build() |
| 583 | .map_err(|e| ToolError::execution_failed(format!("HTTP client: {e}")))?; |
| 584 | |
| 585 | let mut last_error: Option<ToolError> = None; |
| 586 | let mut servers: Option<Vec<McpRegistryServerEntry>> = None; |
| 587 | for _attempt in 0..MAX_SYNC_ATTEMPTS { |
| 588 | match fetch_registry_entries(&client).await { |
| 589 | Ok(entries) => { |
| 590 | servers = Some(viable_entries(entries)); |
| 591 | break; |
| 592 | } |
| 593 | Err(error) => last_error = Some(error), |
| 594 | } |
| 595 | } |
| 596 | let servers = servers.ok_or_else(|| { |
| 597 | last_error.unwrap_or_else(|| ToolError::execution_failed("Registry sync failed")) |
| 598 | })?; |
| 599 | let index = McpRegistryIndex { |
| 600 | version: MCP_REGISTRY_CACHE_VERSION, |
| 601 | count: servers.len(), |
| 602 | servers, |
| 603 | }; |
| 604 | if let Some(parent) = path.parent() { |
| 605 | std::fs::create_dir_all(parent) |
| 606 | .map_err(|e| ToolError::execution_failed(format!("Create cache dir: {e}")))?; |
| 607 | } |
| 608 | let json_str = serde_json::to_string_pretty(&index) |
| 609 | .map_err(|e| ToolError::execution_failed(format!("Serialize: {e}")))?; |
| 610 | write_atomic(path, json_str.as_bytes()) |
| 611 | .map_err(|e| ToolError::execution_failed(format!("Write cache: {e}")))?; |
| 612 | Ok(()) |
| 613 | } |
| 614 | |
| 615 | #[async_trait::async_trait] |
| 616 | impl ToolSpec for McpSyncRegistry { |
| 617 | fn name(&self) -> &str { |
| 618 | "registry_sync" |
| 619 | } |
| 620 | |
| 621 | fn description(&self) -> &str { |
| 622 | "List installable local MCP servers for Registry-first capability \ |
| 623 | selection. Compare their names and descriptions against the user's \ |
| 624 | full task context before using shell commands, local programs, custom \ |
| 625 | code, or manually reproducing a specialized integration. \ |
| 626 | The index contains only stdio packages \ |
| 627 | that declare no environment variables or API keys. If any server \ |
| 628 | plausibly covers the task's core specialized capability, call \ |
| 629 | start_registry_mcp_server with its exact name and inspect its tools \ |
| 630 | before choosing a local alternative; do not run its package command \ |
| 631 | through exec_shell." |
| 632 | } |
| 633 | |
| 634 | fn input_schema(&self) -> Value { |
| 635 | json!({ |
| 636 | "type": "object", |
| 637 | "properties": {}, |
| 638 | "additionalProperties": false |
| 639 | }) |
| 640 | } |
| 641 | |
| 642 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 643 | vec![ToolCapability::Network] |
| 644 | } |
| 645 | |
| 646 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 647 | ApprovalRequirement::Auto |
| 648 | } |
| 649 | |
| 650 | fn supports_parallel(&self) -> bool { |
| 651 | true |
| 652 | } |
| 653 | |
| 654 | async fn execute(&self, _input: Value, _ctx: &ToolContext) -> Result<ToolResult, ToolError> { |
| 655 | let result = load_registry_catalog().await?; |
| 656 | let json = serde_json::to_string(&result) |
| 657 | .map_err(|e| ToolError::execution_failed(format!("Serialize: {e}")))?; |
| 658 | Ok(ToolResult::success(json)) |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | /// Start one zero-environment stdio server selected from the Registry cache. |
| 663 | /// The model supplies a Registry identity and structured CLI values; the host |
| 664 | /// owns command construction, so no arbitrary shell command or environment |
| 665 | /// channel is exposed by the discovery flow. |
| 666 | pub struct StartRegistryMcpServer { |
| 667 | pool: Arc<AsyncMutex<McpPool>>, |
| 668 | } |
| 669 | |
| 670 | impl StartRegistryMcpServer { |
| 671 | pub fn new(pool: Arc<AsyncMutex<McpPool>>) -> Self { |
| 672 | Self { pool } |
| 673 | } |
| 674 | } |
| 675 | |
| 676 | #[async_trait::async_trait] |
| 677 | impl ToolSpec for StartRegistryMcpServer { |
| 678 | fn name(&self) -> &str { |
| 679 | "start_registry_mcp_server" |
| 680 | } |
| 681 | |
| 682 | fn description(&self) -> &str { |
| 683 | "Install and start a local stdio MCP server previously returned by \ |
| 684 | registry_sync. Only Registry packages that declare no environment \ |
| 685 | variables are eligible. Pass the exact registry_name and, when the \ |
| 686 | discovery result lists required_args, provide their values in the \ |
| 687 | structured arguments object. The connected server's complete tool \ |
| 688 | schemas become callable in the same turn." |
| 689 | } |
| 690 | |
| 691 | fn input_schema(&self) -> Value { |
| 692 | json!({ |
| 693 | "type": "object", |
| 694 | "properties": { |
| 695 | "registry_name": { |
| 696 | "type": "string", |
| 697 | "description": "Exact server name returned by registry_sync" |
| 698 | }, |
| 699 | "arguments": { |
| 700 | "type": "object", |
| 701 | "additionalProperties": { "type": "string" }, |
| 702 | "description": "Values keyed by required_args[].name; omit when none are required" |
| 703 | } |
| 704 | }, |
| 705 | "required": ["registry_name"] |
| 706 | }) |
| 707 | } |
| 708 | |
| 709 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 710 | vec![ToolCapability::Network, ToolCapability::ExecutesCode] |
| 711 | } |
| 712 | |
| 713 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 714 | ApprovalRequirement::Required |
| 715 | } |
| 716 | |
| 717 | async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolResult, ToolError> { |
| 718 | let registry_name = input |
| 719 | .get("registry_name") |
| 720 | .and_then(Value::as_str) |
| 721 | .ok_or_else(|| ToolError::invalid_input("missing required field: registry_name"))?; |
| 722 | let supplied: HashMap<String, String> = match input.get("arguments") { |
| 723 | Some(value) => serde_json::from_value(value.clone()) |
| 724 | .map_err(|error| ToolError::invalid_input(format!("invalid arguments: {error}")))?, |
| 725 | None => HashMap::new(), |
| 726 | }; |
| 727 | |
| 728 | let path = cache_path()?; |
| 729 | let cache = read_cache(&path).ok_or_else(|| { |
| 730 | ToolError::execution_failed(format!( |
| 731 | "no current Registry cache at {}; run registry_sync first", |
| 732 | path.display() |
| 733 | )) |
| 734 | })?; |
| 735 | if cache.version != MCP_REGISTRY_CACHE_VERSION { |
| 736 | return Err(ToolError::execution_failed(format!( |
| 737 | "cache at {} is from an older schema version; run registry_sync first", |
| 738 | path.display() |
| 739 | ))); |
| 740 | } |
| 741 | let entry = cache |
| 742 | .servers |
| 743 | .iter() |
| 744 | .find(|server| server.name == registry_name) |
| 745 | .ok_or_else(|| ToolError::invalid_input("registry_name is not present in the cache"))?; |
| 746 | |
| 747 | let expected: HashSet<&str> = entry |
| 748 | .launch |
| 749 | .required_args |
| 750 | .iter() |
| 751 | .map(|arg| arg.name.as_str()) |
| 752 | .collect(); |
| 753 | if let Some(unknown) = supplied |
| 754 | .keys() |
| 755 | .find(|name| !expected.contains(name.as_str())) |
| 756 | { |
| 757 | return Err(ToolError::invalid_input(format!( |
| 758 | "unknown argument '{unknown}' for {registry_name}" |
| 759 | ))); |
| 760 | } |
| 761 | |
| 762 | let mut rendered_args = Vec::new(); |
| 763 | for argument in &entry.launch.required_args { |
| 764 | let value = supplied |
| 765 | .get(&argument.name) |
| 766 | .cloned() |
| 767 | .or_else(|| argument.default.clone()) |
| 768 | .ok_or_else(|| { |
| 769 | ToolError::invalid_input(format!( |
| 770 | "missing required argument '{}' for {registry_name}", |
| 771 | argument.name |
| 772 | )) |
| 773 | })?; |
| 774 | if matches!(argument.kind.as_deref(), Some("named")) && !argument.name.is_empty() { |
| 775 | rendered_args.push(shell_words::quote(&argument.name).into_owned()); |
| 776 | } |
| 777 | rendered_args.push(shell_words::quote(&value).into_owned()); |
| 778 | } |
| 779 | |
| 780 | let command = entry |
| 781 | .launch |
| 782 | .run_command |
| 783 | .replace("<ARGS>", &rendered_args.join(" ")); |
| 784 | let delegated = json!({ |
| 785 | "server": command.trim(), |
| 786 | "name": registry_name, |
| 787 | // Registry packages cold-start through npx/uvx downloads; the |
| 788 | // 10s default connect budget is routinely exceeded on first |
| 789 | // launch. This override is host-supplied only — it is not part |
| 790 | // of the model-facing schema of either tool. |
| 791 | "connect_timeout": REGISTRY_CONNECT_TIMEOUT_SECS, |
| 792 | }); |
| 793 | crate::tools::runtime_mcp::StartRuntimeMcpServer::new(Arc::clone(&self.pool)) |
| 794 | .execute(delegated, ctx) |
| 795 | .await |
| 796 | } |
| 797 | } |
| 798 | |
| 799 | #[cfg(test)] |
| 800 | mod tests { |
| 801 | use super::*; |
| 802 | |
| 803 | #[test] |
| 804 | fn server_no_packages_filtered() { |
| 805 | let server = RegistryServer { |
| 806 | name: "test".into(), |
| 807 | description: "desc".into(), |
| 808 | packages: None, |
| 809 | }; |
| 810 | assert!(server_to_entry(server).is_none()); |
| 811 | } |
| 812 | |
| 813 | #[test] |
| 814 | fn server_remote_only_filtered() { |
| 815 | let server = RegistryServer { |
| 816 | name: "test".into(), |
| 817 | description: "desc".into(), |
| 818 | packages: Some(vec![RegistryPackage { |
| 819 | registry_type: "npm".into(), |
| 820 | identifier: "@test/pkg".into(), |
| 821 | version: Some("1.0.0".into()), |
| 822 | runtime_hint: Some("npx".into()), |
| 823 | transport: Some("streamable-http".into()), |
| 824 | package_arguments: vec![], |
| 825 | runtime_arguments: vec![], |
| 826 | environment_variables: Value::Null, |
| 827 | }]), |
| 828 | }; |
| 829 | assert!(server_to_entry(server).is_none()); |
| 830 | } |
| 831 | |
| 832 | #[test] |
| 833 | fn server_stdio_kept() { |
| 834 | let server = RegistryServer { |
| 835 | name: "test".into(), |
| 836 | description: "desc".into(), |
| 837 | packages: Some(vec![RegistryPackage { |
| 838 | registry_type: "npm".into(), |
| 839 | identifier: "@test/pkg".into(), |
| 840 | version: Some("1.0.0".into()), |
| 841 | runtime_hint: Some("npx".into()), |
| 842 | transport: Some("stdio".into()), |
| 843 | package_arguments: vec![], |
| 844 | runtime_arguments: vec!["-y".into()], |
| 845 | environment_variables: Value::Null, |
| 846 | }]), |
| 847 | }; |
| 848 | let entry = server_to_entry(server).unwrap(); |
| 849 | assert_eq!(entry.launch.run_command, "npx -y @test/pkg@1.0.0 <ARGS>"); |
| 850 | } |
| 851 | |
| 852 | #[test] |
| 853 | fn server_declaring_environment_variables_is_filtered() { |
| 854 | let server = RegistryServer { |
| 855 | name: "needs-secret".into(), |
| 856 | description: "requires an API key".into(), |
| 857 | packages: Some(vec![RegistryPackage { |
| 858 | registry_type: "npm".into(), |
| 859 | identifier: "@test/secret-server".into(), |
| 860 | version: Some("1.0.0".into()), |
| 861 | runtime_hint: Some("npx".into()), |
| 862 | transport: Some("stdio".into()), |
| 863 | package_arguments: vec![], |
| 864 | runtime_arguments: vec!["-y".into()], |
| 865 | environment_variables: json!([{ "name": "API_KEY", "isRequired": true }]), |
| 866 | }]), |
| 867 | }; |
| 868 | assert!(server_to_entry(server).is_none()); |
| 869 | } |
| 870 | |
| 871 | #[test] |
| 872 | fn fixed_positional_package_arguments_render_into_run_command() { |
| 873 | // Regression for the agentic-mermaid launch failure: upstream |
| 874 | // declares `packageArguments: [{"value": "mcp", "type": |
| 875 | // "positional"}]` (no `isRequired`), and dropping that token |
| 876 | // produced `npx -y agentic-mermaid@0.1.2 <ARGS>` — which starts |
| 877 | // the package's default entrypoint, not the MCP server. |
| 878 | let server = RegistryServer { |
| 879 | name: "io.github.adewale/agentic-mermaid".into(), |
| 880 | description: "Render Mermaid diagrams through MCP.".into(), |
| 881 | packages: Some(vec![RegistryPackage { |
| 882 | registry_type: "npm".into(), |
| 883 | identifier: "agentic-mermaid".into(), |
| 884 | version: Some("0.1.2".into()), |
| 885 | runtime_hint: Some("npx".into()), |
| 886 | transport: Some("stdio".into()), |
| 887 | package_arguments: vec![RegistryArg { |
| 888 | name: None, |
| 889 | description: None, |
| 890 | is_required: false, |
| 891 | kind: Some("positional".into()), |
| 892 | value: Some("mcp".into()), |
| 893 | default: None, |
| 894 | }], |
| 895 | runtime_arguments: vec!["-y".into()], |
| 896 | environment_variables: Value::Null, |
| 897 | }]), |
| 898 | }; |
| 899 | let entry = server_to_entry(server).unwrap(); |
| 900 | assert_eq!( |
| 901 | entry.launch.run_command, |
| 902 | "npx -y agentic-mermaid@0.1.2 mcp <ARGS>" |
| 903 | ); |
| 904 | assert!(entry.launch.required_args.is_empty()); |
| 905 | } |
| 906 | |
| 907 | #[test] |
| 908 | fn placeholder_positional_package_argument_stays_in_required_args() { |
| 909 | // The flip side: a positional arg with neither `value` nor |
| 910 | // `default` is user input (e.g. an allowed directory), not a |
| 911 | // fixed token — it must NOT leak into the rendered command. |
| 912 | let server = RegistryServer { |
| 913 | name: "test".into(), |
| 914 | description: "desc".into(), |
| 915 | packages: Some(vec![RegistryPackage { |
| 916 | registry_type: "npm".into(), |
| 917 | identifier: "@test/fs".into(), |
| 918 | version: Some("1.0.0".into()), |
| 919 | runtime_hint: Some("npx".into()), |
| 920 | transport: Some("stdio".into()), |
| 921 | package_arguments: vec![RegistryArg { |
| 922 | name: None, |
| 923 | description: Some("Directory to expose".into()), |
| 924 | is_required: true, |
| 925 | kind: Some("positional".into()), |
| 926 | value: None, |
| 927 | default: None, |
| 928 | }], |
| 929 | runtime_arguments: vec!["-y".into()], |
| 930 | environment_variables: Value::Null, |
| 931 | }]), |
| 932 | }; |
| 933 | let entry = server_to_entry(server).unwrap(); |
| 934 | assert_eq!(entry.launch.run_command, "npx -y @test/fs@1.0.0 <ARGS>"); |
| 935 | assert_eq!(entry.launch.required_args.len(), 1); |
| 936 | } |
| 937 | |
| 938 | #[test] |
| 939 | fn fixed_argument_with_spaces_preserves_one_process_argument() { |
| 940 | let command = build_run_command( |
| 941 | "npx", |
| 942 | "@test/fs", |
| 943 | "1.0.0", |
| 944 | &[], |
| 945 | &[RegistryArg { |
| 946 | name: None, |
| 947 | description: None, |
| 948 | is_required: false, |
| 949 | kind: Some("positional".into()), |
| 950 | value: Some("/tmp/a folder".into()), |
| 951 | default: None, |
| 952 | }], |
| 953 | ); |
| 954 | let parsed = shell_words::split(command.replace("<ARGS>", "").trim()).unwrap(); |
| 955 | assert_eq!(parsed.last().map(String::as_str), Some("/tmp/a folder")); |
| 956 | } |
| 957 | |
| 958 | #[test] |
| 959 | fn server_without_explicit_stdio_transport_is_filtered() { |
| 960 | let server = RegistryServer { |
| 961 | name: "test".into(), |
| 962 | description: "desc".into(), |
| 963 | packages: Some(vec![RegistryPackage { |
| 964 | registry_type: "npm".into(), |
| 965 | identifier: "@test/pkg".into(), |
| 966 | version: Some("1.0.0".into()), |
| 967 | runtime_hint: Some("npx".into()), |
| 968 | transport: None, |
| 969 | package_arguments: vec![], |
| 970 | runtime_arguments: vec![], |
| 971 | environment_variables: Value::Null, |
| 972 | }]), |
| 973 | }; |
| 974 | assert!(server_to_entry(server).is_none()); |
| 975 | } |
| 976 | |
| 977 | #[test] |
| 978 | fn unsupported_registry_runtime_is_not_advertised() { |
| 979 | let server = RegistryServer { |
| 980 | name: "container-only".into(), |
| 981 | description: "OCI stdio server".into(), |
| 982 | packages: Some(vec![RegistryPackage { |
| 983 | registry_type: "oci".into(), |
| 984 | identifier: "docker.io/example/server:1.0.0".into(), |
| 985 | version: None, |
| 986 | runtime_hint: Some("docker".into()), |
| 987 | transport: Some("stdio".into()), |
| 988 | package_arguments: vec![], |
| 989 | runtime_arguments: vec![], |
| 990 | environment_variables: Value::Null, |
| 991 | }]), |
| 992 | }; |
| 993 | assert!(server_to_entry(server).is_none()); |
| 994 | } |
| 995 | |
| 996 | #[test] |
| 997 | fn registry_type_and_runtime_must_match() { |
| 998 | let server = RegistryServer { |
| 999 | name: "mismatched".into(), |
| 1000 | description: "invalid npm runner".into(), |
| 1001 | packages: Some(vec![RegistryPackage { |
| 1002 | registry_type: "npm".into(), |
| 1003 | identifier: "example".into(), |
| 1004 | version: Some("1.0.0".into()), |
| 1005 | runtime_hint: Some("uvx".into()), |
| 1006 | transport: Some("stdio".into()), |
| 1007 | package_arguments: vec![], |
| 1008 | runtime_arguments: vec![], |
| 1009 | environment_variables: Value::Null, |
| 1010 | }]), |
| 1011 | }; |
| 1012 | assert!(server_to_entry(server).is_none()); |
| 1013 | } |
| 1014 | |
| 1015 | /// End-to-end smoke test: drive `McpSyncRegistry::execute()` against the |
| 1016 | /// real MCP Registry API, verify the cache file lands at the right |
| 1017 | /// location with a valid shape, and verify the returned summary is |
| 1018 | /// self-consistent. |
| 1019 | /// |
| 1020 | /// Marked `#[ignore]` because it depends on: |
| 1021 | /// * network access to <https://registry.modelcontextprotocol.io> |
| 1022 | /// * the upstream API schema matching our deserialization types |
| 1023 | /// * ~14s of wall clock for the first cold-cache sync |
| 1024 | /// |
| 1025 | /// Calls the public tool against an empty cache to exercise cold sync. |
| 1026 | /// |
| 1027 | /// Run manually with: |
| 1028 | /// cargo test -p codewhale-tui --bin codewhale-tui --locked \ |
| 1029 | /// execute_writes_cache_file_and_returns_summary -- --ignored --nocapture |
| 1030 | /// |
| 1031 | /// This test deliberately overrides `HOME` to a tempdir so it never |
| 1032 | /// touches the user's real `~/.codewhale/mcp-index.json`. |
| 1033 | #[tokio::test] |
| 1034 | #[ignore = "requires network access to the public MCP Registry; \ |
| 1035 | run with `cargo test -- --ignored`"] |
| 1036 | async fn execute_writes_cache_file_and_returns_summary() { |
| 1037 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 1038 | use crate::tools::spec::ToolContext; |
| 1039 | |
| 1040 | // Serialize env mutation across all tests in this binary. |
| 1041 | let _env_lock = lock_test_env(); |
| 1042 | |
| 1043 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1044 | // Persist the tempdir so we can inspect the cache file after the |
| 1045 | // test returns. `TempDir::keep` (newer API) disables Drop's |
| 1046 | // cleanup so the directory leaks — acceptable for a manual-run |
| 1047 | // integration smoke test that intentionally outlives its scope. |
| 1048 | let tmp_path = tmp.keep(); |
| 1049 | let _home_guard = EnvVarGuard::set("HOME", &tmp_path); |
| 1050 | |
| 1051 | let workspace = tmp_path.clone(); |
| 1052 | let ctx = ToolContext::new(workspace); |
| 1053 | |
| 1054 | let input = json!({}); |
| 1055 | |
| 1056 | let result = McpSyncRegistry |
| 1057 | .execute(input, &ctx) |
| 1058 | .await |
| 1059 | .expect("execute() should not error against the live Registry"); |
| 1060 | |
| 1061 | assert!( |
| 1062 | result.success, |
| 1063 | "execute returned non-success: content={}", |
| 1064 | result.content |
| 1065 | ); |
| 1066 | |
| 1067 | // Cache file should land under the overridden HOME. |
| 1068 | let cache_path = tmp_path.join(".codewhale").join("mcp-index.json"); |
| 1069 | assert!( |
| 1070 | cache_path.exists(), |
| 1071 | "cache file should exist at {:?}", |
| 1072 | cache_path |
| 1073 | ); |
| 1074 | |
| 1075 | // Cache file must be valid JSON matching the McpRegistryIndex |
| 1076 | // schema. If parsing fails here, either the write code is broken |
| 1077 | // or the schema drifted from what execute() writes. |
| 1078 | let raw = std::fs::read_to_string(&cache_path).expect("read cache"); |
| 1079 | let cache: McpRegistryIndex = |
| 1080 | serde_json::from_str(&raw).expect("cache must parse as McpRegistryIndex"); |
| 1081 | |
| 1082 | // The Registry has hundreds of stdio servers as of 2026; expect at |
| 1083 | // least 1 from a single page. If this fails the upstream either |
| 1084 | // removed all stdio entries or our filter is wrong. |
| 1085 | assert!( |
| 1086 | cache.count > 0, |
| 1087 | "expected at least 1 stdio server, got count={}", |
| 1088 | cache.count |
| 1089 | ); |
| 1090 | assert_eq!( |
| 1091 | cache.servers.len(), |
| 1092 | cache.count, |
| 1093 | "cache.count must equal cache.servers.len()" |
| 1094 | ); |
| 1095 | for entry in &cache.servers { |
| 1096 | assert!(!entry.name.is_empty(), "server entry has empty name"); |
| 1097 | assert!( |
| 1098 | entry.launch.run_command.ends_with("<ARGS>"), |
| 1099 | "kept entry {} run_command should end with <ARGS>; got: {}", |
| 1100 | entry.name, |
| 1101 | entry.launch.run_command |
| 1102 | ); |
| 1103 | } |
| 1104 | |
| 1105 | let payload: serde_json::Value = |
| 1106 | serde_json::from_str(&result.content).expect("result content must parse"); |
| 1107 | assert_eq!(payload["count"].as_u64(), Some(cache.count as u64)); |
| 1108 | assert_eq!( |
| 1109 | payload["servers"].as_array().map(Vec::len), |
| 1110 | Some(cache.count) |
| 1111 | ); |
| 1112 | } |
| 1113 | |
| 1114 | fn make_test_cache() -> McpRegistryIndex { |
| 1115 | let server = McpRegistryServerEntry { |
| 1116 | name: "io.modelcontextprotocol/filesystem".into(), |
| 1117 | description: "Read/write local files with sandboxed paths".into(), |
| 1118 | launch: McpLaunchSpec { |
| 1119 | run_command: "npx -y @modelcontextprotocol/server-filesystem@1.0.0 <ARGS>".into(), |
| 1120 | required_args: vec![], |
| 1121 | }, |
| 1122 | }; |
| 1123 | McpRegistryIndex { |
| 1124 | version: MCP_REGISTRY_CACHE_VERSION, |
| 1125 | count: 1, |
| 1126 | servers: vec![server], |
| 1127 | } |
| 1128 | } |
| 1129 | |
| 1130 | #[test] |
| 1131 | fn catalog_exposes_every_server_for_model_selection() { |
| 1132 | let cache = make_test_cache(); |
| 1133 | let catalog = catalog_from_cache(&cache); |
| 1134 | assert_eq!(catalog.count, 1); |
| 1135 | assert_eq!(catalog.servers.len(), 1); |
| 1136 | assert_eq!( |
| 1137 | catalog.servers[0].name, |
| 1138 | "io.modelcontextprotocol/filesystem" |
| 1139 | ); |
| 1140 | assert_eq!( |
| 1141 | catalog.servers[0].description, |
| 1142 | "Read/write local files with sandboxed paths" |
| 1143 | ); |
| 1144 | } |
| 1145 | |
| 1146 | /// Parse one `ServerResponse` JSON object the way the upstream list |
| 1147 | /// endpoint ships it. Lifecycle status travels in registry-managed |
| 1148 | /// `_meta` (`io.modelcontextprotocol.registry/official`), as a |
| 1149 | /// SIBLING of `server` — not inside the server body — and this |
| 1150 | /// helper keeps the tests honest about that path. |
| 1151 | /// <https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml> |
| 1152 | fn parse_server_entry(server_json: Value, status: Option<&str>) -> RegistryServerEntry { |
| 1153 | let mut response = json!({ "server": server_json }); |
| 1154 | if let Some(status) = status { |
| 1155 | response["_meta"] = |
| 1156 | json!({ "io.modelcontextprotocol.registry/official": { "status": status } }); |
| 1157 | } |
| 1158 | serde_json::from_value(response).expect("ServerResponse must deserialize") |
| 1159 | } |
| 1160 | |
| 1161 | #[test] |
| 1162 | fn viable_entries_keeps_only_active_launchable_servers() { |
| 1163 | let launchable = |name: &str, status: Option<&str>| { |
| 1164 | parse_server_entry( |
| 1165 | json!({ |
| 1166 | "name": name, |
| 1167 | "description": "d", |
| 1168 | "packages": [{ |
| 1169 | "registryType": "npm", |
| 1170 | "identifier": "@test/pkg", |
| 1171 | "version": "1.0.0", |
| 1172 | "runtimeHint": "npx", |
| 1173 | "transport": "stdio", |
| 1174 | "packageArguments": [], |
| 1175 | "runtimeArguments": [], |
| 1176 | "environmentVariables": null |
| 1177 | }] |
| 1178 | }), |
| 1179 | status, |
| 1180 | ) |
| 1181 | }; |
| 1182 | let entries = vec![ |
| 1183 | launchable("a/active", Some("active")), |
| 1184 | launchable("b/deprecated", Some("deprecated")), |
| 1185 | launchable("c/deleted", Some("deleted")), |
| 1186 | // No `_meta` at all: treated as active. |
| 1187 | launchable("d/no-meta", None), |
| 1188 | // Missing a launchable package: dropped by the launcher filter. |
| 1189 | parse_server_entry(json!({ "name": "e/no-pkg", "description": "d" }), None), |
| 1190 | ]; |
| 1191 | |
| 1192 | let viable = viable_entries(entries); |
| 1193 | |
| 1194 | let names: Vec<&str> = viable.iter().map(|s| s.name.as_str()).collect(); |
| 1195 | assert_eq!(names, ["a/active", "d/no-meta"]); |
| 1196 | } |
| 1197 | |
| 1198 | /// Minimal cache entry — only `name`/`description`/launch matter to the |
| 1199 | /// catalog conversion tests. |
| 1200 | fn cache_entry(name: &str, description: &str) -> McpRegistryServerEntry { |
| 1201 | McpRegistryServerEntry { |
| 1202 | name: name.into(), |
| 1203 | description: description.into(), |
| 1204 | launch: McpLaunchSpec { |
| 1205 | run_command: "npx pkg@1.0.0 <ARGS>".into(), |
| 1206 | required_args: vec![], |
| 1207 | }, |
| 1208 | } |
| 1209 | } |
| 1210 | |
| 1211 | #[test] |
| 1212 | fn catalog_is_not_programmatically_filtered_or_bounded() { |
| 1213 | let servers = (0..12) |
| 1214 | .map(|index| cache_entry(&format!("example/file-{index}"), "file server")) |
| 1215 | .collect::<Vec<_>>(); |
| 1216 | let cache = McpRegistryIndex { |
| 1217 | version: MCP_REGISTRY_CACHE_VERSION, |
| 1218 | count: servers.len(), |
| 1219 | servers, |
| 1220 | }; |
| 1221 | let result = catalog_from_cache(&cache); |
| 1222 | assert_eq!(result.count, 12); |
| 1223 | assert_eq!(result.servers.len(), 12); |
| 1224 | } |
| 1225 | |
| 1226 | /// Manual smoke run: execute `McpSyncRegistry` against the live Registry |
| 1227 | /// API and print the catalog payload + cache file metadata to stdout so an |
| 1228 | /// operator can inspect what the model would receive. Pure stdout; |
| 1229 | /// assertions stay minimal so a flaky upstream does not mask a useful |
| 1230 | /// manual run. |
| 1231 | /// |
| 1232 | /// Run with: |
| 1233 | /// cargo test -p codewhale-tui --bin codewhale-tui --locked \ |
| 1234 | /// execute_and_print_catalog_for_manual_inspection -- --ignored --nocapture |
| 1235 | #[tokio::test] |
| 1236 | #[ignore = "manual smoke run; prints the tool result to stdout \ |
| 1237 | (requires network access to registry.modelcontextprotocol.io)"] |
| 1238 | // The module tree denies `print_stderr` (scroll-demon guard, #1085) so |
| 1239 | // TUI runtime code can never leak into ratatui's buffer. This test is |
| 1240 | // the deliberate exception: it only runs manually (`--ignored`) and its |
| 1241 | // entire purpose is printing the payload for operator inspection. |
| 1242 | #[allow(clippy::print_stderr)] |
| 1243 | async fn execute_and_print_catalog_for_manual_inspection() { |
| 1244 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 1245 | |
| 1246 | let _env_lock = lock_test_env(); |
| 1247 | |
| 1248 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 1249 | // Persist the tempdir past the test so the cache file survives and |
| 1250 | // can be inspected from the shell after the test returns. |
| 1251 | let tmp_path = tmp.keep(); |
| 1252 | let _home_guard = EnvVarGuard::set("HOME", &tmp_path); |
| 1253 | |
| 1254 | let ctx = ToolContext::new(tmp_path.clone()); |
| 1255 | |
| 1256 | let input = json!({}); |
| 1257 | |
| 1258 | let result = McpSyncRegistry |
| 1259 | .execute(input, &ctx) |
| 1260 | .await |
| 1261 | .expect("execute() should not error against the live Registry"); |
| 1262 | |
| 1263 | let cache_path = tmp_path.join(".codewhale").join("mcp-index.json"); |
| 1264 | |
| 1265 | eprintln!("\n=== registry_sync output ==="); |
| 1266 | eprintln!("tool: registry_sync"); |
| 1267 | eprintln!( |
| 1268 | "status: {}", |
| 1269 | if result.success { "ok" } else { "fail" } |
| 1270 | ); |
| 1271 | eprintln!("cache_path: {}", cache_path.display()); |
| 1272 | eprintln!("cache_exists: {}", cache_path.exists()); |
| 1273 | if cache_path.exists() { |
| 1274 | match std::fs::metadata(&cache_path) { |
| 1275 | Ok(meta) => eprintln!("cache_size_bytes: {}", meta.len()), |
| 1276 | Err(e) => eprintln!("cache_stat_error: {e}"), |
| 1277 | } |
| 1278 | } |
| 1279 | eprintln!("--- catalog payload (what the model sees) ---"); |
| 1280 | eprintln!("{}", result.content); |
| 1281 | eprintln!("=== end ===\n"); |
| 1282 | } |
| 1283 | } |
| 1284 |