Skip to main content

lean_ctx/core/addons/
manifest.rs

1//! The `lean-ctx-addon.toml` manifest — the contract an addon author writes.
2//!
3//! The same shape is reused as a registry entry (see [`super::registry`]) so a
4//! curated catalog and a hand-written manifest deserialize into one type. An
5//! addon declares metadata (`[addon]`) and how lean-ctx runs its MCP server
6//! (`[mcp]`). A registry entry without a runnable `[mcp]` block is *listed*
7//! only (a directory entry that links to its homepage) — never installable
8//! with fabricated wiring.
9
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use super::capabilities::AddonCapabilities;
15use crate::core::gateway::{GatewayServer, TransportKind};
16
17/// `[addon]` — human + catalog metadata.
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(default)]
20pub struct AddonMeta {
21    /// Stable slug (`[a-z0-9-]`); becomes the gateway server name.
22    pub name: String,
23    /// Human-friendly name for UIs (falls back to `name`).
24    pub display_name: String,
25    /// Author-declared version (free-form; may be empty for listed-only entries).
26    pub version: String,
27    /// One-line description shown in `addon list` / the website.
28    pub description: String,
29    /// Maintainer / org.
30    pub author: String,
31    /// Project homepage or repository URL.
32    pub homepage: String,
33    /// SPDX license id (e.g. `Apache-2.0`).
34    pub license: String,
35    /// Coarse buckets for browsing (e.g. `plans`, `workflow`, `search`).
36    pub categories: Vec<String>,
37    /// Free-form search keywords.
38    pub keywords: Vec<String>,
39    /// Minimum lean-ctx version the addon targets (informational).
40    pub min_lean_ctx: String,
41    /// Trust tier. `true` **only** for entries audited and vouched by
42    /// maintainers in the curated registry; community submissions stay `false`.
43    /// Author-set in a local manifest is meaningless — trust is conferred by the
44    /// registry the entry ships in, not by the entry claiming it.
45    pub verified: bool,
46}
47
48/// `[mcp]` — how lean-ctx launches/connects to the addon's MCP server.
49///
50/// Mirrors [`GatewayServer`]'s transport fields so installation is a direct
51/// translation. Absent (default) → the entry is listed-only, not installable.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53#[serde(default)]
54pub struct AddonMcp {
55    /// `stdio` (spawn `command`) or `http` (connect to `url`).
56    pub transport: TransportKind,
57    /// Executable to spawn (stdio transport).
58    pub command: String,
59    /// Arguments passed to `command`.
60    pub args: Vec<String>,
61    /// Extra environment variables for the child process.
62    pub env: BTreeMap<String, String>,
63    /// Optional SHA-256 pin of the stdio `command` binary (P3 supply-chain). The
64    /// value `sha256sum`/`shasum -a 256` prints; the gateway refuses to spawn a
65    /// binary whose hash does not match. Empty = unpinned.
66    pub sha256: String,
67    /// Streamable-HTTP endpoint (http transport).
68    pub url: String,
69    /// Extra request headers (e.g. auth) for the http transport.
70    pub headers: BTreeMap<String, String>,
71}
72
73/// A full addon manifest / registry entry.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AddonManifest {
76    pub addon: AddonMeta,
77    #[serde(default)]
78    pub mcp: AddonMcp,
79    /// `[capabilities]` — declared permissions (network/filesystem/env). Absent
80    /// (`None`) keeps the legacy `addons.sandbox` behaviour; present opts the
81    /// addon into the per-addon, secure-by-default capability model (P1).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub capabilities: Option<AddonCapabilities>,
84    /// `[pricing]` — optional commerce metadata for a sellable addon (Track B).
85    /// Absent (`None`) ⇒ free. A paid entry must clear
86    /// [`super::commerce::paid_listing_gate`] before it may be listed/sold.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub pricing: Option<super::commerce::AddonPricing>,
89}
90
91impl AddonManifest {
92    /// Parse a manifest from TOML text (author's `lean-ctx-addon.toml`).
93    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    /// Read + parse + validate a manifest file from disk.
98    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    /// Human name for display (falls back to the slug).
107    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    /// Validate required metadata. Does **not** require a runnable `[mcp]`
116    /// block — that is [`Self::is_installable`].
117    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    /// The gateway server entry this addon installs.
135    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    /// True when the addon declares a runnable MCP endpoint (one-click
151    /// installable). A registry entry without a valid `[mcp]` block is *listed*
152    /// only and reports `false` here.
153    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        // Flows into the gateway server entry that actually runs.
266        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}