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