1use clap::{Arg, ArgAction, Command, CommandFactory};
7use serde_json::{json, Map, Value};
8
9use super::commands::Cli;
10
11const SKIP_SUBCOMMANDS: &[&str] = &[
12 "help", "mcp",
14];
15
16pub fn export_mcp_tools_catalog() -> Value {
18 let root = Cli::command();
19 let mut tools: Vec<Value> = Vec::new();
20
21 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 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 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 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 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
225pub 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 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}