mcp_cli_builder/
lib.rs

1//! # mcp-cli-builder
2//!
3//! Command-line interface generation for MCP servers built with [`mcp-utils`](https://docs.rs/mcp-utils/latest/mcp_utils/index.html).
4//!
5//! For more information, see the **repository's [documentation](https://github.com/seaofvoices/rust-mcp-utils)**.
6//!
7//! This crate provides utilities to automatically generate command-line interfaces for MCP (Model Context Protocol) servers using
8//! [`clap`](https://docs.rs/clap/latest/clap/).
9//!
10//! ## Features
11//!
12//! - **Automatic CLI generation**: Creates a complete command-line interface from your [`ServerBuilder`] configuration
13//! - **Dual server modes**: Supports both stdio (default) and HTTP server modes with `--host`/`--port` options
14//! - **Rich help output**: Automatically generates help text with tool descriptions and usage instructions
15//! - **Timeout configuration**: Built-in support for request timeouts using [`humantime`](https://docs.rs/humantime/latest/humantime/) formats
16//! - **Zero configuration**: Works out of the box with any [`ToolBox`] implementation
17
18use std::{env, ffi::OsString};
19
20use clap::{Arg, Command};
21pub use mcp_utils::server_prelude::ServerBuilder;
22use mcp_utils::server_prelude::ToolBox;
23use rust_mcp_sdk::{
24    error::McpSdkError,
25    schema::{CallToolRequestParams, schema_utils::CallToolError},
26};
27
28const DEFAULT_PORT: u16 = 8080;
29
30const ARG_TIMEOUT: &str = "timeout";
31const ARG_HOST: &str = "host";
32const ARG_PORT: &str = "port";
33
34/// Runs an MCP server with automatically generated command-line interface.
35///
36/// This function creates a complete CLI application from a [`ServerBuilder`] configuration
37/// and tool collection. It handles argument parsing, generates help text with tool descriptions,
38/// and starts the server in either stdio or HTTP mode based on the provided arguments.
39///
40/// The function automatically parses command-line arguments from [`std::env::args_os()`]
41/// and configures the server accordingly. If parsing fails, it displays help and exits.
42/// Runtime errors are returned as formatted strings.
43///
44/// # Type Parameters
45///
46/// * `T`
47///
48///   A type implementing [`ToolBox`] that represents your collection of MCP tools.
49///   This is generated using the `setup_tools!` macro from [`mcp-utils`](https://docs.rs/mcp-utils/latest/mcp_utils/index.html).
50///
51/// # Server Behavior
52///
53/// - When called **without** `--host` or `--port` the server starts in stdio mode
54/// - When called **with** `--host` and/or `--port` the server starts an HTTP server with Server-Sent Events
55///
56/// # Examples
57///
58/// ```rust,no_run
59/// use mcp_cli_builder::{run, ServerBuilder};
60/// use mcp_utils::{tool_prelude::*, server_prelude::*};
61///
62/// # #[mcp_tool(name = "example", description = "An example tool")]
63/// # #[derive(Debug, JsonSchema, Serialize, Deserialize)]
64/// # pub struct ExampleTool { pub message: String }
65/// # impl TextTool for ExampleTool {
66/// #     type Output = String;
67/// #     fn call(&self) -> Self::Output { self.message.clone() }
68/// # }
69/// setup_tools!(pub MyTools, [
70///     text(ExampleTool),
71/// ]);
72///
73/// fn main() -> Result<(), String> {
74///     let builder = ServerBuilder::new()
75///         .with_name(env!("CARGO_PKG_NAME")) // uses the name from Cargo.toml
76///         .with_version(env!("CARGO_PKG_VERSION")) // uses the version from Cargo.toml
77///         .with_title("My MCP Server")
78///         .with_instructions("Demonstrates MCP server functionality");
79///
80///     run::<MyTools>(builder)
81/// }
82/// ```
83pub fn run<T>(builder: ServerBuilder) -> Result<(), String>
84where
85    T: ToolBox + TryFrom<CallToolRequestParams, Error = CallToolError> + Send + Sync + 'static,
86{
87    match inner_run::<T, _>(builder, env::args_os()) {
88        Ok(Ok(())) => Ok(()),
89        Ok(Err(start_error)) => {
90            eprintln!(
91                "{}",
92                start_error
93                    .rpc_error_message()
94                    .unwrap_or(&start_error.to_string())
95            );
96            Err(start_error.to_string())
97        }
98        Err(clap_err) => clap_err.exit(),
99    }
100}
101
102fn inner_run<T, IntoArg>(
103    mut builder: ServerBuilder,
104    args: impl IntoIterator<Item = IntoArg>,
105) -> Result<Result<(), McpSdkError>, clap::Error>
106where
107    T: ToolBox + TryFrom<CallToolRequestParams, Error = CallToolError> + Send + Sync + 'static,
108    IntoArg: Into<OsString> + Clone,
109{
110    let bold = clap::builder::styling::Style::new().bold();
111    let underlined = clap::builder::styling::Style::new().underline();
112    let dimmed = clap::builder::styling::Style::new().dimmed();
113
114    let tools = T::get_tools();
115    let mut tool_names: Vec<_> = tools
116        .iter()
117        .enumerate()
118        .map(|(i, tool)| {
119            if let Some(description) = tool.description.as_ref() {
120                format!(
121                    "{}. {underlined}{}{underlined:#}\n    {}",
122                    i + 1,
123                    tool.title.as_ref().unwrap_or(&tool.name).as_str(),
124                    description
125                )
126            } else {
127                format!(
128                    "{}. {underlined}{}{underlined:#}: {dimmed}no description available{dimmed:#}",
129                    i + 1,
130                    tool.title.as_ref().unwrap_or(&tool.name).as_str(),
131                )
132            }
133        })
134        .collect();
135    tool_names.sort();
136
137    let matches = Command::new(builder.name().to_owned())
138        .about(format!(
139            r#"{underlined}{}{underlined:#}
140
141Start the MCP server in stdio mode by running the command:
142  {bold}{}{bold:#}
143
144To use SSE (Server-Sent Events), pass the --host and/or the --port options
145  {bold}{} --port 8080{bold:#}
146"#,
147            builder.title(),
148            builder.name(),
149            builder.name(),
150        ))
151        .version(builder.version().to_owned())
152        .after_long_help(format!(
153            "MCP server: {}\n\n{bold}Instructions:{bold:#}\n{}\n\n{bold}Tools:{bold:#}\n{}",
154            builder.title(),
155            builder.instructions(),
156            tool_names.join("\n")
157        ))
158        .arg(
159            Arg::new(ARG_TIMEOUT)
160                .help("Timeout for requests made  (in humantime format, see <https://docs.rs/humantime/latest/humantime/>)")
161                .default_value("60s")
162                .long("timeout")
163                .value_parser(clap::value_parser!(humantime::Duration)),
164        )
165        .arg(
166            Arg::new(ARG_HOST)
167                .help("Host to bind the server to")
168                .long("host")
169                .value_parser(clap::value_parser!(String)),
170        )
171        .arg(
172            Arg::new(ARG_PORT)
173                .help("Port to bind the server to")
174                .long("port")
175                .short('p')
176                .value_parser(clap::value_parser!(u16)),
177        )
178        .try_get_matches_from(args)?;
179
180    let timeout = matches
181        .get_one::<humantime::Duration>(ARG_TIMEOUT)
182        .cloned()
183        .map(Into::into)
184        .unwrap_or_else(|| std::time::Duration::from_secs(60));
185
186    builder.set_timeout(timeout);
187
188    let host = matches.get_one::<String>(ARG_HOST).cloned();
189    let port = matches.get_one::<u16>(ARG_PORT).cloned();
190
191    tokio::runtime::Builder::new_multi_thread()
192        .enable_all()
193        .build()
194        .unwrap()
195        .block_on(async {
196            Ok(match (host, port) {
197                (None, None) => builder.start_stdio::<T>().await,
198                (host, port) => {
199                    builder
200                        .start_server::<T>(
201                            host.as_deref().unwrap_or("127.0.0.1"),
202                            port.unwrap_or(DEFAULT_PORT),
203                        )
204                        .await
205                }
206            })
207        })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use mcp_utils::server_prelude::setup_tools;
214    use mcp_utils::tool_prelude::*;
215
216    #[mcp_tool(
217        name = "test_tool",
218        description = "A test tool for demonstration",
219        title = "Test Tool"
220    )]
221    #[derive(Debug, JsonSchema, Serialize, Deserialize)]
222    pub struct TestTool {
223        /// A message to process
224        pub message: String,
225    }
226
227    impl StructuredTool for TestTool {
228        type Output = String;
229
230        fn call(&self) -> Self::Output {
231            format!("Processed: {}", self.message)
232        }
233    }
234
235    #[mcp_tool(name = "another_tool", description = "A tool that doubles a number")]
236    #[derive(Debug, JsonSchema, Serialize, Deserialize)]
237    pub struct AnotherTool {
238        /// A value to double
239        pub value: i32,
240    }
241
242    impl StructuredTool for AnotherTool {
243        type Output = i32;
244
245        fn call(&self) -> Self::Output {
246            self.value * 2
247        }
248    }
249
250    // Use the setup_tools macro to create a proper ToolBox
251    setup_tools!(pub TestTools, [
252        structured(TestTool),
253        structured(AnotherTool),
254    ]);
255
256    fn get_builder() -> ServerBuilder {
257        ServerBuilder::new()
258            .with_name("test-server")
259            .with_title("Test MCP Server")
260            .with_version("1.0.0")
261            .with_instructions("This is a test server for demonstration purposes")
262    }
263
264    #[test]
265    fn test_help_command_snapshot() {
266        let builder = get_builder();
267
268        let help_output = match inner_run::<TestTools, _>(builder, ["test-server", "--help"]) {
269            Err(e) => e.to_string(),
270            Ok(_) => panic!("Expected help error, but inner_run succeeded"),
271        };
272
273        insta::assert_snapshot!("help_output", help_output);
274    }
275
276    #[test]
277    fn test_short_help_command_snapshot() {
278        let builder = get_builder();
279
280        let err = match inner_run::<TestTools, _>(builder, ["test-server", "-h"]) {
281            Err(e) => e.to_string(),
282            Ok(_) => panic!("Expected help error, but inner_run succeeded"),
283        };
284
285        insta::assert_snapshot!("help_short_output", err);
286    }
287
288    #[test]
289    fn test_version_command_snapshot() {
290        let builder = get_builder();
291
292        let err = match inner_run::<TestTools, _>(builder, ["test-server", "--version"]) {
293            Err(e) => e.to_string(),
294            Ok(_) => panic!("Expected help error, but inner_run succeeded"),
295        };
296
297        insta::assert_snapshot!("version_output", err);
298    }
299}