mcp_sync/
plugin.rs

1//! # Plugin System
2//!
3//! Extensible plugin architecture for transforming MCP configurations.
4//!
5//! Plugins can:
6//! - Transform the canonical config before syncing
7//! - Modify target-specific output
8//! - Add custom server definitions
9//!
10//! # Built-in Plugins
11//!
12//! - `env-expander`: Expands environment variables in config values
13//!
14//! # Creating Custom Plugins
15//!
16//! Plugins are dynamic libraries (`.dylib`/`.so`/`.dll`) that export a `create_plugin` function:
17//!
18//! ```ignore
19//! #[no_mangle]
20//! pub extern "C" fn create_plugin() -> *mut dyn Plugin {
21//!     Box::into_raw(Box::new(MyPlugin::new()))
22//! }
23//! ```
24
25use crate::canon::Canon;
26use anyhow::Result;
27use serde_json::Value as JsonValue;
28use std::path::Path;
29
30/// Trait for MCP-Sync plugins.
31///
32/// Plugins can hook into various stages of the sync process to
33/// transform configurations.
34pub trait Plugin: Send + Sync {
35    /// Returns the plugin name for logging and identification.
36    fn name(&self) -> &str;
37
38    /// Called when the plugin is loaded with its configuration.
39    ///
40    /// # Arguments
41    /// * `config` - Plugin-specific configuration from `mcp.yaml`
42    fn on_load(&mut self, config: &JsonValue) -> Result<()>;
43
44    /// Transforms the canonical configuration before syncing to targets.
45    ///
46    /// This hook is called once per sync operation, before any target sync.
47    ///
48    /// # Arguments
49    /// * `canon` - Mutable reference to the canonical configuration
50    fn transform_canon(&self, canon: &mut Canon) -> Result<()>;
51
52    /// Transforms the output for a specific target before writing.
53    ///
54    /// This hook is called for each target file that will be written.
55    ///
56    /// # Arguments
57    /// * `target` - Name of the target (e.g., "Antigravity", "Claude")
58    /// * `value` - Mutable reference to the JSON value being written
59    fn transform_output(&self, target: &str, value: &mut JsonValue) -> Result<()>;
60}
61
62/// Manages plugin loading and execution.
63pub struct PluginManager {
64    plugins: Vec<Box<dyn Plugin>>,
65    #[allow(dead_code)]
66    libraries: Vec<libloading::Library>,
67}
68
69impl PluginManager {
70    /// Creates a new empty plugin manager.
71    pub fn new() -> Self {
72        Self {
73            plugins: Vec::new(),
74            libraries: Vec::new(),
75        }
76    }
77
78    /// Loads a built-in plugin by name.
79    pub fn load_builtin(&mut self, name: &str, config: &JsonValue) -> Result<()> {
80        let mut plugin: Box<dyn Plugin> = match name {
81            "env-expander" => Box::new(EnvExpanderPlugin::new()),
82            _ => anyhow::bail!("unknown built-in plugin: {}", name),
83        };
84        plugin.on_load(config)?;
85        self.plugins.push(plugin);
86        Ok(())
87    }
88
89    /// Loads a plugin from a dynamic library.
90    ///
91    /// # Safety
92    /// The library must export a valid `create_plugin` function.
93    pub unsafe fn load_dynamic(&mut self, path: &Path, config: &JsonValue) -> Result<()> {
94        // SAFETY: Caller guarantees the library is valid
95        let lib = unsafe { libloading::Library::new(path)? };
96        let create: libloading::Symbol<unsafe extern "C" fn() -> *mut dyn Plugin> =
97            unsafe { lib.get(b"create_plugin")? };
98        // SAFETY: create_plugin returns a valid Box<dyn Plugin> pointer
99        let raw = unsafe { create() };
100        let mut plugin = unsafe { Box::from_raw(raw) };
101        plugin.on_load(config)?;
102        self.plugins.push(plugin);
103        self.libraries.push(lib);
104        Ok(())
105    }
106
107    /// Runs `transform_canon` on all loaded plugins.
108    pub fn transform_canon(&self, canon: &mut Canon) -> Result<()> {
109        for plugin in &self.plugins {
110            plugin.transform_canon(canon)?;
111        }
112        Ok(())
113    }
114
115    /// Runs `transform_output` on all loaded plugins.
116    pub fn transform_output(&self, target: &str, value: &mut JsonValue) -> Result<()> {
117        for plugin in &self.plugins {
118            plugin.transform_output(target, value)?;
119        }
120        Ok(())
121    }
122
123    /// Returns the number of loaded plugins.
124    pub fn count(&self) -> usize {
125        self.plugins.len()
126    }
127}
128
129impl Default for PluginManager {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135// ─────────────────────────── Built-in Plugins ───────────────────────────
136
137/// Plugin that expands environment variables in configuration values.
138///
139/// # Configuration
140/// ```yaml
141/// plugins:
142///   - name: env-expander
143///     config:
144///       prefix: "${"
145///       suffix: "}"
146/// ```
147pub struct EnvExpanderPlugin {
148    prefix: String,
149    suffix: String,
150}
151
152impl EnvExpanderPlugin {
153    /// Creates a new plugin with default delimiters (`${` and `}`).
154    pub fn new() -> Self {
155        Self {
156            prefix: "${".to_string(),
157            suffix: "}".to_string(),
158        }
159    }
160
161    fn expand_string(&self, s: &str) -> String {
162        let mut result = s.to_string();
163        let mut start = 0;
164        
165        while let Some(prefix_pos) = result[start..].find(&self.prefix) {
166            let abs_prefix = start + prefix_pos;
167            let search_start = abs_prefix + self.prefix.len();
168            
169            if let Some(suffix_pos) = result[search_start..].find(&self.suffix) {
170                let var_name = &result[search_start..search_start + suffix_pos];
171                if let Ok(value) = std::env::var(var_name) {
172                    let full_pattern = format!("{}{}{}", self.prefix, var_name, self.suffix);
173                    result = result.replace(&full_pattern, &value);
174                    // Don't advance start since we replaced content
175                } else {
176                    start = search_start + suffix_pos + self.suffix.len();
177                }
178            } else {
179                break;
180            }
181        }
182        
183        result
184    }
185
186    fn expand_value(&self, value: &mut JsonValue) {
187        match value {
188            JsonValue::String(s) => {
189                *s = self.expand_string(s);
190            }
191            JsonValue::Array(arr) => {
192                for item in arr {
193                    self.expand_value(item);
194                }
195            }
196            JsonValue::Object(obj) => {
197                for (_, v) in obj.iter_mut() {
198                    self.expand_value(v);
199                }
200            }
201            _ => {}
202        }
203    }
204}
205
206impl Default for EnvExpanderPlugin {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212impl Plugin for EnvExpanderPlugin {
213    fn name(&self) -> &str {
214        "env-expander"
215    }
216
217    fn on_load(&mut self, config: &JsonValue) -> Result<()> {
218        if let Some(prefix) = config.get("prefix").and_then(|v| v.as_str()) {
219            self.prefix = prefix.to_string();
220        }
221        if let Some(suffix) = config.get("suffix").and_then(|v| v.as_str()) {
222            self.suffix = suffix.to_string();
223        }
224        Ok(())
225    }
226
227    fn transform_canon(&self, canon: &mut Canon) -> Result<()> {
228        for server in canon.servers.values_mut() {
229            if let Some(cmd) = &mut server.command {
230                *cmd = self.expand_string(cmd);
231            }
232            if let Some(args) = &mut server.args {
233                for arg in args.iter_mut() {
234                    *arg = self.expand_string(arg);
235                }
236            }
237            if let Some(url) = &mut server.url {
238                *url = self.expand_string(url);
239            }
240            if let Some(env) = &mut server.env {
241                for value in env.values_mut() {
242                    *value = self.expand_string(value);
243                }
244            }
245        }
246        Ok(())
247    }
248
249    fn transform_output(&self, _target: &str, value: &mut JsonValue) -> Result<()> {
250        self.expand_value(value);
251        Ok(())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::canon::{Canon, CanonServer};
259    use std::collections::BTreeMap;
260
261    // Helper functions for Rust 2024 unsafe env var operations
262    fn set_env(key: &str, value: &str) {
263        // SAFETY: Test environment only, single-threaded test execution
264        unsafe { std::env::set_var(key, value) };
265    }
266    
267    fn remove_env(key: &str) {
268        // SAFETY: Test environment only, single-threaded test execution
269        unsafe { std::env::remove_var(key) };
270    }
271
272    // ─────────────────────────── EnvExpanderPlugin tests ───────────────────────────
273
274    #[test]
275    fn test_env_expander_new_default_delimiters() {
276        let plugin = EnvExpanderPlugin::new();
277        assert_eq!(plugin.prefix, "${");
278        assert_eq!(plugin.suffix, "}");
279    }
280
281    #[test]
282    fn test_env_expander_expand_string_simple() {
283        set_env("TEST_VAR_123", "hello");
284        let plugin = EnvExpanderPlugin::new();
285        
286        let result = plugin.expand_string("prefix ${TEST_VAR_123} suffix");
287        
288        assert_eq!(result, "prefix hello suffix");
289        remove_env("TEST_VAR_123");
290    }
291
292    #[test]
293    fn test_env_expander_expand_string_multiple() {
294        set_env("VAR_A", "first");
295        set_env("VAR_B", "second");
296        let plugin = EnvExpanderPlugin::new();
297        
298        let result = plugin.expand_string("${VAR_A} and ${VAR_B}");
299        
300        assert_eq!(result, "first and second");
301        remove_env("VAR_A");
302        remove_env("VAR_B");
303    }
304
305    #[test]
306    fn test_env_expander_expand_string_undefined_var() {
307        let plugin = EnvExpanderPlugin::new();
308        remove_env("UNDEFINED_VAR_XYZ");
309        
310        let result = plugin.expand_string("value: ${UNDEFINED_VAR_XYZ}");
311        
312        // Undefined vars are left as-is
313        assert_eq!(result, "value: ${UNDEFINED_VAR_XYZ}");
314    }
315
316    #[test]
317    fn test_env_expander_expand_string_no_vars() {
318        let plugin = EnvExpanderPlugin::new();
319        
320        let result = plugin.expand_string("no variables here");
321        
322        assert_eq!(result, "no variables here");
323    }
324
325    #[test]
326    fn test_env_expander_expand_string_custom_delimiters() {
327        set_env("CUSTOM_VAR", "custom_value");
328        let mut plugin = EnvExpanderPlugin::new();
329        plugin.prefix = "{{".to_string();
330        plugin.suffix = "}}".to_string();
331        
332        let result = plugin.expand_string("value: {{CUSTOM_VAR}}");
333        
334        assert_eq!(result, "value: custom_value");
335        remove_env("CUSTOM_VAR");
336    }
337
338    #[test]
339    fn test_env_expander_on_load_custom_config() {
340        let mut plugin = EnvExpanderPlugin::new();
341        let config = serde_json::json!({
342            "prefix": "[[",
343            "suffix": "]]"
344        });
345        
346        plugin.on_load(&config).unwrap();
347        
348        assert_eq!(plugin.prefix, "[[");
349        assert_eq!(plugin.suffix, "]]");
350    }
351
352    #[test]
353    fn test_env_expander_on_load_partial_config() {
354        let mut plugin = EnvExpanderPlugin::new();
355        let config = serde_json::json!({
356            "prefix": "%%"
357        });
358        
359        plugin.on_load(&config).unwrap();
360        
361        assert_eq!(plugin.prefix, "%%");
362        assert_eq!(plugin.suffix, "}"); // unchanged
363    }
364
365    #[test]
366    fn test_env_expander_on_load_empty_config() {
367        let mut plugin = EnvExpanderPlugin::new();
368        let config = serde_json::json!({});
369        
370        plugin.on_load(&config).unwrap();
371        
372        assert_eq!(plugin.prefix, "${");
373        assert_eq!(plugin.suffix, "}");
374    }
375
376    #[test]
377    fn test_env_expander_transform_canon_command() {
378        set_env("CMD_PATH", "/usr/bin/custom");
379        let plugin = EnvExpanderPlugin::new();
380        
381        let mut servers = BTreeMap::new();
382        servers.insert("test".to_string(), CanonServer {
383            kind: None,
384            command: Some("${CMD_PATH}".to_string()),
385            args: None,
386            env: None,
387            cwd: None,
388            url: None,
389            headers: None,
390            bearer_token_env_var: None,
391            enabled: None,
392        });
393        let mut canon = Canon { version: Some(1), servers, plugins: vec![] };
394        
395        plugin.transform_canon(&mut canon).unwrap();
396        
397        assert_eq!(canon.servers["test"].command, Some("/usr/bin/custom".to_string()));
398        remove_env("CMD_PATH");
399    }
400
401    #[test]
402    fn test_env_expander_transform_canon_args() {
403        set_env("ARG_VAL", "expanded_arg");
404        let plugin = EnvExpanderPlugin::new();
405        
406        let mut servers = BTreeMap::new();
407        servers.insert("test".to_string(), CanonServer {
408            kind: None,
409            command: Some("cmd".to_string()),
410            args: Some(vec!["--value".to_string(), "${ARG_VAL}".to_string()]),
411            env: None,
412            cwd: None,
413            url: None,
414            headers: None,
415            bearer_token_env_var: None,
416            enabled: None,
417        });
418        let mut canon = Canon { version: Some(1), servers, plugins: vec![] };
419        
420        plugin.transform_canon(&mut canon).unwrap();
421        
422        let args = canon.servers["test"].args.as_ref().unwrap();
423        assert_eq!(args[1], "expanded_arg");
424        remove_env("ARG_VAL");
425    }
426
427    #[test]
428    fn test_env_expander_transform_canon_url() {
429        set_env("API_HOST", "api.example.com");
430        let plugin = EnvExpanderPlugin::new();
431        
432        let mut servers = BTreeMap::new();
433        servers.insert("api".to_string(), CanonServer {
434            kind: Some("http".to_string()),
435            command: None,
436            args: None,
437            env: None,
438            cwd: None,
439            url: Some("https://${API_HOST}/v1".to_string()),
440            headers: None,
441            bearer_token_env_var: None,
442            enabled: None,
443        });
444        let mut canon = Canon { version: Some(1), servers, plugins: vec![] };
445        
446        plugin.transform_canon(&mut canon).unwrap();
447        
448        assert_eq!(canon.servers["api"].url, Some("https://api.example.com/v1".to_string()));
449        remove_env("API_HOST");
450    }
451
452    #[test]
453    fn test_env_expander_transform_output() {
454        set_env("OUT_VAL", "output_value");
455        let plugin = EnvExpanderPlugin::new();
456        let mut value = serde_json::json!({
457            "key": "${OUT_VAL}",
458            "nested": {
459                "inner": "${OUT_VAL}"
460            }
461        });
462        
463        plugin.transform_output("test", &mut value).unwrap();
464        
465        assert_eq!(value["key"], "output_value");
466        assert_eq!(value["nested"]["inner"], "output_value");
467        remove_env("OUT_VAL");
468    }
469
470    #[test]
471    fn test_env_expander_expand_value_array() {
472        set_env("ARR_VAL", "array_item");
473        let plugin = EnvExpanderPlugin::new();
474        let mut value = serde_json::json!(["static", "${ARR_VAL}", "other"]);
475        
476        plugin.expand_value(&mut value);
477        
478        assert_eq!(value[1], "array_item");
479        remove_env("ARR_VAL");
480    }
481
482    #[test]
483    fn test_env_expander_name() {
484        let plugin = EnvExpanderPlugin::new();
485        assert_eq!(plugin.name(), "env-expander");
486    }
487
488    // ─────────────────────────── PluginManager tests ───────────────────────────
489
490    #[test]
491    fn test_plugin_manager_new() {
492        let manager = PluginManager::new();
493        assert_eq!(manager.count(), 0);
494    }
495
496    #[test]
497    fn test_plugin_manager_default() {
498        let manager = PluginManager::default();
499        assert_eq!(manager.count(), 0);
500    }
501
502    #[test]
503    fn test_plugin_manager_load_builtin_env_expander() {
504        let mut manager = PluginManager::new();
505        
506        manager.load_builtin("env-expander", &serde_json::json!({})).unwrap();
507        
508        assert_eq!(manager.count(), 1);
509    }
510
511    #[test]
512    fn test_plugin_manager_load_builtin_unknown_error() {
513        let mut manager = PluginManager::new();
514        
515        let result = manager.load_builtin("unknown-plugin", &serde_json::json!({}));
516        
517        assert!(result.is_err());
518    }
519
520    #[test]
521    fn test_plugin_manager_transform_canon() {
522        set_env("MGR_TEST", "manager_value");
523        let mut manager = PluginManager::new();
524        manager.load_builtin("env-expander", &serde_json::json!({})).unwrap();
525        
526        let mut servers = BTreeMap::new();
527        servers.insert("test".to_string(), CanonServer {
528            kind: None,
529            command: Some("${MGR_TEST}".to_string()),
530            args: None,
531            env: None,
532            cwd: None,
533            url: None,
534            headers: None,
535            bearer_token_env_var: None,
536            enabled: None,
537        });
538        let mut canon = Canon { version: Some(1), servers, plugins: vec![] };
539        
540        manager.transform_canon(&mut canon).unwrap();
541        
542        assert_eq!(canon.servers["test"].command, Some("manager_value".to_string()));
543        remove_env("MGR_TEST");
544    }
545
546    #[test]
547    fn test_plugin_manager_transform_output() {
548        set_env("OUT_MGR", "output_mgr");
549        let mut manager = PluginManager::new();
550        manager.load_builtin("env-expander", &serde_json::json!({})).unwrap();
551        let mut value = serde_json::json!({"key": "${OUT_MGR}"});
552        
553        manager.transform_output("test", &mut value).unwrap();
554        
555        assert_eq!(value["key"], "output_mgr");
556        remove_env("OUT_MGR");
557    }
558
559    #[test]
560    fn test_plugin_manager_multiple_plugins() {
561        let mut manager = PluginManager::new();
562        manager.load_builtin("env-expander", &serde_json::json!({})).unwrap();
563        // Note: Currently only one built-in, but count should work
564        
565        assert_eq!(manager.count(), 1);
566    }
567}
568