Skip to main content

lean_ctx/core/context_package/
addon_manifest.rs

1//! `lean-ctx-addon.toml` — the authoring manifest, parsed.
2//!
3//! An addon declares one or both of:
4//!
5//! - **WASM modules**, carried inside the package and loaded into the context
6//!   pipeline (see [`super::addons`]);
7//! - **an MCP server**, declared under `[mcp]` and wired into the gateway.
8//!
9//! The two are not alternatives dressed up as one feature. A compressor has to
10//! run *inside* the pipeline, so it is WASM. A tool that speaks MCP already has
11//! a process model of its own, so wrapping it in WASM would buy nothing — it is
12//! declared and wired instead. Most addons are one or the other; nothing stops
13//! an addon from being both.
14//!
15//! Per `docs/contracts/addon-manifest-v1.md`, `[mcp]` mirrors a
16//! `[[gateway.servers]]` entry, so installation is a translation rather than an
17//! interpretation. What this parser deliberately does **not** carry over from
18//! the pre-3.9.20 manifest is `[install]`: lean-ctx no longer runs
19//! `uv tool install` or `npx` on your behalf. Fetching the server is the user's
20//! step, and the addon only says how to run it once it is there.
21
22use std::collections::BTreeMap;
23
24use crate::core::mcp_catalog::config::{GatewayServer, TransportKind};
25
26/// The `[addon]` table.
27#[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/// The `[mcp]` table, when present.
36#[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    /// Typed-integration adapter slug (L4), or empty for a passthrough server.
46    pub integration: String,
47    /// `auth = "oauth"`: the server needs a browser login (#1391).
48    pub oauth: bool,
49    /// `scopes = [...]` requested at login; empty = what the server advertises.
50    pub oauth_scopes: Vec<String>,
51}
52
53impl McpWiring {
54    /// One line a human can check before consenting — the whole point of
55    /// showing it is that the reader recognises what will be spawned.
56    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    /// Translate into a gateway entry.
71    ///
72    /// `enabled: true` because a user who just consented to installing this
73    /// addon means for it to work; the per-server switch stays available for
74    /// turning it off later without removing the addon.
75    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/// A parsed authoring manifest.
95#[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
133/// Validate the L4 adapter slug, canonicalising it on the way through.
134///
135/// `IntegrationKind::parse` maps anything it does not recognise to `None`, so a
136/// typo (`code_grah`) would install cleanly and route through the generic path
137/// forever — working software that silently does less than the author asked
138/// for. Rejecting it here costs one line and names the manifest.
139fn 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
154/// Resolve the adapter slug from the three places it may come from.
155///
156/// `[addon] integration` is where the field has always been documented and
157/// where every manifest in the wild puts it (see GH #1391). `[mcp] integration`
158/// mirrors the gateway server entry the table translates into, so it is
159/// accepted too and wins when both are present, being the more specific of the
160/// two. Reading only `[mcp]` — as this parser first did — would have silently
161/// ignored the field in every existing manifest, which is the precise failure
162/// the strict validation below exists to prevent.
163///
164/// Falling back to `[addon] categories` restores the documented "empty derives
165/// from categories" behaviour, which used to happen at install time against a
166/// store that no longer exists. Derivation is deliberately **lenient**:
167/// categories are free-form browsing labels (`plans`, `workflow`), so an
168/// unrecognised one means "no adapter", never an error. Only a slug the author
169/// wrote *as* an integration is held to the vocabulary.
170fn 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
192/// Parse `lean-ctx-addon.toml`.
193///
194/// Fails closed on anything that would produce a half-configured gateway entry:
195/// a stdio server without a command, an http server without a URL, or a URL
196/// that is not `http(s)`. Catching those here means the error names the
197/// manifest line, not a spawn failure hours later.
198pub 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    /// A WASM-only addon has no `[mcp]` table, and that is not an error.
340    #[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    /// The manifest from GH #1391, verbatim. `integration` sits under
347    /// `[addon]` there — the documented location, and the one every manifest in
348    /// the wild uses. Reading only `[mcp]` would have ignored it silently.
349    #[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    /// `[mcp]` is the more specific of the two and wins when both are set.
371    #[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    /// The documented "empty derives from categories" fallback. Categories are
382    /// free-form browsing labels, so an unrecognised one means "no adapter" —
383    /// erroring there would reject perfectly good manifests.
384    #[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    /// A typo in `integration` would otherwise install cleanly and route
400    /// through the generic path forever, which looks like working software.
401    #[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        // Aliases are accepted and stored canonically, so the gateway entry
409        // and `addon info` agree on one spelling.
410        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        // Absent stays absent — an empty slug is the documented default.
424        let m = parse("[addon]\nname = \"x\"\n[mcp]\ncommand = \"c\"\n").expect("parse");
425        assert_eq!(m.mcp.expect("wiring").integration, "");
426    }
427
428    /// Each of these would produce a gateway entry that cannot work. Failing at
429    /// parse names the manifest; failing at spawn names a mystery.
430    #[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}