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        targets: Vec::new(),
317    })
318}
319
320fn server_config(server: &RegistryServer) -> Result<McpServerConfig> {
321    if let Some(package) = server
322        .packages
323        .iter()
324        .find(|package| package.is_stdio())
325        .or_else(|| server.packages.first())
326    {
327        return stdio_config(server, package);
328    }
329    if !server.remotes.is_empty() {
330        let remote = preferred_remote(server)?;
331        return remote_config(server, remote);
332    }
333    Err(TuffError::unsupported(format!(
334        "registry entry '{}' lists no package and no remote endpoint, so there is nothing to launch or connect to",
335        server.name
336    )))
337}
338
339fn stdio_config(server: &RegistryServer, package: &RegistryPackage) -> Result<McpServerConfig> {
340    if !package.is_stdio() {
341        return Err(TuffError::unsupported(format!(
342            "registry entry '{}' only ships a '{}' transport package, which Tuff cannot launch",
343            server.name,
344            package.transport_type()
345        )));
346    }
347    let command = launch_command(package).ok_or_else(|| {
348        TuffError::unsupported(format!(
349            "registry entry '{}' is published as '{}', which Tuff has no launcher for",
350            server.name, package.registry_type
351        ))
352    })?;
353
354    let env: BTreeMap<String, EnvRef> = package
355        .environment_variables
356        .iter()
357        .map(|variable| {
358            (
359                variable.name.clone(),
360                EnvRef {
361                    from_env: variable.name.clone(),
362                },
363            )
364        })
365        .collect();
366
367    let mut args = Vec::new();
368    if command == "npx" {
369        args.push("-y".to_string());
370    }
371    if command == "docker" {
372        args.extend(["run".to_string(), "-i".to_string(), "--rm".to_string()]);
373        for name in env.keys() {
374            args.push("-e".to_string());
375            args.push(name.clone());
376        }
377    }
378    for argument in &package.runtime_arguments {
379        args.extend(render_argument(server, argument)?);
380    }
381    args.push(package_reference(package));
382    for argument in &package.package_arguments {
383        args.extend(render_argument(server, argument)?);
384    }
385
386    Ok(McpServerConfig {
387        transport: McpTransport::Stdio,
388        command: Some(command),
389        args,
390        url: None,
391        env,
392        // Registry entries that authenticate with a header are refused in
393        // `remote_config` until RFC-106 milestone 3 maps them.
394        headers: BTreeMap::new(),
395        metadata: Some(McpServerMetadata {
396            tools_summary: None,
397        }),
398    })
399}
400
401fn remote_config(server: &RegistryServer, remote: &RegistryRemote) -> Result<McpServerConfig> {
402    reject_placeholders(server, &remote.url)?;
403    let headers = remote_headers(server, remote)?;
404    Ok(McpServerConfig {
405        transport: McpTransport::Http,
406        command: None,
407        args: Vec::new(),
408        url: Some(remote.url.clone()),
409        env: BTreeMap::new(),
410        headers,
411        metadata: Some(McpServerMetadata {
412            tools_summary: None,
413        }),
414    })
415}
416
417/// The remote endpoint Tuff will connect to.
418///
419/// Entries may list several. `streamable-http` is the transport Tuff speaks
420/// and `tuff mcp doctor` probes; `sse` is the superseded 2024-11-05
421/// HTTP+SSE transport, which has a different handshake entirely. Preferring
422/// the former matters for the 303 entries that publish both, and an entry
423/// offering only `sse` is refused rather than installed as though it were
424/// Streamable HTTP, which would write a config no harness could use.
425fn preferred_remote(server: &RegistryServer) -> Result<&RegistryRemote> {
426    if let Some(remote) = server
427        .remotes
428        .iter()
429        .find(|remote| remote.transport_type != SSE_TRANSPORT)
430    {
431        return Ok(remote);
432    }
433    Err(TuffError::unsupported(format!(
434        "registry entry '{}' offers only the superseded 'sse' transport, which Tuff does not speak",
435        server.name
436    )))
437}
438
439const SSE_TRANSPORT: &str = "sse";
440
441/// Turn a remote's declared headers into `{ from_env = … }` references.
442///
443/// Measured against every current registry release on 2026-09-03, headers
444/// come in three shapes, and each gets a different answer:
445///
446/// - **No `value` (2,656 of 3,072).** The publisher documents a header but
447///   not how to build it, so the variable holds the whole header value,
448///   prefix and all. Tuff writes no `format`: inventing `Bearer ` for an
449///   `Authorization` header would be right often and wrong silently, and a
450///   wrong guess produces a config that looks correct and fails inside the
451///   agent.
452/// - **A `value` with exactly one `{placeholder}` (403).** The publisher
453///   said how to build it, so that becomes the `format` and the
454///   placeholder's own name becomes the variable.
455/// - **A `value` with no placeholder (13).** A literal, such as
456///   `Accept: application/json`. There is deliberately no field in a Tuff
457///   manifest a literal header value can occupy, so these are refused.
458///
459/// Optional headers are left out. Over a thousand entries declare one, and
460/// requiring a variable the server does not require would report every one
461/// of them as `missing env`. The caller names what was skipped so the
462/// choice is visible rather than silent.
463fn remote_headers(
464    server: &RegistryServer,
465    remote: &RegistryRemote,
466) -> Result<BTreeMap<String, HeaderRef>> {
467    let capability_id = default_capability_id(&server.name);
468    let mut headers = BTreeMap::new();
469
470    for header in remote.headers.iter().filter(|header| header.is_required) {
471        let name = header.name.trim();
472        if name.is_empty() {
473            return Err(TuffError::unsupported(format!(
474                "registry entry '{}' declares a header with no name",
475                server.name
476            )));
477        }
478
479        let reference = match header.value.as_deref() {
480            Some(value) => header_from_template(server, name, value, &capability_id)?,
481            None => HeaderRef {
482                from_env: sanitized_env_name(&format!("{capability_id}_{name}")),
483                format: None,
484            },
485        };
486        headers.insert(name.to_string(), reference);
487    }
488
489    Ok(headers)
490}
491
492/// Split a documented template such as `Bearer {api_key}` into the format
493/// Tuff records and the variable that fills it.
494fn header_from_template(
495    server: &RegistryServer,
496    header: &str,
497    value: &str,
498    capability_id: &str,
499) -> Result<HeaderRef> {
500    let placeholders = placeholder_names(value);
501    let [placeholder] = placeholders.as_slice() else {
502        let reason = if placeholders.is_empty() {
503            "a literal value, and a Tuff manifest has no field a literal header value can occupy"
504        } else {
505            "a value built from more than one variable, which Tuff cannot express"
506        };
507        return Err(TuffError::unsupported(format!(
508            "registry entry '{}' declares the {header} header with {reason}",
509            server.name
510        )));
511    };
512
513    // A publisher-chosen name like `smithery_api_key` already says which
514    // service it belongs to. A generic one does not, and two servers both
515    // wanting `API_KEY` would quietly share a variable.
516    let qualified = if GENERIC_VARIABLE_NAMES.contains(&placeholder.to_ascii_lowercase().as_str()) {
517        format!("{capability_id}_{placeholder}")
518    } else {
519        placeholder.clone()
520    };
521
522    Ok(HeaderRef {
523        from_env: sanitized_env_name(&qualified),
524        format: Some(value.replacen(&format!("{{{placeholder}}}"), FORMAT_PLACEHOLDER, 1)),
525    })
526}
527
528/// Placeholder names in a header template, in order of appearance.
529fn placeholder_names(value: &str) -> Vec<String> {
530    let mut names = Vec::new();
531    let mut rest = value;
532    while let Some(open) = rest.find('{') {
533        let Some(close) = rest[open..].find('}') else {
534            break;
535        };
536        names.push(rest[open + 1..open + close].to_string());
537        rest = &rest[open + close + 1..];
538    }
539    names
540}
541
542/// Placeholder names that say nothing about which service they belong to.
543const GENERIC_VARIABLE_NAMES: &[&str] = &["api_key", "apikey", "token", "key", "secret", "auth"];
544
545/// A name legal in a shell environment: upper case, underscores only, no
546/// leading digit.
547fn sanitized_env_name(raw: &str) -> String {
548    let mut name = String::with_capacity(raw.len());
549    for character in raw.chars() {
550        if character.is_ascii_alphanumeric() {
551            name.push(character.to_ascii_uppercase());
552        } else if !name.ends_with('_') {
553            name.push('_');
554        }
555    }
556    let name = name.trim_matches('_').to_string();
557    if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
558        format!("_{name}")
559    } else {
560        name
561    }
562}
563
564/// Headers a registry entry declares but Tuff leaves out of the manifest,
565/// because the server does not require them. Named at install time so the
566/// omission is visible and can be added by hand.
567pub fn skipped_optional_headers(server: &RegistryServer) -> Vec<String> {
568    // A package always wins over a remote, so an entry shipping one never
569    // reaches the header path at all.
570    if !server.packages.is_empty() {
571        return Vec::new();
572    }
573    let Ok(remote) = preferred_remote(server) else {
574        return Vec::new();
575    };
576    let mut names: Vec<String> = remote
577        .headers
578        .iter()
579        .filter(|header| !header.is_required)
580        .map(|header| header.name.trim().to_string())
581        .filter(|name| !name.is_empty())
582        .collect();
583    names.sort();
584    names.dedup();
585    names
586}
587
588/// `npx`/`uvx`/`docker`/`dnx`, from the entry's hint or its package kind.
589fn launch_command(package: &RegistryPackage) -> Option<String> {
590    if let Some(hint) = package
591        .runtime_hint
592        .as_deref()
593        .map(str::trim)
594        .filter(|hint| !hint.is_empty())
595    {
596        return Some(hint.to_string());
597    }
598    match package.registry_type.as_str() {
599        "npm" => Some("npx".to_string()),
600        "pypi" => Some("uvx".to_string()),
601        "oci" | "docker" => Some("docker".to_string()),
602        "nuget" => Some("dnx".to_string()),
603        _ => None,
604    }
605}
606
607/// How the package is named on its own command line.
608fn package_reference(package: &RegistryPackage) -> String {
609    if package.version.is_empty() {
610        return package.identifier.clone();
611    }
612    // An image is `name:tag`; every other ecosystem Tuff launches uses `@`.
613    let separator = if matches!(package.registry_type.as_str(), "oci" | "docker") {
614        ':'
615    } else {
616        '@'
617    };
618    format!("{}{separator}{}", package.identifier, package.version)
619}
620
621fn render_argument(server: &RegistryServer, argument: &RegistryArgument) -> Result<Vec<String>> {
622    let value = argument
623        .value
624        .as_deref()
625        .or(argument.default.as_deref())
626        .unwrap_or_default();
627    reject_placeholders(server, value)?;
628    if let Some(name) = argument.name.as_deref() {
629        reject_placeholders(server, name)?;
630        if value.is_empty() {
631            return Ok(vec![name.to_string()]);
632        }
633        return Ok(vec![name.to_string(), value.to_string()]);
634    }
635    if value.is_empty() {
636        return Err(TuffError::unsupported(format!(
637            "registry entry '{}' has a positional argument with no value, so Tuff cannot build its command line",
638            server.name
639        )));
640    }
641    Ok(vec![value.to_string()])
642}
643
644/// A `{placeholder}` means the registry expects the client to substitute a
645/// value. Tuff has nowhere to ask for one, and a guessed value produces a
646/// command that looks right and fails at launch.
647fn reject_placeholders(server: &RegistryServer, value: &str) -> Result<()> {
648    if value.contains('{') && value.contains('}') {
649        return Err(TuffError::unsupported(format!(
650            "registry entry '{}' needs a value substituted into '{value}', which Tuff cannot supply",
651            server.name
652        ))
653        .with_hint("install it from a local manifest with the value filled in"));
654    }
655    Ok(())
656}
657
658impl RegistryPackage {
659    fn transport_type(&self) -> &str {
660        self.transport
661            .as_ref()
662            .map(|transport| transport.transport_type.as_str())
663            .unwrap_or("stdio")
664    }
665
666    fn is_stdio(&self) -> bool {
667        self.transport_type() == "stdio"
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    fn npm_package(identifier: &str, version: &str) -> RegistryPackage {
676        RegistryPackage {
677            registry_type: "npm".into(),
678            identifier: identifier.into(),
679            version: version.into(),
680            runtime_hint: None,
681            transport: Some(RegistryTransport {
682                transport_type: "stdio".into(),
683                url: None,
684                headers: Vec::new(),
685            }),
686            runtime_arguments: Vec::new(),
687            package_arguments: Vec::new(),
688            environment_variables: Vec::new(),
689        }
690    }
691
692    fn server(name: &str, packages: Vec<RegistryPackage>) -> RegistryServer {
693        RegistryServer {
694            name: name.into(),
695            description: "A test server.".into(),
696            version: "1.2.3".into(),
697            packages,
698            remotes: Vec::new(),
699        }
700    }
701
702    fn config_of(server: &RegistryServer) -> McpServerConfig {
703        to_manifest(server, "test").unwrap().server.unwrap()
704    }
705
706    #[test]
707    fn an_npm_package_becomes_an_npx_command_pinned_to_its_version() {
708        let config = config_of(&server(
709            "io.github.acme/thing",
710            vec![npm_package("thing", "1.2.3")],
711        ));
712        assert_eq!(config.command.as_deref(), Some("npx"));
713        // -y so a first run does not stop at a prompt inside the harness.
714        assert_eq!(config.args, vec!["-y", "thing@1.2.3"]);
715        assert_eq!(config.transport, McpTransport::Stdio);
716    }
717
718    #[test]
719    fn the_command_comes_from_the_package_kind_when_no_hint_is_given() {
720        // Most registry entries omit runtimeHint, so deriving the launcher
721        // from registryType is the common path, not the fallback.
722        let mut pypi = npm_package("thing", "1.0.0");
723        pypi.registry_type = "pypi".into();
724        assert_eq!(
725            config_of(&server("a/thing", vec![pypi])).command.as_deref(),
726            Some("uvx")
727        );
728
729        let mut nuget = npm_package("thing", "1.0.0");
730        nuget.registry_type = "nuget".into();
731        assert_eq!(
732            config_of(&server("a/thing", vec![nuget]))
733                .command
734                .as_deref(),
735            Some("dnx")
736        );
737    }
738
739    #[test]
740    fn a_runtime_hint_wins_over_the_package_kind() {
741        let mut package = npm_package("thing", "1.0.0");
742        package.runtime_hint = Some("bunx".into());
743        assert_eq!(
744            config_of(&server("a/thing", vec![package]))
745                .command
746                .as_deref(),
747            Some("bunx")
748        );
749    }
750
751    #[test]
752    fn an_image_is_run_with_docker_and_told_about_its_environment() {
753        // A container sees no environment unless each variable is passed
754        // through explicitly, so the -e flags are not optional.
755        let mut package = npm_package("ghcr.io/acme/thing", "2.0.0");
756        package.registry_type = "oci".into();
757        package.environment_variables = vec![RegistryVariable {
758            name: "API_TOKEN".into(),
759            is_required: true,
760            description: None,
761            value: None,
762        }];
763        let config = config_of(&server("a/thing", vec![package]));
764        assert_eq!(config.command.as_deref(), Some("docker"));
765        assert_eq!(
766            config.args,
767            vec![
768                "run",
769                "-i",
770                "--rm",
771                "-e",
772                "API_TOKEN",
773                "ghcr.io/acme/thing:2.0.0"
774            ]
775        );
776    }
777
778    #[test]
779    fn environment_variables_become_references_never_values() {
780        let mut package = npm_package("thing", "1.0.0");
781        package.environment_variables = vec![RegistryVariable {
782            name: "API_TOKEN".into(),
783            is_required: true,
784            description: Some("A token.".into()),
785            value: None,
786        }];
787        let config = config_of(&server("a/thing", vec![package]));
788        assert_eq!(config.env["API_TOKEN"].from_env, "API_TOKEN");
789    }
790
791    #[test]
792    fn arguments_are_ordered_runtime_then_package_then_package_arguments() {
793        let mut package = npm_package("thing", "1.0.0");
794        package.runtime_arguments = vec![RegistryArgument {
795            argument_type: "positional".into(),
796            name: None,
797            value: Some("--quiet".into()),
798            default: None,
799        }];
800        package.package_arguments = vec![
801            RegistryArgument {
802                argument_type: "positional".into(),
803                name: None,
804                value: Some("serve".into()),
805                default: None,
806            },
807            RegistryArgument {
808                argument_type: "named".into(),
809                name: Some("--port".into()),
810                value: Some("8080".into()),
811                default: None,
812            },
813        ];
814        let config = config_of(&server("a/thing", vec![package]));
815        assert_eq!(
816            config.args,
817            vec!["-y", "--quiet", "thing@1.0.0", "serve", "--port", "8080"]
818        );
819    }
820
821    #[test]
822    fn an_entry_needing_a_substituted_value_is_refused_rather_than_guessed() {
823        // The registry lets a publisher leave {placeholders} for the client
824        // to fill in. A guessed value builds a command that looks right and
825        // fails at launch, which is worse than saying no.
826        let mut package = npm_package("thing", "1.0.0");
827        package.package_arguments = vec![RegistryArgument {
828            argument_type: "positional".into(),
829            name: None,
830            value: Some("{directory}".into()),
831            default: None,
832        }];
833        let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
834        assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
835        assert!(error.to_string().contains("{directory}"), "{error}");
836        assert!(error.hint().is_some());
837    }
838
839    fn header(name: &str, is_required: bool, value: Option<&str>) -> RegistryVariable {
840        RegistryVariable {
841            name: name.into(),
842            is_required,
843            description: None,
844            value: value.map(str::to_string),
845        }
846    }
847
848    fn remote_entry(name: &str, headers: Vec<RegistryVariable>) -> RegistryServer {
849        let mut entry = server(name, Vec::new());
850        entry.remotes = vec![RegistryRemote {
851            transport_type: "streamable-http".into(),
852            url: "https://mcp.example.com/v1".into(),
853            headers,
854        }];
855        entry
856    }
857
858    /// The common shape by a wide margin: the publisher names the header
859    /// and says nothing about how to build its value.
860    #[test]
861    fn a_header_without_a_documented_value_takes_the_whole_value_from_one_variable() {
862        let entry = remote_entry("a/thing", vec![header("Authorization", true, None)]);
863        let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
864
865        let reference = &config.headers["Authorization"];
866        assert_eq!(reference.from_env, "THING_AUTHORIZATION");
867        // No invented `Bearer `. The variable holds the entire header
868        // value, prefix included, because nobody wrote down which prefix.
869        assert_eq!(reference.format, None);
870    }
871
872    #[test]
873    fn a_documented_template_becomes_the_format_and_names_the_variable() {
874        let entry = remote_entry(
875            "ai.smithery/notion",
876            vec![header(
877                "Authorization",
878                true,
879                Some("Bearer {smithery_api_key}"),
880            )],
881        );
882        let config = to_manifest(&entry, "notion").unwrap().server.unwrap();
883
884        let reference = &config.headers["Authorization"];
885        assert_eq!(reference.from_env, "SMITHERY_API_KEY");
886        assert_eq!(reference.format.as_deref(), Some("Bearer {}"));
887        assert_eq!(reference.render("secret"), "Bearer secret");
888    }
889
890    /// `{api_key}` says nothing about whose key it is, so two servers would
891    /// quietly share one variable.
892    #[test]
893    fn a_generic_placeholder_name_is_qualified_by_the_capability() {
894        let entry = remote_entry(
895            "ai.bowmark/bowmark",
896            vec![header("Authorization", true, Some("Bearer {api_key}"))],
897        );
898        let config = to_manifest(&entry, "bowmark").unwrap().server.unwrap();
899        assert_eq!(config.headers["Authorization"].from_env, "BOWMARK_API_KEY");
900    }
901
902    #[test]
903    fn a_literal_header_value_is_refused_rather_than_smuggled_into_the_manifest() {
904        let entry = remote_entry(
905            "a/thing",
906            vec![header("Accept", true, Some("application/json"))],
907        );
908        let error = to_manifest(&entry, "thing").unwrap_err();
909        assert_eq!(error.kind(), crate::error::ErrorKind::Unsupported);
910        assert!(error.to_string().contains("Accept"), "{error}");
911        assert!(error.to_string().contains("literal"), "{error}");
912    }
913
914    #[test]
915    fn a_header_built_from_two_variables_is_refused() {
916        let entry = remote_entry(
917            "a/thing",
918            vec![header("Authorization", true, Some("{scheme} {token}"))],
919        );
920        let error = to_manifest(&entry, "thing").unwrap_err();
921        assert!(error.to_string().contains("more than one"), "{error}");
922    }
923
924    /// Over a thousand entries declare an optional header. Requiring one
925    /// would report a working server as `missing env`.
926    #[test]
927    fn optional_headers_are_left_out_and_named_instead() {
928        let entry = remote_entry(
929            "a/thing",
930            vec![
931                header("Authorization", true, None),
932                header("X-Request-Id", false, None),
933            ],
934        );
935        let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
936
937        assert!(config.headers.contains_key("Authorization"));
938        assert!(!config.headers.contains_key("X-Request-Id"));
939        assert_eq!(skipped_optional_headers(&entry), vec!["X-Request-Id"]);
940    }
941
942    /// `sse` is the superseded transport with a different handshake, not a
943    /// dialect of Streamable HTTP.
944    #[test]
945    fn streamable_http_is_preferred_and_an_sse_only_entry_is_refused() {
946        let mut entry = remote_entry("a/thing", Vec::new());
947        entry.remotes.insert(
948            0,
949            RegistryRemote {
950                transport_type: "sse".into(),
951                url: "https://mcp.example.com/sse".into(),
952                headers: Vec::new(),
953            },
954        );
955        let config = to_manifest(&entry, "thing").unwrap().server.unwrap();
956        assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
957
958        entry.remotes.truncate(1);
959        let error = to_manifest(&entry, "thing").unwrap_err();
960        assert!(error.to_string().contains("sse"), "{error}");
961    }
962
963    #[test]
964    fn a_variable_name_is_made_legal_for_a_shell() {
965        assert_eq!(sanitized_env_name("smithery-api.key"), "SMITHERY_API_KEY");
966        assert_eq!(sanitized_env_name("thing_X-Api-Key"), "THING_X_API_KEY");
967        assert_eq!(sanitized_env_name("_leading_"), "LEADING");
968        assert_eq!(sanitized_env_name("2fa token"), "_2FA_TOKEN");
969    }
970
971    #[test]
972    fn a_remote_without_headers_installs_as_an_http_server() {
973        let mut entry = server("a/thing", Vec::new());
974        entry.remotes = vec![RegistryRemote {
975            transport_type: "streamable-http".into(),
976            url: "https://mcp.example.com/v1".into(),
977            headers: Vec::new(),
978        }];
979        let config = config_of(&entry);
980        assert_eq!(config.transport, McpTransport::Http);
981        assert_eq!(config.url.as_deref(), Some("https://mcp.example.com/v1"));
982    }
983
984    #[test]
985    fn a_package_kind_with_no_launcher_is_refused() {
986        let mut package = npm_package("thing", "1.0.0");
987        package.registry_type = "mcpb".into();
988        let error = to_manifest(&server("a/thing", vec![package]), "test").unwrap_err();
989        assert!(error.to_string().contains("mcpb"), "{error}");
990    }
991
992    #[test]
993    fn an_entry_with_nothing_to_launch_is_refused() {
994        let error = to_manifest(&server("a/thing", Vec::new()), "test").unwrap_err();
995        assert!(
996            error.to_string().contains("no package and no remote"),
997            "{error}"
998        );
999    }
1000
1001    #[test]
1002    fn a_capability_id_keeps_the_publishers_name_when_the_last_segment_is_generic() {
1003        assert_eq!(
1004            default_capability_id("io.github.domdomegg/filesystem-mcp"),
1005            "filesystem-mcp"
1006        );
1007        // "com.notion/mcp" as "mcp" would say nothing about what it is.
1008        assert_eq!(default_capability_id("com.notion/mcp"), "notion-mcp");
1009        assert_eq!(
1010            default_capability_id("ai.smithery/server"),
1011            "smithery-server"
1012        );
1013        assert_eq!(default_capability_id("plain-name"), "plain-name");
1014    }
1015
1016    #[test]
1017    fn a_capability_id_never_contains_a_path_separator() {
1018        // `tuff add` rejects an id with a slash, so the derivation has to
1019        // produce one component whatever the registry name looks like.
1020        for name in [
1021            "io.github.owner/deep/nested/name",
1022            "weird name/with spaces",
1023            "a/b",
1024        ] {
1025            let id = default_capability_id(name);
1026            assert!(!id.contains('/'), "{name} produced {id}");
1027            assert!(!id.is_empty(), "{name} produced an empty id");
1028        }
1029    }
1030}