Skip to main content

xbp_cli/cli/
mcp_tools_export.rs

1//! Export every clap leaf command as an MCP tool specification (JSON).
2//!
3//! Generated tools are consumed by `xbp_mcp` so agents can call the full CLI
4//! surface without hand-maintaining hundreds of wrappers.
5
6use clap::{Arg, ArgAction, Command, CommandFactory};
7use serde_json::{json, Map, Value};
8
9use super::commands::Cli;
10
11const SKIP_SUBCOMMANDS: &[&str] = &[
12    "help", // MCP meta commands — not useful as recursive agent tools
13    "mcp",
14];
15
16/// Full MCP tool catalog derived from the live clap tree.
17pub fn export_mcp_tools_catalog() -> Value {
18    let root = Cli::command();
19    let mut tools: Vec<Value> = Vec::new();
20
21    // Always include the escape hatch first.
22    tools.push(json!({
23        "name": "xbp_raw",
24        "description": "Run any xbp command by passing a raw argument array (full CLI surface).",
25        "argv": [],
26        "positionals": [],
27        "flags": [],
28        "inputSchema": {
29            "type": "object",
30            "required": ["args"],
31            "properties": {
32                "args": {"type": "array", "items": {"type": "string"}, "description": "CLI args after `xbp` (e.g. [\"linear\",\"list\",\"--assignee\",\"me\"])"},
33                "debug": {"type": "boolean"},
34                "cwd": {"type": "string"},
35                "timeout_seconds": {"type": "integer", "minimum": 1}
36            }
37        },
38        "handcrafted": true
39    }));
40
41    walk_command(&root, &[], &mut tools);
42
43    // Stable order for diffs.
44    tools.sort_by(|a, b| {
45        let na = a.get("name").and_then(Value::as_str).unwrap_or("");
46        let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
47        na.cmp(nb)
48    });
49    // Keep xbp_raw first after sort: re-insert
50    if let Some(idx) = tools
51        .iter()
52        .position(|t| t.get("name").and_then(Value::as_str) == Some("xbp_raw"))
53    {
54        let raw = tools.remove(idx);
55        tools.insert(0, raw);
56    }
57
58    json!({
59        "version": 1,
60        "generator": "xbp mcp export-tools",
61        "tool_count": tools.len(),
62        "tools": tools
63    })
64}
65
66fn walk_command(cmd: &Command, path: &[&str], out: &mut Vec<Value>) {
67    let subs: Vec<&Command> = cmd
68        .get_subcommands()
69        .filter(|c| !c.is_hide_set())
70        .filter(|c| !SKIP_SUBCOMMANDS.contains(&c.get_name()))
71        .collect();
72
73    if subs.is_empty() {
74        if path.is_empty() {
75            return;
76        }
77        out.push(leaf_tool_spec(cmd, path));
78        return;
79    }
80
81    // Parent with its own runnable args (rare) — still emit leaf for bare path
82    // only when no subcommands required; clap usually requires subcommand.
83    for sub in subs {
84        let mut next = path.to_vec();
85        next.push(sub.get_name());
86        walk_command(sub, &next, out);
87    }
88}
89
90fn leaf_tool_requires_features(path: &[&str]) -> Vec<&'static str> {
91    let mut features = Vec::new();
92    match path.first().copied() {
93        Some("linear") => features.push("linear"),
94        Some("secrets") => features.push("secrets"),
95        Some("docker") => features.push("docker"),
96        Some("kubernetes") | Some("k8s") => features.push("kubernetes"),
97        Some("nordvpn") => features.push("nordvpn"),
98        Some("monitoring") => features.push("monitoring"),
99        Some("config") if path.get(1).copied() == Some("linear") => features.push("linear"),
100        _ => {}
101    }
102    features
103}
104
105fn leaf_tool_spec(cmd: &Command, path: &[&str]) -> Value {
106    let tool_name = format!("xbp_{}", path.join("_").replace('-', "_"));
107    let about = cmd
108        .get_about()
109        .or_else(|| cmd.get_long_about())
110        .map(|s| s.to_string())
111        .unwrap_or_else(|| format!("Run `xbp {}`.", path.join(" ")));
112
113    let mut positionals: Vec<Value> = Vec::new();
114    let mut flags: Vec<Value> = Vec::new();
115    let mut properties = Map::new();
116    let mut required: Vec<String> = Vec::new();
117
118    // Common MCP runtime props (not passed as CLI flags except debug via runner).
119    properties.insert("debug".into(), json!({"type": "boolean"}));
120    properties.insert(
121        "cwd".into(),
122        json!({"type": "string", "description": "Working directory for the command"}),
123    );
124    properties.insert(
125        "timeout_seconds".into(),
126        json!({"type": "integer", "minimum": 1}),
127    );
128
129    let mut args: Vec<&Arg> = cmd
130        .get_arguments()
131        .filter(|arg| {
132            !arg.is_hide_set()
133                && !arg.is_global_set()
134                && arg.get_id() != "help"
135                && arg.get_id() != "version"
136                && arg.get_id() != "debug"
137                && arg.get_id() != "print_version"
138        })
139        .collect();
140    args.sort_by_key(|a| a.get_id().to_string());
141
142    for arg in args {
143        let id = arg.get_id().to_string();
144        let prop_name = id.replace('-', "_");
145        let help = arg
146            .get_help()
147            .or_else(|| arg.get_long_help())
148            .map(|s| s.to_string())
149            .unwrap_or_default();
150
151        if arg.is_positional() {
152            let mut schema = json!({"type": "string"});
153            if !help.is_empty() {
154                schema["description"] = json!(help);
155            }
156            properties.insert(prop_name.clone(), schema);
157            if arg.is_required_set() {
158                required.push(prop_name.clone());
159            }
160            positionals.push(json!({
161                "name": prop_name,
162                "required": arg.is_required_set(),
163                "multiple": matches!(arg.get_action(), ArgAction::Append),
164            }));
165            continue;
166        }
167
168        let long = arg
169            .get_long()
170            .map(|s| format!("--{s}"))
171            .or_else(|| arg.get_short().map(|c| format!("-{c}")))
172            .unwrap_or_else(|| format!("--{}", id.replace('_', "-")));
173
174        let (kind, schema_type) = match arg.get_action() {
175            ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count => {
176                ("boolean", json!({"type": "boolean"}))
177            }
178            ArgAction::Append => (
179                "string_array",
180                json!({"type": "array", "items": {"type": "string"}}),
181            ),
182            _ => ("string", json!({"type": "string"})),
183        };
184
185        let mut schema = schema_type;
186        if !help.is_empty() {
187            schema["description"] = json!(help);
188        }
189        properties.insert(prop_name.clone(), schema);
190        if arg.is_required_set() {
191            required.push(prop_name.clone());
192        }
193        flags.push(json!({
194            "name": prop_name,
195            "cli": long,
196            "kind": kind,
197            "required": arg.is_required_set(),
198        }));
199    }
200
201    let mut input_schema = json!({
202        "type": "object",
203        "properties": properties,
204    });
205    if !required.is_empty() {
206        input_schema["required"] = json!(required);
207    }
208
209    let required_features = leaf_tool_requires_features(path);
210    let mut tool = json!({
211        "name": tool_name,
212        "description": about,
213        "argv": path,
214        "positionals": positionals,
215        "flags": flags,
216        "inputSchema": input_schema,
217        "handcrafted": false
218    });
219    if !required_features.is_empty() {
220        tool["features"] = json!(required_features);
221    }
222    tool
223}
224
225/// Pretty-printed catalog JSON for `xbp mcp export-tools`.
226pub fn export_mcp_tools_catalog_pretty() -> Result<String, String> {
227    let value = export_mcp_tools_catalog();
228    serde_json::to_string_pretty(&value).map_err(|e| e.to_string())
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn exports_core_and_todos_leaves() {
237        let catalog = export_mcp_tools_catalog();
238        let tools = catalog
239            .get("tools")
240            .and_then(Value::as_array)
241            .expect("tools array");
242        let names: Vec<&str> = tools
243            .iter()
244            .filter_map(|t| t.get("name").and_then(Value::as_str))
245            .collect();
246        assert!(names.contains(&"xbp_raw"));
247        assert!(names.contains(&"xbp_issues_sync"));
248        assert!(names.contains(&"xbp_todos_sync"));
249        assert!(names.contains(&"xbp_github_create"));
250        assert!(names.contains(&"xbp_workers_list"));
251        assert!(names.contains(&"xbp_deploy"));
252        assert!(names.contains(&"xbp_commit"));
253        // mcp tree itself skipped
254        assert!(!names.iter().any(|n| n.starts_with("xbp_mcp_")));
255        assert!(
256            tools.len() > 50,
257            "expected broad coverage, got {}",
258            tools.len()
259        );
260
261        #[cfg(feature = "linear")]
262        {
263            assert!(names.contains(&"xbp_linear_list"));
264            let linear = tools
265                .iter()
266                .find(|t| t.get("name").and_then(Value::as_str) == Some("xbp_linear_list"))
267                .expect("linear list tool");
268            assert_eq!(
269                linear
270                    .get("features")
271                    .and_then(Value::as_array)
272                    .map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
273                Some(vec!["linear"])
274            );
275        }
276        #[cfg(not(feature = "linear"))]
277        {
278            assert!(!names.iter().any(|n| n.starts_with("xbp_linear")));
279            assert!(!names.iter().any(|n| n.starts_with("xbp_config_linear")));
280        }
281
282        #[cfg(feature = "docker")]
283        {
284            assert!(
285                names.iter().any(|n| n.starts_with("xbp_docker")),
286                "docker feature should export docker leaf tools"
287            );
288        }
289        #[cfg(feature = "kubernetes")]
290        {
291            assert!(
292                names
293                    .iter()
294                    .any(|n| n.starts_with("xbp_kubernetes") || n.starts_with("xbp_k8s")),
295                "kubernetes feature should export k8s leaf tools"
296            );
297        }
298        #[cfg(feature = "secrets")]
299        {
300            assert!(
301                names.iter().any(|n| n.starts_with("xbp_secrets")),
302                "secrets feature should export secrets leaf tools"
303            );
304        }
305    }
306}