lean_ctx/core/addons/
manifest.rs1use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use super::capabilities::AddonCapabilities;
15use crate::core::gateway::{GatewayServer, TransportKind};
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(default)]
20pub struct AddonMeta {
21 pub name: String,
23 pub display_name: String,
25 pub version: String,
27 pub description: String,
29 pub author: String,
31 pub homepage: String,
33 pub license: String,
35 pub categories: Vec<String>,
37 pub keywords: Vec<String>,
39 pub min_lean_ctx: String,
41 pub verified: bool,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(default)]
54pub struct AddonMcp {
55 pub transport: TransportKind,
57 pub command: String,
59 pub args: Vec<String>,
61 pub env: BTreeMap<String, String>,
63 pub sha256: String,
67 pub url: String,
69 pub headers: BTreeMap<String, String>,
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AddonManifest {
76 pub addon: AddonMeta,
77 #[serde(default)]
78 pub mcp: AddonMcp,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub capabilities: Option<AddonCapabilities>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub pricing: Option<super::commerce::AddonPricing>,
89}
90
91impl AddonManifest {
92 pub fn from_toml(text: &str) -> Result<Self, String> {
94 toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
95 }
96
97 pub fn from_path(path: &Path) -> Result<Self, String> {
99 let raw = std::fs::read_to_string(path)
100 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
101 let manifest = Self::from_toml(&raw)?;
102 manifest.validate()?;
103 Ok(manifest)
104 }
105
106 pub fn display_name(&self) -> &str {
108 if self.addon.display_name.trim().is_empty() {
109 &self.addon.name
110 } else {
111 &self.addon.display_name
112 }
113 }
114
115 pub fn validate(&self) -> Result<(), String> {
118 let name = self.addon.name.trim();
119 if name.is_empty() {
120 return Err("addon manifest is missing `addon.name`".into());
121 }
122 if !is_slug(name) {
123 return Err(format!(
124 "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
125 no leading/trailing dash)"
126 ));
127 }
128 if let Some(caps) = &self.capabilities {
129 caps.validate()?;
130 }
131 Ok(())
132 }
133
134 pub fn to_gateway_server(&self) -> GatewayServer {
136 GatewayServer {
137 name: self.addon.name.clone(),
138 transport: self.mcp.transport,
139 enabled: true,
140 command: self.mcp.command.clone(),
141 args: self.mcp.args.clone(),
142 env: self.mcp.env.clone(),
143 binary_sha256: self.mcp.sha256.clone(),
144 url: self.mcp.url.clone(),
145 headers: self.mcp.headers.clone(),
146 capabilities: self.capabilities.clone(),
147 }
148 }
149
150 pub fn is_installable(&self) -> bool {
154 self.to_gateway_server().resolve().is_ok()
155 }
156}
157
158fn is_slug(s: &str) -> bool {
159 !s.is_empty()
160 && !s.starts_with('-')
161 && !s.ends_with('-')
162 && s.chars()
163 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn stdio_manifest() -> AddonManifest {
171 AddonManifest::from_toml(
172 r#"
173[addon]
174name = "demo"
175display_name = "Demo Addon"
176version = "1.2.3"
177description = "A demo"
178author = "tester"
179categories = ["search"]
180keywords = ["alpha", "beta"]
181
182[mcp]
183transport = "stdio"
184command = "demo-mcp"
185args = ["serve"]
186"#,
187 )
188 .expect("parse")
189 }
190
191 #[test]
192 fn parses_full_stdio_manifest() {
193 let m = stdio_manifest();
194 assert_eq!(m.addon.name, "demo");
195 assert_eq!(m.display_name(), "Demo Addon");
196 assert_eq!(m.mcp.transport, TransportKind::Stdio);
197 assert_eq!(m.mcp.command, "demo-mcp");
198 assert!(m.is_installable());
199 let srv = m.to_gateway_server();
200 assert_eq!(srv.name, "demo");
201 assert_eq!(srv.args, vec!["serve".to_string()]);
202 assert!(srv.enabled);
203 }
204
205 #[test]
206 fn listed_only_entry_is_not_installable() {
207 let m = AddonManifest::from_toml(
208 r#"
209[addon]
210name = "listed"
211description = "no mcp block"
212homepage = "https://example.com"
213"#,
214 )
215 .expect("parse");
216 assert!(m.validate().is_ok());
217 assert!(!m.is_installable(), "no [mcp] block → listed only");
218 }
219
220 #[test]
221 fn http_manifest_is_installable() {
222 let m = AddonManifest::from_toml(
223 r#"
224[addon]
225name = "remote"
226
227[mcp]
228transport = "http"
229url = "https://example.com/mcp"
230"#,
231 )
232 .expect("parse");
233 assert!(m.is_installable());
234 assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
235 }
236
237 #[test]
238 fn display_name_falls_back_to_slug() {
239 let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
240 assert_eq!(m.display_name(), "slug-only");
241 }
242
243 #[test]
244 fn capabilities_block_parses_and_threads_to_gateway() {
245 let m = AddonManifest::from_toml(
246 r#"
247[addon]
248name = "caps"
249
250[mcp]
251transport = "stdio"
252command = "caps-mcp"
253
254[capabilities]
255network = "full"
256filesystem = "read_write"
257env = ["GITHUB_TOKEN"]
258"#,
259 )
260 .expect("parse");
261 let caps = m.capabilities.as_ref().expect("capabilities present");
262 assert!(caps.network_allowed());
263 assert!(caps.filesystem_writable());
264 assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
265 assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
267 }
268
269 #[test]
270 fn absent_capabilities_is_none() {
271 let m = stdio_manifest();
272 assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
273 assert!(m.to_gateway_server().capabilities.is_none());
274 }
275
276 #[test]
277 fn invalid_capability_env_name_fails_validation() {
278 let m = AddonManifest::from_toml(
279 "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
280 )
281 .expect("parse");
282 assert!(m.validate().is_err());
283 }
284
285 #[test]
286 fn rejects_missing_and_bad_names() {
287 assert!(AddonManifest::default().validate().is_err());
288 let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
289 assert!(bad.validate().is_err());
290 let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
291 assert!(bad2.validate().is_err());
292 }
293
294 #[test]
295 fn slug_validation() {
296 assert!(is_slug("lmd"));
297 assert!(is_slug("my-addon-2"));
298 assert!(!is_slug("Bad"));
299 assert!(!is_slug("-x"));
300 assert!(!is_slug("x-"));
301 assert!(!is_slug("under_score"));
302 assert!(!is_slug(""));
303 }
304}