1use clap::{Args, Parser, Subcommand, ValueEnum};
30use serde_json::json;
31use std::collections::HashMap;
32use tokio::runtime::Runtime;
33
34#[derive(Parser, Debug)]
36#[command(
37 name = "turbomcp-cli",
38 version,
39 about = "Command-line interface for interacting with MCP servers - list tools, call tools, and export schemas."
40)]
41pub struct Cli {
42 #[command(subcommand)]
44 pub command: Commands,
45}
46
47#[derive(Subcommand, Debug)]
49pub enum Commands {
50 #[command(name = "tools-list")]
52 ToolsList(Connection),
53 #[command(name = "tools-call")]
55 ToolsCall {
56 #[command(flatten)]
57 conn: Connection,
58 #[arg(long)]
60 name: String,
61 #[arg(long, default_value = "{}")]
63 arguments: String,
64 },
65 #[command(name = "schema-export")]
67 SchemaExport {
68 #[command(flatten)]
69 conn: Connection,
70 #[arg(long)]
72 output: Option<String>,
73 },
74}
75
76pub fn run_cli() {
78 let cli = Cli::parse();
79 let rt = Runtime::new().expect("tokio rt");
80 rt.block_on(async move {
81 match cli.command {
82 Commands::ToolsList(conn) => {
83 if let Err(e) = cmd_tools_list(conn).await {
84 eprintln!("error: {e}");
85 std::process::exit(1);
86 }
87 }
88 Commands::ToolsCall {
89 conn,
90 name,
91 arguments,
92 } => {
93 if let Err(e) = cmd_tools_call(conn, name, arguments).await {
94 eprintln!("error: {e}");
95 std::process::exit(1);
96 }
97 }
98 Commands::SchemaExport { conn, output } => {
99 if let Err(e) = cmd_schema_export(conn, output).await {
100 eprintln!("error: {e}");
101 std::process::exit(1);
102 }
103 }
104 }
105 });
106}
107
108#[derive(Args, Debug, Clone)]
110pub struct Connection {
111 #[arg(long, value_enum)]
113 pub transport: Option<TransportKind>,
114 #[arg(long, default_value = "http://localhost:8080/mcp")]
116 pub url: String,
117 #[arg(long)]
119 pub command: Option<String>,
120 #[arg(long)]
122 pub auth: Option<String>,
123 #[arg(long)]
125 pub json: bool,
126}
127
128#[derive(Debug, Clone, ValueEnum, PartialEq)]
130pub enum TransportKind {
131 Stdio,
133 Http,
135 Ws,
137}
138
139fn determine_transport(conn: &Connection) -> TransportKind {
141 if let Some(transport) = &conn.transport {
143 return transport.clone();
144 }
145
146 if conn.command.is_some()
148 || (!conn.url.starts_with("http://")
149 && !conn.url.starts_with("https://")
150 && !conn.url.starts_with("ws://")
151 && !conn.url.starts_with("wss://"))
152 {
153 TransportKind::Stdio
154 } else if conn.url.starts_with("ws://") || conn.url.starts_with("wss://") {
155 TransportKind::Ws
156 } else {
157 TransportKind::Http
158 }
159}
160
161pub async fn cmd_tools_list(conn: Connection) -> Result<(), String> {
162 let transport = determine_transport(&conn);
163 match transport {
164 TransportKind::Stdio => stdio_list_tools(&conn).await,
165 TransportKind::Ws => ws_list_tools(&conn).await,
166 TransportKind::Http => http_list_tools(&conn).await,
167 }
168}
169
170pub async fn cmd_tools_call(
171 conn: Connection,
172 name: String,
173 arguments: String,
174) -> Result<(), String> {
175 let transport = determine_transport(&conn);
176 match transport {
177 TransportKind::Stdio => stdio_call_tool(&conn, name, arguments).await,
178 TransportKind::Ws => ws_call_tool(&conn, name, arguments).await,
179 TransportKind::Http => http_call_tool(&conn, name, arguments).await,
180 }
181}
182
183pub async fn cmd_schema_export(
184 conn: Connection,
185 output_path: Option<String>,
186) -> Result<(), String> {
187 let transport = determine_transport(&conn);
189 let schema_data = match transport {
190 TransportKind::Stdio => stdio_get_schemas(&conn).await?,
191 TransportKind::Ws => ws_get_schemas(&conn).await?,
192 TransportKind::Http => http_get_schemas(&conn).await?,
193 };
194
195 if let Some(path) = output_path {
197 use std::fs;
198 let pretty_json = serde_json::to_string_pretty(&schema_data)
199 .map_err(|e| format!("Failed to format JSON: {e}"))?;
200 fs::write(&path, pretty_json).map_err(|e| format!("Failed to write to {}: {e}", path))?;
201 eprintln!("Schemas exported to {}", path);
202 } else {
203 output(&conn, &schema_data)?;
204 }
205
206 Ok(())
207}
208
209async fn http_list_tools(conn: &Connection) -> Result<(), String> {
210 let req = json!({"jsonrpc":"2.0","id":"1","method":"tools/list"});
211 let res = http_post(conn, req).await?;
212 output(conn, &res)
213}
214
215async fn http_call_tool(conn: &Connection, name: String, arguments: String) -> Result<(), String> {
216 let args_map: HashMap<String, serde_json::Value> =
217 serde_json::from_str(&arguments).map_err(|e| format!("invalid --arguments JSON: {e}"))?;
218 let req = json!({
219 "jsonrpc":"2.0","id":"1","method":"tools/call",
220 "params": {"name": name, "arguments": args_map}
221 });
222 let res = http_post(conn, req).await?;
223 output(conn, &res)
224}
225
226async fn http_get_schemas(conn: &Connection) -> Result<serde_json::Value, String> {
227 let req = json!({"jsonrpc":"2.0","id":"1","method":"tools/list"});
229 let res = http_post(conn, req).await?;
230 if let Some(result) = res.get("result")
231 && let Some(tools) = result.get("tools").and_then(|v| v.as_array())
232 {
233 let mut out = vec![];
234 for t in tools {
235 let name = t.get("name").and_then(|v| v.as_str()).unwrap_or("");
236 let schema = t.get("inputSchema").cloned().unwrap_or(json!({}));
237 out.push(json!({"name": name, "schema": schema}));
238 }
239 return Ok(json!({"schemas": out}));
240 }
241 Ok(res)
242}
243
244async fn http_post(
245 conn: &Connection,
246 body: serde_json::Value,
247) -> Result<serde_json::Value, String> {
248 let client = reqwest::Client::new();
249 let mut req = client.post(&conn.url).json(&body);
250 if let Some(auth) = &conn.auth {
251 req = req.bearer_auth(auth);
252 }
253 let res = req.send().await.map_err(|e| e.to_string())?;
254 let status = res.status();
255 let text = res.text().await.map_err(|e| e.to_string())?;
256 if !status.is_success() {
257 return Err(format!("HTTP {status}: {text}"));
258 }
259 serde_json::from_str(&text).map_err(|e| format!("invalid JSON: {e}"))
260}
261
262async fn ws_list_tools(conn: &Connection) -> Result<(), String> {
264 use serde_json::json;
265
266 let request = json!({
267 "jsonrpc": "2.0",
268 "id": 1,
269 "method": "tools/list",
270 "params": {}
271 });
272
273 let response = ws_send_request(conn, request).await?;
274 output(conn, &response)
275}
276
277async fn ws_call_tool(conn: &Connection, name: String, arguments: String) -> Result<(), String> {
278 use serde_json::json;
279
280 let args: serde_json::Value =
281 serde_json::from_str(&arguments).map_err(|e| format!("Invalid JSON arguments: {e}"))?;
282
283 let request = json!({
284 "jsonrpc": "2.0",
285 "id": 2,
286 "method": "tools/call",
287 "params": {
288 "name": name,
289 "arguments": args
290 }
291 });
292
293 let response = ws_send_request(conn, request).await?;
294 output(conn, &response)
295}
296
297async fn ws_get_schemas(conn: &Connection) -> Result<serde_json::Value, String> {
298 use serde_json::json;
299
300 let request = json!({
301 "jsonrpc": "2.0",
302 "id": 3,
303 "method": "tools/list",
304 "params": {}
305 });
306
307 let response = ws_send_request(conn, request).await?;
308
309 if let Some(result) = response.get("result")
311 && let Some(tools) = result.get("tools").and_then(|t| t.as_array())
312 {
313 let mut out = Vec::new();
314 for tool in tools {
315 let name = tool
316 .get("name")
317 .and_then(|n| n.as_str())
318 .unwrap_or("unknown");
319 let schema = tool.get("inputSchema").cloned().unwrap_or(json!({}));
320 out.push(json!({"name": name, "schema": schema}));
321 }
322 return Ok(json!({"schemas": out}));
323 }
324 Ok(response)
325}
326
327async fn ws_send_request(
328 conn: &Connection,
329 request: serde_json::Value,
330) -> Result<serde_json::Value, String> {
331 use futures::{SinkExt, StreamExt};
332 use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
333
334 let ws_url = conn
336 .url
337 .replace("http://", "ws://")
338 .replace("https://", "wss://")
339 .replace("/mcp", "/ws");
340
341 let (ws_stream, _) = connect_async(&ws_url)
343 .await
344 .map_err(|e| format!("Failed to connect to WebSocket at {ws_url}: {e}"))?;
345
346 let (mut ws_sender, mut ws_receiver) = ws_stream.split();
347
348 let request_text =
350 serde_json::to_string(&request).map_err(|e| format!("Failed to serialize request: {e}"))?;
351
352 ws_sender
353 .send(Message::Text(request_text.into()))
354 .await
355 .map_err(|e| format!("Failed to send WebSocket message: {e}"))?;
356
357 match ws_receiver.next().await {
359 Some(Ok(Message::Text(response_text))) => serde_json::from_str(&response_text)
360 .map_err(|e| format!("Failed to parse JSON response: {e}")),
361 Some(Ok(msg)) => Err(format!("Unexpected WebSocket message type: {msg:?}")),
362 Some(Err(e)) => Err(format!("WebSocket error: {e}")),
363 None => Err("WebSocket connection closed unexpectedly".to_string()),
364 }
365}
366
367async fn stdio_list_tools(conn: &Connection) -> Result<(), String> {
369 use serde_json::json;
370
371 let request = json!({
372 "jsonrpc": "2.0",
373 "id": 1,
374 "method": "tools/list",
375 "params": {}
376 });
377
378 let response = stdio_send_request(conn, request).await?;
379 output(conn, &response)
380}
381
382async fn stdio_call_tool(conn: &Connection, name: String, arguments: String) -> Result<(), String> {
383 use serde_json::json;
384
385 let args: serde_json::Value =
386 serde_json::from_str(&arguments).map_err(|e| format!("Invalid JSON arguments: {e}"))?;
387
388 let request = json!({
389 "jsonrpc": "2.0",
390 "id": 2,
391 "method": "tools/call",
392 "params": {
393 "name": name,
394 "arguments": args
395 }
396 });
397
398 let response = stdio_send_request(conn, request).await?;
399 output(conn, &response)
400}
401
402async fn stdio_get_schemas(conn: &Connection) -> Result<serde_json::Value, String> {
403 use serde_json::json;
404
405 let request = json!({
406 "jsonrpc": "2.0",
407 "id": 3,
408 "method": "tools/list",
409 "params": {}
410 });
411
412 let response = stdio_send_request(conn, request).await?;
413
414 if let Some(result) = response.get("result")
416 && let Some(tools) = result.get("tools").and_then(|t| t.as_array())
417 {
418 let mut out = Vec::new();
419 for tool in tools {
420 let name = tool
421 .get("name")
422 .and_then(|n| n.as_str())
423 .unwrap_or("unknown");
424 let schema = tool.get("inputSchema").cloned().unwrap_or(json!({}));
425 out.push(json!({"name": name, "schema": schema}));
426 }
427 return Ok(json!({"schemas": out}));
428 }
429 Ok(response)
430}
431
432async fn stdio_send_request(
433 conn: &Connection,
434 request: serde_json::Value,
435) -> Result<serde_json::Value, String> {
436 use std::io::{BufRead, BufReader, Write};
437 use std::process::{Command, Stdio};
438
439 let command_str = conn.command.as_deref().unwrap_or(&conn.url);
441 let mut parts = command_str.split_whitespace();
442 let command = parts
443 .next()
444 .ok_or("No command specified for STDIO transport")?;
445 let args: Vec<&str> = parts.collect();
446
447 let mut child = Command::new(command)
448 .args(&args)
449 .stdin(Stdio::piped())
450 .stdout(Stdio::piped())
451 .stderr(Stdio::piped())
452 .spawn()
453 .map_err(|e| format!("Failed to spawn command '{command}': {e}"))?;
454
455 let stdin = child.stdin.as_mut().ok_or("Failed to get stdin handle")?;
457 let request_str =
458 serde_json::to_string(&request).map_err(|e| format!("Failed to serialize request: {e}"))?;
459 writeln!(stdin, "{request_str}").map_err(|e| format!("Failed to write request: {e}"))?;
460
461 let stdout = child.stdout.take().ok_or("Failed to get stdout handle")?;
463 let mut reader = BufReader::new(stdout);
464 let mut response_line = String::new();
465
466 loop {
468 response_line.clear();
469 let bytes_read = reader
470 .read_line(&mut response_line)
471 .map_err(|e| format!("Failed to read response: {e}"))?;
472
473 if bytes_read == 0 {
474 return Err("No JSON response received from server".to_string());
475 }
476
477 if serde_json::from_str::<serde_json::Value>(&response_line).is_ok() {
479 break;
480 }
481
482 if response_line.trim().starts_with('{') {
484 break;
485 }
486
487 }
489
490 let output = child
492 .wait()
493 .map_err(|e| format!("Process execution failed: {e}"))?;
494
495 if !output.success() {
496 return Err(format!(
497 "Command failed with exit code: {}",
498 output.code().unwrap_or(-1)
499 ));
500 }
501
502 serde_json::from_str(&response_line).map_err(|e| format!("Invalid JSON response: {e}"))
504}
505
506pub fn output(conn: &Connection, value: &serde_json::Value) -> Result<(), String> {
507 if conn.json {
508 println!(
509 "{}",
510 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
511 );
512 } else {
513 println!("{value}");
514 }
515 Ok(())
516}