lean_ctx/core/addons/
manifest.rs1use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use crate::core::gateway::{GatewayServer, TransportKind};
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[serde(default)]
19pub struct AddonMeta {
20 pub name: String,
22 pub display_name: String,
24 pub version: String,
26 pub description: String,
28 pub author: String,
30 pub homepage: String,
32 pub license: String,
34 pub categories: Vec<String>,
36 pub keywords: Vec<String>,
38 pub min_lean_ctx: String,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47#[serde(default)]
48pub struct AddonMcp {
49 pub transport: TransportKind,
51 pub command: String,
53 pub args: Vec<String>,
55 pub env: BTreeMap<String, String>,
57 pub url: String,
59 pub headers: BTreeMap<String, String>,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct AddonManifest {
66 pub addon: AddonMeta,
67 #[serde(default)]
68 pub mcp: AddonMcp,
69}
70
71impl AddonManifest {
72 pub fn from_toml(text: &str) -> Result<Self, String> {
74 toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
75 }
76
77 pub fn from_path(path: &Path) -> Result<Self, String> {
79 let raw = std::fs::read_to_string(path)
80 .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
81 let manifest = Self::from_toml(&raw)?;
82 manifest.validate()?;
83 Ok(manifest)
84 }
85
86 pub fn display_name(&self) -> &str {
88 if self.addon.display_name.trim().is_empty() {
89 &self.addon.name
90 } else {
91 &self.addon.display_name
92 }
93 }
94
95 pub fn validate(&self) -> Result<(), String> {
98 let name = self.addon.name.trim();
99 if name.is_empty() {
100 return Err("addon manifest is missing `addon.name`".into());
101 }
102 if !is_slug(name) {
103 return Err(format!(
104 "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
105 no leading/trailing dash)"
106 ));
107 }
108 Ok(())
109 }
110
111 pub fn to_gateway_server(&self) -> GatewayServer {
113 GatewayServer {
114 name: self.addon.name.clone(),
115 transport: self.mcp.transport,
116 enabled: true,
117 command: self.mcp.command.clone(),
118 args: self.mcp.args.clone(),
119 env: self.mcp.env.clone(),
120 url: self.mcp.url.clone(),
121 headers: self.mcp.headers.clone(),
122 }
123 }
124
125 pub fn is_installable(&self) -> bool {
129 self.to_gateway_server().resolve().is_ok()
130 }
131}
132
133fn is_slug(s: &str) -> bool {
134 !s.is_empty()
135 && !s.starts_with('-')
136 && !s.ends_with('-')
137 && s.chars()
138 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn stdio_manifest() -> AddonManifest {
146 AddonManifest::from_toml(
147 r#"
148[addon]
149name = "demo"
150display_name = "Demo Addon"
151version = "1.2.3"
152description = "A demo"
153author = "tester"
154categories = ["search"]
155keywords = ["alpha", "beta"]
156
157[mcp]
158transport = "stdio"
159command = "demo-mcp"
160args = ["serve"]
161"#,
162 )
163 .expect("parse")
164 }
165
166 #[test]
167 fn parses_full_stdio_manifest() {
168 let m = stdio_manifest();
169 assert_eq!(m.addon.name, "demo");
170 assert_eq!(m.display_name(), "Demo Addon");
171 assert_eq!(m.mcp.transport, TransportKind::Stdio);
172 assert_eq!(m.mcp.command, "demo-mcp");
173 assert!(m.is_installable());
174 let srv = m.to_gateway_server();
175 assert_eq!(srv.name, "demo");
176 assert_eq!(srv.args, vec!["serve".to_string()]);
177 assert!(srv.enabled);
178 }
179
180 #[test]
181 fn listed_only_entry_is_not_installable() {
182 let m = AddonManifest::from_toml(
183 r#"
184[addon]
185name = "listed"
186description = "no mcp block"
187homepage = "https://example.com"
188"#,
189 )
190 .expect("parse");
191 assert!(m.validate().is_ok());
192 assert!(!m.is_installable(), "no [mcp] block → listed only");
193 }
194
195 #[test]
196 fn http_manifest_is_installable() {
197 let m = AddonManifest::from_toml(
198 r#"
199[addon]
200name = "remote"
201
202[mcp]
203transport = "http"
204url = "https://example.com/mcp"
205"#,
206 )
207 .expect("parse");
208 assert!(m.is_installable());
209 assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
210 }
211
212 #[test]
213 fn display_name_falls_back_to_slug() {
214 let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
215 assert_eq!(m.display_name(), "slug-only");
216 }
217
218 #[test]
219 fn rejects_missing_and_bad_names() {
220 assert!(AddonManifest::default().validate().is_err());
221 let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
222 assert!(bad.validate().is_err());
223 let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
224 assert!(bad2.validate().is_err());
225 }
226
227 #[test]
228 fn slug_validation() {
229 assert!(is_slug("lmd"));
230 assert!(is_slug("my-addon-2"));
231 assert!(!is_slug("Bad"));
232 assert!(!is_slug("-x"));
233 assert!(!is_slug("x-"));
234 assert!(!is_slug("under_score"));
235 assert!(!is_slug(""));
236 }
237}