Skip to main content

agent_plugins/
launch.rs

1//! From validated configuration to a runnable subprocess (§9.1).
2//!
3//! A [`LaunchPlan`] is the fully resolved recipe for starting a stdio MCP
4//! server: program, expanded arguments, environment overlay, reserved
5//! variables, working directory. It is pure data — build it anywhere, hand it
6//! to `std::process`, `tokio::process`, or a sandbox of your choosing. The
7//! §9.1 ordering (configured overlay first, then `PLUGIN_ROOT`/`PLUGIN_DATA`
8//! replacing any same-name entries) is baked into the plan's shape.
9
10use std::path::PathBuf;
11
12use crate::mcp::{Command as ConfiguredCommand, Cwd, StdioServer};
13use crate::template::Anchors;
14
15/// The resolved program to execute.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Program {
18    /// A bare executable name, to be found by the platform's search rules.
19    /// Whether a configured `PATH` participates is client-defined (§7.2.1).
20    Bare(String),
21    /// An absolute path to an executable bundled in the plugin.
22    Bundled(PathBuf),
23}
24
25/// Everything needed to launch one stdio MCP server.
26#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct LaunchPlan {
29    /// The program to execute.
30    pub program: Program,
31    /// Arguments after placeholder expansion, in order.
32    pub args: Vec<String>,
33    /// The configured environment overlay after placeholder expansion.
34    /// Apply these to your chosen base environment *first* (§9.1).
35    pub env_overlay: Vec<(String, String)>,
36    /// The absolute plugin root — set as `PLUGIN_ROOT` *after* the overlay,
37    /// replacing any same-name entry (§9.1).
38    pub plugin_root: PathBuf,
39    /// The absolute plugin data directory — set as `PLUGIN_DATA` *after* the
40    /// overlay. The client must create it and keep it writable (§9.1).
41    pub plugin_data: PathBuf,
42    /// The resolved working directory (§7.2.1).
43    pub cwd: PathBuf,
44}
45
46impl StdioServer {
47    /// Resolve this server against the client's anchors into a launch plan.
48    ///
49    /// All placeholder expansion (§9.2) and path resolution (§7.2.1) happens
50    /// here; the resulting plan contains no placeholders and no relative
51    /// paths.
52    pub fn launch_plan(&self, anchors: &Anchors) -> LaunchPlan {
53        let program = match &self.command {
54            ConfiguredCommand::Bare(name) => Program::Bare(name.clone()),
55            ConfiguredCommand::Bundled(path) => {
56                Program::Bundled(path.resolve(&anchors.plugin_root))
57            }
58        };
59        let args = self.args.iter().map(|t| t.expand(anchors)).collect();
60        let env_overlay =
61            self.env.iter().map(|(name, value)| (name.clone(), value.expand(anchors))).collect();
62        let cwd = match &self.cwd {
63            Cwd::PluginRoot => anchors.plugin_root.clone(),
64            Cwd::Relative(path) => path.resolve(&anchors.plugin_root),
65            Cwd::RootAnchored(subpath) => join_subpath(&anchors.plugin_root, subpath),
66            Cwd::DataAnchored(subpath) => join_subpath(&anchors.plugin_data, subpath),
67        };
68        LaunchPlan {
69            program,
70            args,
71            env_overlay,
72            plugin_root: anchors.plugin_root.clone(),
73            plugin_data: anchors.plugin_data.clone(),
74            cwd,
75        }
76    }
77}
78
79fn join_subpath(anchor: &std::path::Path, subpath: &str) -> PathBuf {
80    let mut out = anchor.to_path_buf();
81    for segment in subpath.split('/').filter(|s| !s.is_empty() && *s != ".") {
82        if segment == ".." {
83            out.pop();
84        } else {
85            out.push(segment);
86        }
87    }
88    out
89}
90
91impl LaunchPlan {
92    /// The two reserved variables, ready to set last (§9.1).
93    pub fn reserved_env(&self) -> [(&'static str, &std::path::Path); 2] {
94        [("PLUGIN_ROOT", self.plugin_root.as_path()), ("PLUGIN_DATA", self.plugin_data.as_path())]
95    }
96
97    /// Materialize as a `std::process::Command`, inheriting this process's
98    /// environment as the base (a permitted client choice under §9.1), then
99    /// applying the overlay, then the reserved variables.
100    pub fn to_command(&self) -> std::process::Command {
101        let mut command = match &self.program {
102            Program::Bare(name) => std::process::Command::new(name),
103            Program::Bundled(path) => std::process::Command::new(path),
104        };
105        command.args(&self.args);
106        for (name, value) in &self.env_overlay {
107            command.env(name, value);
108        }
109        for (name, value) in self.reserved_env() {
110            command.env(name, value);
111        }
112        command.current_dir(&self.cwd);
113        command
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::manifest::SpecVersion;
121    use crate::mcp::{McpConfig, McpServer};
122
123    fn stdio_server(json: &str) -> StdioServer {
124        let doc = format!(
125            r#"{{"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
126                 "mcpServers": {{"s": {json}}}}}"#
127        );
128        let (config, diags) = McpConfig::parse(doc.as_bytes(), SpecVersion::V1_0_0).unwrap();
129        assert!(diags.is_empty(), "{diags:?}");
130        match &config.servers[0].server {
131            McpServer::Stdio(server) => server.clone(),
132            other => panic!("expected stdio, got {other:?}"),
133        }
134    }
135
136    #[test]
137    fn plan_expands_and_resolves() {
138        let server = stdio_server(
139            r#"{"type": "stdio", "command": "./bin/validator",
140                "args": ["--data", "${PLUGIN_DATA}/validator"],
141                "env": {"CONFIG": "${PLUGIN_ROOT}/config.json"},
142                "cwd": "${PLUGIN_DATA}/work"}"#,
143        );
144        let anchors = Anchors::new("/plugins/devtools", "/plugins/data/devtools");
145        let plan = server.launch_plan(&anchors);
146        assert_eq!(plan.program, Program::Bundled("/plugins/devtools/bin/validator".into()));
147        assert_eq!(plan.args, ["--data", "/plugins/data/devtools/validator"]);
148        assert_eq!(
149            plan.env_overlay,
150            [("CONFIG".to_owned(), "/plugins/devtools/config.json".to_owned())]
151        );
152        assert_eq!(plan.cwd, PathBuf::from("/plugins/data/devtools/work"));
153        assert_eq!(plan.reserved_env()[0].1, std::path::Path::new("/plugins/devtools"));
154    }
155
156    #[test]
157    fn default_cwd_is_the_plugin_root() {
158        let server = stdio_server(r#"{"type": "stdio", "command": "npx"}"#);
159        let anchors = Anchors::new("/root", "/data");
160        let plan = server.launch_plan(&anchors);
161        assert_eq!(plan.program, Program::Bare("npx".into()));
162        assert_eq!(plan.cwd, PathBuf::from("/root"));
163    }
164}