use clap::{Arg, ArgAction, Command, CommandFactory};
use serde_json::{json, Map, Value};
use super::commands::Cli;
const SKIP_SUBCOMMANDS: &[&str] = &[
"help", "mcp",
];
pub fn export_mcp_tools_catalog() -> Value {
let root = Cli::command();
let mut tools: Vec<Value> = Vec::new();
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);
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)
});
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;
}
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();
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
}
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_issues_sync"));
assert!(names.contains(&"xbp_todos_sync"));
assert!(names.contains(&"xbp_github_create"));
assert!(names.contains(&"xbp_workers_list"));
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")));
}
}
}