lean_ctx/core/context_package/
addon_wiring.rs1use crate::core::config::Config;
24use crate::core::mcp_catalog::config::GatewayServer;
25
26use super::addon_manifest::AddonManifest;
27
28#[derive(Debug, PartialEq, Eq)]
30pub enum Wired {
31 NothingToWire,
33 Added(String),
35 Replaced(String),
40}
41
42pub 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 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
89pub 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 #[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
111pub 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 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 #[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 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 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 #[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}