Skip to main content

tuff_core/
catalog.rs

1//! The built-in MCP server catalog (RFC-102).
2//!
3//! `tuff add mcp github` resolves against this list instead of a path or git
4//! URL. Each entry is turned into an in-memory [`CapabilityManifest`] and
5//! installed through exactly the same path as one loaded from `tuff.toml`,
6//! so every lifecycle verb works on catalog installs without special cases —
7//! the only catalog-aware code is in `update`, `outdated`, and `diff`, which
8//! re-resolve here instead of cloning a git repository.
9
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12
13use serde::Deserialize;
14
15use crate::error::{Result, TuffError};
16use crate::manifest::{
17    CapabilityManifest, CapabilityType, EnvRef, McpServerConfig, McpServerMetadata, McpTransport,
18};
19
20const CATALOG_TOML: &str = include_str!("../assets/mcp-catalog.toml");
21
22/// Recorded as `SourceMetadata.source_type` for catalog installs.
23pub const SOURCE_TYPE: &str = "catalog";
24/// Recorded as `SourceMetadata.url` for catalog installs — there is no
25/// remote; the catalog ships inside the binary.
26pub const SOURCE_URL: &str = "builtin";
27
28#[derive(Debug, Deserialize)]
29struct Catalog {
30    #[serde(default)]
31    servers: Vec<CatalogServer>,
32}
33
34#[derive(Debug, Deserialize)]
35struct CatalogServer {
36    id: String,
37    /// Independent per entry — bumping one server's version does not mark
38    /// every other installed catalog server "outdated" (an earlier, global
39    /// `catalog_version` did exactly that).
40    version: String,
41    description: String,
42    #[serde(default)]
43    transport: McpTransport,
44    #[serde(default)]
45    command: Option<String>,
46    #[serde(default)]
47    args: Vec<String>,
48    #[serde(default)]
49    url: Option<String>,
50    /// Environment variable names the server needs. Each becomes a
51    /// `{ from_env = "NAME" }` reference in the generated manifest.
52    #[serde(default)]
53    env: Vec<String>,
54    #[serde(default)]
55    tools_summary: Option<String>,
56}
57
58fn catalog() -> Catalog {
59    toml::from_str(CATALOG_TOML).expect("embedded MCP catalog must parse; covered by unit test")
60}
61
62/// Every catalog id, in file order.
63pub fn ids() -> Vec<String> {
64    catalog().servers.into_iter().map(|s| s.id).collect()
65}
66
67/// Resolve a catalog id into a manifest ready for `resolve_capability`.
68/// Returns `Ok(None)` for an unknown id so callers can fall through to other
69/// source kinds with their own error message.
70pub fn lookup(id: &str) -> Result<Option<CapabilityManifest>> {
71    let catalog = catalog();
72    let Some(server) = catalog.servers.into_iter().find(|s| s.id == id) else {
73        return Ok(None);
74    };
75
76    let env: BTreeMap<String, EnvRef> = server
77        .env
78        .into_iter()
79        .map(|name| (name.clone(), EnvRef { from_env: name }))
80        .collect();
81    let config = McpServerConfig {
82        transport: server.transport,
83        command: server.command,
84        args: server.args,
85        url: server.url,
86        env,
87        metadata: server.tools_summary.map(|tools_summary| McpServerMetadata {
88            tools_summary: Some(tools_summary),
89        }),
90    };
91    crate::manifest::validate_mcp_server(&config)
92        .map_err(|error| TuffError::new(format!("catalog entry '{id}' is invalid: {error}")))?;
93
94    Ok(Some(CapabilityManifest {
95        id: server.id,
96        version: server.version,
97        capability_type: CapabilityType::McpServer,
98        description: server.description,
99        files: Vec::new(),
100        parameters: None,
101        implementation: None,
102        hook: None,
103        workflow: None,
104        server: Some(config),
105        targets: Vec::new(),
106        root: PathBuf::new(),
107    }))
108}
109
110/// Environment variables a server declaration expects the developer to
111/// export, in a stable order. Used to print a post-install reminder.
112pub fn required_env(server: &McpServerConfig) -> Vec<String> {
113    server
114        .env
115        .values()
116        .map(|reference| reference.from_env.clone())
117        .collect::<std::collections::BTreeSet<_>>()
118        .into_iter()
119        .collect()
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn embedded_catalog_parses_and_every_entry_validates() {
128        let catalog = catalog();
129        assert!(!catalog.servers.is_empty());
130        for id in ids() {
131            let manifest = lookup(&id).unwrap().expect("listed id resolves");
132            assert_eq!(manifest.id, id);
133            assert_eq!(manifest.capability_type, CapabilityType::McpServer);
134            assert!(!manifest.version.is_empty());
135            assert!(manifest.server.is_some());
136        }
137    }
138
139    #[test]
140    fn each_entry_versions_independently() {
141        // A global version meant bumping one entry marked every installed
142        // server "outdated" — confirm the schema really is per-entry.
143        let ids = ids();
144        assert!(ids.len() >= 2);
145        for id in &ids {
146            let manifest = lookup(id).unwrap().unwrap();
147            assert_eq!(manifest.version, "1.0.0");
148        }
149    }
150
151    #[test]
152    fn github_entry_uses_the_current_docker_based_launch() {
153        // The old npm package (@modelcontextprotocol/server-github) was
154        // archived upstream in 2025-04; GitHub's own server ships via
155        // Docker instead. Pin this so a future edit can't silently regress
156        // back to the broken launch command.
157        let manifest = lookup("github").unwrap().unwrap();
158        let server = manifest.server.unwrap();
159        assert_eq!(server.transport, McpTransport::Stdio);
160        assert_eq!(server.command.as_deref(), Some("docker"));
161        assert!(
162            server
163                .args
164                .iter()
165                .any(|arg| arg == "ghcr.io/github/github-mcp-server")
166        );
167        assert_eq!(
168            server.env["GITHUB_PERSONAL_ACCESS_TOKEN"].from_env,
169            "GITHUB_PERSONAL_ACCESS_TOKEN"
170        );
171        assert_eq!(
172            required_env(&server),
173            vec!["GITHUB_PERSONAL_ACCESS_TOKEN".to_string()]
174        );
175    }
176
177    #[test]
178    fn excluded_servers_are_not_in_the_catalog() {
179        // Regression guard for the exclusions documented in the catalog
180        // file's header — these package names are confirmed archived or
181        // otherwise don't meet the sourcing bar; they should not silently
182        // reappear.
183        let ids = ids();
184        for excluded in [
185            "postgres", "sqlite", "slack", "gitlab", "linear", "context7",
186        ] {
187            assert!(
188                !ids.contains(&excluded.to_string()),
189                "{excluded} should not be in the catalog"
190            );
191        }
192    }
193
194    #[test]
195    fn unknown_id_is_none_not_an_error() {
196        assert!(lookup("does-not-exist").unwrap().is_none());
197    }
198}