Skip to main content

mermaid_cli/app/
plugin_assets.rs

1//! Plugin-contributed assets beyond skills: MCP servers (`manifest.mcp`),
2//! slash-command prompts (`manifest.prompts`), and agent types
3//! (`manifest.agents`).
4//!
5//! One loader, one store read: `load()` walks the installed-plugin store
6//! once (enabled plugins only — the same trust boundary as hooks: enabling a
7//! plugin that declares MCP servers grants command execution, an explicit
8//! `mermaid plugin enable` decision) and `apply()` folds the result into the
9//! ALREADY-MERGED `Config` at the top of both entrypoints. Deliberately NOT
10//! a config layer: the project layer forbids `mcp_servers`/`agents`, and
11//! plugin trust is a different boundary than file trust. Everything
12//! downstream rides free — `State::new` seeds plugin servers, MCP init
13//! starts them (and PR-1 deferral bounds their tools), subagents see the
14//! merged agent types, and the recording header captures the merged config
15//! (replay-faithful). Like skills, changes require a restart.
16
17use std::collections::HashMap;
18use std::path::Path;
19
20use mermaid_domain::{AgentTypeConfig, McpServerConfig};
21
22/// Everything enabled plugins contribute (besides skills), plus the
23/// warnings the startup path should surface.
24#[derive(Debug, Default)]
25pub struct PluginAssets {
26    /// MCP servers keyed by their manifest-file name (NOT prefixed — a
27    /// prefix would pollute the `mcp__<server>__<tool>` tool names).
28    pub mcp_servers: HashMap<String, McpServerConfig>,
29    /// Prompt-backed slash commands.
30    pub commands: Vec<mermaid_domain::PluginCommand>,
31    /// Agent types, merged into `config.agents.types` for absent names only.
32    pub agent_types: HashMap<String, AgentTypeConfig>,
33    pub warnings: Vec<String>,
34}
35
36/// A plugin's `manifest.mcp` / `manifest.agents` TOML file shapes.
37#[derive(serde::Deserialize)]
38struct McpBundle {
39    #[serde(default)]
40    servers: HashMap<String, McpServerConfig>,
41}
42
43#[derive(serde::Deserialize)]
44struct AgentBundle {
45    #[serde(default)]
46    types: HashMap<String, AgentTypeConfig>,
47}
48
49/// Load every enabled plugin's assets from the runtime store. Degrades to
50/// empty on store/parse failure (the skills precedent — a broken plugin
51/// must not kill startup). Plugins are visited in sorted-name order so
52/// plugin-vs-plugin collisions resolve deterministically.
53#[must_use]
54pub fn load() -> PluginAssets {
55    let mut assets = PluginAssets::default();
56    let Ok(store) = mermaid_runtime::RuntimeStore::open_default() else {
57        return assets;
58    };
59    let Ok(mut plugins) = store.plugins().list() else {
60        return assets;
61    };
62    plugins.sort_by(|a, b| a.name.cmp(&b.name));
63    for plugin in plugins {
64        if !plugin.enabled {
65            continue;
66        }
67        let Ok(manifest) =
68            serde_json::from_str::<mermaid_runtime::PluginManifest>(&plugin.manifest_json)
69        else {
70            assets.warnings.push(format!(
71                "plugin '{}': unreadable manifest; skipped",
72                plugin.name
73            ));
74            continue;
75        };
76        let Ok(root) = std::fs::canonicalize(&plugin.source) else {
77            continue;
78        };
79        merge_assets(&mut assets, assets_from_manifest(&root, &manifest));
80    }
81    assets
82}
83
84/// Fold `next` into `acc` with first-wins collision policy (plugins arrive
85/// in sorted order, so the winner is deterministic).
86fn merge_assets(acc: &mut PluginAssets, next: PluginAssets) {
87    for (name, server) in next.mcp_servers {
88        if acc.mcp_servers.contains_key(&name) {
89            acc.warnings.push(format!(
90                "plugin MCP server '{name}' is defined by more than one plugin; keeping the first"
91            ));
92            continue;
93        }
94        acc.mcp_servers.insert(name, server);
95    }
96    for command in next.commands {
97        if acc.commands.iter().any(|c| c.name == command.name) {
98            acc.warnings.push(format!(
99                "plugin command '/{}' is defined by more than one plugin; keeping the first",
100                command.name
101            ));
102            continue;
103        }
104        acc.commands.push(command);
105    }
106    for (name, agent) in next.agent_types {
107        if acc.agent_types.contains_key(&name) {
108            acc.warnings.push(format!(
109                "plugin agent type '{name}' is defined by more than one plugin; keeping the first"
110            ));
111            continue;
112        }
113        acc.agent_types.insert(name, agent);
114    }
115    acc.warnings.extend(next.warnings);
116}
117
118/// Parse one plugin's declared asset files against its canonical root.
119/// Pure-ish (filesystem reads only) so tests need no `RuntimeStore`.
120pub(crate) fn assets_from_manifest(
121    root: &Path,
122    manifest: &mermaid_runtime::PluginManifest,
123) -> PluginAssets {
124    let mut assets = PluginAssets::default();
125    let plugin = &manifest.name;
126    for entry in &manifest.mcp {
127        let Some(raw) = read_contained(root, entry, plugin, &mut assets.warnings) else {
128            continue;
129        };
130        match toml::from_str::<McpBundle>(&raw) {
131            Ok(bundle) => {
132                for (name, mut server) in bundle.servers {
133                    // A `./`-relative command resolves against the plugin
134                    // root (with containment); anything else is PATH-looked-up
135                    // like a config-defined server. A url-only entry has an
136                    // empty command and loads un-rewritten (validated later
137                    // by `transport_kind` at server start).
138                    if let Some(rel) = server
139                        .command
140                        .strip_prefix("./")
141                        .map(str::to_string)
142                        .filter(|r| !r.is_empty())
143                    {
144                        match std::fs::canonicalize(root.join(&rel)) {
145                            Ok(resolved) if resolved.starts_with(root) => {
146                                server.command = resolved.display().to_string();
147                            },
148                            _ => {
149                                assets.warnings.push(format!(
150                                    "plugin '{plugin}': MCP server '{name}' command '{}' escapes \
151                                     the plugin directory or is missing; skipped",
152                                    server.command
153                                ));
154                                continue;
155                            },
156                        }
157                    }
158                    assets.mcp_servers.insert(name, server);
159                }
160            },
161            Err(err) => assets.warnings.push(format!(
162                "plugin '{plugin}': MCP bundle {entry} did not parse: {err}"
163            )),
164        }
165    }
166    for entry in &manifest.prompts {
167        let Some(raw) = read_contained(root, entry, plugin, &mut assets.warnings) else {
168            continue;
169        };
170        let (name, description, body) = super::skills::parse_frontmatter_with_body(&raw);
171        let stem = Path::new(entry)
172            .file_stem()
173            .map(|s| s.to_string_lossy().to_string())
174            .unwrap_or_default();
175        let name = name.unwrap_or(stem);
176        if !name
177            .chars()
178            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
179            || name.is_empty()
180        {
181            assets.warnings.push(format!(
182                "plugin '{plugin}': prompt name '{name}' is not [a-z0-9-]+; skipped"
183            ));
184            continue;
185        }
186        if body.trim().is_empty() {
187            assets.warnings.push(format!(
188                "plugin '{plugin}': prompt '/{name}' has an empty body; skipped"
189            ));
190            continue;
191        }
192        // Builtins always win — a plugin must not shadow /help or /quit.
193        if mermaid_domain::slash_commands::COMMAND_REGISTRY
194            .iter()
195            .any(|c| c.name == name || c.aliases.contains(&name.as_str()))
196        {
197            assets.warnings.push(format!(
198                "plugin '{plugin}': prompt '/{name}' shadows a built-in command; skipped"
199            ));
200            continue;
201        }
202        assets.commands.push(mermaid_domain::PluginCommand {
203            name,
204            description: description.unwrap_or_default(),
205            body: body.trim().to_string(),
206            plugin: plugin.clone(),
207        });
208    }
209    for entry in &manifest.agents {
210        let Some(raw) = read_contained(root, entry, plugin, &mut assets.warnings) else {
211            continue;
212        };
213        match toml::from_str::<AgentBundle>(&raw) {
214            Ok(bundle) => assets.agent_types.extend(bundle.types),
215            Err(err) => assets.warnings.push(format!(
216                "plugin '{plugin}': agent bundle {entry} did not parse: {err}"
217            )),
218        }
219    }
220    assets
221}
222
223/// Read a declared asset file with the same canonicalize + containment check
224/// as plugin skills/hooks: a symlink inside the plugin root must not reach
225/// files outside it.
226fn read_contained(
227    root: &Path,
228    entry: &str,
229    plugin: &str,
230    warnings: &mut Vec<String>,
231) -> Option<String> {
232    let resolved = std::fs::canonicalize(root.join(entry)).ok()?;
233    if !resolved.starts_with(root) {
234        warnings.push(format!(
235            "plugin '{plugin}': asset {entry} escapes the plugin directory; skipped"
236        ));
237        return None;
238    }
239    std::fs::read_to_string(&resolved).ok()
240}
241
242/// Fold plugin assets into the already-merged `Config`. Config-defined
243/// entries always win (a user's `[mcp_servers.x]` / `[agents.types.x]`
244/// beats a plugin's); returns the warnings to surface at startup.
245pub fn apply(config: &mut mermaid_domain::Config, assets: &PluginAssets) -> Vec<String> {
246    let mut warnings = assets.warnings.clone();
247    for (name, server) in &assets.mcp_servers {
248        if config.mcp_servers.contains_key(name) {
249            warnings.push(format!(
250                "plugin MCP server '{name}' is shadowed by [mcp_servers.{name}] in config; \
251                 using the config entry"
252            ));
253            continue;
254        }
255        config.mcp_servers.insert(name.clone(), server.clone());
256    }
257    for (name, agent) in &assets.agent_types {
258        if config.agents.types.contains_key(name) {
259            warnings.push(format!(
260                "plugin agent type '{name}' is shadowed by [agents.types.{name}] in config; \
261                 using the config entry"
262            ));
263            continue;
264        }
265        config.agents.types.insert(name.clone(), agent.clone());
266    }
267    warnings
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn fixture_root(tag: &str) -> std::path::PathBuf {
275        let dir = std::env::temp_dir().join(format!(
276            "mermaid-plugin-assets-{}-{}",
277            tag,
278            std::process::id()
279        ));
280        let _ = std::fs::remove_dir_all(&dir);
281        std::fs::create_dir_all(&dir).unwrap();
282        std::fs::canonicalize(&dir).unwrap()
283    }
284
285    fn manifest(
286        root: &std::path::Path,
287        mcp: &[&str],
288        prompts: &[&str],
289        agents: &[&str],
290    ) -> mermaid_runtime::PluginManifest {
291        let _ = root;
292        mermaid_runtime::PluginManifest {
293            name: "demo".to_string(),
294            version: None,
295            description: None,
296            skills: vec![],
297            agents: agents.iter().map(|s| s.to_string()).collect(),
298            hooks: vec![],
299            mcp: mcp.iter().map(|s| s.to_string()).collect(),
300            capabilities: vec![],
301            prompts: prompts.iter().map(|s| s.to_string()).collect(),
302            bin: vec![],
303        }
304    }
305
306    #[test]
307    fn fixture_plugin_parses_all_three_asset_kinds() {
308        let root = fixture_root("full");
309        std::fs::write(
310            root.join("servers.toml"),
311            "[servers.context7]\ncommand = \"npx\"\nargs = [\"-y\", \"context7\"]\n",
312        )
313        .unwrap();
314        std::fs::write(
315            root.join("deploy.md"),
316            "---\nname: deploy\ndescription: Deploy the app\n---\nDeploy to $ARGUMENTS now.\n",
317        )
318        .unwrap();
319        std::fs::write(
320            root.join("types.toml"),
321            "[types.scout]\nsafety = \"read_only\"\npreamble = \"\"\"\nBe brief.\n\"\"\"\n",
322        )
323        .unwrap();
324        let m = manifest(&root, &["servers.toml"], &["deploy.md"], &["types.toml"]);
325        let assets = assets_from_manifest(&root, &m);
326        assert!(assets.warnings.is_empty(), "{:?}", assets.warnings);
327        assert_eq!(assets.mcp_servers["context7"].command, "npx");
328        assert_eq!(assets.commands.len(), 1);
329        assert_eq!(assets.commands[0].name, "deploy");
330        assert_eq!(assets.commands[0].body, "Deploy to $ARGUMENTS now.");
331        assert_eq!(
332            assets.agent_types["scout"].safety.as_deref(),
333            Some("read_only")
334        );
335        let _ = std::fs::remove_dir_all(&root);
336    }
337
338    #[test]
339    fn prompt_name_falls_back_to_stem_and_rejects_bad_names() {
340        let root = fixture_root("names");
341        std::fs::write(root.join("ship-it.md"), "Ship the thing.\n").unwrap();
342        std::fs::write(root.join("Bad Name.md"), "body\n").unwrap();
343        std::fs::write(root.join("empty.md"), "---\nname: empty\n---\n\n").unwrap();
344        let m = manifest(&root, &[], &["ship-it.md", "Bad Name.md", "empty.md"], &[]);
345        let assets = assets_from_manifest(&root, &m);
346        assert_eq!(assets.commands.len(), 1, "{:?}", assets.warnings);
347        assert_eq!(assets.commands[0].name, "ship-it");
348        assert!(assets.warnings.iter().any(|w| w.contains("not [a-z0-9-]+")));
349        assert!(assets.warnings.iter().any(|w| w.contains("empty body")));
350        let _ = std::fs::remove_dir_all(&root);
351    }
352
353    #[test]
354    fn prompt_shadowing_a_builtin_is_skipped() {
355        let root = fixture_root("shadow");
356        std::fs::write(root.join("help.md"), "hijack the help\n").unwrap();
357        std::fs::write(root.join("q.md"), "hijack the quit alias\n").unwrap();
358        let m = manifest(&root, &[], &["help.md", "q.md"], &[]);
359        let assets = assets_from_manifest(&root, &m);
360        assert!(assets.commands.is_empty());
361        assert_eq!(
362            assets
363                .warnings
364                .iter()
365                .filter(|w| w.contains("shadows a built-in"))
366                .count(),
367            2,
368            "{:?}",
369            assets.warnings
370        );
371        let _ = std::fs::remove_dir_all(&root);
372    }
373
374    #[cfg(unix)]
375    #[test]
376    fn symlink_escape_is_skipped() {
377        let root = fixture_root("escape");
378        let outside = fixture_root("escape-outside");
379        std::fs::write(
380            outside.join("evil.toml"),
381            "[servers.evil]\ncommand = \"sh\"\n",
382        )
383        .unwrap();
384        std::os::unix::fs::symlink(outside.join("evil.toml"), root.join("link.toml")).unwrap();
385        let m = manifest(&root, &["link.toml"], &[], &[]);
386        let assets = assets_from_manifest(&root, &m);
387        assert!(assets.mcp_servers.is_empty());
388        assert!(
389            assets.warnings.iter().any(|w| w.contains("escapes")),
390            "{:?}",
391            assets.warnings
392        );
393        let _ = std::fs::remove_dir_all(&root);
394        let _ = std::fs::remove_dir_all(&outside);
395    }
396
397    #[cfg(unix)]
398    #[test]
399    fn dot_slash_command_resolves_in_root_with_containment() {
400        let root = fixture_root("cmd");
401        std::fs::write(root.join("server.sh"), "#!/bin/sh\n").unwrap();
402        std::fs::write(
403            root.join("servers.toml"),
404            "[servers.local]\ncommand = \"./server.sh\"\n[servers.gone]\ncommand = \"./missing.sh\"\n",
405        )
406        .unwrap();
407        let m = manifest(&root, &["servers.toml"], &[], &[]);
408        let assets = assets_from_manifest(&root, &m);
409        assert!(
410            assets.mcp_servers["local"].command.ends_with("server.sh"),
411            "{}",
412            assets.mcp_servers["local"].command
413        );
414        assert!(std::path::Path::new(&assets.mcp_servers["local"].command).is_absolute());
415        assert!(!assets.mcp_servers.contains_key("gone"));
416        assert!(assets.warnings.iter().any(|w| w.contains("missing.sh")));
417        let _ = std::fs::remove_dir_all(&root);
418    }
419
420    #[cfg(unix)]
421    #[test]
422    fn url_only_bundle_entry_loads_without_command_rewrite() {
423        // An HTTP server entry has no command; the `./`-relative rewrite must
424        // not touch it (or warn it away as a missing file).
425        let root = fixture_root("url-only");
426        std::fs::write(
427            root.join("servers.toml"),
428            "[servers.remote]\nurl = \"https://example.com/mcp\"\n",
429        )
430        .unwrap();
431        let m = manifest(&root, &["servers.toml"], &[], &[]);
432        let assets = assets_from_manifest(&root, &m);
433        assert_eq!(
434            assets.mcp_servers["remote"].url.as_deref(),
435            Some("https://example.com/mcp")
436        );
437        assert!(assets.mcp_servers["remote"].command.is_empty());
438        assert!(assets.warnings.is_empty(), "{:?}", assets.warnings);
439        let _ = std::fs::remove_dir_all(&root);
440    }
441
442    #[test]
443    fn apply_lets_config_win_and_merges_the_rest() {
444        let mut config = mermaid_domain::Config::default();
445        config.mcp_servers.insert(
446            "shared".to_string(),
447            McpServerConfig {
448                command: "config-wins".to_string(),
449                ..Default::default()
450            },
451        );
452        let mut assets = PluginAssets::default();
453        assets.mcp_servers.insert(
454            "shared".to_string(),
455            McpServerConfig {
456                command: "plugin-loses".to_string(),
457                ..Default::default()
458            },
459        );
460        assets.mcp_servers.insert(
461            "fresh".to_string(),
462            McpServerConfig {
463                command: "plugin-wins".to_string(),
464                ..Default::default()
465            },
466        );
467        assets.agent_types.insert(
468            "scout".to_string(),
469            AgentTypeConfig {
470                tools: None,
471                safety: Some("read_only".to_string()),
472                preamble: None,
473                model: None,
474                isolation: None,
475            },
476        );
477        let warnings = apply(&mut config, &assets);
478        assert_eq!(config.mcp_servers["shared"].command, "config-wins");
479        assert_eq!(config.mcp_servers["fresh"].command, "plugin-wins");
480        assert!(config.agents.types.contains_key("scout"));
481        assert!(
482            warnings.iter().any(|w| w.contains("shadowed")),
483            "{warnings:?}"
484        );
485    }
486
487    #[test]
488    fn merge_assets_is_first_wins_deterministic() {
489        let mut acc = PluginAssets::default();
490        let mut first = PluginAssets::default();
491        first.mcp_servers.insert(
492            "s".to_string(),
493            McpServerConfig {
494                command: "first".to_string(),
495                ..Default::default()
496            },
497        );
498        let mut second = PluginAssets::default();
499        second.mcp_servers.insert(
500            "s".to_string(),
501            McpServerConfig {
502                command: "second".to_string(),
503                ..Default::default()
504            },
505        );
506        merge_assets(&mut acc, first);
507        merge_assets(&mut acc, second);
508        assert_eq!(acc.mcp_servers["s"].command, "first");
509        assert!(
510            acc.warnings
511                .iter()
512                .any(|w| w.contains("more than one plugin"))
513        );
514    }
515}