1use 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 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 #[serde(default)]
48 env: Vec<String>,
49 #[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
62pub fn ids() -> Vec<String> {
64 catalog().servers.into_iter().map(|s| s.id).collect()
65}
66
67pub 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
111pub 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 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 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 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 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}