xbp 10.38.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Export every clap leaf command as an MCP tool specification (JSON).
//!
//! Generated tools are consumed by `xbp_mcp` so agents can call the full CLI
//! surface without hand-maintaining hundreds of wrappers.

use clap::{Arg, ArgAction, Command, CommandFactory};
use serde_json::{json, Map, Value};

use super::commands::Cli;

const SKIP_SUBCOMMANDS: &[&str] = &[
    "help",
    // MCP meta commands — not useful as recursive agent tools
    "mcp",
];

/// Full MCP tool catalog derived from the live clap tree.
pub fn export_mcp_tools_catalog() -> Value {
    let root = Cli::command();
    let mut tools: Vec<Value> = Vec::new();

    // Always include the escape hatch first.
    tools.push(json!({
        "name": "xbp_raw",
        "description": "Run any xbp command by passing a raw argument array (full CLI surface).",
        "argv": [],
        "positionals": [],
        "flags": [],
        "inputSchema": {
            "type": "object",
            "required": ["args"],
            "properties": {
                "args": {"type": "array", "items": {"type": "string"}, "description": "CLI args after `xbp` (e.g. [\"linear\",\"list\",\"--assignee\",\"me\"])"},
                "debug": {"type": "boolean"},
                "cwd": {"type": "string"},
                "timeout_seconds": {"type": "integer", "minimum": 1}
            }
        },
        "handcrafted": true
    }));

    walk_command(&root, &[], &mut tools);

    // Stable order for diffs.
    tools.sort_by(|a, b| {
        let na = a.get("name").and_then(Value::as_str).unwrap_or("");
        let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
        na.cmp(nb)
    });
    // Keep xbp_raw first after sort: re-insert
    if let Some(idx) = tools.iter().position(|t| t.get("name").and_then(Value::as_str) == Some("xbp_raw")) {
        let raw = tools.remove(idx);
        tools.insert(0, raw);
    }

    json!({
        "version": 1,
        "generator": "xbp mcp export-tools",
        "tool_count": tools.len(),
        "tools": tools
    })
}

fn walk_command(cmd: &Command, path: &[&str], out: &mut Vec<Value>) {
    let subs: Vec<&Command> = cmd
        .get_subcommands()
        .filter(|c| !c.is_hide_set())
        .filter(|c| !SKIP_SUBCOMMANDS.contains(&c.get_name()))
        .collect();

    if subs.is_empty() {
        if path.is_empty() {
            return;
        }
        out.push(leaf_tool_spec(cmd, path));
        return;
    }

    // Parent with its own runnable args (rare) — still emit leaf for bare path
    // only when no subcommands required; clap usually requires subcommand.
    for sub in subs {
        let mut next = path.to_vec();
        next.push(sub.get_name());
        walk_command(sub, &next, out);
    }
}

fn leaf_tool_requires_features(path: &[&str]) -> Vec<&'static str> {
    let mut features = Vec::new();
    if path.first().copied() == Some("linear")
        || (path.first().copied() == Some("config") && path.get(1).copied() == Some("linear"))
    {
        features.push("linear");
    }
    features
}

fn leaf_tool_spec(cmd: &Command, path: &[&str]) -> Value {
    let tool_name = format!("xbp_{}", path.join("_").replace('-', "_"));
    let about = cmd
        .get_about()
        .or_else(|| cmd.get_long_about())
        .map(|s| s.to_string())
        .unwrap_or_else(|| format!("Run `xbp {}`.", path.join(" ")));

    let mut positionals: Vec<Value> = Vec::new();
    let mut flags: Vec<Value> = Vec::new();
    let mut properties = Map::new();
    let mut required: Vec<String> = Vec::new();

    // Common MCP runtime props (not passed as CLI flags except debug via runner).
    properties.insert("debug".into(), json!({"type": "boolean"}));
    properties.insert(
        "cwd".into(),
        json!({"type": "string", "description": "Working directory for the command"}),
    );
    properties.insert(
        "timeout_seconds".into(),
        json!({"type": "integer", "minimum": 1}),
    );

    let mut args: Vec<&Arg> = cmd
        .get_arguments()
        .filter(|arg| {
            !arg.is_hide_set()
                && !arg.is_global_set()
                && arg.get_id() != "help"
                && arg.get_id() != "version"
                && arg.get_id() != "debug"
                && arg.get_id() != "print_version"
        })
        .collect();
    args.sort_by_key(|a| a.get_id().to_string());

    for arg in args {
        let id = arg.get_id().to_string();
        let prop_name = id.replace('-', "_");
        let help = arg
            .get_help()
            .or_else(|| arg.get_long_help())
            .map(|s| s.to_string())
            .unwrap_or_default();

        if arg.is_positional() {
            let mut schema = json!({"type": "string"});
            if !help.is_empty() {
                schema["description"] = json!(help);
            }
            properties.insert(prop_name.clone(), schema);
            if arg.is_required_set() {
                required.push(prop_name.clone());
            }
            positionals.push(json!({
                "name": prop_name,
                "required": arg.is_required_set(),
                "multiple": matches!(arg.get_action(), ArgAction::Append),
            }));
            continue;
        }

        let long = arg
            .get_long()
            .map(|s| format!("--{s}"))
            .or_else(|| arg.get_short().map(|c| format!("-{c}")))
            .unwrap_or_else(|| format!("--{}", id.replace('_', "-")));

        let (kind, schema_type) = match arg.get_action() {
            ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count => {
                ("boolean", json!({"type": "boolean"}))
            }
            ArgAction::Append => (
                "string_array",
                json!({"type": "array", "items": {"type": "string"}}),
            ),
            _ => ("string", json!({"type": "string"})),
        };

        let mut schema = schema_type;
        if !help.is_empty() {
            schema["description"] = json!(help);
        }
        properties.insert(prop_name.clone(), schema);
        if arg.is_required_set() {
            required.push(prop_name.clone());
        }
        flags.push(json!({
            "name": prop_name,
            "cli": long,
            "kind": kind,
            "required": arg.is_required_set(),
        }));
    }

    let mut input_schema = json!({
        "type": "object",
        "properties": properties,
    });
    if !required.is_empty() {
        input_schema["required"] = json!(required);
    }

    let required_features = leaf_tool_requires_features(path);
    let mut tool = json!({
        "name": tool_name,
        "description": about,
        "argv": path,
        "positionals": positionals,
        "flags": flags,
        "inputSchema": input_schema,
        "handcrafted": false
    });
    if !required_features.is_empty() {
        tool["features"] = json!(required_features);
    }
    tool
}

/// Pretty-printed catalog JSON for `xbp mcp export-tools`.
pub fn export_mcp_tools_catalog_pretty() -> Result<String, String> {
    let value = export_mcp_tools_catalog();
    serde_json::to_string_pretty(&value).map_err(|e| e.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exports_core_and_todos_leaves() {
        let catalog = export_mcp_tools_catalog();
        let tools = catalog
            .get("tools")
            .and_then(Value::as_array)
            .expect("tools array");
        let names: Vec<&str> = tools
            .iter()
            .filter_map(|t| t.get("name").and_then(Value::as_str))
            .collect();
        assert!(names.contains(&"xbp_raw"));
        assert!(names.contains(&"xbp_todos_sync"));
        assert!(names.contains(&"xbp_github_create"));
        assert!(names.contains(&"xbp_workers_list"));
        // mcp tree itself skipped
        assert!(!names.iter().any(|n| n.starts_with("xbp_mcp_")));
        assert!(tools.len() > 50, "expected broad coverage, got {}", tools.len());

        #[cfg(feature = "linear")]
        {
            assert!(names.contains(&"xbp_linear_list"));
            let linear = tools
                .iter()
                .find(|t| t.get("name").and_then(Value::as_str) == Some("xbp_linear_list"))
                .expect("linear list tool");
            assert_eq!(
                linear
                    .get("features")
                    .and_then(Value::as_array)
                    .map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
                Some(vec!["linear"])
            );
        }
        #[cfg(not(feature = "linear"))]
        {
            assert!(!names.iter().any(|n| n.starts_with("xbp_linear")));
            assert!(!names.iter().any(|n| n.starts_with("xbp_config_linear")));
        }
    }
}