turbomcp_cli/
lib.rs

1//! # `TurboMCP` CLI
2//!
3//! Command-line interface for interacting with MCP servers, providing tools for
4//! testing, debugging, and managing MCP server instances.
5//!
6//! ## Features
7//!
8//! - Connect to MCP servers via multiple transports (HTTP, WebSocket, STDIO)
9//! - List available tools and their schemas
10//! - Call tools with JSON arguments
11//! - Export tool schemas for documentation
12//! - Support for authentication via bearer tokens
13//! - JSON and human-readable output formats
14//!
15//! ## Usage
16//!
17//! ```bash
18//! # List tools from HTTP server
19//! turbomcp-cli tools-list --transport http --url http://localhost:8080/mcp
20//!
21//! # Call a tool with arguments
22//! turbomcp-cli tools-call --transport http --url http://localhost:8080/mcp \
23//!   add --arguments '{"a": 5, "b": 3}'
24//!
25//! # Export tool schemas
26//! turbomcp-cli schema-export --transport http --url http://localhost:8080/mcp --json
27//! ```
28
29use clap::{Args, Parser, Subcommand, ValueEnum};
30use serde_json::json;
31use std::collections::HashMap;
32use tokio::runtime::Runtime;
33
34/// Main CLI application structure
35#[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    /// Subcommand to run
43    #[command(subcommand)]
44    pub command: Commands,
45}
46
47/// Available CLI subcommands
48#[derive(Subcommand, Debug)]
49pub enum Commands {
50    /// List tools from a running server
51    #[command(name = "tools-list")]
52    ToolsList(Connection),
53    /// Call a tool on a running server  
54    #[command(name = "tools-call")]
55    ToolsCall {
56        #[command(flatten)]
57        conn: Connection,
58        /// Tool name
59        #[arg(long)]
60        name: String,
61        /// Arguments as JSON (object)
62        #[arg(long, default_value = "{}")]
63        arguments: String,
64    },
65    /// Export tool schemas from a running server
66    #[command(name = "schema-export")]
67    SchemaExport {
68        #[command(flatten)]
69        conn: Connection,
70        /// Output file path (if not specified, outputs to stdout)
71        #[arg(long)]
72        output: Option<String>,
73    },
74}
75
76/// Run the CLI application
77pub 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/// Connection configuration for connecting to MCP servers
109#[derive(Args, Debug, Clone)]
110pub struct Connection {
111    /// Transport protocol (stdio, http, ws) - auto-detected if not specified
112    #[arg(long, value_enum)]
113    pub transport: Option<TransportKind>,
114    /// Server URL for http/ws or command for stdio
115    #[arg(long, default_value = "http://localhost:8080/mcp")]
116    pub url: String,
117    /// Command to execute for stdio transport (overrides --url if provided)
118    #[arg(long)]
119    pub command: Option<String>,
120    /// Bearer token or API key
121    #[arg(long)]
122    pub auth: Option<String>,
123    /// Emit JSON output
124    #[arg(long)]
125    pub json: bool,
126}
127
128/// Available transport types for connecting to MCP servers
129#[derive(Debug, Clone, ValueEnum, PartialEq)]
130pub enum TransportKind {
131    /// Standard input/output transport
132    Stdio,
133    /// HTTP transport with JSON-RPC
134    Http,
135    /// WebSocket transport
136    Ws,
137}
138
139/// Determine transport based on explicit setting or auto-detection
140fn determine_transport(conn: &Connection) -> TransportKind {
141    // Use explicit transport if provided
142    if let Some(transport) = &conn.transport {
143        return transport.clone();
144    }
145
146    // Auto-detect based on command/URL patterns
147    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    // Get schema data
188    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    // Output to file or stdout
196    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    // List, then return each tool's inputSchema
228    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
262// WebSocket implementation functions
263async 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    // Transform response to extract schemas
310    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    // Convert HTTP/HTTPS URL to WebSocket URL
335    let ws_url = conn
336        .url
337        .replace("http://", "ws://")
338        .replace("https://", "wss://")
339        .replace("/mcp", "/ws");
340
341    // Connect to WebSocket server
342    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    // Send the JSON-RPC request
349    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    // Wait for response
358    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
367// Stdio implementation functions
368async 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    // Transform response to extract schemas
415    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    // Use --command option if provided, otherwise use --url
440    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    // Send request
456    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    // Read response from stdout while discarding stderr
462    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    // Read lines until we get valid JSON (ignore log lines)
467    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        // Try to parse as JSON - if it works, we found our response
478        if serde_json::from_str::<serde_json::Value>(&response_line).is_ok() {
479            break;
480        }
481
482        // If line starts with '{' it might be JSON, try it anyway
483        if response_line.trim().starts_with('{') {
484            break;
485        }
486
487        // Otherwise it's probably a log line, continue reading
488    }
489
490    // Wait for process to complete
491    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    // Parse JSON response
503    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}