agent-plugins 0.1.1

Sans-IO implementation of the Agent Plugins specification (agent-plugins.org): load, validate, and prepare agent plugins from any IO
Documentation
//! From validated configuration to a runnable subprocess (§9.1).
//!
//! A [`LaunchPlan`] is the fully resolved recipe for starting a stdio MCP
//! server: program, expanded arguments, environment overlay, reserved
//! variables, working directory. It is pure data — build it anywhere, hand it
//! to `std::process`, `tokio::process`, or a sandbox of your choosing. The
//! §9.1 ordering (configured overlay first, then `PLUGIN_ROOT`/`PLUGIN_DATA`
//! replacing any same-name entries) is baked into the plan's shape.

use std::path::PathBuf;

use crate::mcp::{Command as ConfiguredCommand, Cwd, StdioServer};
use crate::template::Anchors;

/// The resolved program to execute.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Program {
    /// A bare executable name, to be found by the platform's search rules.
    /// Whether a configured `PATH` participates is client-defined (§7.2.1).
    Bare(String),
    /// An absolute path to an executable bundled in the plugin.
    Bundled(PathBuf),
}

/// Everything needed to launch one stdio MCP server.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LaunchPlan {
    /// The program to execute.
    pub program: Program,
    /// Arguments after placeholder expansion, in order.
    pub args: Vec<String>,
    /// The configured environment overlay after placeholder expansion.
    /// Apply these to your chosen base environment *first* (§9.1).
    pub env_overlay: Vec<(String, String)>,
    /// The absolute plugin root — set as `PLUGIN_ROOT` *after* the overlay,
    /// replacing any same-name entry (§9.1).
    pub plugin_root: PathBuf,
    /// The absolute plugin data directory — set as `PLUGIN_DATA` *after* the
    /// overlay. The client must create it and keep it writable (§9.1).
    pub plugin_data: PathBuf,
    /// The resolved working directory (§7.2.1).
    pub cwd: PathBuf,
}

impl StdioServer {
    /// Resolve this server against the client's anchors into a launch plan.
    ///
    /// All placeholder expansion (§9.2) and path resolution (§7.2.1) happens
    /// here; the resulting plan contains no placeholders and no relative
    /// paths.
    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 {
    /// The two reserved variables, ready to set last (§9.1).
    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())]
    }

    /// Materialize as a `std::process::Command`, inheriting this process's
    /// environment as the base (a permitted client choice under §9.1), then
    /// applying the overlay, then the reserved variables.
    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"));
    }
}