1use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use super::bootstrap::AddonInstall;
15use super::capabilities::AddonCapabilities;
16use crate::core::gateway::{GatewayServer, TransportKind};
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct AddonMeta {
22 pub name: String,
24 pub display_name: String,
26 pub version: String,
28 pub description: String,
30 pub author: String,
32 pub homepage: String,
34 pub license: String,
36 pub categories: Vec<String>,
38 pub integration: String,
45 pub keywords: Vec<String>,
47 pub min_lean_ctx: String,
49 pub verified: bool,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61#[serde(default)]
62pub struct AddonMcp {
63 pub transport: TransportKind,
65 pub command: String,
67 pub args: Vec<String>,
69 pub env: BTreeMap<String, String>,
71 pub sha256: String,
75 pub url: String,
77 pub headers: BTreeMap<String, String>,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct AddonManifest {
84 pub addon: AddonMeta,
85 #[serde(default)]
86 pub mcp: AddonMcp,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub capabilities: Option<AddonCapabilities>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub pricing: Option<super::commerce::AddonPricing>,
97 #[serde(default, skip_serializing_if = "AddonInstall::is_absent")]
102 pub install: AddonInstall,
103}
104
105impl AddonManifest {
106 pub fn from_toml(text: &str) -> Result<Self, String> {
108 toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
109 }
110
111 pub fn from_path(path: &Path) -> Result<Self, String> {
113 let raw = std::fs::read_to_string(path)
114 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
115 let manifest = Self::from_toml(&raw)?;
116 manifest.validate()?;
117 Ok(manifest)
118 }
119
120 pub fn integration_kind(&self) -> String {
124 use crate::core::gateway::adapters::IntegrationKind;
125 let explicit = self.addon.integration.trim();
126 let kind = if explicit.is_empty() {
127 IntegrationKind::from_categories(&self.addon.categories)
128 } else {
129 IntegrationKind::parse(explicit)
130 };
131 kind.as_str().to_string()
132 }
133
134 pub fn display_name(&self) -> &str {
136 if self.addon.display_name.trim().is_empty() {
137 &self.addon.name
138 } else {
139 &self.addon.display_name
140 }
141 }
142
143 pub fn validate(&self) -> Result<(), String> {
146 let name = self.addon.name.trim();
147 if name.is_empty() {
148 return Err("addon manifest is missing `addon.name`".into());
149 }
150 if !is_slug(name) {
151 return Err(format!(
152 "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
153 no leading/trailing dash)"
154 ));
155 }
156 if let Some(caps) = &self.capabilities {
157 caps.validate()?;
158 }
159 self.install.validate()?;
160 Ok(())
161 }
162
163 pub fn to_gateway_server(&self) -> GatewayServer {
165 GatewayServer {
166 name: self.addon.name.clone(),
167 transport: self.mcp.transport,
168 enabled: true,
169 command: self.mcp.command.clone(),
170 args: self.mcp.args.clone(),
171 env: self.mcp.env.clone(),
172 binary_sha256: self.mcp.sha256.clone(),
173 url: self.mcp.url.clone(),
174 headers: self.mcp.headers.clone(),
175 capabilities: self.capabilities.clone(),
176 integration: self.integration_kind(),
179 }
180 }
181
182 pub fn is_installable(&self) -> bool {
186 self.to_gateway_server().resolve().is_ok()
187 }
188}
189
190fn is_slug(s: &str) -> bool {
191 !s.is_empty()
192 && !s.starts_with('-')
193 && !s.ends_with('-')
194 && s.chars()
195 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn stdio_manifest() -> AddonManifest {
203 AddonManifest::from_toml(
204 r#"
205[addon]
206name = "demo"
207display_name = "Demo Addon"
208version = "1.2.3"
209description = "A demo"
210author = "tester"
211categories = ["search"]
212keywords = ["alpha", "beta"]
213
214[mcp]
215transport = "stdio"
216command = "demo-mcp"
217args = ["serve"]
218"#,
219 )
220 .expect("parse")
221 }
222
223 #[test]
224 fn parses_full_stdio_manifest() {
225 let m = stdio_manifest();
226 assert_eq!(m.addon.name, "demo");
227 assert_eq!(m.display_name(), "Demo Addon");
228 assert_eq!(m.mcp.transport, TransportKind::Stdio);
229 assert_eq!(m.mcp.command, "demo-mcp");
230 assert!(m.is_installable());
231 let srv = m.to_gateway_server();
232 assert_eq!(srv.name, "demo");
233 assert_eq!(srv.args, vec!["serve".to_string()]);
234 assert!(srv.enabled);
235 }
236
237 #[test]
238 fn listed_only_entry_is_not_installable() {
239 let m = AddonManifest::from_toml(
240 r#"
241[addon]
242name = "listed"
243description = "no mcp block"
244homepage = "https://example.com"
245"#,
246 )
247 .expect("parse");
248 assert!(m.validate().is_ok());
249 assert!(!m.is_installable(), "no [mcp] block → listed only");
250 }
251
252 #[test]
253 fn http_manifest_is_installable() {
254 let m = AddonManifest::from_toml(
255 r#"
256[addon]
257name = "remote"
258
259[mcp]
260transport = "http"
261url = "https://example.com/mcp"
262"#,
263 )
264 .expect("parse");
265 assert!(m.is_installable());
266 assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
267 }
268
269 #[test]
270 fn display_name_falls_back_to_slug() {
271 let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
272 assert_eq!(m.display_name(), "slug-only");
273 }
274
275 #[test]
276 fn capabilities_block_parses_and_threads_to_gateway() {
277 let m = AddonManifest::from_toml(
278 r#"
279[addon]
280name = "caps"
281
282[mcp]
283transport = "stdio"
284command = "caps-mcp"
285
286[capabilities]
287network = "full"
288filesystem = "read_write"
289env = ["GITHUB_TOKEN"]
290"#,
291 )
292 .expect("parse");
293 let caps = m.capabilities.as_ref().expect("capabilities present");
294 assert!(caps.network_allowed());
295 assert!(caps.filesystem_writable());
296 assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
297 assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
299 }
300
301 #[test]
302 fn absent_capabilities_is_none() {
303 let m = stdio_manifest();
304 assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
305 assert!(m.to_gateway_server().capabilities.is_none());
306 }
307
308 #[test]
309 fn invalid_capability_env_name_fails_validation() {
310 let m = AddonManifest::from_toml(
311 "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
312 )
313 .expect("parse");
314 assert!(m.validate().is_err());
315 }
316
317 #[test]
318 fn rejects_missing_and_bad_names() {
319 assert!(AddonManifest::default().validate().is_err());
320 let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
321 assert!(bad.validate().is_err());
322 let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
323 assert!(bad2.validate().is_err());
324 }
325
326 #[test]
327 fn install_block_parses_validates_and_records_receipt() {
328 let m = AddonManifest::from_toml(
329 r#"
330[addon]
331name = "boot"
332
333[mcp]
334transport = "stdio"
335command = "boot"
336args = ["serve"]
337
338[install]
339manager = "uv"
340package = "boot-ai[mcp]"
341version = "1.4.2"
342bin = "boot"
343"#,
344 )
345 .expect("parse");
346 assert!(m.install.is_declared());
347 assert!(m.validate().is_ok());
348 assert!(m.is_installable(), "an installed-binary command resolves");
349 let receipt = m.install.to_receipt();
350 assert_eq!(receipt.manager, "uv");
351 assert_eq!(receipt.bin, "boot");
352 assert_eq!(
353 m.install.install_argv(),
354 ["tool", "install", "boot-ai[mcp]==1.4.2"]
355 );
356 }
357
358 #[test]
359 fn install_block_with_bad_pin_fails_manifest_validation() {
360 let m = AddonManifest::from_toml(
361 "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
362 [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
363 )
364 .expect("parse");
365 assert!(m.validate().is_err(), "floating version is rejected");
366 }
367
368 #[test]
369 fn absent_install_block_is_default() {
370 let m = stdio_manifest();
371 assert!(!m.install.is_declared(), "no [install] → no bootstrap");
372 }
373
374 #[test]
375 fn slug_validation() {
376 assert!(is_slug("lmd"));
377 assert!(is_slug("my-addon-2"));
378 assert!(!is_slug("Bad"));
379 assert!(!is_slug("-x"));
380 assert!(!is_slug("x-"));
381 assert!(!is_slug("under_score"));
382 assert!(!is_slug(""));
383 }
384}