返回 CodeWhale
mcp_registry.rs
根目录 / crates / tui / src / tools / mcp_registry.rs
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
1284 lines RUST