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, HeaderRef, McpServerConfig, McpServerMetadata,
18    McpTransport,
19};
20
21const CATALOG_TOML: &str = include_str!("../assets/mcp-catalog.toml");
22
23#[derive(Debug, Deserialize)]
24struct Catalog {
25    #[serde(default)]
26    servers: Vec<CatalogServer>,
27}
28
29#[derive(Debug, Deserialize)]
30struct CatalogServer {
31    id: String,
32    /// Independent per entry — bumping one server's version does not mark
33    /// every other installed catalog server "outdated" (an earlier, global
34    /// `catalog_version` did exactly that).
35    version: String,
36    description: String,
37    #[serde(default)]
38    transport: McpTransport,
39    #[serde(default)]
40    command: Option<String>,
41    #[serde(default)]
42    args: Vec<String>,
43    #[serde(default)]
44    url: Option<String>,
45    /// Environment variable names the server needs. Each becomes a
46    /// `{ from_env = "NAME" }` reference in the generated manifest.
47    #[serde(default)]
48    env: Vec<String>,
49    /// HTTP request headers a remote server needs, as the same reference-only
50    /// shape a manifest's `[server.headers]` takes: a variable name and an
51    /// optional `format`, never a value.
52    #[serde(default)]
53    headers: BTreeMap<String, HeaderRef>,
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        headers: server.headers,
88        metadata: server.tools_summary.map(|tools_summary| McpServerMetadata {
89            tools_summary: Some(tools_summary),
90        }),
91    };
92    crate::manifest::validate_mcp_server(&config)
93        .map_err(|error| TuffError::new(format!("catalog entry '{id}' is invalid: {error}")))?;
94
95    Ok(Some(CapabilityManifest {
96        id: server.id,
97        version: server.version,
98        capability_type: CapabilityType::McpServer,
99        description: server.description,
100        files: Vec::new(),
101        parameters: None,
102        implementation: None,
103        hook: None,
104        server: Some(config),
105        policy: None,
106        targets: Vec::new(),
107        root: PathBuf::new(),
108    }))
109}
110
111/// Environment variables a server declaration expects the developer to
112/// export, in a stable order, across both `[server.env]` and
113/// `[server.headers]`. Used to print a post-install reminder.
114pub fn required_env(server: &McpServerConfig) -> Vec<String> {
115    server
116        .env
117        .values()
118        .map(|reference| reference.from_env.clone())
119        .chain(
120            server
121                .headers
122                .values()
123                .map(|reference| reference.from_env.clone()),
124        )
125        .collect::<std::collections::BTreeSet<_>>()
126        .into_iter()
127        .collect()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn embedded_catalog_parses_and_every_entry_validates() {
136        let catalog = catalog();
137        assert!(!catalog.servers.is_empty());
138        for id in ids() {
139            let manifest = lookup(&id).unwrap().expect("listed id resolves");
140            assert_eq!(manifest.id, id);
141            assert_eq!(manifest.capability_type, CapabilityType::McpServer);
142            assert!(!manifest.version.is_empty());
143            assert!(manifest.server.is_some());
144        }
145    }
146
147    #[test]
148    fn each_entry_versions_independently() {
149        // A global version meant bumping one entry marked every installed
150        // server "outdated" — confirm the schema really is per-entry.
151        let ids = ids();
152        assert!(ids.len() >= 2);
153        for id in &ids {
154            let manifest = lookup(id).unwrap().unwrap();
155            assert_eq!(manifest.version, "1.0.0");
156        }
157    }
158
159    #[test]
160    fn github_entry_uses_the_current_docker_based_launch() {
161        // The old npm package (@modelcontextprotocol/server-github) was
162        // archived upstream in 2025-04; GitHub's own server ships via
163        // Docker instead. Pin this so a future edit can't silently regress
164        // back to the broken launch command.
165        let manifest = lookup("github").unwrap().unwrap();
166        let server = manifest.server.unwrap();
167        assert_eq!(server.transport, McpTransport::Stdio);
168        assert_eq!(server.command.as_deref(), Some("docker"));
169        assert!(
170            server
171                .args
172                .iter()
173                .any(|arg| arg == "ghcr.io/github/github-mcp-server")
174        );
175        assert_eq!(
176            server.env["GITHUB_PERSONAL_ACCESS_TOKEN"].from_env,
177            "GITHUB_PERSONAL_ACCESS_TOKEN"
178        );
179        assert_eq!(
180            required_env(&server),
181            vec!["GITHUB_PERSONAL_ACCESS_TOKEN".to_string()]
182        );
183    }
184
185    #[test]
186    fn excluded_servers_are_not_in_the_catalog() {
187        // Regression guard for the exclusions documented in the catalog
188        // file's header — these package names are confirmed archived or
189        // otherwise don't meet the sourcing bar; they should not silently
190        // reappear.
191        let ids = ids();
192        for excluded in ["postgres", "sqlite", "slack", "gitlab"] {
193            assert!(
194                !ids.contains(&excluded.to_string()),
195                "{excluded} should not be in the catalog"
196            );
197        }
198    }
199
200    #[test]
201    fn remote_entries_carry_their_auth_header_as_a_reference() {
202        // linear and context7 were excluded until the schema could express
203        // an `Authorization` header. Both vendors document the same shape,
204        // `Authorization: Bearer <key>` on a Streamable HTTP url, so pin
205        // that the catalog records the variable and the format, never a
206        // value, and that the post-install reminder names the variable.
207        for (id, url, variable) in [
208            ("linear", "https://mcp.linear.app/mcp", "LINEAR_API_KEY"),
209            (
210                "context7",
211                "https://mcp.context7.com/mcp",
212                "CONTEXT7_API_KEY",
213            ),
214        ] {
215            let manifest = lookup(id).unwrap().unwrap();
216            let server = manifest.server.unwrap();
217            assert_eq!(server.transport, McpTransport::Http, "{id}");
218            assert_eq!(server.url.as_deref(), Some(url), "{id}");
219            assert!(server.command.is_none(), "{id}");
220            assert!(server.env.is_empty(), "{id}");
221            let header = &server.headers["Authorization"];
222            assert_eq!(header.from_env, variable, "{id}");
223            assert_eq!(header.format.as_deref(), Some("Bearer {}"), "{id}");
224            assert_eq!(
225                header.render("${TOKEN}"),
226                "Bearer ${TOKEN}",
227                "{id}: the harness expands the reference, Tuff never sees the key"
228            );
229            assert_eq!(required_env(&server), vec![variable.to_string()], "{id}");
230        }
231    }
232
233    #[test]
234    fn unknown_id_is_none_not_an_error() {
235        assert!(lookup("does-not-exist").unwrap().is_none());
236    }
237}