Skip to main content

tuff_core/
registry.rs

1//! Resolving MCP servers from the official MCP registry.
2//!
3//! The built-in catalog (`catalog.rs`) is twelve entries compiled into the
4//! binary. This module reaches the community registry at
5//! <https://registry.modelcontextprotocol.io>, which holds thousands, and
6//! turns one of its entries into the same [`CapabilityManifest`] the catalog
7//! produces, so installation, drift, update and `mcp doctor` all work on a
8//! registry server without knowing where it came from.
9//!
10//! ## Turning a registry entry into a launch command
11//!
12//! A registry entry does not carry a command line. It describes a *package*
13//! (npm, PyPI, OCI, NuGet) plus the arguments and environment variables that
14//! package needs, and leaves assembling the invocation to the client. The
15//! rules used here, in order:
16//!
17//! 1. Prefer a package whose transport is `stdio`; that is what Tuff can
18//!    launch and what `tuff mcp doctor` can probe.
19//! 2. The command is the entry's `runtimeHint` when it sets one, otherwise
20//!    it is derived from `registryType`: `npm` runs under `npx`, `pypi`
21//!    under `uvx`, `oci` under `docker`, `nuget` under `dnx`.
22//! 3. Arguments are `runtimeArguments`, then the package reference itself,
23//!    then `packageArguments`. `npx` also gets `-y` so a first run does not
24//!    stop on a prompt, and `docker` gets `run -i --rm` plus one `-e` per
25//!    environment variable, because a container sees nothing otherwise.
26//! 4. Environment variables contribute their *names* only, as
27//!    `{ from_env = "NAME" }` references. A registry entry can carry a
28//!    default value for a variable; Tuff never copies one into a manifest,
29//!    because a manifest is committed and a value there would be a leaked
30//!    secret waiting to happen.
31//!
32//! ## What is refused, and why refusing is the right answer
33//!
34//! An entry that cannot be expressed exactly is refused with the reason
35//! rather than installed approximately: a wrong launch command wastes more
36//! of someone's time than a clear "no".
37//!
38//! - **Unresolved `{placeholders}`.** The registry lets an argument or URL
39//!   carry variables for the client to fill in. Tuff has nowhere to ask, and
40//!   guessing a value would produce a command that looks right and fails.
41//! - **Literal header values.** A manifest has no field a literal can
42//!   occupy, by design, so a header the registry documents as a constant
43//!   (`Accept: application/json`) cannot be represented.
44//! - **The superseded `sse` transport.** A different handshake from
45//!   Streamable HTTP; installing one as the other would write a config no
46//!   harness could use.
47//! - **Package kinds with no launcher**, such as `mcpb` bundles.
48//!
49//! ## Remote servers and their headers
50//!
51//! A remote entry's required headers become `[server.headers]` references
52//! (RFC-106). Where the publisher documents the shape of the value, as
53//! `Bearer {api_key}`, that becomes the header's `format` and the
54//! placeholder's name becomes the variable. Where they document only the
55//! header, the variable holds the entire value, prefix included: Tuff does
56//! not guess a `Bearer ` that nobody wrote down. Optional headers are left
57//! out and named at install time, since requiring a variable the server
58//! does not require would report a working server as broken.
59
60use std::collections::BTreeMap;
61
62use serde::Deserialize;
63
64use crate::error::{Result, TuffError};
65use crate::manifest::{
66    CapabilityManifest, CapabilityType, EnvRef, FORMAT_PLACEHOLDER, HeaderRef, McpServerConfig,
67    McpServerMetadata, McpTransport,
68};
69
70/// The official registry. Overridable so a team can point at their own.
71pub const DEFAULT_REGISTRY: &str = "https://registry.modelcontextprotocol.io";
72
73const USER_AGENT: &str = concat!("tuff/", env!("CARGO_PKG_VERSION"));
74
75#[derive(Debug, Deserialize)]
76struct SearchResponse {
77    #[serde(default)]
78    servers: Vec<ServerEnvelope>,
79}
80
81#[derive(Debug, Deserialize)]
82struct ServerEnvelope {
83    server: RegistryServer,
84}
85
86/// One server as the registry describes it. Only the fields Tuff reads.
87#[derive(Debug, Clone, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct RegistryServer {
90    pub name: String,
91    #[serde(default)]
92    pub description: String,
93    #[serde(default)]
94    pub version: String,
95    #[serde(default)]
96    pub packages: Vec<RegistryPackage>,
97    #[serde(default)]
98    pub remotes: Vec<RegistryRemote>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub struct RegistryPackage {
104    pub registry_type: String,
105    pub identifier: String,
106    #[serde(default)]
107    pub version: String,
108    #[serde(default)]
109    pub runtime_hint: Option<String>,
110    #[serde(default)]
111    pub transport: Option<RegistryTransport>,
112    #[serde(default)]
113    pub runtime_arguments: Vec<RegistryArgument>,
114    #[serde(default)]
115    pub package_arguments: Vec<RegistryArgument>,
116    #[serde(default)]
117    pub environment_variables: Vec<RegistryVariable>,
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct RegistryTransport {
122    #[serde(rename = "type", default)]
123    pub transport_type: String,
124    #[serde(default)]
125    pub url: Option<String>,
126    #[serde(default)]
127    pub headers: Vec<RegistryVariable>,
128}
129
130#[derive(Debug, Clone, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct RegistryRemote {
133    #[serde(rename = "type", default)]
134    pub transport_type: String,
135    #[serde(default)]
136    pub url: String,
137    #[serde(default)]
138    pub headers: Vec<RegistryVariable>,
139}
140
141#[derive(Debug, Clone, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct RegistryArgument {
144    #[serde(rename = "type", default)]
145    pub argument_type: String,
146    /// Present on a named argument: the flag, leading dashes included.
147    #[serde(default)]
148    pub name: Option<String>,
149    #[serde(default)]
150    pub value: Option<String>,
151    #[serde(default)]
152    pub default: Option<String>,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct RegistryVariable {
158    pub name: String,
159    #[serde(default)]
160    pub is_required: bool,
161    #[serde(default)]
162    pub description: Option<String>,
163    /// On a header, the template the publisher documents, with the secret
164    /// standing in as a `{named}` placeholder — `Bearer {api_key}`. Absent
165    /// on most headers, meaning the whole value is supplied by the user.
166    #[serde(default)]
167    pub value: Option<String>,
168}
169
170/// Search the registry for the current release of each matching server.
171///
172/// `version=latest` matters: without it the registry returns every version
173/// ever published, so one server appears several times and the first hit is
174/// the oldest. Installing a superseded release because it sorted first is
175/// exactly the wrong default.
176pub async fn search(base_url: &str, query: &str, limit: usize) -> Result<Vec<RegistryServer>> {
177    let url = format!(
178        "{}/v0/servers?search={}&limit={limit}&version=latest",
179        base_url.trim_end_matches('/'),
180        urlencode(query)
181    );
182    let response: SearchResponse = get_json(&url).await?;
183    Ok(response
184        .servers
185        .into_iter()
186        .map(|envelope| envelope.server)
187        .collect())
188}
189
190/// Look one server up by its exact registry name, at its current version.
191///
192/// The registry has no exact-name endpoint on `v0`, so this searches and
193/// then matches the name exactly rather than trusting the first hit: a
194/// search for `github` should not silently install someone else's fork.
195pub async fn fetch(base_url: &str, name: &str) -> Result<Option<RegistryServer>> {
196    let servers = search(base_url, name, 100).await?;
197    Ok(servers.into_iter().find(|server| server.name == name))
198}
199
200async fn get_json<T: serde::de::DeserializeOwned>(url: &str) -> Result<T> {
201    let client = reqwest::Client::builder()
202        .user_agent(USER_AGENT)
203        .build()
204        .map_err(|error| {
205            TuffError::source_failed(format!("could not build the registry HTTP client: {error}"))
206        })?;
207    let response = client.get(url).send().await.map_err(|error| {
208        TuffError::source_failed(format!("could not reach the MCP registry: {error}"))
209    })?;
210    let status = response.status();
211    if !status.is_success() {
212        return Err(TuffError::source_failed(format!(
213            "the MCP registry returned {status} for {url}"
214        )));
215    }
216    let body = response.bytes().await.map_err(|error| {
217        TuffError::source_failed(format!("could not read the MCP registry response: {error}"))
218    })?;
219    serde_json::from_slice(&body).map_err(|error| {
220        TuffError::corrupt(format!(
221            "the MCP registry returned a response Tuff could not parse: {error}"
222        ))
223    })
224}
225
226/// Percent-encode a query value. Only the characters a search term can
227/// realistically contain; the registry accepts the rest verbatim.
228fn urlencode(value: &str) -> String {
229    let mut out = String::with_capacity(value.len());
230    for byte in value.bytes() {
231        match byte {
232            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
233                out.push(byte as char)
234            }
235            _ => out.push_str(&format!("%{byte:02X}")),
236        }
237    }
238    out
239}
240
241/// Segments that identify a protocol rather than a server. `com.notion/mcp`
242/// would otherwise install as `mcp`, which says nothing about what it is.
243const GENERIC_SEGMENTS: &[&str] = &["mcp", "server", "mcp-server", "server-mcp", "main"];
244
245/// The capability id Tuff installs a registry server under.
246///
247/// Registry names are reverse-DNS with a path, `io.github.owner/server`, and
248/// a capability id must be one path component. The last segment is usually
249/// what the publisher would call it, so that is the id; the full name stays
250/// recorded in the lockfile as the source.
251///
252/// When that segment only names the protocol, the publisher's own name is
253/// prepended, so `com.notion/mcp` installs as `notion-mcp` rather than the
254/// useless `mcp`.
255pub fn default_capability_id(name: &str) -> String {
256    let (namespace, last) = match name.rsplit_once('/') {
257        Some((namespace, last)) => (namespace, last),
258        None => ("", name),
259    };
260    let last = sanitize_segment(last);
261    if !GENERIC_SEGMENTS.contains(&last.as_str()) {
262        return last;
263    }
264    let publisher = namespace.rsplit('.').next().unwrap_or_default();
265    let publisher = sanitize_segment(publisher);
266    if publisher.is_empty() {
267        return last;
268    }
269    if last.is_empty() {
270        return publisher;
271    }
272    format!("{publisher}-{last}")
273}
274
275fn sanitize_segment(value: &str) -> String {
276    let cleaned: String = value
277        .chars()
278        .map(|c| {
279            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
280                c
281            } else {
282                '-'
283            }
284        })
285        .collect();
286    cleaned.trim_matches('-').to_string()
287}
288
289/// Build an installable manifest from a registry entry.
290///
291/// See the module docs for the rules and the deliberate refusals.
292pub fn to_manifest(server: &RegistryServer, id: &str) -> Result<CapabilityManifest> {
293    let config = server_config(server)?;
294    crate::manifest::validate_mcp_server(&config).map_err(|error| {
295        TuffError::unsupported(format!(
296            "registry entry '{}' does not describe a server Tuff can install: {error}",
297            server.name
298        ))
299    })?;
300    Ok(CapabilityManifest {
301        id: id.to_string(),
302        version: if server.version.is_empty() {
303            "0.0.0".to_string()
304        } else {
305            server.version.clone()
306        },
307        capability_type: CapabilityType::McpServer,
308        description: server.description.clone(),
309        files: Vec::new(),
310        root: std::path::PathBuf::new(),
311        implementation: None,
312        parameters: None,
313        workflow: None,
314        hook: None,
315        server: Some(config),
316        policy: None,
317        targets: Vec::new(),
318    })
319}
320
321fn server_config(server: &RegistryServer) -> Result<McpServerConfig> {
322    if let Some(package) = server
323        .packages
324        .iter()
325        .find(|package| package.is_stdio())
326        .or_else(|| server.packages.first())
327    {
328        return stdio_config(server, package);
329    }
330    if !server.remotes.is_empty() {
331        let remote = preferred_remote(server)?;
332        return remote_config(server, remote);
333    }
334    Err(TuffError::unsupported(format!(
335        "registry entry '{}' lists no package and no remote endpoint, so there is nothing to launch or connect to",
336        server.name
337    )))
338}
339
340fn stdio_config(server: &RegistryServer, package: &RegistryPackage) -> Result<McpServerConfig> {
341    if !package.is_stdio() {
342        return Err(TuffError::unsupported(format!(
343            "registry entry '{}' only ships a '{}' transport package, which Tuff cannot launch",
344            server.name,
345            package.transport_type()
346        )));
347    }
348    let command = launch_command(package).ok_or_else(|| {
349        TuffError::unsupported(format!(
350            "registry entry '{}' is published as '{}', which Tuff has no launcher for",
351            server.name, package.registry_type
352        ))
353    })?;
354
355    let env: BTreeMap<String, EnvRef> = package
356        .environment_variables
357        .iter()
358        .map(|variable| {
359            (
360                variable.name.clone(),
361                EnvRef {
362                    from_env: variable.name.clone(),
363                },
364            )
365        })
366        .collect();
367
368    let mut args = Vec::new();
369    if command == "npx" {
370        args.push("-y".to_string());
371    }
372    if command == "docker" {
373        args.extend(["run".to_string(), "-i".to_string(), "--rm".to_string()]);
374        for name in env.keys() {
375            args.push("-e".to_string());
376            args.push(name.clone());
377        }
378    }
379    for argument in &package.runtime_arguments {
380        args.extend(render_argument(server, argument)?);
381    }
382    args.push(package_reference(package));
383    for argument in &package.package_arguments {
384        args.extend(render_argument(server, argument)?);
385    }
386
387    Ok(McpServerConfig {
388        transport: McpTransport::Stdio,
389        command: Some(command),
390        args,
391        url: None,
392        env,
393        // Registry entries that authenticate with a header are refused in
394        // `remote_config` until RFC-106 milestone 3 maps them.
395        headers: BTreeMap::new(),
396        metadata: Some(McpServerMetadata {
397            tools_summary: None,
398        }),
399    })
400}
401
402fn remote_config(server: &RegistryServer, remote: &RegistryRemote) -> Result<McpServerConfig> {
403    reject_placeholders(server, &remote.url)?;
404    let headers = remote_headers(server, remote)?;
405    Ok(McpServerConfig {
406        transport: McpTransport::Http,
407        command: None,
408        args: Vec::new(),
409        url: Some(remote.url.clone()),
410        env: BTreeMap::new(),
411        headers,
412        metadata: Some(McpServerMetadata {
413            tools_summary: None,
414        }),
415    })
416}
417
418/// The remote endpoint Tuff will connect to.
419///
420/// Entries may list several. `streamable-http` is the transport Tuff speaks
421/// and `tuff mcp doctor` probes; `sse` is the superseded 2024-11-05
422/// HTTP+SSE transport, which has a different handshake entirely. Preferring
423/// the former matters for the 303 entries that publish both, and an entry
424/// offering only `sse` is refused rather than installed as though it were
425/// Streamable HTTP, which would write a config no harness could use.
426fn preferred_remote(server: &RegistryServer) -> Result<&RegistryRemote> {
427    if let Some(remote) = server
428        .remotes
429        .iter()
430        .find(|remote| remote.transport_type != SSE_TRANSPORT)
431    {
432        return Ok(remote);
433    }
434    Err(TuffError::unsupported(format!(
435        "registry entry '{}' offers only the superseded 'sse' transport, which Tuff does not speak",
436        server.name
437    )))
438}
439
440const SSE_TRANSPORT: &str = "sse";
441
442/// Turn a remote's declared headers into `{ from_env = … }` references.
443///
444/// Measured against every current registry release on 2026-09-03, headers
445/// come in three shapes, and each gets a different answer:
446///
447/// - **No `value` (2,656 of 3,072).** The publisher documents a header but
448///   not how to build it, so the variable holds the whole header value,
449///   prefix and all. Tuff writes no `format`: inventing `Bearer ` for an
450///   `Authorization` header would be right often and wrong silently, and a
451///   wrong guess produces a config that looks correct and fails inside the
452///   agent.
453/// - **A `value` with exactly one `{placeholder}` (403).** The publisher
454///   said how to build it, so that becomes the `format` and the
455///   placeholder's own name becomes the variable.
456/// - **A `value` with no placeholder (13).** A literal, such as
457///   `Accept: application/json`. There is deliberately no field in a Tuff
458///   manifest a literal header value can occupy, so these are refused.
459///
460/// Optional headers are left out. Over a thousand entries declare one, and
461/// requiring a variable the server does not require would report every one
462/// of them as `missing env`. The caller names what was skipped so the
463/// choice is visible rather than silent.
464fn remote_headers(
465    server: &RegistryServer,
466    remote: &RegistryRemote,
467) -> Result<BTreeMap<String, HeaderRef>> {
468    let capability_id = default_capability_id(&server.name);
469    let mut headers = BTreeMap::new();
470
471    for header in remote.headers.iter().filter(|header| header.is_required) {
472        let name = header.name.trim();
473        if name.is_empty() {
474            return Err(TuffError::unsupported(format!(
475                "registry entry '{}' declares a header with no name",
476                server.name
477            )));
478        }
479
480        let reference = match header.value.as_deref() {
481            Some(value) => header_from_template(server, name, value, &capability_id)?,
482            None => HeaderRef {
483                from_env: sanitized_env_name(&format!("{capability_id}_{name}")),
484                format: None,
485            },
486        };
487        headers.insert(name.to_string(), reference);
488    }
489
490    Ok(headers)
491}
492
493/// Split a documented template such as `Bearer {api_key}` into the format
494/// Tuff records and the variable that fills it.
495fn header_from_template(
496    server: &RegistryServer,
497    header: &str,
498    value: &str,
499    capability_id: &str,
500) -> Result<HeaderRef> {
501    let placeholders = placeholder_names(value);
502    let [placeholder] = placeholders.as_slice() else {
503        let reason = if placeholders.is_empty() {
504            "a literal value, and a Tuff manifest has no field a literal header value can occupy"
505        } else {
506            "a value built from more than one variable, which Tuff cannot express"
507        };
508        return Err(TuffError::unsupported(format!(
509            "registry entry '{}' declares the {header} header with {reason}",
510            server.name
511        )));
512    };
513
514    // A publisher-chosen name like `smithery_api_key` already says which
515    // service it belongs to. A generic one does not, and two servers both
516    // wanting `API_KEY` would quietly share a variable.
517    let qualified = if GENERIC_VARIABLE_NAMES.contains(&placeholder.to_ascii_lowercase().as_str()) {
518        format!("{capability_id}_{placeholder}")
519    } else {
520        placeholder.clone()
521    };
522
523    Ok(HeaderRef {
524        from_env: sanitized_env_name(&qualified),
525        format: Some(value.replacen(&format!("{{{placeholder}}}"), FORMAT_PLACEHOLDER, 1)),
526    })
527}
528
529/// Placeholder names in a header template, in order of appearance.
530fn placeholder_names(value: &str) -> Vec<String> {
531    let mut names = Vec::new();
532    let mut rest = value;
533    while let Some(open) = rest.find('{') {
534        let Some(close) = rest[open..].find('}') else {
535            break;
536        };
537        names.push(rest[open + 1..open + close].to_string());
538        rest = &rest[open + close + 1..];
539    }
540    names
541}
542
543/// Placeholder names that say nothing about which service they belong to.
544const GENERIC_VARIABLE_NAMES: &[&str] = &["api_key", "apikey", "token", "key", "secret", "auth"];
545
546/// A name legal in a shell environment: upper case, underscores only, no
547/// leading digit.
548fn sanitized_env_name(raw: &str) -> String {
549    let mut name = String::with_capacity(raw.len());
550    for character in raw.chars() {
551        if character.is_ascii_alphanumeric() {
552            name.push(character.to_ascii_uppercase());
553        } else if !name.ends_with('_') {
554            name.push('_');
555        }
556    }
557    let name = name.trim_matches('_').to_string();
558    if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
559        format!("_{name}")
560    } else {
561        name
562    }
563}
564
565/// Headers a registry entry declares but Tuff leaves out of the manifest,
566/// because the server does not require them. Named at install time so the
567/// omission is visible and can be added by hand.
568pub fn skipped_optional_headers(server: &RegistryServer) -> Vec<String> {
569    // A package always wins over a remote, so an entry shipping one never
570    // reaches the header path at all.
571    if !server.packages.is_empty() {
572        return Vec::new();
573    }
574    let Ok(remote) = preferred_remote(server) else {
575        return Vec::new();
576    };
577    let mut names: Vec<String> = remote
578        .headers
579        .iter()
580        .filter(|header| !header.is_required)
581        .map(|header| header.name.trim().to_string())
582        .filter(|name| !name.is_empty())
583        .collect();
584    names.sort();
585    names.dedup();
586    names
587}
588
589/// `npx`/`uvx`/`docker`/`dnx`, from the entry's hint or its package kind.
590fn launch_command(package: &RegistryPackage) -> Option<String> {
591    if let Some(hint) = package
592        .runtime_hint
593        .as_deref()
594        .map(str::trim)
595        .filter(|hint| !hint.is_empty())
596    {
597        return Some(hint.to_string());
598    }
599    match package.registry_type.as_str() {
600        "npm" => Some("npx".to_string()),
601        "pypi" => Some("uvx".to_string()),
602        "oci" | "docker" => Some("docker".to_string()),
603        "nuget" => Some("dnx".to_string()),
604        _ => None,
605    }
606}
607
608/// How the package is named on its own command line.
609fn package_reference(package: &RegistryPackage) -> String {
610    if package.version.is_empty() {
611        return package.identifier.clone();
612    }
613    // An image is `name:tag`; every other ecosystem Tuff launches uses `@`.
614    let separator = if matches!(package.registry_type.as_str(), "oci" | "docker") {
615        ':'
616    } else {
617        '@'
618    };
619    format!("{}{separator}{}", package.identifier, package.version)
620}
621
622fn render_argument(server: &RegistryServer, argument: &RegistryArgument) -> Result<Vec<String>> {
623    let value = argument
624        .value
625        .as_deref()
626        .or(argument.default.as_deref())
627        .unwrap_or_default();
628    reject_placeholders(server, value)?;
629    if let Some(name) = argument.name.as_deref() {
630        reject_placeholders(server, name)?;
631        if value.is_empty() {
632            return Ok(vec![name.to_string()]);
633        }
634        return Ok(vec![name.to_string(), value.to_string()]);
635    }
636    if value.is_empty() {
637        return Err(TuffError::unsupported(format!(
638            "registry entry '{}' has a positional argument with no value, so Tuff cannot build its command line",
639            server.name
640        )));
641    }
642    Ok(vec![value.to_string()])
643}
644
645/// A `{placeholder}` means the registry expects the client to substitute a
646/// value. Tuff has nowhere to ask for one, and a guessed value produces a
647/// command that looks right and fails at launch.
648fn reject_placeholders(server: &RegistryServer, value: &str) -> Result<()> {
649    if value.contains('{') && value.contains('}') {
650        return Err(TuffError::unsupported(format!(
651            "registry entry '{}' needs a value substituted into '{value}', which Tuff cannot supply",
652            server.name
653        ))
654        .with_hint("install it from a local manifest with the value filled in"));
655    }
656    Ok(())
657}
658
659impl RegistryPackage {
660    fn transport_type(&self) -> &str {
661        self.transport
662            .as_ref()
663            .map(|transport| transport.transport_type.as_str())
664            .unwrap_or("stdio")
665    }
666
667    fn is_stdio(&self) -> bool {
668        self.transport_type() == "stdio"
669    }
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    fn npm_package(identifier: &str, version: &str) -> RegistryPackage {
677        RegistryPackage {
678            registry_type: "npm".into(),
679            identifier: identifier.into(),
680            version: version.into(),
681            runtime_hint: None,
682            transport: Some(RegistryTransport {
683                transport_type: "stdio".into(),
684                url: None,
685                headers: Vec::new(),
686            }),
687            runtime_arguments: Vec::new(),
688            package_arguments: Vec::new(),
689            environment_variables: Vec::new(),
690        }
691    }
692
693    fn server(name: &str, packages: Vec<RegistryPackage>) -> RegistryServer {
694        RegistryServer {
695            name: name.into(),
696            description: "A test server.".into(),
697            version: "1.2.3".into(),
698            packages,
699            remotes: Vec::new(),
700        }
701    }
702
703    fn config_of(server: &RegistryServer) -> McpServerConfig {
704        to_manifest(server, "test").unwrap().server.unwrap()
705    }
706
707    #[test]
708    fn an_npm_package_becomes_an_npx_command_pinned_to_its_version() {
709        let config = config_of(&server(
710            "io.github.acme/thing",
711            vec![npm_package("thing", "1.2.3")],
712        ));
713        assert_eq!(config.command.as_deref(), Some("npx"));
714        // -y so a first run does not stop at a prompt inside the harness.
715        assert_eq!(config.args, vec!["-y", "thing@1.2.3"]);
716        assert_eq!(config.transport, McpTransport::Stdio);
717    }
718
719    #[test]
720    fn the_command_comes_from_the_package_kind_when_no_hint_is_given() {
721        // Most registry entries omit runtimeHint, so deriving the launcher
722        // from registryType is the common path, not the fallback.
723        let mut pypi = npm_package("thing", "1.0.0");
724        pypi.registry_type = "pypi".into();
725        assert_eq!(
726            config_of(&server("a/thing", vec![pypi])).command.as_deref(),
727            Some("uvx")
728        );
729
730        let mut nuget = npm_package("thing", "1.0.0");
731        nuget.registry_type = "nuget".into();
732        assert_eq!(
733            config_of(&server("a/thing", vec![nuget]))
734                .command
735                .as_deref(),
736            Some("dnx")
737        );
738    }
739
740    #[test]
741    fn a_runtime_hint_wins_over_the_package_kind() {
742        let mut package = npm_package("thing", "1.0.0");
743        package.runtime_hint = Some("bunx".into());
744        assert_eq!(
745            config_of(&server("a/thing", vec![package]))
746                .command
747                .as_deref(),
748            Some("bunx")
749        );
750    }
751
752    #[test]
753    fn an_image_is_run_with_docker_and_told_about_its_environment() {
754        // A container sees no environment unless each variable is passed
755        // through explicitly, so the -e flags are not optional.
756        let mut package = npm_package("ghcr.io/acme/thing", "2.0.0");
757        package.registry_type = "oci".into();
758        package.environment_variables = vec![RegistryVariable {
759            name: "API_TOKEN".into(),
760            is_required: true,
761            description: None,
762            value: None,
763        }];
764        let config = config_of(&server("a/thing", vec![package]));
765        assert_eq!(config.command.as_deref(), Some("docker"));
766        assert_eq!(
767            config.args,
768            vec![
769                "run",
770                "-i",
771                "--rm",
772                "-e",
773                "API_TOKEN",
774                "ghcr.io/acme/thing:2.0.0"
775            ]
776        );
777    }
778
779    #[test]
780    fn environment_variables_become_references_never_values() {
781        let mut package = npm_package("thing", "1.0.0");
782        package.environment_variables = vec![RegistryVariable {
783            name: "API_TOKEN".into(),
784            is_required: true,
785            description: Some("A token.".into()),
786            value: None,
787        }];
788        let config = config_of(&server("a/thing", vec![package]));
789        assert_eq!(config.env["API_TOKEN"].from_env, "API_TOKEN");
790    }
791
792    #[test]
793    fn arguments_are_ordered_runtime_then_package_then_package_arguments() {
794        let mut package = npm_package("thing", "1.0.0");
795        package.runtime_arguments = vec![RegistryArgument {
796            argument_type: "positional".into(),
797            name: None,
798            value: Some("--quiet".into()),
799            default: None,
800        }];
801        package.package_arguments = vec![
802            RegistryArgument {
803                argument_type: "positional".into(),
804                name: None,
805                value: Some("serve".into()),
806                default: None,
807            },
808            RegistryArgument {
809                argument_type: "named".into(),
810                name: Some("--port".into()),
811                value: Some("8080".into()),
812                default: None,
813            },
814        ];
815        let config = config_of(&server("a/thing", vec![package]));
816        assert_eq!(
817            config.args,
818            vec!["-y", "--quiet", "thing@1.0.0", "serve", "--port", "8080"]
819        );
820    }
821
822    #[test]
823    fn an_entry_needing_a_substituted_value_is_refused_rather_than_guessed() {
824        // The registry lets a publisher leave {placeholders} for the client
825        // to fill in. A guessed value builds a command that looks right and
826        // fails at launch, which is worse than saying no.
827        let mut package = npm_package("thing", "1.0.0");
828        package.package_arguments = vec![RegistryArgument {
829            argument_type: "positional".into(),
830            name: None,
831            value: Some("{directory}".into()),
832            default: None,
833        }];
834        let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
835        assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
836        assert!(error.to_string().contains("{directory}"), "{error}");
837        assert!(error.hint().is_some());
838    }
839
840    fn header(name: &str, is_required: bool, value: Option<&str>) -> RegistryVariable {
841        RegistryVariable {
842            name: name.into(),
843            is_required,
844            description: None,
845            value: value.map(str::to_string),
846        }
847    }
848
849    fn remote_entry(name: &str, headers: Vec<RegistryVariable>) -> RegistryServer {
850        let mut entry = server(name, Vec::new());
851        entry.remotes = vec![RegistryRemote {
852            transport_type: "streamable-http".into(),
853            url: "https://mcp.example.com/v1".into(),
854            headers,
855        }];
856        entry
857    }
858
859    /// The common shape by a wide margin: the publisher names the header
860    /// and says nothing about how to build its value.
861    #[test]
862    fn a_header_without_a_documented_value_takes_the_whole_value_from_one_variable() {
863        let entry = remote_entry("a/thing", vec![header("Authorization", true, None)]);
864        let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
865
866        let reference = &config.headers["Authorization"];
867        assert_eq!(reference.from_env, "THING_AUTHORIZATION");
868        // No invented `Bearer `. The variable holds the entire header
869        // value, prefix included, because nobody wrote down which prefix.
870        assert_eq!(reference.format, None);
871    }
872
873    #[test]
874    fn a_documented_template_becomes_the_format_and_names_the_variable() {
875        let entry = remote_entry(
876            "ai.smithery/notion",
877            vec![header(
878                "Authorization",
879                true,
880                Some("Bearer {smithery_api_key}"),
881            )],
882        );
883        let config = to_manifest(&entry, "notion").unwrap().server.unwrap();
884
885        let reference = &config.headers["Authorization"];
886        assert_eq!(reference.from_env, "SMITHERY_API_KEY");
887        assert_eq!(reference.format.as_deref(), Some("Bearer {}"));
888        assert_eq!(reference.render("secret"), "Bearer secret");
889    }
890
891    /// `{api_key}` says nothing about whose key it is, so two servers would
892    /// quietly share one variable.
893    #[test]
894    fn a_generic_placeholder_name_is_qualified_by_the_capability() {
895        let entry = remote_entry(
896            "ai.bowmark/bowmark",
897            vec![header("Authorization", true, Some("Bearer {api_key}"))],
898        );
899        let config = to_manifest(&entry, "bowmark").unwrap().server.unwrap();
900        assert_eq!(config.headers["Authorization"].from_env, "BOWMARK_API_KEY");
901    }
902
903    #[test]
904    fn a_literal_header_value_is_refused_rather_than_smuggled_into_the_manifest() {
905        let entry = remote_entry(
906            "a/thing",
907            vec![header("Accept", true, Some("application/json"))],
908        );
909        let error = to_manifest(&entry, "thing").unwrap_err();
910        assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
911        assert!(error.to_string().contains("Accept"), "{error}");
912        assert!(error.to_string().contains("literal"), "{error}");
913    }
914
915    #[test]
916    fn a_header_built_from_two_variables_is_refused() {
917        let entry = remote_entry(
918            "a/thing",
919            vec![header("Authorization", true, Some("{scheme} {token}"))],
920        );
921        let error = to_manifest(&entry, "thing").unwrap_err();
922        assert!(error.to_string().contains("more than one"), "{error}");
923    }
924
925    /// Over a thousand entries declare an optional header. Requiring one
926    /// would report a working server as `missing env`.
927    #[test]
928    fn optional_headers_are_left_out_and_named_instead() {
929        let entry = remote_entry(
930            "a/thing",
931            vec![
932                header("Authorization", true, None),
933                header("X-Request-Id", false, None),
934            ],
935        );
936        let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
937
938        assert!(config.headers.contains_key("Authorization"));
939        assert!(!config.headers.contains_key("X-Request-Id"));
940        assert_eq!(skipped_optional_headers(&entry), vec!["X-Request-Id"]);
941    }
942
943    /// `sse` is the superseded transport with a different handshake, not a
944    /// dialect of Streamable HTTP.
945    #[test]
946    fn streamable_http_is_preferred_and_an_sse_only_entry_is_refused() {
947        let mut entry = remote_entry("a/thing", Vec::new());
948        entry.remotes.insert(
949            0,
950            RegistryRemote {
951                transport_type: "sse".into(),
952                url: "https://mcp.example.com/sse".into(),
953                headers: Vec::new(),
954            },
955        );
956        let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
957        assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
958
959        entry.remotes.truncate(1);
960        let error = to_manifest(&entry, "thing").unwrap_err();
961        assert!(error.to_string().contains("sse"), "{error}");
962    }
963
964    #[test]
965    fn a_variable_name_is_made_legal_for_a_shell() {
966        assert_eq!(sanitized_env_name("smithery-api.key"), "SMITHERY_API_KEY");
967        assert_eq!(sanitized_env_name("thing_X-Api-Key"), "THING_X_API_KEY");
968        assert_eq!(sanitized_env_name("_leading_"), "LEADING");
969        assert_eq!(sanitized_env_name("2fa token"), "_2FA_TOKEN");
970    }
971
972    #[test]
973    fn a_remote_without_headers_installs_as_an_http_server() {
974        let mut entry = server("a/thing", Vec::new());
975        entry.remotes = vec![RegistryRemote {
976            transport_type: "streamable-http".into(),
977            url: "https://mcp.example.com/v1".into(),
978            headers: Vec::new(),
979        }];
980        let config = config_of(&entry);
981        assert_eq!(config.transport, McpTransport::Http);
982        assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
983    }
984
985    #[test]
986    fn a_package_kind_with_no_launcher_is_refused() {
987        let mut package = npm_package("thing", "1.0.0");
988        package.registry_type = "mcpb".into();
989        let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
990        assert!(error.to_string().contains("mcpb"), "{error}");
991    }
992
993    #[test]
994    fn an_entry_with_nothing_to_launch_is_refused() {
995        let error = to_manifest(&server("a/thing", Vec::new()), "test").unwrap_err();
996        assert!(
997            error.to_string().contains("no package and no remote"),
998            "{error}"
999        );
1000    }
1001
1002    #[test]
1003    fn a_capability_id_keeps_the_publishers_name_when_the_last_segment_is_generic() {
1004        assert_eq!(
1005            default_capability_id("io.github.domdomegg/filesystem-mcp"),
1006            "filesystem-mcp"
1007        );
1008        // "com.notion/mcp" as "mcp" would say nothing about what it is.
1009        assert_eq!(default_capability_id("com.notion/mcp"), "notion-mcp");
1010        assert_eq!(
1011            default_capability_id("ai.smithery/server"),
1012            "smithery-server"
1013        );
1014        assert_eq!(default_capability_id("plain-name"), "plain-name");
1015    }
1016
1017    #[test]
1018    fn a_capability_id_never_contains_a_path_separator() {
1019        // `tuff add` rejects an id with a slash, so the derivation has to
1020        // produce one component whatever the registry name looks like.
1021        for name in [
1022            "io.github.owner/deep/nested/name",
1023            "weird name/with spaces",
1024            "a/b",
1025        ] {
1026            let id = default_capability_id(name);
1027            assert!(!id.contains('/'), "{name} produced {id}");
1028            assert!(!id.is_empty(), "{name} produced an empty id");
1029        }
1030    }
1031}