Skip to main content

lean_ctx/core/context_package/
addon_wiring.rs

1//! Gateway wiring for `kind=addon` packages.
2//!
3//! An addon that declares `[mcp]` is asking lean-ctx to run an external MCP
4//! server on its behalf. That is a bigger ask than storing a sandboxed WASM
5//! module: the server is an ordinary process with the user's own privileges.
6//!
7//! Three deliberate limits, all of them things the pre-3.9.20 channel did and
8//! this one does not:
9//!
10//! 1. **lean-ctx never installs the server.** No `uv tool install`, no `npx`,
11//!    no download. The manifest says how to *run* something; putting it on the
12//!    machine stays the user's step, where their package manager's own trust
13//!    model applies.
14//! 2. **The exact command is shown before consent**, not summarised. A user who
15//!    is about to let a process spawn should read its argv.
16//! 3. **`[gateway]` stays global-only and opt-in.** Adding a server does not
17//!    enable the gateway; an addon cannot switch it on for you.
18//!
19//! And one rule that only shows up on the second install: an upgrade replaces
20//! what the *author* declares and keeps what the *user* configured — their
21//! credentials and their per-server off switch. See [`register`].
22
23use crate::core::config::Config;
24use crate::core::mcp_catalog::config::GatewayServer;
25
26use super::addon_manifest::AddonManifest;
27
28/// What `register` did, so the caller can tell the user precisely.
29#[derive(Debug, PartialEq, Eq)]
30pub enum Wired {
31    /// The addon declares no `[mcp]` table.
32    NothingToWire,
33    /// A new gateway entry was added.
34    Added(String),
35    /// An entry with this name already existed and was replaced (re-install or
36    /// upgrade). Replacing rather than duplicating keeps the catalog namespace
37    /// unambiguous — two servers named `mdcast` would make `mdcast::tool`
38    /// mean two things.
39    Replaced(String),
40}
41
42/// Add (or replace) the addon's gateway entry in the **global** config.
43///
44/// A replace is an upgrade, not a fresh install, so it must not clobber the
45/// parts of the entry the *user* owns rather than the author:
46///
47/// - `secret_env` / `secret_headers` are memento references to credentials. A
48///   manifest cannot carry them by design, so a wholesale replace would silently
49///   drop the token the user configured and the server would simply stop
50///   authenticating, with nothing saying why.
51/// - `enabled = false` is a deliberate decision to keep an addon installed but
52///   idle. Re-enabling it on upgrade would override that silently, which is the
53///   one thing a per-server switch exists to prevent.
54///
55/// Everything else — transport, command, args, url, pin, integration — is the
56/// author's to change between versions, and is taken from the new manifest.
57pub fn register(manifest: &AddonManifest) -> Result<Wired, String> {
58    let Some(wiring) = manifest.mcp.as_ref() else {
59        return Ok(Wired::NothingToWire);
60    };
61    let name = manifest.addon.name.clone();
62    let mut server: GatewayServer = wiring.to_gateway_server(&name);
63
64    let mut cfg = Config::load();
65    let previous = cfg.gateway.servers.iter().find(|s| s.name == name).cloned();
66    if let Some(prev) = &previous {
67        server.secret_env = prev.secret_env.clone();
68        server.secret_headers = prev.secret_headers.clone();
69        server.enabled = prev.enabled;
70        // A browser login the user completed (`addon auth`, #1391) is theirs:
71        // an upgrade whose manifest does not declare `auth` must not drop it.
72        server.oauth |= prev.oauth;
73        if server.oauth_scopes.is_empty() {
74            server.oauth_scopes.clone_from(&prev.oauth_scopes);
75        }
76    }
77
78    cfg.gateway.servers.retain(|s| s.name != name);
79    cfg.gateway.servers.push(server);
80    cfg.save().map_err(|e| format!("save config: {e}"))?;
81
82    Ok(if previous.is_some() {
83        Wired::Replaced(name)
84    } else {
85        Wired::Added(name)
86    })
87}
88
89/// Drop the addon's gateway entry. Returns whether one was there.
90///
91/// Removal is by name and unconditional: leaving a gateway entry behind after
92/// `addon remove` would keep spawning a process for an addon the user believes
93/// is gone.
94pub fn unregister(name: &str) -> Result<bool, String> {
95    let mut cfg = Config::load();
96    let Some(removed) = cfg.gateway.servers.iter().find(|s| s.name == name).cloned() else {
97        return Ok(false);
98    };
99    cfg.gateway.servers.retain(|s| s.name != name);
100    cfg.save().map_err(|e| format!("save config: {e}"))?;
101    // Tokens for a server the user removed must not linger on disk (#1391).
102    #[cfg(feature = "http-server")]
103    if removed.oauth {
104        crate::core::mcp_catalog::oauth::forget(&removed.url)?;
105    }
106    #[cfg(not(feature = "http-server"))]
107    let _ = removed;
108    Ok(true)
109}
110
111/// Whether the gateway itself is on. An addon can be wired while the gateway
112/// is off; the CLI says so rather than letting the user think it is running.
113pub fn gateway_enabled() -> bool {
114    Config::load().gateway.enabled
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::core::context_package::addon_manifest;
121
122    const STDIO: &str = r#"
123[addon]
124name = "mdcast"
125[mcp]
126command = "mdcast"
127args = ["mcp", "serve"]
128"#;
129
130    #[test]
131    fn a_wasm_only_addon_wires_nothing() {
132        let m = addon_manifest::parse("[addon]\nname = \"squeeze\"\n").unwrap();
133        assert_eq!(register(&m).unwrap(), Wired::NothingToWire);
134    }
135
136    #[test]
137    fn registering_adds_then_replaces_rather_than_duplicating() {
138        let _iso = crate::core::data_dir::isolated_data_dir();
139        let m = addon_manifest::parse(STDIO).unwrap();
140
141        assert_eq!(register(&m).unwrap(), Wired::Added("mdcast".into()));
142        let cfg = Config::load();
143        let entry = cfg
144            .gateway
145            .servers
146            .iter()
147            .find(|s| s.name == "mdcast")
148            .expect("wired");
149        assert_eq!(entry.command, "mdcast");
150        assert_eq!(entry.args, vec!["mcp", "serve"]);
151
152        // Re-install must not leave two servers claiming the same namespace.
153        assert_eq!(register(&m).unwrap(), Wired::Replaced("mdcast".into()));
154        assert_eq!(
155            Config::load()
156                .gateway
157                .servers
158                .iter()
159                .filter(|s| s.name == "mdcast")
160                .count(),
161            1
162        );
163    }
164
165    /// An upgrade must not silently undo the user's own configuration. The
166    /// credential is the sharp case: a manifest cannot carry `secret_headers`
167    /// by design, so replacing the entry wholesale would drop the token the
168    /// user configured and the server would just stop authenticating, with
169    /// nothing in the output explaining why.
170    #[test]
171    fn an_upgrade_keeps_the_users_secrets_and_their_off_switch() {
172        let _iso = crate::core::data_dir::isolated_data_dir();
173        let m = addon_manifest::parse(STDIO).unwrap();
174        register(&m).unwrap();
175
176        // The user configures a credential and turns the server off.
177        let mut cfg = Config::load();
178        {
179            let entry = cfg
180                .gateway
181                .servers
182                .iter_mut()
183                .find(|s| s.name == "mdcast")
184                .expect("wired");
185            entry.secret_env.insert(
186                "API_TOKEN".to_string(),
187                crate::core::mcp_catalog::config::SecretMementoRef {
188                    id: "memento-1".to_string(),
189                    format: String::new(),
190                },
191            );
192            entry.enabled = false;
193        }
194        cfg.save().unwrap();
195
196        // A newer version of the same addon is installed over it.
197        let upgraded = addon_manifest::parse(
198            "[addon]\nname = \"mdcast\"\n[mcp]\ncommand = \"mdcast\"\nargs = [\"mcp\", \"v2\"]\n",
199        )
200        .unwrap();
201        assert_eq!(
202            register(&upgraded).unwrap(),
203            Wired::Replaced("mdcast".into())
204        );
205
206        let entry = Config::load()
207            .gateway
208            .servers
209            .into_iter()
210            .find(|s| s.name == "mdcast")
211            .expect("still wired");
212        assert_eq!(entry.args, vec!["mcp", "v2"], "the author's change applies");
213        assert!(
214            entry.secret_env.contains_key("API_TOKEN"),
215            "the user's credential survived the upgrade"
216        );
217        assert!(
218            !entry.enabled,
219            "a deliberate off switch is not flipped back on by an upgrade"
220        );
221    }
222
223    #[test]
224    fn unregister_removes_the_entry_and_reports_whether_it_was_there() {
225        let _iso = crate::core::data_dir::isolated_data_dir();
226        let m = addon_manifest::parse(STDIO).unwrap();
227        register(&m).unwrap();
228
229        assert!(unregister("mdcast").unwrap(), "it was wired");
230        assert!(
231            !Config::load()
232                .gateway
233                .servers
234                .iter()
235                .any(|s| s.name == "mdcast"),
236            "a removed addon must not keep spawning a process"
237        );
238        assert!(!unregister("mdcast").unwrap(), "second call is a no-op");
239    }
240
241    /// Wiring a server must not silently switch the gateway on — that is a
242    /// separate, deliberate decision by the user.
243    #[test]
244    fn registering_does_not_enable_the_gateway() {
245        let _iso = crate::core::data_dir::isolated_data_dir();
246        let before = Config::load().gateway.enabled;
247        register(&addon_manifest::parse(STDIO).unwrap()).unwrap();
248        assert_eq!(
249            Config::load().gateway.enabled,
250            before,
251            "an addon may not turn the gateway on for the user"
252        );
253    }
254}