use std::path::PathBuf;
use crate::mcp::{Command as ConfiguredCommand, Cwd, StdioServer};
use crate::template::Anchors;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Program {
Bare(String),
Bundled(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LaunchPlan {
pub program: Program,
pub args: Vec<String>,
pub env_overlay: Vec<(String, String)>,
pub plugin_root: PathBuf,
pub plugin_data: PathBuf,
pub cwd: PathBuf,
}
impl StdioServer {
pub fn launch_plan(&self, anchors: &Anchors) -> LaunchPlan {
let program = match &self.command {
ConfiguredCommand::Bare(name) => Program::Bare(name.clone()),
ConfiguredCommand::Bundled(path) => {
Program::Bundled(path.resolve(&anchors.plugin_root))
}
};
let args = self.args.iter().map(|t| t.expand(anchors)).collect();
let env_overlay =
self.env.iter().map(|(name, value)| (name.clone(), value.expand(anchors))).collect();
let cwd = match &self.cwd {
Cwd::PluginRoot => anchors.plugin_root.clone(),
Cwd::Relative(path) => path.resolve(&anchors.plugin_root),
Cwd::RootAnchored(subpath) => join_subpath(&anchors.plugin_root, subpath),
Cwd::DataAnchored(subpath) => join_subpath(&anchors.plugin_data, subpath),
};
LaunchPlan {
program,
args,
env_overlay,
plugin_root: anchors.plugin_root.clone(),
plugin_data: anchors.plugin_data.clone(),
cwd,
}
}
}
fn join_subpath(anchor: &std::path::Path, subpath: &str) -> PathBuf {
let mut out = anchor.to_path_buf();
for segment in subpath.split('/').filter(|s| !s.is_empty() && *s != ".") {
if segment == ".." {
out.pop();
} else {
out.push(segment);
}
}
out
}
impl LaunchPlan {
pub fn reserved_env(&self) -> [(&'static str, &std::path::Path); 2] {
[("PLUGIN_ROOT", self.plugin_root.as_path()), ("PLUGIN_DATA", self.plugin_data.as_path())]
}
pub fn to_command(&self) -> std::process::Command {
let mut command = match &self.program {
Program::Bare(name) => std::process::Command::new(name),
Program::Bundled(path) => std::process::Command::new(path),
};
command.args(&self.args);
for (name, value) in &self.env_overlay {
command.env(name, value);
}
for (name, value) in self.reserved_env() {
command.env(name, value);
}
command.current_dir(&self.cwd);
command
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::SpecVersion;
use crate::mcp::{McpConfig, McpServer};
fn stdio_server(json: &str) -> StdioServer {
let doc = format!(
r#"{{"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
"mcpServers": {{"s": {json}}}}}"#
);
let (config, diags) = McpConfig::parse(doc.as_bytes(), SpecVersion::V1_0_0).unwrap();
assert!(diags.is_empty(), "{diags:?}");
match &config.servers[0].server {
McpServer::Stdio(server) => server.clone(),
other => panic!("expected stdio, got {other:?}"),
}
}
#[test]
fn plan_expands_and_resolves() {
let server = stdio_server(
r#"{"type": "stdio", "command": "./bin/validator",
"args": ["--data", "${PLUGIN_DATA}/validator"],
"env": {"CONFIG": "${PLUGIN_ROOT}/config.json"},
"cwd": "${PLUGIN_DATA}/work"}"#,
);
let anchors = Anchors::new("/plugins/devtools", "/plugins/data/devtools");
let plan = server.launch_plan(&anchors);
assert_eq!(plan.program, Program::Bundled("/plugins/devtools/bin/validator".into()));
assert_eq!(plan.args, ["--data", "/plugins/data/devtools/validator"]);
assert_eq!(
plan.env_overlay,
[("CONFIG".to_owned(), "/plugins/devtools/config.json".to_owned())]
);
assert_eq!(plan.cwd, PathBuf::from("/plugins/data/devtools/work"));
assert_eq!(plan.reserved_env()[0].1, std::path::Path::new("/plugins/devtools"));
}
#[test]
fn default_cwd_is_the_plugin_root() {
let server = stdio_server(r#"{"type": "stdio", "command": "npx"}"#);
let anchors = Anchors::new("/root", "/data");
let plan = server.launch_plan(&anchors);
assert_eq!(plan.program, Program::Bare("npx".into()));
assert_eq!(plan.cwd, PathBuf::from("/root"));
}
}