1use std::path::PathBuf;
11
12use crate::mcp::{Command as ConfiguredCommand, Cwd, StdioServer};
13use crate::template::Anchors;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Program {
18 Bare(String),
21 Bundled(PathBuf),
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27#[non_exhaustive]
28pub struct LaunchPlan {
29 pub program: Program,
31 pub args: Vec<String>,
33 pub env_overlay: Vec<(String, String)>,
36 pub plugin_root: PathBuf,
39 pub plugin_data: PathBuf,
42 pub cwd: PathBuf,
44}
45
46impl StdioServer {
47 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 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 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}