lean_ctx/core/context_package/
addon_manifest.rs1use std::collections::BTreeMap;
23
24use crate::core::mcp_catalog::config::{GatewayServer, TransportKind};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AddonMeta {
29 pub name: String,
30 pub version: String,
31 pub description: String,
32 pub homepage: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct McpWiring {
38 pub transport: TransportKind,
39 pub command: String,
40 pub args: Vec<String>,
41 pub env: BTreeMap<String, String>,
42 pub url: String,
43 pub headers: BTreeMap<String, String>,
44 pub sha256: String,
45 pub integration: String,
47 pub oauth: bool,
49 pub oauth_scopes: Vec<String>,
51}
52
53impl McpWiring {
54 pub fn describe(&self) -> String {
57 match self.transport {
58 TransportKind::Stdio => {
59 if self.args.is_empty() {
60 self.command.clone()
61 } else {
62 format!("{} {}", self.command, self.args.join(" "))
63 }
64 }
65 TransportKind::Http if self.oauth => format!("{} (OAuth browser login)", self.url),
66 TransportKind::Http => self.url.clone(),
67 }
68 }
69
70 pub(crate) fn to_gateway_server(&self, name: &str) -> GatewayServer {
76 GatewayServer {
77 name: name.to_string(),
78 transport: self.transport,
79 enabled: true,
80 command: self.command.clone(),
81 args: self.args.clone(),
82 env: self.env.clone(),
83 binary_sha256: self.sha256.clone(),
84 url: self.url.clone(),
85 headers: self.headers.clone(),
86 oauth: self.oauth,
87 oauth_scopes: self.oauth_scopes.clone(),
88 integration: self.integration.clone(),
89 ..GatewayServer::default()
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct AddonManifest {
97 pub addon: AddonMeta,
98 pub mcp: Option<McpWiring>,
99}
100
101fn string_at(table: &toml::Value, key: &str) -> String {
102 table
103 .get(key)
104 .and_then(toml::Value::as_str)
105 .unwrap_or_default()
106 .to_string()
107}
108
109fn string_list(table: &toml::Value, key: &str) -> Vec<String> {
110 table
111 .get(key)
112 .and_then(toml::Value::as_array)
113 .map(|a| {
114 a.iter()
115 .filter_map(|v| v.as_str().map(str::to_string))
116 .collect()
117 })
118 .unwrap_or_default()
119}
120
121fn string_map(table: &toml::Value, key: &str) -> BTreeMap<String, String> {
122 table
123 .get(key)
124 .and_then(toml::Value::as_table)
125 .map(|t| {
126 t.iter()
127 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
128 .collect()
129 })
130 .unwrap_or_default()
131}
132
133fn parse_integration(raw: &str, table: &str) -> Result<String, String> {
140 let slug = raw.trim();
141 if slug.is_empty() {
142 return Ok(String::new());
143 }
144 let kind = crate::core::mcp_catalog::adapters::IntegrationKind::parse(slug);
145 if kind.is_none() && !slug.eq_ignore_ascii_case("none") {
146 return Err(format!(
147 "lean-ctx-addon.toml: [{table}] unknown integration `{slug}` \
148 (codebase-pack | code-graph | code-symbols | memory | compression | none)"
149 ));
150 }
151 Ok(kind.as_str().to_string())
152}
153
154fn resolve_integration(
171 addon_table: &toml::Value,
172 mcp_table: &toml::Value,
173) -> Result<String, String> {
174 let explicit = parse_integration(&string_at(mcp_table, "integration"), "mcp")?;
175 if !explicit.is_empty() {
176 return Ok(explicit);
177 }
178 let from_addon = parse_integration(&string_at(addon_table, "integration"), "addon")?;
179 if !from_addon.is_empty() {
180 return Ok(from_addon);
181 }
182
183 let categories = string_list(addon_table, "categories");
184 let derived = crate::core::mcp_catalog::adapters::IntegrationKind::from_categories(&categories);
185 Ok(if derived.is_none() {
186 String::new()
187 } else {
188 derived.as_str().to_string()
189 })
190}
191
192pub fn parse(text: &str) -> Result<AddonManifest, String> {
199 let doc: toml::Value = toml::from_str(text).map_err(|e| format!("lean-ctx-addon.toml: {e}"))?;
200
201 let addon_table = doc
202 .get("addon")
203 .ok_or("lean-ctx-addon.toml: missing [addon] table")?;
204 let name = string_at(addon_table, "name");
205 if name.trim().is_empty() {
206 return Err("lean-ctx-addon.toml: [addon] name is required".into());
207 }
208
209 let addon = AddonMeta {
210 name,
211 version: string_at(addon_table, "version"),
212 description: string_at(addon_table, "description"),
213 homepage: string_at(addon_table, "homepage"),
214 };
215
216 let mcp = match doc.get("mcp") {
217 None => None,
218 Some(t) => {
219 let transport = match string_at(t, "transport").as_str() {
220 "" | "stdio" => TransportKind::Stdio,
221 "http" => TransportKind::Http,
222 other => {
223 return Err(format!(
224 "lean-ctx-addon.toml: [mcp] unknown transport `{other}` (stdio | http)"
225 ));
226 }
227 };
228 let command = string_at(t, "command");
229 let url = string_at(t, "url");
230
231 match transport {
232 TransportKind::Stdio if command.trim().is_empty() => {
233 return Err(
234 "lean-ctx-addon.toml: [mcp] transport=stdio requires `command`".into(),
235 );
236 }
237 TransportKind::Http if url.trim().is_empty() => {
238 return Err("lean-ctx-addon.toml: [mcp] transport=http requires `url`".into());
239 }
240 TransportKind::Http
241 if !(url.starts_with("http://") || url.starts_with("https://")) =>
242 {
243 return Err(format!(
244 "lean-ctx-addon.toml: [mcp] url must be http(s), got `{url}`"
245 ));
246 }
247 _ => {}
248 }
249
250 let oauth = match string_at(t, "auth").as_str() {
251 "" | "none" => false,
252 "oauth" if transport == TransportKind::Http => true,
253 "oauth" => {
254 return Err(
255 "lean-ctx-addon.toml: [mcp] auth = \"oauth\" requires transport = \"http\""
256 .into(),
257 );
258 }
259 other => {
260 return Err(format!(
261 "lean-ctx-addon.toml: [mcp] unknown auth `{other}` (none | oauth)"
262 ));
263 }
264 };
265
266 Some(McpWiring {
267 transport,
268 command,
269 args: string_list(t, "args"),
270 env: string_map(t, "env"),
271 url,
272 headers: string_map(t, "headers"),
273 sha256: string_at(t, "sha256"),
274 integration: resolve_integration(addon_table, t)?,
275 oauth,
276 oauth_scopes: string_list(t, "scopes"),
277 })
278 }
279 };
280
281 Ok(AddonManifest { addon, mcp })
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn parses_a_stdio_server() {
290 let m = parse(
291 r#"
292[addon]
293name = "mdcast"
294version = "2.0.0"
295description = "Macro/directive markdown renderer"
296
297[mcp]
298transport = "stdio"
299command = "mdcast"
300args = ["mcp", "serve"]
301env = { LEAN_MD_MODE = "strict" }
302"#,
303 )
304 .expect("parse");
305
306 assert_eq!(m.addon.name, "mdcast");
307 let mcp = m.mcp.expect("wiring");
308 assert_eq!(mcp.describe(), "mdcast mcp serve");
309 assert_eq!(
310 mcp.env.get("LEAN_MD_MODE").map(String::as_str),
311 Some("strict")
312 );
313
314 let server = mcp.to_gateway_server("mdcast");
315 assert_eq!(server.command, "mdcast");
316 assert_eq!(server.args, vec!["mcp", "serve"]);
317 assert!(server.enabled, "a just-consented addon should work");
318 }
319
320 #[test]
321 fn parses_an_http_server() {
322 let m = parse(
323 r#"
324[addon]
325name = "remote"
326
327[mcp]
328transport = "http"
329url = "https://example.test/mcp"
330headers = { Authorization = "Bearer x" }
331"#,
332 )
333 .expect("parse");
334 let mcp = m.mcp.expect("wiring");
335 assert_eq!(mcp.transport, TransportKind::Http);
336 assert_eq!(mcp.describe(), "https://example.test/mcp");
337 }
338
339 #[test]
341 fn a_manifest_without_mcp_is_valid() {
342 let m = parse("[addon]\nname = \"squeeze\"\n").expect("parse");
343 assert!(m.mcp.is_none());
344 }
345
346 #[test]
350 fn integration_is_read_from_the_addon_table_as_documented() {
351 let m = parse(
352 r#"
353[addon]
354name = "twinmind"
355display_name = "TwinMind MCP"
356version = "0.1.0"
357description = "TwinMind MCP memories, prompts, and tasks over HTTP."
358categories = ["memory"]
359integration = "memory"
360
361[mcp]
362transport = "http"
363url = "https://api.twinmind.com/mcp"
364"#,
365 )
366 .expect("parse");
367 assert_eq!(m.mcp.expect("wiring").integration, "memory");
368 }
369
370 #[test]
372 fn the_mcp_table_overrides_the_addon_table() {
373 let m = parse(
374 "[addon]\nname = \"x\"\nintegration = \"memory\"\n\
375 [mcp]\ncommand = \"c\"\nintegration = \"compression\"\n",
376 )
377 .expect("parse");
378 assert_eq!(m.mcp.expect("wiring").integration, "compression");
379 }
380
381 #[test]
385 fn categories_derive_an_adapter_leniently() {
386 let derived = parse(
387 "[addon]\nname = \"x\"\ncategories = [\"workflow\", \"memory\"]\n[mcp]\ncommand = \"c\"\n",
388 )
389 .expect("parse");
390 assert_eq!(derived.mcp.expect("wiring").integration, "memory");
391
392 let none = parse(
393 "[addon]\nname = \"x\"\ncategories = [\"plans\", \"workflow\"]\n[mcp]\ncommand = \"c\"\n",
394 )
395 .expect("unknown categories are not an error");
396 assert_eq!(none.mcp.expect("wiring").integration, "");
397 }
398
399 #[test]
402 fn an_unknown_integration_is_refused_and_a_known_one_canonicalises() {
403 let err =
404 parse("[addon]\nname = \"x\"\n[mcp]\ncommand = \"c\"\nintegration = \"code_grah\"\n")
405 .expect_err("must refuse");
406 assert!(err.contains("unknown integration"), "{err}");
407
408 for (written, canonical) in [
411 ("repomix", "codebase-pack"),
412 ("callgraph", "code-graph"),
413 ("compressor", "compression"),
414 ("none", "none"),
415 ] {
416 let m = parse(&format!(
417 "[addon]\nname = \"x\"\n[mcp]\ncommand = \"c\"\nintegration = \"{written}\"\n"
418 ))
419 .expect("parse");
420 assert_eq!(m.mcp.expect("wiring").integration, canonical);
421 }
422
423 let m = parse("[addon]\nname = \"x\"\n[mcp]\ncommand = \"c\"\n").expect("parse");
425 assert_eq!(m.mcp.expect("wiring").integration, "");
426 }
427
428 #[test]
431 fn half_configured_wiring_is_refused() {
432 let cases = [
433 (
434 "[addon]\nname = \"x\"\n[mcp]\ntransport = \"stdio\"\n",
435 "requires `command`",
436 ),
437 (
438 "[addon]\nname = \"x\"\n[mcp]\ntransport = \"http\"\n",
439 "requires `url`",
440 ),
441 (
442 "[addon]\nname = \"x\"\n[mcp]\ntransport = \"http\"\nurl = \"ftp://nope\"\n",
443 "must be http(s)",
444 ),
445 (
446 "[addon]\nname = \"x\"\n[mcp]\ntransport = \"carrier-pigeon\"\n",
447 "unknown transport",
448 ),
449 ("[addon]\nversion = \"1\"\n", "name is required"),
450 ("[mcp]\ncommand = \"x\"\n", "missing [addon]"),
451 ];
452 for (text, expected) in cases {
453 let err = parse(text).expect_err("must refuse");
454 assert!(err.contains(expected), "expected {expected:?} in {err:?}");
455 }
456 }
457}