1use async_trait::async_trait;
36use clap::{Arg, ArgAction, Command};
37use rust_mcp_sdk::{
38 McpServer, StdioTransport, TransportOptions,
39 mcp_server::{McpServerOptions, ServerHandler, ToMcpServerHandler, server_runtime},
40 schema::{
41 CallToolError, CallToolRequestParams, CallToolResult, ContentBlock, GetPromptRequestParams,
42 GetPromptResult, Implementation, InitializeResult, LATEST_PROTOCOL_VERSION,
43 ListPromptsResult, ListResourcesResult, ListToolsResult, LoggingLevel,
44 LoggingMessageNotificationParams, PaginatedRequestParams, Prompt, PromptMessage,
45 ReadResourceContent, ReadResourceRequestParams, ReadResourceResult, Resource, Role,
46 RpcError, ServerCapabilities, ServerCapabilitiesPrompts, ServerCapabilitiesResources,
47 ServerCapabilitiesTools, TextResourceContents, Tool, ToolInputSchema, schema_utils,
48 },
49};
50use serde::{Deserialize, Serialize};
51use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
52
53#[cfg(any(feature = "tracing", feature = "log"))]
54pub mod logging;
55
56pub mod content;
58
59#[cfg(feature = "derive")]
60pub use clap_mcp_macros::ClapMcp;
61
62#[macro_export]
81macro_rules! clap_mcp_main {
82 ($root:ty, |$args:ident| $run_expr:expr) => {{
83 let $args = $crate::parse_or_serve_mcp_attr::<$root>();
84 $run_expr
85 }};
86 ($root:ty, $run_expr:expr) => {{
87 macro_rules! __clap_mcp_with_args {
88 ($args:ident, $expr:expr) => {{
89 let $args = $crate::parse_or_serve_mcp_attr::<$root>();
90 $expr
91 }};
92 }
93 __clap_mcp_with_args!(args, $run_expr)
94 }};
95}
96
97pub const MCP_FLAG_LONG: &str = "mcp";
99
100pub const EXPORT_SKILLS_FLAG_LONG: &str = "export-skills";
102
103pub const MCP_RESOURCE_URI_SCHEMA: &str = "clap://schema";
105
106pub trait ClapMcpConfigProvider {
130 fn clap_mcp_config() -> ClapMcpConfig;
131}
132
133pub trait ClapMcpSchemaMetadataProvider {
139 fn clap_mcp_schema_metadata() -> ClapMcpSchemaMetadata;
140}
141
142pub trait ClapMcpRunnable {
146 fn run(self) -> String;
147}
148
149#[derive(Debug, Clone)]
155pub struct ClapMcpToolError {
156 pub message: String,
158 pub structured: Option<serde_json::Value>,
160}
161
162impl ClapMcpToolError {
163 pub fn text(message: impl Into<String>) -> Self {
165 Self {
166 message: message.into(),
167 structured: None,
168 }
169 }
170
171 pub fn structured(message: impl Into<String>, value: serde_json::Value) -> Self {
173 Self {
174 message: message.into(),
175 structured: Some(value),
176 }
177 }
178}
179
180impl From<String> for ClapMcpToolError {
181 fn from(s: String) -> Self {
182 Self::text(s)
183 }
184}
185
186impl From<&str> for ClapMcpToolError {
187 fn from(s: &str) -> Self {
188 Self::text(s)
189 }
190}
191
192pub trait IntoClapMcpResult {
204 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError>;
205}
206
207impl IntoClapMcpResult for String {
208 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
209 Ok(ClapMcpToolOutput::Text(self))
210 }
211}
212
213impl IntoClapMcpResult for &str {
214 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
215 Ok(ClapMcpToolOutput::Text(self.to_string()))
216 }
217}
218
219#[derive(Debug, Clone)]
234pub struct AsStructured<T>(pub T);
235
236impl<T: Serialize> IntoClapMcpResult for AsStructured<T> {
237 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
238 serde_json::to_value(&self.0)
239 .map(ClapMcpToolOutput::Structured)
240 .map_err(|e| ClapMcpToolError::text(e.to_string()))
241 }
242}
243
244impl<O: IntoClapMcpResult> IntoClapMcpResult for Option<O> {
245 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
246 match self {
247 None => Ok(ClapMcpToolOutput::Text(String::new())),
248 Some(o) => o.into_tool_result(),
249 }
250 }
251}
252
253pub trait IntoClapMcpToolError {
259 fn into_tool_error(self) -> ClapMcpToolError;
260}
261
262impl IntoClapMcpToolError for String {
263 fn into_tool_error(self) -> ClapMcpToolError {
264 ClapMcpToolError::text(self)
265 }
266}
267
268impl IntoClapMcpToolError for &str {
269 fn into_tool_error(self) -> ClapMcpToolError {
270 ClapMcpToolError::text(self.to_string())
271 }
272}
273
274impl<O: IntoClapMcpResult, E: IntoClapMcpToolError> IntoClapMcpResult for Result<O, E> {
275 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
276 match self {
277 Ok(o) => o.into_tool_result(),
278 Err(e) => Err(e.into_tool_error()),
279 }
280 }
281}
282
283#[cfg(unix)]
286fn run_with_stdout_capture<R, F>(f: F) -> (R, String)
287where
288 F: FnOnce() -> R,
289{
290 use std::io::{Read, Write};
291 use std::os::unix::io::FromRawFd;
292
293 let mut fds: [libc::c_int; 2] = [0, 0];
298 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
299 return (f(), String::new());
300 }
301 let (read_fd, write_fd) = (fds[0], fds[1]);
302
303 let stdout_fd = libc::STDOUT_FILENO;
304 let saved_stdout = unsafe { libc::dup(stdout_fd) };
305 if saved_stdout < 0 {
306 unsafe {
307 libc::close(read_fd);
308 libc::close(write_fd);
309 }
310 return (f(), String::new());
311 }
312
313 if unsafe { libc::dup2(write_fd, stdout_fd) } < 0 {
314 unsafe {
315 libc::close(saved_stdout);
316 libc::close(read_fd);
317 libc::close(write_fd);
318 }
319 return (f(), String::new());
320 }
321
322 let result = f();
323
324 let _ = std::io::stdout().flush();
325 unsafe {
326 libc::dup2(saved_stdout, stdout_fd);
327 libc::close(saved_stdout);
328 libc::close(write_fd);
329 }
330
331 let mut reader = unsafe { std::fs::File::from_raw_fd(read_fd) };
332 let mut captured = String::new();
333 let _ = reader.read_to_string(&mut captured);
334
335 (result, captured)
336}
337
338#[cfg(not(unix))]
339fn run_with_stdout_capture<R, F>(f: F) -> (R, String)
340where
341 F: FnOnce() -> R,
342{
343 (f(), String::new())
344}
345
346#[derive(Debug, Clone)]
364pub enum ClapMcpToolOutput {
365 Text(String),
367 Structured(serde_json::Value),
369}
370
371impl ClapMcpToolOutput {
372 pub fn into_string(self) -> String {
383 match self {
384 ClapMcpToolOutput::Text(s) => s,
385 ClapMcpToolOutput::Structured(v) => {
386 serde_json::to_string(&v).unwrap_or_else(|_| v.to_string())
387 }
388 }
389 }
390
391 pub fn as_text(&self) -> Option<&str> {
402 match self {
403 ClapMcpToolOutput::Text(s) => Some(s),
404 ClapMcpToolOutput::Structured(_) => None,
405 }
406 }
407
408 pub fn as_structured(&self) -> Option<&serde_json::Value> {
420 match self {
421 ClapMcpToolOutput::Text(_) => None,
422 ClapMcpToolOutput::Structured(v) => Some(v),
423 }
424 }
425}
426
427pub trait ClapMcpToolExecutor {
435 fn execute_for_mcp(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError>;
436}
437
438impl<T: ClapMcpToolExecutor> ClapMcpRunnable for T {
439 fn run(self) -> String {
440 self.execute_for_mcp()
441 .unwrap_or_else(|e| ClapMcpToolOutput::Text(e.message))
442 .into_string()
443 }
444}
445
446#[derive(Debug, thiserror::Error)]
448pub enum ClapMcpError {
449 #[error("failed to serialize clap schema to JSON: {0}")]
450 SchemaJson(#[from] serde_json::Error),
451 #[error("MCP transport error: {0}")]
452 Transport(#[from] rust_mcp_sdk::TransportError),
453 #[error("MCP runtime error: {0}")]
454 McpSdk(#[from] rust_mcp_sdk::error::McpSdkError),
455 #[error("I/O error during skill export: {0}")]
456 Io(#[from] std::io::Error),
457 #[error("tokio runtime context: {0}")]
458 RuntimeContext(String),
459 #[error("async tool thread panicked or failed: {0}")]
460 ToolThread(String),
461}
462
463#[derive(Debug, Clone)]
495pub struct ClapMcpConfig {
496 pub reinvocation_safe: bool,
500
501 pub parallel_safe: bool,
504
505 pub share_runtime: bool,
515
516 pub catch_in_process_panics: bool,
523
524 pub allow_mcp_without_subcommand: bool,
529}
530
531impl Default for ClapMcpConfig {
532 fn default() -> Self {
533 Self {
534 reinvocation_safe: false,
535 parallel_safe: false,
536 share_runtime: false,
537 catch_in_process_panics: false,
538 allow_mcp_without_subcommand: true,
539 }
540 }
541}
542
543#[derive(Debug, Default)]
559pub struct ClapMcpServeOptions {
560 pub log_rx: Option<tokio::sync::mpsc::Receiver<LoggingMessageNotificationParams>>,
563
564 #[cfg(unix)]
569 pub capture_stdout: bool,
570
571 pub custom_resources: Vec<content::CustomResource>,
573
574 pub custom_prompts: Vec<content::CustomPrompt>,
576}
577
578pub const LOG_INTERPRETATION_INSTRUCTIONS: &str = r#"When this server emits log messages (notifications/message), the `logger` field indicates the source:
583- "stderr": Subprocess stderr (CLI tools run as subprocesses)
584- "app": In-process application logs
585- Other: Application-defined logger names"#;
586
587pub const PROMPT_LOGGING_GUIDE: &str = "clap-mcp-logging-guide";
589
590pub const LOGGING_GUIDE_CONTENT: &str = r#"# clap-mcp Logging Guide
595
596When this server emits log messages (notifications/message), use the `logger` field to interpret the source:
597
598- **"stderr"**: Output from subprocess stderr (CLI tools run as subprocesses). The `meta` field may include `tool` for the command name.
599- **"app"**: In-process application logs.
600- **Other**: Application-defined logger names.
601
602The `level` field uses RFC 5424 syslog severity: debug, info, notice, warning, error, critical, alert, emergency.
603The `data` field contains the message (string or JSON object)."#;
604
605#[derive(Debug, Clone, Default)]
625pub struct ClapMcpSchemaMetadata {
626 pub skip_commands: Vec<String>,
628 pub skip_args: std::collections::HashMap<String, Vec<String>>,
630 pub requires_args: std::collections::HashMap<String, Vec<String>>,
632 pub skip_root_command_when_subcommands: bool,
636 pub output_schema: Option<serde_json::Value>,
640}
641
642#[cfg(feature = "output-schema")]
645pub fn output_schema_for_type<T: schemars::JsonSchema>() -> Option<serde_json::Value> {
646 serde_json::to_value(schemars::schema_for!(T)).ok()
647}
648
649#[cfg(not(feature = "output-schema"))]
650pub fn output_schema_for_type<T>() -> Option<serde_json::Value> {
651 let _ = std::marker::PhantomData::<T>;
652 None
653}
654
655#[macro_export]
659macro_rules! output_schema_one_of {
660 ($($T:ty),+ $(,)?) => {{
661 #[cfg(feature = "output-schema")]
662 {
663 let mut one_of = vec![];
664 $( one_of.push(serde_json::to_value(&schemars::schema_for!($T)).unwrap()); )+
665 Some(serde_json::json!({ "oneOf": one_of }))
666 }
667 #[cfg(not(feature = "output-schema"))]
668 {
669 None::<serde_json::Value>
670 }
671 }};
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize)]
677pub struct ClapSchema {
678 pub root: ClapCommand,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize)]
683pub struct ClapCommand {
684 pub name: String,
685 pub about: Option<String>,
686 pub long_about: Option<String>,
687 pub version: Option<String>,
688 pub args: Vec<ClapArg>,
689 pub subcommands: Vec<ClapCommand>,
690}
691
692impl ClapCommand {
693 pub fn all_commands(&self) -> Vec<&ClapCommand> {
695 let mut out = Vec::new();
696 fn walk<'a>(cmd: &'a ClapCommand, acc: &mut Vec<&'a ClapCommand>) {
697 acc.push(cmd);
698 for sub in &cmd.subcommands {
699 walk(sub, acc);
700 }
701 }
702 walk(self, &mut out);
703 out
704 }
705}
706
707pub(crate) fn is_builtin_arg(id: &str) -> bool {
709 matches!(
710 id,
711 "help" | "version" | MCP_FLAG_LONG | EXPORT_SKILLS_FLAG_LONG
712 )
713}
714
715pub fn tools_from_schema(schema: &ClapSchema) -> Vec<Tool> {
737 tools_from_schema_with_config(schema, &ClapMcpConfig::default())
738}
739
740pub fn tools_from_schema_with_config(schema: &ClapSchema, config: &ClapMcpConfig) -> Vec<Tool> {
760 tools_from_schema_with_config_and_metadata(schema, config, &ClapMcpSchemaMetadata::default())
761}
762
763pub fn tools_from_schema_with_config_and_metadata(
768 schema: &ClapSchema,
769 config: &ClapMcpConfig,
770 metadata: &ClapMcpSchemaMetadata,
771) -> Vec<Tool> {
772 let commands: Vec<&ClapCommand> =
773 if metadata.skip_root_command_when_subcommands && !schema.root.subcommands.is_empty() {
774 schema
775 .root
776 .subcommands
777 .iter()
778 .flat_map(|c| c.all_commands())
779 .collect()
780 } else {
781 schema.root.all_commands()
782 };
783 commands
784 .into_iter()
785 .map(|cmd| command_to_tool_with_config(cmd, config, metadata.output_schema.as_ref()))
786 .collect()
787}
788
789fn command_to_tool_with_config(
790 cmd: &ClapCommand,
791 config: &ClapMcpConfig,
792 output_schema: Option<&serde_json::Value>,
793) -> Tool {
794 let args: Vec<&ClapArg> = cmd
795 .args
796 .iter()
797 .filter(|a| !is_builtin_arg(a.id.as_str()))
798 .collect();
799
800 let mut properties: HashMap<String, serde_json::Map<String, serde_json::Value>> =
801 HashMap::new();
802 for arg in &args {
803 let mut prop = serde_json::Map::new();
804 let (json_type, items) = mcp_type_for_arg(arg);
805 prop.insert("type".to_string(), json_type);
806 if let Some(items) = items {
807 prop.insert("items".to_string(), items);
808 }
809 let desc = arg
810 .long_help
811 .as_deref()
812 .or(arg.help.as_deref())
813 .map(String::from);
814 let mut desc = desc.unwrap_or_default();
815 if let Some(hint) = mcp_action_description_hint(arg) {
816 desc.push_str(&hint);
817 }
818 if !desc.is_empty() {
819 prop.insert("description".to_string(), serde_json::Value::String(desc));
820 }
821 properties.insert(arg.id.clone(), prop);
822 }
823
824 let required: Vec<String> = args
825 .iter()
826 .filter(|a| a.required)
827 .map(|a| a.id.clone())
828 .collect();
829
830 let input_schema = ToolInputSchema::new(required, Some(properties), None);
831
832 let description = cmd
833 .long_about
834 .as_deref()
835 .or(cmd.about.as_deref())
836 .map(String::from);
837 let title = cmd.about.as_ref().map(String::from);
838
839 let meta = {
840 let mut m = serde_json::Map::new();
841 m.insert(
842 "clapMcp".into(),
843 serde_json::json!({
844 "reinvocationSafe": config.reinvocation_safe,
845 "parallelSafe": config.parallel_safe,
846 "shareRuntime": config.share_runtime,
847 }),
848 );
849 Some(m)
850 };
851
852 Tool {
853 name: cmd.name.clone(),
854 title,
855 description,
856 input_schema,
857 annotations: None,
858 execution: None,
859 icons: vec![],
860 meta,
861 output_schema: output_schema
862 .cloned()
863 .and_then(|v| serde_json::from_value::<rust_mcp_sdk::schema::ToolOutputSchema>(v).ok()),
864 }
865}
866
867#[derive(Debug, Clone, Serialize, Deserialize)]
869pub struct ClapArg {
870 pub id: String,
871 pub long: Option<String>,
872 pub short: Option<char>,
873 pub help: Option<String>,
874 pub long_help: Option<String>,
875 pub required: bool,
876 pub global: bool,
877 pub index: Option<usize>,
878 pub action: Option<String>,
879 pub value_names: Vec<String>,
880 pub num_args: Option<String>,
881}
882
883fn mcp_type_for_arg(arg: &ClapArg) -> (serde_json::Value, Option<serde_json::Value>) {
892 let action = arg.action.as_deref().unwrap_or("Set");
893 let is_multi = matches!(action, "Append")
894 || arg
895 .num_args
896 .as_deref()
897 .is_some_and(|n| n.contains("..") && !n.contains("=1"));
898 let (json_type, items) = if matches!(action, "SetTrue" | "SetFalse") {
899 (serde_json::json!("boolean"), None)
900 } else if action == "Count" {
901 (serde_json::json!("integer"), None)
902 } else if is_multi {
903 let item_desc = arg
904 .value_names
905 .first()
906 .map(|name| format!("A {} value", name));
907 let items_schema = match item_desc {
908 Some(desc) => serde_json::json!({ "type": "string", "description": desc }),
909 None => serde_json::json!({ "type": "string" }),
910 };
911 (serde_json::json!("array"), Some(items_schema))
912 } else {
913 (serde_json::json!("string"), None)
914 };
915 (json_type, items)
916}
917
918fn mcp_action_description_hint(arg: &ClapArg) -> Option<String> {
920 let action = arg.action.as_deref()?;
921 let hint: String = match action {
922 "SetTrue" => " Boolean flag: set to true to pass this flag.".into(),
923 "SetFalse" => " Boolean flag: set to false to pass this flag (e.g. --no-xxx).".into(),
924 "Count" => " Number of times the flag is passed (e.g. -vvv).".into(),
925 "Append" => {
926 if let Some(name) = arg.value_names.first() {
927 format!(
928 " List of {} values; pass a JSON array (e.g. [\"a\", \"b\"]).",
929 name
930 )
931 } else {
932 " List of values; pass a JSON array (e.g. [\"a\", \"b\"]).".into()
933 }
934 }
935 _ => return None,
936 };
937 Some(hint)
938}
939
940pub fn command_with_mcp_flag(mut cmd: Command) -> Command {
956 let already = cmd
957 .get_arguments()
958 .any(|a| a.get_long().is_some_and(|l| l == MCP_FLAG_LONG));
959 if already {
960 return cmd;
961 }
962
963 cmd = cmd.arg(
964 Arg::new(MCP_FLAG_LONG)
965 .long(MCP_FLAG_LONG)
966 .help("Run an MCP server over stdio that exposes this CLI's clap schema")
967 .action(ArgAction::SetTrue)
968 .global(true),
969 );
970
971 cmd
972}
973
974pub fn command_with_export_skills_flag(mut cmd: Command) -> Command {
989 let already = cmd
990 .get_arguments()
991 .any(|a| a.get_long().is_some_and(|l| l == EXPORT_SKILLS_FLAG_LONG));
992 if already {
993 return cmd;
994 }
995
996 cmd = cmd.arg(
997 Arg::new(EXPORT_SKILLS_FLAG_LONG)
998 .long(EXPORT_SKILLS_FLAG_LONG)
999 .value_name("DIR")
1000 .help("Generate Agent Skills (SKILL.md) from tools, resources, and prompts, then exit")
1001 .action(ArgAction::Set)
1002 .required(false)
1003 .global(true),
1004 );
1005
1006 cmd
1007}
1008
1009pub fn command_with_mcp_and_export_skills_flags(cmd: Command) -> Command {
1012 command_with_export_skills_flag(command_with_mcp_flag(cmd))
1013}
1014
1015fn argv_requests_mcp_without_subcommand(cmd: &Command) -> bool {
1018 let args: Vec<String> = std::env::args().skip(1).collect();
1019 argv_requests_mcp_without_subcommand_from_args(&args, cmd)
1020}
1021
1022pub(crate) fn argv_requests_mcp_without_subcommand_from_args(
1024 args: &[String],
1025 cmd: &Command,
1026) -> bool {
1027 let subcommand_names: std::collections::HashSet<String> = cmd
1028 .get_subcommands()
1029 .map(|s| s.get_name().to_string())
1030 .collect();
1031 let has_mcp = args.iter().any(|a| a == "--mcp");
1032 let has_subcommand = args.iter().any(|a| subcommand_names.contains(a.as_str()));
1033 has_mcp && !has_subcommand
1034}
1035
1036fn argv_export_skills_dir() -> Option<Option<std::path::PathBuf>> {
1039 let args: Vec<String> = std::env::args().skip(1).collect();
1040 argv_export_skills_dir_from_args(&args)
1041}
1042
1043pub(crate) fn argv_export_skills_dir_from_args(
1045 args: &[String],
1046) -> Option<Option<std::path::PathBuf>> {
1047 for (i, arg) in args.iter().enumerate() {
1048 if arg == "--export-skills" {
1049 return Some(
1050 args.get(i + 1)
1051 .filter(|s| !s.starts_with('-'))
1052 .map(std::path::PathBuf::from),
1053 );
1054 }
1055 if let Some(dir) = arg.strip_prefix("--export-skills=") {
1056 return Some(Some(std::path::PathBuf::from(dir)));
1057 }
1058 }
1059 None
1060}
1061
1062pub fn schema_from_command(cmd: &Command) -> ClapSchema {
1081 schema_from_command_with_metadata(cmd, &ClapMcpSchemaMetadata::default())
1082}
1083
1084pub fn schema_from_command_with_metadata(
1088 cmd: &Command,
1089 metadata: &ClapMcpSchemaMetadata,
1090) -> ClapSchema {
1091 let skip_commands: std::collections::HashSet<_> =
1092 metadata.skip_commands.iter().cloned().collect();
1093 ClapSchema {
1094 root: command_to_schema_with_metadata(cmd, metadata, &skip_commands),
1095 }
1096}
1097
1098fn command_to_schema_with_metadata(
1099 cmd: &Command,
1100 metadata: &ClapMcpSchemaMetadata,
1101 skip_commands: &std::collections::HashSet<String>,
1102) -> ClapCommand {
1103 let mut args: Vec<ClapArg> = cmd
1104 .get_arguments()
1105 .filter(|a| {
1106 let long = a.get_long();
1107 long != Some(MCP_FLAG_LONG) && long != Some(EXPORT_SKILLS_FLAG_LONG)
1108 })
1109 .map(arg_to_schema)
1110 .collect();
1111
1112 let cmd_name = cmd.get_name().to_string();
1113 let skip_args: std::collections::HashSet<_> = metadata
1114 .skip_args
1115 .get(&cmd_name)
1116 .map(|v| v.iter().cloned().collect())
1117 .unwrap_or_default();
1118
1119 let requires_args: std::collections::HashSet<_> = metadata
1120 .requires_args
1121 .get(&cmd_name)
1122 .map(|v| v.iter().cloned().collect())
1123 .unwrap_or_default();
1124
1125 args.retain(|a| !skip_args.contains(&a.id));
1126 for arg in &mut args {
1127 if requires_args.contains(&arg.id) {
1128 arg.required = true;
1129 }
1130 }
1131 args.sort_by(|a, b| a.id.cmp(&b.id));
1132
1133 let subcommands: Vec<ClapCommand> = cmd
1134 .get_subcommands()
1135 .filter(|s| !skip_commands.contains(&s.get_name().to_string()))
1136 .map(|s| command_to_schema_with_metadata(s, metadata, skip_commands))
1137 .collect();
1138
1139 ClapCommand {
1140 name: cmd.get_name().to_string(),
1141 about: cmd.get_about().map(|s| s.to_string()),
1142 long_about: cmd.get_long_about().map(|s| s.to_string()),
1143 version: cmd.get_version().map(|s| s.to_string()),
1144 args,
1145 subcommands,
1146 }
1147}
1148
1149pub fn get_matches_or_serve_mcp(cmd: Command) -> clap::ArgMatches {
1166 get_matches_or_serve_mcp_with_config(cmd, ClapMcpConfig::default())
1167}
1168
1169pub fn get_matches_or_serve_mcp_with_config(
1174 cmd: Command,
1175 config: ClapMcpConfig,
1176) -> clap::ArgMatches {
1177 get_matches_or_serve_mcp_with_config_and_metadata(
1178 cmd,
1179 config,
1180 &ClapMcpSchemaMetadata::default(),
1181 )
1182}
1183
1184pub fn get_matches_or_serve_mcp_with_config_and_metadata(
1188 cmd: Command,
1189 config: ClapMcpConfig,
1190 metadata: &ClapMcpSchemaMetadata,
1191) -> clap::ArgMatches {
1192 let schema = schema_from_command_with_metadata(&cmd, metadata);
1193 let cmd = command_with_mcp_and_export_skills_flags(cmd);
1194
1195 if let Some(maybe_dir) = argv_export_skills_dir() {
1196 let tools = tools_from_schema_with_config_and_metadata(&schema, &config, metadata);
1197 let output_dir = maybe_dir.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
1198 let app_name = schema.root.name.as_str();
1199 let serve_options = ClapMcpServeOptions::default();
1200 if let Err(e) = content::export_skills(
1201 &schema,
1202 metadata,
1203 &tools,
1204 &serve_options.custom_resources,
1205 &serve_options.custom_prompts,
1206 &output_dir,
1207 app_name,
1208 ) {
1209 eprintln!("export-skills failed: {}", e);
1210 std::process::exit(1);
1211 }
1212 std::process::exit(0);
1213 }
1214
1215 if config.allow_mcp_without_subcommand && argv_requests_mcp_without_subcommand(&cmd) {
1216 let schema_json = match serde_json::to_string_pretty(&schema) {
1217 Ok(s) => s,
1218 Err(e) => {
1219 eprintln!("Failed to serialize CLI schema: {}", e);
1220 std::process::exit(1);
1221 }
1222 };
1223 if let Err(e) = serve_schema_json_over_stdio_blocking(
1224 schema_json,
1225 None,
1226 config,
1227 None,
1228 ClapMcpServeOptions::default(),
1229 metadata,
1230 ) {
1231 eprintln!("MCP server error: {}", e);
1232 std::process::exit(1);
1233 }
1234 std::process::exit(0);
1235 }
1236
1237 let matches = cmd.get_matches();
1238 if matches.get_flag(MCP_FLAG_LONG) {
1239 let schema_json = match serde_json::to_string_pretty(&schema) {
1240 Ok(s) => s,
1241 Err(e) => {
1242 eprintln!("Failed to serialize CLI schema: {}", e);
1243 std::process::exit(1);
1244 }
1245 };
1246 if let Err(e) = serve_schema_json_over_stdio_blocking(
1247 schema_json,
1248 None,
1249 config,
1250 None,
1251 ClapMcpServeOptions::default(),
1252 metadata,
1253 ) {
1254 eprintln!("MCP server error: {}", e);
1255 std::process::exit(1);
1256 }
1257 std::process::exit(0);
1258 }
1259
1260 matches
1261}
1262
1263pub trait ParseOrServeMcp {
1285 fn parse_or_serve_mcp() -> Self;
1286}
1287
1288impl<T> ParseOrServeMcp for T
1289where
1290 T: ClapMcpConfigProvider
1291 + ClapMcpSchemaMetadataProvider
1292 + ClapMcpToolExecutor
1293 + clap::Parser
1294 + clap::CommandFactory
1295 + clap::FromArgMatches
1296 + 'static,
1297{
1298 fn parse_or_serve_mcp() -> Self {
1299 parse_or_serve_mcp_attr::<T>()
1300 }
1301}
1302
1303pub fn parse_or_serve_mcp<T>() -> T
1331where
1332 T: ClapMcpSchemaMetadataProvider
1333 + ClapMcpToolExecutor
1334 + clap::Parser
1335 + clap::CommandFactory
1336 + clap::FromArgMatches
1337 + 'static,
1338{
1339 parse_or_serve_mcp_with_config::<T>(ClapMcpConfig::default())
1340}
1341
1342pub fn parse_or_serve_mcp_attr<T>() -> T
1372where
1373 T: ClapMcpConfigProvider
1374 + ClapMcpSchemaMetadataProvider
1375 + ClapMcpToolExecutor
1376 + clap::Parser
1377 + clap::CommandFactory
1378 + clap::FromArgMatches
1379 + 'static,
1380{
1381 parse_or_serve_mcp_with_config::<T>(T::clap_mcp_config())
1382}
1383
1384pub fn run_or_serve_mcp<A, F, R, E>(f: F) -> Result<R, E>
1400where
1401 A: ClapMcpConfigProvider
1402 + ClapMcpSchemaMetadataProvider
1403 + ClapMcpToolExecutor
1404 + clap::Parser
1405 + clap::CommandFactory
1406 + clap::FromArgMatches
1407 + 'static,
1408 F: FnOnce(A) -> Result<R, E>,
1409{
1410 let args = parse_or_serve_mcp_attr::<A>();
1411 f(args)
1412}
1413
1414pub fn parse_or_serve_mcp_with_config<T>(config: ClapMcpConfig) -> T
1420where
1421 T: ClapMcpSchemaMetadataProvider
1422 + ClapMcpToolExecutor
1423 + clap::Parser
1424 + clap::CommandFactory
1425 + clap::FromArgMatches
1426 + 'static,
1427{
1428 parse_or_serve_mcp_with_config_and_options::<T>(config, ClapMcpServeOptions::default())
1429}
1430
1431pub fn parse_or_serve_mcp_with_config_and_options<T>(
1436 config: ClapMcpConfig,
1437 serve_options: ClapMcpServeOptions,
1438) -> T
1439where
1440 T: ClapMcpSchemaMetadataProvider
1441 + ClapMcpToolExecutor
1442 + clap::Parser
1443 + clap::CommandFactory
1444 + clap::FromArgMatches
1445 + 'static,
1446{
1447 let mut cmd = T::command();
1448 cmd = command_with_mcp_and_export_skills_flags(cmd);
1449
1450 if let Some(maybe_dir) = argv_export_skills_dir() {
1451 let base_cmd = T::command();
1452 let metadata = T::clap_mcp_schema_metadata();
1453 let schema = schema_from_command_with_metadata(&base_cmd, &metadata);
1454 let tools = tools_from_schema_with_config_and_metadata(&schema, &config, &metadata);
1455 let output_dir = maybe_dir.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
1456 let app_name = schema.root.name.as_str();
1457 if let Err(e) = content::export_skills(
1458 &schema,
1459 &metadata,
1460 &tools,
1461 &serve_options.custom_resources,
1462 &serve_options.custom_prompts,
1463 &output_dir,
1464 app_name,
1465 ) {
1466 eprintln!("export-skills failed: {}", e);
1467 std::process::exit(1);
1468 }
1469 std::process::exit(0);
1470 }
1471
1472 if config.allow_mcp_without_subcommand && argv_requests_mcp_without_subcommand(&cmd) {
1473 let base_cmd = T::command();
1474 let metadata = T::clap_mcp_schema_metadata();
1475 let schema = schema_from_command_with_metadata(&base_cmd, &metadata);
1476 let schema_json = match serde_json::to_string_pretty(&schema) {
1477 Ok(s) => s,
1478 Err(e) => {
1479 eprintln!("Failed to serialize CLI schema: {}", e);
1480 std::process::exit(1);
1481 }
1482 };
1483 let exe = std::env::current_exe().ok();
1484
1485 let in_process_handler = if config.reinvocation_safe {
1486 #[cfg(unix)]
1487 let capture_stdout = serve_options.capture_stdout;
1488 #[cfg(not(unix))]
1489 let capture_stdout = false;
1490 Some(make_in_process_handler::<T>(schema.clone(), capture_stdout))
1491 } else {
1492 None
1493 };
1494
1495 if let Err(e) = serve_schema_json_over_stdio_blocking(
1496 schema_json,
1497 if config.reinvocation_safe { None } else { exe },
1498 config,
1499 in_process_handler,
1500 serve_options,
1501 &metadata,
1502 ) {
1503 eprintln!("MCP server error: {}", e);
1504 std::process::exit(1);
1505 }
1506
1507 std::process::exit(0);
1508 }
1509
1510 let matches = cmd.get_matches();
1511 let mcp_requested = matches.get_flag(MCP_FLAG_LONG);
1512
1513 if mcp_requested {
1514 let base_cmd = T::command();
1515 let metadata = T::clap_mcp_schema_metadata();
1516 let schema = schema_from_command_with_metadata(&base_cmd, &metadata);
1517 let schema_json = match serde_json::to_string_pretty(&schema) {
1518 Ok(s) => s,
1519 Err(e) => {
1520 eprintln!("Failed to serialize CLI schema: {}", e);
1521 std::process::exit(1);
1522 }
1523 };
1524 let exe = std::env::current_exe().ok();
1525
1526 let in_process_handler = if config.reinvocation_safe {
1527 #[cfg(unix)]
1528 let capture_stdout = serve_options.capture_stdout;
1529 #[cfg(not(unix))]
1530 let capture_stdout = false;
1531 Some(make_in_process_handler::<T>(schema.clone(), capture_stdout))
1532 } else {
1533 None
1534 };
1535
1536 if let Err(e) = serve_schema_json_over_stdio_blocking(
1537 schema_json,
1538 if config.reinvocation_safe { None } else { exe },
1539 config,
1540 in_process_handler,
1541 serve_options,
1542 &metadata,
1543 ) {
1544 eprintln!("MCP server error: {}", e);
1545 std::process::exit(1);
1546 }
1547
1548 std::process::exit(0);
1549 }
1550
1551 T::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
1552}
1553
1554fn arg_to_schema(arg: &clap::Arg) -> ClapArg {
1555 let value_names = arg
1556 .get_value_names()
1557 .map(|names| names.iter().map(|n| n.to_string()).collect())
1558 .unwrap_or_default();
1559
1560 ClapArg {
1561 id: arg.get_id().to_string(),
1562 long: arg.get_long().map(|s| s.to_string()),
1563 short: arg.get_short(),
1564 help: arg.get_help().map(|s| s.to_string()),
1565 long_help: arg.get_long_help().map(|s| s.to_string()),
1566 required: arg.is_required_set(),
1567 global: arg.is_global_set(),
1568 index: arg.get_index(),
1569 action: Some(format!("{:?}", arg.get_action())),
1570 value_names,
1571 num_args: arg.get_num_args().map(|r| format!("{r:?}")),
1572 }
1573}
1574
1575fn validate_required_args(
1578 schema: &ClapSchema,
1579 command_name: &str,
1580 arguments: &serde_json::Map<String, serde_json::Value>,
1581) -> Result<(), String> {
1582 let cmd = schema
1583 .root
1584 .all_commands()
1585 .into_iter()
1586 .find(|c| c.name == command_name);
1587 let Some(cmd) = cmd else {
1588 return Ok(());
1589 };
1590 let missing: Vec<_> = cmd
1591 .args
1592 .iter()
1593 .filter(|a| {
1594 if !a.required || is_builtin_arg(a.id.as_str()) {
1595 return false;
1596 }
1597 let has_value = arguments.get(&a.id).map(|v| {
1598 let action = a.action.as_deref().unwrap_or("Set");
1599 if matches!(action, "SetTrue" | "SetFalse" | "Count") {
1600 true
1602 } else if action == "Append" || v.is_array() {
1603 !value_to_strings(v).is_some_and(|s| s.is_empty())
1604 } else {
1605 value_to_string(v).is_some_and(|s| !s.is_empty())
1606 }
1607 });
1608 !has_value.unwrap_or(false)
1609 })
1610 .map(|a| a.id.clone())
1611 .collect();
1612 if missing.is_empty() {
1613 Ok(())
1614 } else {
1615 Err(format!(
1616 "Missing required argument(s): {}. The MCP tool schema marks these as required.",
1617 missing.join(", ")
1618 ))
1619 }
1620}
1621
1622fn build_argv_for_clap(
1624 schema: &ClapSchema,
1625 command_name: &str,
1626 arguments: serde_json::Map<String, serde_json::Value>,
1627) -> Vec<String> {
1628 let args = build_tool_argv(schema, command_name, arguments);
1629 let mut argv = vec!["cli".to_string()]; if let Some(path) = command_path(schema, command_name) {
1631 argv.extend(path.into_iter().skip(1));
1632 }
1633 argv.extend(args);
1634 argv
1635}
1636
1637fn command_path(schema: &ClapSchema, command_name: &str) -> Option<Vec<String>> {
1638 fn walk(cmd: &ClapCommand, command_name: &str, path: &mut Vec<String>) -> bool {
1639 path.push(cmd.name.clone());
1640 if cmd.name == command_name {
1641 return true;
1642 }
1643 for subcommand in &cmd.subcommands {
1644 if walk(subcommand, command_name, path) {
1645 return true;
1646 }
1647 }
1648 path.pop();
1649 false
1650 }
1651
1652 let mut path = Vec::new();
1653 if walk(&schema.root, command_name, &mut path) {
1654 Some(path)
1655 } else {
1656 None
1657 }
1658}
1659
1660fn build_tool_argv(
1664 schema: &ClapSchema,
1665 command_name: &str,
1666 arguments: serde_json::Map<String, serde_json::Value>,
1667) -> Vec<String> {
1668 let cmd = schema
1669 .root
1670 .all_commands()
1671 .into_iter()
1672 .find(|c| c.name == command_name);
1673 let Some(cmd) = cmd else {
1674 return Vec::new();
1675 };
1676
1677 let args: Vec<&ClapArg> = cmd
1678 .args
1679 .iter()
1680 .filter(|a| !is_builtin_arg(a.id.as_str()))
1681 .collect();
1682
1683 let mut positionals: Vec<&ClapArg> =
1684 args.iter().filter(|a| a.long.is_none()).copied().collect();
1685 positionals.sort_by_key(|a| a.index.unwrap_or(0));
1686 let optionals: Vec<&ClapArg> = args.iter().filter(|a| a.long.is_some()).copied().collect();
1687
1688 let mut out = Vec::new();
1689
1690 for arg in positionals {
1691 if let Some(v) = arguments.get(&arg.id)
1692 && let Some(strings) = value_to_strings(v)
1693 {
1694 for s in strings {
1695 out.push(s);
1696 }
1697 }
1698 }
1699 for arg in optionals {
1700 if let Some(long) = &arg.long {
1701 let action = arg.action.as_deref().unwrap_or("Set");
1702 let v = arguments.get(&arg.id);
1703 match action {
1704 "SetTrue" => {
1705 if v.and_then(value_to_string).is_some_and(|s| s == "true")
1706 || v.and_then(|x| x.as_bool()).is_some_and(|b| b)
1707 {
1708 out.push(format!("--{long}"));
1709 }
1710 }
1711 "SetFalse" => {
1712 if v.and_then(value_to_string).is_some_and(|s| s == "false")
1713 || v.and_then(|x| x.as_bool()).is_some_and(|b| !b)
1714 {
1715 out.push(format!("--{long}"));
1716 }
1717 }
1718 "Count" => {
1719 let n = v.and_then(|x| x.as_i64()).unwrap_or(0).clamp(0, i64::MAX) as usize;
1720 for _ in 0..n {
1721 out.push(format!("--{long}"));
1722 }
1723 }
1724 "Append" => {
1725 if let Some(v) = v.and_then(value_to_strings) {
1726 for s in v {
1727 if !s.is_empty() {
1728 out.push(format!("--{long}"));
1729 out.push(s);
1730 }
1731 }
1732 } else if let Some(s) = v.and_then(value_to_string)
1733 && !s.is_empty()
1734 {
1735 out.push(format!("--{long}"));
1736 out.push(s);
1737 }
1738 }
1739 _ => {
1740 if let Some(s) = v.and_then(value_to_string)
1741 && !s.is_empty()
1742 {
1743 out.push(format!("--{long}"));
1744 out.push(s);
1745 }
1746 }
1747 }
1748 }
1749 }
1750
1751 out
1752}
1753
1754pub type InProcessToolHandler = Arc<
1759 dyn Fn(
1760 &str,
1761 serde_json::Map<String, serde_json::Value>,
1762 ) -> Result<ClapMcpToolOutput, ClapMcpToolError>
1763 + Send
1764 + Sync,
1765>;
1766
1767fn merge_captured_stdout(
1768 result: Result<ClapMcpToolOutput, ClapMcpToolError>,
1769 captured: String,
1770) -> Result<ClapMcpToolOutput, ClapMcpToolError> {
1771 match result {
1772 Ok(ClapMcpToolOutput::Text(text)) if !captured.is_empty() => {
1773 let merged = if text.is_empty() {
1774 captured.trim().to_string()
1775 } else {
1776 let cap = captured.trim();
1777 if cap.is_empty() {
1778 text
1779 } else {
1780 format!("{text}\n{cap}")
1781 }
1782 };
1783 Ok(ClapMcpToolOutput::Text(merged))
1784 }
1785 other => other,
1786 }
1787}
1788
1789fn execute_in_process_command<T>(
1790 schema: &ClapSchema,
1791 command_name: &str,
1792 arguments: serde_json::Map<String, serde_json::Value>,
1793 capture_stdout: bool,
1794) -> Result<ClapMcpToolOutput, ClapMcpToolError>
1795where
1796 T: ClapMcpToolExecutor + clap::CommandFactory + clap::FromArgMatches,
1797{
1798 validate_required_args(schema, command_name, &arguments).map_err(ClapMcpToolError::text)?;
1799 let argv = build_argv_for_clap(schema, command_name, arguments.clone());
1800 let matches = T::command()
1801 .try_get_matches_from(&argv)
1802 .map_err(|e| ClapMcpToolError::text(e.to_string()))?;
1803 let cli = T::from_arg_matches(&matches).map_err(|e| ClapMcpToolError::text(e.to_string()))?;
1804
1805 if capture_stdout {
1806 let (result, captured) =
1807 run_with_stdout_capture(|| <T as ClapMcpToolExecutor>::execute_for_mcp(cli));
1808 merge_captured_stdout(result, captured)
1809 } else {
1810 <T as ClapMcpToolExecutor>::execute_for_mcp(cli)
1811 }
1812}
1813
1814fn make_in_process_handler<T>(schema: ClapSchema, capture_stdout: bool) -> InProcessToolHandler
1815where
1816 T: ClapMcpToolExecutor + clap::CommandFactory + clap::FromArgMatches + 'static,
1817{
1818 Arc::new(
1819 move |cmd: &str, args: serde_json::Map<String, serde_json::Value>| {
1820 execute_in_process_command::<T>(&schema, cmd, args, capture_stdout)
1821 },
1822 ) as InProcessToolHandler
1823}
1824
1825fn format_panic_payload(payload: &(dyn std::any::Any + Send)) -> String {
1826 if let Some(s) = payload.downcast_ref::<&str>() {
1827 return (*s).to_string();
1828 }
1829 if let Some(s) = payload.downcast_ref::<String>() {
1830 return s.clone();
1831 }
1832 "<panic>".to_string()
1833}
1834
1835fn value_to_string(v: &serde_json::Value) -> Option<String> {
1836 if v.is_null() {
1837 return None;
1838 }
1839 Some(match v {
1840 serde_json::Value::String(s) => s.clone(),
1841 serde_json::Value::Number(n) => n.to_string(),
1842 serde_json::Value::Bool(b) => b.to_string(),
1843 other => other.to_string(),
1844 })
1845}
1846
1847fn value_to_strings(v: &serde_json::Value) -> Option<Vec<String>> {
1849 if v.is_null() {
1850 return None;
1851 }
1852 match v {
1853 serde_json::Value::Array(arr) => {
1854 let out: Vec<String> = arr
1855 .iter()
1856 .filter_map(value_to_string)
1857 .filter(|s| !s.is_empty())
1858 .collect();
1859 Some(out)
1860 }
1861 _ => value_to_string(v).map(|s| vec![s]),
1862 }
1863}
1864
1865fn clap_schema_resource() -> Resource {
1866 Resource {
1867 name: "clap-schema".into(),
1868 uri: MCP_RESOURCE_URI_SCHEMA.into(),
1869 title: Some("Clap CLI schema".into()),
1870 description: Some("JSON schema extracted from clap Command definitions".into()),
1871 mime_type: Some("application/json".into()),
1872 annotations: None,
1873 icons: vec![],
1874 meta: None,
1875 size: None,
1876 }
1877}
1878
1879fn list_resources_result(custom_resources: &[content::CustomResource]) -> ListResourcesResult {
1880 let mut resources = vec![clap_schema_resource()];
1881 for resource in custom_resources {
1882 resources.push(resource.to_list_resource());
1883 }
1884 ListResourcesResult {
1885 resources,
1886 meta: None,
1887 next_cursor: None,
1888 }
1889}
1890
1891async fn read_resource_result(
1892 schema_json: &str,
1893 custom_resources: &[content::CustomResource],
1894 params: ReadResourceRequestParams,
1895) -> std::result::Result<ReadResourceResult, RpcError> {
1896 if params.uri == MCP_RESOURCE_URI_SCHEMA {
1897 return Ok(ReadResourceResult {
1898 contents: vec![ReadResourceContent::TextResourceContents(
1899 TextResourceContents {
1900 uri: params.uri,
1901 mime_type: Some("application/json".into()),
1902 text: schema_json.to_string(),
1903 meta: None,
1904 },
1905 )],
1906 meta: None,
1907 });
1908 }
1909 let custom = custom_resources
1910 .iter()
1911 .find(|resource| resource.uri == params.uri);
1912 let Some(resource) = custom else {
1913 return Err(RpcError::invalid_params()
1914 .with_message(format!("unknown resource uri: {}", params.uri)));
1915 };
1916 let text = content::resolve_resource_content(resource, ¶ms.uri).await?;
1917 Ok(ReadResourceResult {
1918 contents: vec![ReadResourceContent::TextResourceContents(
1919 TextResourceContents {
1920 uri: params.uri.clone(),
1921 mime_type: resource.mime_type.clone(),
1922 text,
1923 meta: None,
1924 },
1925 )],
1926 meta: None,
1927 })
1928}
1929
1930fn logging_guide_prompt() -> Prompt {
1931 Prompt {
1932 name: PROMPT_LOGGING_GUIDE.to_string(),
1933 description: Some("How to interpret log messages from this clap-mcp server".to_string()),
1934 arguments: vec![],
1935 icons: vec![],
1936 meta: None,
1937 title: Some("clap-mcp Logging Guide".to_string()),
1938 }
1939}
1940
1941fn list_prompts_result(
1942 logging_enabled: bool,
1943 custom_prompts: &[content::CustomPrompt],
1944) -> ListPromptsResult {
1945 let mut prompts = Vec::new();
1946 if logging_enabled {
1947 prompts.push(logging_guide_prompt());
1948 }
1949 for prompt in custom_prompts {
1950 prompts.push(prompt.to_list_prompt());
1951 }
1952 ListPromptsResult {
1953 prompts,
1954 meta: None,
1955 next_cursor: None,
1956 }
1957}
1958
1959async fn get_prompt_result(
1960 logging_enabled: bool,
1961 custom_prompts: &[content::CustomPrompt],
1962 params: GetPromptRequestParams,
1963) -> std::result::Result<GetPromptResult, RpcError> {
1964 if params.name == PROMPT_LOGGING_GUIDE {
1965 if !logging_enabled {
1966 return Err(
1967 RpcError::invalid_params().with_message(format!("unknown prompt: {}", params.name))
1968 );
1969 }
1970 return Ok(GetPromptResult {
1971 description: Some(
1972 "How to interpret log messages from this clap-mcp server".to_string(),
1973 ),
1974 messages: vec![PromptMessage {
1975 content: ContentBlock::text_content(LOGGING_GUIDE_CONTENT.to_string()),
1976 role: Role::User,
1977 }],
1978 meta: None,
1979 });
1980 }
1981 let custom = custom_prompts
1982 .iter()
1983 .find(|prompt| prompt.name == params.name);
1984 let Some(prompt) = custom else {
1985 return Err(
1986 RpcError::invalid_params().with_message(format!("unknown prompt: {}", params.name))
1987 );
1988 };
1989 let arguments: serde_json::Map<String, serde_json::Value> = params
1990 .arguments
1991 .as_ref()
1992 .map(|map| {
1993 map.iter()
1994 .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
1995 .collect()
1996 })
1997 .unwrap_or_default();
1998 let messages = content::resolve_prompt_content(prompt, ¶ms.name, &arguments).await?;
1999 Ok(GetPromptResult {
2000 description: prompt.description.clone(),
2001 messages,
2002 meta: None,
2003 })
2004}
2005
2006fn validate_tool_argument_names(
2007 tool: &Tool,
2008 tool_name: &str,
2009 arguments: &serde_json::Map<String, serde_json::Value>,
2010) -> std::result::Result<(), CallToolError> {
2011 if let Some(ref props) = tool.input_schema.properties {
2012 for key in arguments.keys() {
2013 if !props.contains_key(key) {
2014 return Err(CallToolError::invalid_arguments(
2015 tool_name,
2016 Some(format!("unknown argument: {key}")),
2017 ));
2018 }
2019 }
2020 }
2021 Ok(())
2022}
2023
2024fn call_tool_result_from_output(output: ClapMcpToolOutput) -> CallToolResult {
2025 let (content, structured_content) = match output {
2026 ClapMcpToolOutput::Text(text) => (vec![ContentBlock::text_content(text)], None),
2027 ClapMcpToolOutput::Structured(value) => {
2028 let json_text =
2029 serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
2030 let structured = value.as_object().cloned();
2031 (vec![ContentBlock::text_content(json_text)], structured)
2032 }
2033 };
2034 CallToolResult {
2035 content,
2036 is_error: None,
2037 meta: None,
2038 structured_content,
2039 }
2040}
2041
2042fn call_tool_result_from_tool_error(error: ClapMcpToolError) -> CallToolResult {
2043 let structured_content = error
2044 .structured
2045 .as_ref()
2046 .and_then(|value| value.as_object().cloned());
2047 CallToolResult {
2048 content: vec![ContentBlock::text_content(error.message)],
2049 is_error: Some(true),
2050 meta: None,
2051 structured_content,
2052 }
2053}
2054
2055fn call_tool_result_from_panic(panic_payload: &(dyn std::any::Any + Send)) -> CallToolResult {
2056 let msg = format_panic_payload(panic_payload);
2057 CallToolResult {
2058 content: vec![ContentBlock::text_content(format!(
2059 "Tool panicked: {}",
2060 msg
2061 ))],
2062 is_error: Some(true),
2063 meta: None,
2064 structured_content: None,
2065 }
2066}
2067
2068fn schema_parse_failure_result() -> CallToolResult {
2069 CallToolResult {
2070 content: vec![ContentBlock::text_content("Failed to parse schema".into())],
2071 is_error: Some(true),
2072 meta: None,
2073 structured_content: None,
2074 }
2075}
2076
2077fn command_launch_failure_result(error: &std::io::Error) -> CallToolResult {
2078 CallToolResult {
2079 content: vec![ContentBlock::text_content(format!(
2080 "Failed to run command: {}",
2081 error
2082 ))],
2083 is_error: Some(true),
2084 meta: None,
2085 structured_content: None,
2086 }
2087}
2088
2089fn placeholder_tool_result(
2090 name: &str,
2091 arguments: &serde_json::Map<String, serde_json::Value>,
2092) -> CallToolResult {
2093 let args_json = serde_json::Value::Object(arguments.clone());
2094 CallToolResult::from_content(vec![ContentBlock::text_content(format!(
2095 "Would invoke clap command '{name}' with arguments: {args_json:?}"
2096 ))])
2097}
2098
2099fn build_execution_command(
2100 executable_path: &std::path::Path,
2101 schema: &ClapSchema,
2102 root_name: &str,
2103 tool_name: &str,
2104 arguments: &serde_json::Map<String, serde_json::Value>,
2105) -> std::process::Command {
2106 let argv = build_tool_argv(schema, tool_name, arguments.clone());
2107 let mut command = std::process::Command::new(executable_path);
2108 if let Some(path) = command_path(schema, tool_name) {
2109 for segment in path.into_iter().skip(1) {
2110 command.arg(segment);
2111 }
2112 } else if tool_name != root_name {
2113 command.arg(tool_name);
2114 }
2115 for arg in &argv {
2116 command.arg(arg);
2117 }
2118 command
2119}
2120
2121fn subprocess_stderr_log_params(
2122 tool_name: &str,
2123 stderr: &str,
2124) -> Option<LoggingMessageNotificationParams> {
2125 let trimmed = stderr.trim();
2126 if trimmed.is_empty() {
2127 return None;
2128 }
2129 let mut meta = serde_json::Map::new();
2130 meta.insert(
2131 "tool".to_string(),
2132 serde_json::Value::String(tool_name.to_string()),
2133 );
2134 Some(LoggingMessageNotificationParams {
2135 data: serde_json::Value::String(trimmed.to_string()),
2136 level: LoggingLevel::Info,
2137 logger: Some("stderr".to_string()),
2138 meta: Some(meta),
2139 })
2140}
2141
2142fn call_tool_result_from_subprocess_output(output: &std::process::Output) -> CallToolResult {
2143 let stdout = String::from_utf8_lossy(&output.stdout);
2144 let stderr = String::from_utf8_lossy(&output.stderr);
2145 if !output.status.success() {
2146 let code = output
2147 .status
2148 .code()
2149 .map(|value| value.to_string())
2150 .unwrap_or_else(|| "unknown".to_string());
2151 let mut msg = format!("Tool process exited with non-zero status (code: {})", code);
2152 if !stderr.is_empty() {
2153 msg.push_str("\nstderr:\n");
2154 msg.push_str(stderr.trim());
2155 }
2156 return CallToolResult {
2157 content: vec![ContentBlock::text_content(msg)],
2158 is_error: Some(true),
2159 meta: None,
2160 structured_content: None,
2161 };
2162 }
2163 let text = if stderr.is_empty() {
2164 stdout.trim().to_string()
2165 } else {
2166 format!("{}\nstderr:\n{}", stdout.trim(), stderr.trim())
2167 };
2168 CallToolResult::from_content(vec![ContentBlock::text_content(text)])
2169}
2170
2171pub async fn serve_schema_json_over_stdio(
2201 schema_json: String,
2202 executable_path: Option<PathBuf>,
2203 config: ClapMcpConfig,
2204 in_process_handler: Option<InProcessToolHandler>,
2205 serve_options: ClapMcpServeOptions,
2206 metadata: &ClapMcpSchemaMetadata,
2207) -> std::result::Result<(), ClapMcpError> {
2208 let schema: ClapSchema = serde_json::from_str(&schema_json)?;
2209 let tools = tools_from_schema_with_config_and_metadata(&schema, &config, metadata);
2210 let root_name = schema.root.name.clone();
2211
2212 let tool_execution_lock: Option<Arc<tokio::sync::Mutex<()>>> = if config.parallel_safe {
2213 None
2214 } else {
2215 Some(Arc::new(tokio::sync::Mutex::new(())))
2216 };
2217
2218 let logging_enabled = serve_options.log_rx.is_some();
2219 let (runtime_tx, runtime_rx) = if logging_enabled {
2220 let (tx, rx) = tokio::sync::oneshot::channel::<Arc<dyn rust_mcp_sdk::McpServer>>();
2221 (
2222 Some(std::sync::Arc::new(std::sync::Mutex::new(Some(tx)))),
2223 Some(rx),
2224 )
2225 } else {
2226 (None, None)
2227 };
2228
2229 if let (Some(mut log_rx), Some(runtime_rx)) = (serve_options.log_rx, runtime_rx) {
2230 tokio::spawn(async move {
2231 let Ok(runtime) = runtime_rx.await else {
2232 return;
2233 };
2234 while let Some(params) = log_rx.recv().await {
2235 let _ = runtime.notify_log_message(params).await;
2236 }
2237 });
2238 }
2239
2240 type RuntimeTx = Option<
2241 Arc<
2242 std::sync::Mutex<
2243 Option<tokio::sync::oneshot::Sender<Arc<dyn rust_mcp_sdk::McpServer>>>,
2244 >,
2245 >,
2246 >;
2247
2248 struct Handler {
2249 schema_json: String,
2250 tools: Vec<Tool>,
2251 executable_path: Option<PathBuf>,
2252 in_process_handler: Option<InProcessToolHandler>,
2253 root_name: String,
2254 tool_execution_lock: Option<Arc<tokio::sync::Mutex<()>>>,
2255 runtime_tx: RuntimeTx,
2256 catch_in_process_panics: bool,
2257 custom_resources: Vec<content::CustomResource>,
2258 custom_prompts: Vec<content::CustomPrompt>,
2259 logging_enabled: bool,
2260 }
2261
2262 #[async_trait]
2263 impl ServerHandler for Handler {
2264 async fn handle_list_resources_request(
2265 &self,
2266 _params: Option<PaginatedRequestParams>,
2267 _runtime: Arc<dyn rust_mcp_sdk::McpServer>,
2268 ) -> std::result::Result<ListResourcesResult, RpcError> {
2269 Ok(list_resources_result(&self.custom_resources))
2270 }
2271
2272 async fn handle_read_resource_request(
2273 &self,
2274 params: ReadResourceRequestParams,
2275 _runtime: Arc<dyn rust_mcp_sdk::McpServer>,
2276 ) -> std::result::Result<ReadResourceResult, RpcError> {
2277 read_resource_result(&self.schema_json, &self.custom_resources, params).await
2278 }
2279
2280 async fn handle_list_tools_request(
2281 &self,
2282 _params: Option<PaginatedRequestParams>,
2283 _runtime: Arc<dyn rust_mcp_sdk::McpServer>,
2284 ) -> std::result::Result<ListToolsResult, RpcError> {
2285 Ok(ListToolsResult {
2286 tools: self.tools.clone(),
2287 meta: None,
2288 next_cursor: None,
2289 })
2290 }
2291
2292 async fn handle_list_prompts_request(
2293 &self,
2294 _params: Option<PaginatedRequestParams>,
2295 _runtime: Arc<dyn rust_mcp_sdk::McpServer>,
2296 ) -> std::result::Result<ListPromptsResult, RpcError> {
2297 Ok(list_prompts_result(
2298 self.logging_enabled,
2299 &self.custom_prompts,
2300 ))
2301 }
2302
2303 async fn handle_get_prompt_request(
2304 &self,
2305 params: GetPromptRequestParams,
2306 _runtime: Arc<dyn rust_mcp_sdk::McpServer>,
2307 ) -> std::result::Result<GetPromptResult, RpcError> {
2308 get_prompt_result(self.logging_enabled, &self.custom_prompts, params).await
2309 }
2310
2311 async fn handle_call_tool_request(
2312 &self,
2313 params: CallToolRequestParams,
2314 runtime: Arc<dyn rust_mcp_sdk::McpServer>,
2315 ) -> std::result::Result<CallToolResult, CallToolError> {
2316 if let Some(ref tx) = self.runtime_tx
2317 && let Ok(mut guard) = tx.lock()
2318 && let Some(sender) = guard.take()
2319 {
2320 let _ = sender.send(runtime.clone());
2321 }
2322
2323 let tool = self.tools.iter().find(|t| t.name == params.name);
2324 let Some(tool) = tool else {
2325 return Err(CallToolError::unknown_tool(params.name.clone()));
2326 };
2327
2328 let args_map = params.arguments.unwrap_or_default();
2330 validate_tool_argument_names(tool, ¶ms.name, &args_map)?;
2331
2332 let _guard = if let Some(ref lock) = self.tool_execution_lock {
2333 Some(lock.lock().await)
2334 } else {
2335 None
2336 };
2337
2338 if let Some(ref handler) = self.in_process_handler {
2339 let name = params.name.clone();
2340 let args = args_map;
2341 let result = if self.catch_in_process_panics {
2342 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler(&name, args)))
2343 } else {
2344 Ok(handler(&name, args))
2345 };
2346 match result {
2347 Ok(Ok(output)) => return Ok(call_tool_result_from_output(output)),
2348 Ok(Err(error)) => return Ok(call_tool_result_from_tool_error(error)),
2349 Err(panic_payload) => {
2350 return Ok(call_tool_result_from_panic(panic_payload.as_ref()));
2351 }
2352 }
2353 }
2354
2355 if let Some(ref exe) = self.executable_path {
2356 let schema: ClapSchema = match serde_json::from_str(&self.schema_json) {
2357 Ok(schema) => schema,
2358 Err(_) => return Ok(schema_parse_failure_result()),
2359 };
2360 if let Err(e) = validate_required_args(&schema, ¶ms.name, &args_map) {
2361 return Ok(call_tool_result_from_tool_error(ClapMcpToolError::text(e)));
2362 }
2363 let mut cmd =
2364 build_execution_command(exe, &schema, &self.root_name, ¶ms.name, &args_map);
2365 match cmd.output() {
2366 Ok(output) => {
2367 if let Some(log_params) = subprocess_stderr_log_params(
2368 ¶ms.name,
2369 &String::from_utf8_lossy(&output.stderr),
2370 ) {
2371 let _ = runtime.notify_log_message(log_params).await;
2373 }
2374 return Ok(call_tool_result_from_subprocess_output(&output));
2375 }
2376 Err(error) => return Ok(command_launch_failure_result(&error)),
2377 }
2378 }
2379
2380 Ok(placeholder_tool_result(¶ms.name, &args_map))
2381 }
2382 }
2383
2384 let meta = {
2385 let mut m = serde_json::Map::new();
2386 m.insert(
2387 "clapMcp".into(),
2388 serde_json::json!({
2389 "version": env!("CARGO_PKG_VERSION"),
2390 "commit": env!("CLAP_MCP_GIT_COMMIT"),
2391 "buildDate": env!("CLAP_MCP_BUILD_DATE"),
2392 }),
2393 );
2394 Some(m)
2395 };
2396
2397 let server_details = InitializeResult {
2398 server_info: Implementation {
2399 name: "clap-mcp".into(),
2400 version: env!("CARGO_PKG_VERSION").into(),
2401 title: Some("clap-mcp".into()),
2402 description: Some("Expose clap CLI schema over MCP (stdio)".into()),
2403 icons: vec![],
2404 website_url: None,
2405 },
2406 capabilities: ServerCapabilities {
2407 resources: Some(ServerCapabilitiesResources {
2408 list_changed: Some(false),
2409 subscribe: Some(false),
2410 }),
2411 tools: Some(ServerCapabilitiesTools {
2412 list_changed: Some(false),
2413 }),
2414 logging: if logging_enabled {
2415 Some(serde_json::Map::new())
2416 } else {
2417 None
2418 },
2419 prompts: Some(ServerCapabilitiesPrompts {
2420 list_changed: Some(false),
2421 }),
2422 ..Default::default()
2423 },
2424 protocol_version: LATEST_PROTOCOL_VERSION.into(),
2425 instructions: if logging_enabled {
2426 Some(LOG_INTERPRETATION_INSTRUCTIONS.to_string())
2427 } else {
2428 None
2429 },
2430 meta,
2431 };
2432
2433 let transport_options = TransportOptions {
2435 timeout: Duration::from_secs(30),
2436 };
2437 let transport = StdioTransport::<schema_utils::ClientMessage>::new(transport_options)?;
2439
2440 let handler = Handler {
2441 schema_json,
2442 tools,
2443 executable_path,
2444 in_process_handler,
2445 root_name,
2446 tool_execution_lock,
2447 runtime_tx,
2448 catch_in_process_panics: config.catch_in_process_panics,
2449 custom_resources: serve_options.custom_resources.clone(),
2450 custom_prompts: serve_options.custom_prompts.clone(),
2451 logging_enabled,
2452 }
2453 .to_mcp_server_handler();
2454 let server = server_runtime::create_server(McpServerOptions {
2455 server_details,
2456 transport,
2457 handler,
2458 task_store: None,
2459 client_task_store: None,
2460 });
2461
2462 server.start().await?;
2463 Ok(())
2464}
2465
2466pub fn serve_schema_json_over_stdio_blocking(
2478 schema_json: String,
2479 executable_path: Option<PathBuf>,
2480 config: ClapMcpConfig,
2481 in_process_handler: Option<InProcessToolHandler>,
2482 serve_options: ClapMcpServeOptions,
2483 metadata: &ClapMcpSchemaMetadata,
2484) -> std::result::Result<(), ClapMcpError> {
2485 let use_multi_thread = config.reinvocation_safe && config.share_runtime;
2486 let rt = if use_multi_thread {
2487 tokio::runtime::Builder::new_multi_thread()
2488 .enable_all()
2489 .build()?
2490 } else {
2491 tokio::runtime::Builder::new_current_thread()
2492 .enable_all()
2493 .build()?
2494 };
2495 rt.block_on(serve_schema_json_over_stdio(
2496 schema_json,
2497 executable_path,
2498 config,
2499 in_process_handler,
2500 serve_options,
2501 metadata,
2502 ))
2503}
2504
2505pub fn run_async_tool<Fut, O>(
2536 config: &ClapMcpConfig,
2537 f: impl FnOnce() -> Fut + Send,
2538) -> std::result::Result<O, ClapMcpError>
2539where
2540 Fut: std::future::Future<Output = O> + Send,
2541 O: Send,
2542{
2543 if config.reinvocation_safe && config.share_runtime {
2544 tokio::task::block_in_place(|| {
2545 let handle = tokio::runtime::Handle::try_current()
2546 .map_err(|e| ClapMcpError::RuntimeContext(e.to_string()))?;
2547 Ok(handle.block_on(f()))
2548 })
2549 } else {
2550 std::thread::scope(|s| {
2551 let join_handle = s.spawn(|| {
2552 let rt = tokio::runtime::Builder::new_current_thread()
2553 .enable_all()
2554 .build()?;
2555 Ok(rt.block_on(f()))
2556 });
2557 match join_handle.join() {
2558 Ok(inner) => inner,
2559 Err(e) => Err(ClapMcpError::ToolThread(format!("{:?}", e))),
2560 }
2561 })
2562 }
2563}
2564
2565#[cfg(test)]
2566mod tests {
2567 use super::*;
2568 use clap::{ArgAction, CommandFactory};
2569 use serde_json::json;
2570 use std::error::Error;
2571 use std::sync::Mutex;
2572
2573 #[cfg(unix)]
2574 use std::os::unix::process::ExitStatusExt;
2575
2576 fn sample_helper_schema() -> ClapSchema {
2577 schema_from_command(
2578 &Command::new("sample")
2579 .arg(Arg::new("input").help("Input file").required(true).index(1))
2580 .arg(
2581 Arg::new("verbose")
2582 .long("verbose")
2583 .help("Verbose mode")
2584 .action(ArgAction::SetTrue),
2585 )
2586 .arg(
2587 Arg::new("no-cache")
2588 .long("no-cache")
2589 .help("Disable cache")
2590 .action(ArgAction::SetFalse),
2591 )
2592 .arg(
2593 Arg::new("level")
2594 .long("level")
2595 .help("Verbosity level")
2596 .action(ArgAction::Count),
2597 )
2598 .arg(
2599 Arg::new("tag")
2600 .long("tag")
2601 .help("Tags to include")
2602 .action(ArgAction::Append)
2603 .value_name("TAG"),
2604 )
2605 .arg(
2606 Arg::new("mode")
2607 .long("mode")
2608 .help("Execution mode")
2609 .action(ArgAction::Set),
2610 )
2611 .subcommand(Command::new("serve").about("Serve the sample app")),
2612 )
2613 }
2614
2615 fn nested_schema() -> ClapSchema {
2616 schema_from_command(
2617 &Command::new("sample")
2618 .subcommand(
2619 Command::new("parent")
2620 .subcommand(Command::new("child").arg(Arg::new("value").long("value"))),
2621 )
2622 .subcommand(Command::new("echo").arg(Arg::new("message").long("message"))),
2623 )
2624 }
2625
2626 #[derive(Debug)]
2627 struct TestError(&'static str);
2628
2629 impl std::fmt::Display for TestError {
2630 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2631 f.write_str(self.0)
2632 }
2633 }
2634
2635 impl Error for TestError {}
2636
2637 struct TestPromptProvider {
2638 response: Result<Vec<PromptMessage>, &'static str>,
2639 seen: Mutex<Vec<(String, serde_json::Map<String, serde_json::Value>)>>,
2640 }
2641
2642 #[async_trait]
2643 impl content::PromptContentProvider for TestPromptProvider {
2644 async fn get(
2645 &self,
2646 name: &str,
2647 arguments: &serde_json::Map<String, serde_json::Value>,
2648 ) -> std::result::Result<Vec<PromptMessage>, Box<dyn Error + Send + Sync>> {
2649 self.seen
2650 .lock()
2651 .expect("prompt provider mutex should lock")
2652 .push((name.to_string(), arguments.clone()));
2653 match &self.response {
2654 Ok(messages) => Ok(messages.clone()),
2655 Err(message) => Err(Box::new(TestError(message))),
2656 }
2657 }
2658 }
2659
2660 struct TestResourceProvider {
2661 response: Result<String, &'static str>,
2662 }
2663
2664 #[async_trait]
2665 impl content::ResourceContentProvider for TestResourceProvider {
2666 async fn read(
2667 &self,
2668 _uri: &str,
2669 ) -> std::result::Result<String, Box<dyn Error + Send + Sync>> {
2670 match &self.response {
2671 Ok(text) => Ok(text.clone()),
2672 Err(message) => Err(Box::new(TestError(message))),
2673 }
2674 }
2675 }
2676
2677 #[derive(Debug, clap::Parser)]
2678 #[command(name = "exec-cli", subcommand_required = true)]
2679 enum ExecCli {
2680 PrintOnly,
2681 PrintAndText,
2682 Structured,
2683 Echo {
2684 #[arg(long)]
2685 value: String,
2686 },
2687 }
2688
2689 impl ClapMcpToolExecutor for ExecCli {
2690 fn execute_for_mcp(self) -> Result<ClapMcpToolOutput, ClapMcpToolError> {
2691 match self {
2692 Self::PrintOnly => {
2693 print!("captured only");
2694 Ok(ClapMcpToolOutput::Text(String::new()))
2695 }
2696 Self::PrintAndText => {
2697 print!("captured extra");
2698 Ok(ClapMcpToolOutput::Text("returned text".to_string()))
2699 }
2700 Self::Structured => {
2701 print!("ignored capture");
2702 Ok(ClapMcpToolOutput::Structured(json!({ "status": "ok" })))
2703 }
2704 Self::Echo { value } => Ok(ClapMcpToolOutput::Text(value)),
2705 }
2706 }
2707 }
2708
2709 #[test]
2710 fn test_format_panic_payload() {
2711 let s: Box<dyn std::any::Any + Send> = Box::new("hello");
2712 assert_eq!(format_panic_payload(s.as_ref()), "hello");
2713 let s: Box<dyn std::any::Any + Send> = Box::new("world".to_string());
2714 assert_eq!(format_panic_payload(s.as_ref()), "world");
2715 let n: Box<dyn std::any::Any + Send> = Box::new(42i32);
2716 assert_eq!(format_panic_payload(n.as_ref()), "<panic>");
2717 }
2718
2719 #[test]
2720 fn test_mcp_type_for_arg_and_description_hints() {
2721 let boolean_arg = ClapArg {
2722 id: "verbose".to_string(),
2723 long: Some("verbose".to_string()),
2724 short: None,
2725 help: Some("Verbose mode".to_string()),
2726 long_help: None,
2727 required: false,
2728 global: false,
2729 index: None,
2730 action: Some("SetTrue".to_string()),
2731 value_names: vec![],
2732 num_args: None,
2733 };
2734 let (json_type, items) = mcp_type_for_arg(&boolean_arg);
2735 assert_eq!(json_type, json!("boolean"));
2736 assert!(items.is_none());
2737 assert_eq!(
2738 mcp_action_description_hint(&boolean_arg),
2739 Some(" Boolean flag: set to true to pass this flag.".to_string())
2740 );
2741
2742 let false_arg = ClapArg {
2743 action: Some("SetFalse".to_string()),
2744 ..boolean_arg.clone()
2745 };
2746 assert_eq!(mcp_type_for_arg(&false_arg).0, json!("boolean"));
2747 assert_eq!(
2748 mcp_action_description_hint(&false_arg),
2749 Some(" Boolean flag: set to false to pass this flag (e.g. --no-xxx).".to_string())
2750 );
2751
2752 let count_arg = ClapArg {
2753 action: Some("Count".to_string()),
2754 ..boolean_arg.clone()
2755 };
2756 assert_eq!(mcp_type_for_arg(&count_arg).0, json!("integer"));
2757 assert_eq!(
2758 mcp_action_description_hint(&count_arg),
2759 Some(" Number of times the flag is passed (e.g. -vvv).".to_string())
2760 );
2761
2762 let append_arg = ClapArg {
2763 action: Some("Append".to_string()),
2764 value_names: vec!["TAG".to_string()],
2765 ..boolean_arg
2766 };
2767 let (json_type, items) = mcp_type_for_arg(&append_arg);
2768 assert_eq!(json_type, json!("array"));
2769 assert_eq!(
2770 items,
2771 Some(json!({ "type": "string", "description": "A TAG value" }))
2772 );
2773 assert_eq!(
2774 mcp_action_description_hint(&append_arg),
2775 Some(" List of TAG values; pass a JSON array (e.g. [\"a\", \"b\"]).".to_string())
2776 );
2777
2778 let multi_value_arg = ClapArg {
2779 id: "names".to_string(),
2780 long: Some("name".to_string()),
2781 short: None,
2782 help: None,
2783 long_help: None,
2784 required: false,
2785 global: false,
2786 index: None,
2787 action: Some("Set".to_string()),
2788 value_names: vec!["NAME".to_string()],
2789 num_args: Some("1..".to_string()),
2790 };
2791 let (json_type, items) = mcp_type_for_arg(&multi_value_arg);
2792 assert_eq!(json_type, json!("array"));
2793 assert_eq!(
2794 items,
2795 Some(json!({ "type": "string", "description": "A NAME value" }))
2796 );
2797 }
2798
2799 #[test]
2800 fn test_command_to_tool_with_config_reflects_arg_shapes() {
2801 let schema = sample_helper_schema();
2802 let tool = command_to_tool_with_config(
2803 &schema.root,
2804 &ClapMcpConfig {
2805 reinvocation_safe: true,
2806 parallel_safe: false,
2807 share_runtime: true,
2808 ..Default::default()
2809 },
2810 None,
2811 );
2812
2813 assert_eq!(tool.name, "sample");
2814 assert_eq!(tool.description, None);
2815
2816 let props = tool
2817 .input_schema
2818 .properties
2819 .expect("tool should include input schema properties");
2820 assert_eq!(tool.input_schema.required, vec!["input".to_string()]);
2821 assert_eq!(
2822 props["verbose"]
2823 .get("type")
2824 .and_then(|value| value.as_str()),
2825 Some("boolean")
2826 );
2827 assert!(
2828 props["verbose"]["description"]
2829 .as_str()
2830 .expect("verbose description")
2831 .contains("Boolean flag")
2832 );
2833 assert_eq!(
2834 props["level"].get("type").and_then(|value| value.as_str()),
2835 Some("integer")
2836 );
2837 assert_eq!(
2838 props["tag"].get("type").and_then(|value| value.as_str()),
2839 Some("array")
2840 );
2841 assert_eq!(
2842 props["tag"]["items"]["description"].as_str(),
2843 Some("A TAG value")
2844 );
2845 assert_eq!(
2846 tool.meta
2847 .as_ref()
2848 .and_then(|meta| meta.get("clapMcp"))
2849 .and_then(|value| value.get("shareRuntime"))
2850 .and_then(|value| value.as_bool()),
2851 Some(true)
2852 );
2853 }
2854
2855 #[test]
2856 fn test_validate_required_args_handles_missing_empty_and_flag_values() {
2857 let schema = sample_helper_schema();
2858 let mut provided = serde_json::Map::new();
2859 provided.insert("verbose".to_string(), json!(false));
2860 provided.insert("level".to_string(), json!(0));
2861 provided.insert("input".to_string(), json!("input.txt"));
2862 assert!(validate_required_args(&schema, "sample", &provided).is_ok());
2863
2864 let mut missing_text = serde_json::Map::new();
2865 missing_text.insert("input".to_string(), json!(""));
2866 let error = validate_required_args(&schema, "sample", &missing_text)
2867 .expect_err("empty required string should fail");
2868 assert!(error.contains("Missing required argument(s): input"));
2869
2870 let mut missing_array = serde_json::Map::new();
2871 missing_array.insert("input".to_string(), json!([]));
2872 let error = validate_required_args(&schema, "sample", &missing_array)
2873 .expect_err("empty array should fail");
2874 assert!(error.contains("input"));
2875
2876 assert!(validate_required_args(&schema, "unknown", &serde_json::Map::new()).is_ok());
2877 }
2878
2879 #[test]
2880 fn test_build_tool_argv_handles_positional_flags_and_lists() {
2881 let schema = sample_helper_schema();
2882 let arguments = serde_json::Map::from_iter([
2883 ("input".to_string(), json!("input.txt")),
2884 ("verbose".to_string(), json!(true)),
2885 ("no-cache".to_string(), json!(false)),
2886 ("level".to_string(), json!(2)),
2887 ("tag".to_string(), json!(["alpha", "", "beta"])),
2888 ("mode".to_string(), json!("fast")),
2889 ]);
2890
2891 let argv = build_tool_argv(&schema, "sample", arguments);
2892 assert_eq!(
2893 argv,
2894 vec![
2895 "input.txt",
2896 "--level",
2897 "--level",
2898 "--mode",
2899 "fast",
2900 "--no-cache",
2901 "--tag",
2902 "alpha",
2903 "--tag",
2904 "beta",
2905 "--verbose",
2906 ]
2907 );
2908 }
2909
2910 #[test]
2911 fn test_value_to_string_and_value_to_strings_cover_scalar_and_array_inputs() {
2912 assert_eq!(value_to_string(&json!("hello")), Some("hello".to_string()));
2913 assert_eq!(value_to_string(&json!(3)), Some("3".to_string()));
2914 assert_eq!(value_to_string(&json!(false)), Some("false".to_string()));
2915 assert_eq!(value_to_string(&serde_json::Value::Null), None);
2916 assert_eq!(
2917 value_to_string(&json!({"name":"sample"})),
2918 Some("{\"name\":\"sample\"}".to_string())
2919 );
2920
2921 assert_eq!(
2922 value_to_strings(&json!(["alpha", "", 3, null, false])),
2923 Some(vec![
2924 "alpha".to_string(),
2925 "3".to_string(),
2926 "false".to_string()
2927 ])
2928 );
2929 assert_eq!(
2930 value_to_strings(&json!("solo")),
2931 Some(vec!["solo".to_string()])
2932 );
2933 assert_eq!(value_to_strings(&serde_json::Value::Null), None);
2934 }
2935
2936 #[test]
2937 fn test_command_flag_helpers_are_idempotent() {
2938 let cmd = command_with_mcp_flag(command_with_mcp_flag(Command::new("sample")));
2939 let mcp_args = cmd
2940 .get_arguments()
2941 .filter(|arg| arg.get_long() == Some(MCP_FLAG_LONG))
2942 .count();
2943 assert_eq!(mcp_args, 1);
2944
2945 let cmd = command_with_export_skills_flag(command_with_export_skills_flag(Command::new(
2946 "sample",
2947 )));
2948 let export_args = cmd
2949 .get_arguments()
2950 .filter(|arg| arg.get_long() == Some(EXPORT_SKILLS_FLAG_LONG))
2951 .count();
2952 assert_eq!(export_args, 1);
2953
2954 let cmd = command_with_mcp_and_export_skills_flags(Command::new("bare"));
2955 assert_eq!(
2956 cmd.get_arguments()
2957 .filter(|arg| arg.get_long() == Some(MCP_FLAG_LONG))
2958 .count(),
2959 1
2960 );
2961 assert_eq!(
2962 cmd.get_arguments()
2963 .filter(|arg| arg.get_long() == Some(EXPORT_SKILLS_FLAG_LONG))
2964 .count(),
2965 1
2966 );
2967 }
2968
2969 #[test]
2970 fn test_argv_export_skills_dir_from_args() {
2971 assert!(argv_export_skills_dir_from_args(&[]).is_none());
2972 assert!(argv_export_skills_dir_from_args(&["--other".to_string()]).is_none());
2973 assert_eq!(
2974 argv_export_skills_dir_from_args(&["--export-skills".to_string()]),
2975 Some(None)
2976 );
2977 assert_eq!(
2978 argv_export_skills_dir_from_args(&["--export-skills".to_string(), "/path".to_string()]),
2979 Some(Some(std::path::PathBuf::from("/path")))
2980 );
2981 assert_eq!(
2982 argv_export_skills_dir_from_args(&["--export-skills".to_string(), "--mcp".to_string()]),
2983 Some(None)
2984 );
2985 assert_eq!(
2986 argv_export_skills_dir_from_args(&["--export-skills=/out".to_string()]),
2987 Some(Some(std::path::PathBuf::from("/out")))
2988 );
2989 }
2990
2991 #[test]
2992 fn test_argv_requests_mcp_without_subcommand_from_args() {
2993 let cmd = Command::new("app").subcommand(Command::new("run"));
2994 assert!(argv_requests_mcp_without_subcommand_from_args(
2995 &["--mcp".to_string()],
2996 &cmd
2997 ));
2998 assert!(!argv_requests_mcp_without_subcommand_from_args(
2999 &["--mcp".to_string(), "run".to_string()],
3000 &cmd
3001 ));
3002 assert!(!argv_requests_mcp_without_subcommand_from_args(
3003 &["run".to_string()],
3004 &cmd
3005 ));
3006 assert!(!argv_requests_mcp_without_subcommand_from_args(&[], &cmd));
3007 }
3008
3009 #[test]
3010 fn test_is_builtin_arg() {
3011 assert!(is_builtin_arg("help"));
3012 assert!(is_builtin_arg("version"));
3013 assert!(is_builtin_arg(MCP_FLAG_LONG));
3014 assert!(is_builtin_arg(EXPORT_SKILLS_FLAG_LONG));
3015 assert!(!is_builtin_arg("input"));
3016 assert!(!is_builtin_arg("path"));
3017 }
3018
3019 #[test]
3020 fn test_tools_from_schema_wrapper() {
3021 let schema = sample_helper_schema();
3022 let tools = tools_from_schema(&schema);
3023 assert!(!tools.is_empty());
3024 }
3025
3026 #[test]
3027 fn test_command_path_and_build_argv_for_clap() {
3028 let schema = nested_schema();
3029 assert_eq!(command_path(&schema, "sample"), Some(vec!["sample".into()]));
3030 assert_eq!(
3031 command_path(&schema, "child"),
3032 Some(vec!["sample".into(), "parent".into(), "child".into()])
3033 );
3034 assert_eq!(command_path(&schema, "nonexistent"), None);
3035
3036 let args = serde_json::Map::from_iter([("value".to_string(), json!("v"))]);
3037 let argv = build_argv_for_clap(&schema, "child", args);
3038 assert_eq!(argv[0], "cli");
3039 assert_eq!(argv[1], "parent");
3040 assert_eq!(argv[2], "child");
3041 assert!(argv.contains(&"--value".to_string()));
3042 assert!(argv.contains(&"v".to_string()));
3043
3044 let empty_argv = build_tool_argv(&schema, "nonexistent", serde_json::Map::new());
3045 assert!(empty_argv.is_empty());
3046 }
3047
3048 #[cfg(not(feature = "output-schema"))]
3049 #[test]
3050 fn test_output_schema_for_type_without_schemars() {
3051 assert!(output_schema_for_type::<()>().is_none());
3052 }
3053
3054 #[cfg(feature = "output-schema")]
3055 #[test]
3056 fn test_output_schema_for_type_with_schemars() {
3057 use schemars::JsonSchema;
3058 #[derive(JsonSchema)]
3059 struct Dummy {
3060 _x: i32,
3061 }
3062 let schema = output_schema_for_type::<Dummy>();
3063 assert!(schema.is_some());
3064 }
3065
3066 #[tokio::test]
3067 async fn test_resource_helpers_cover_builtin_custom_and_error_paths() {
3068 let custom = vec![content::CustomResource {
3069 uri: "test://dynamic".to_string(),
3070 name: "dynamic".to_string(),
3071 title: None,
3072 description: Some("dynamic resource".to_string()),
3073 mime_type: Some("text/plain".to_string()),
3074 content: content::ResourceContent::Dynamic(Arc::new(TestResourceProvider {
3075 response: Ok("dynamic body".to_string()),
3076 })),
3077 }];
3078
3079 let listed = list_resources_result(&custom);
3080 assert_eq!(listed.resources.len(), 2);
3081 assert_eq!(listed.resources[0].uri, MCP_RESOURCE_URI_SCHEMA);
3082 assert_eq!(listed.resources[1].uri, "test://dynamic");
3083
3084 let schema_read = read_resource_result(
3085 "{\"name\":\"sample\"}",
3086 &custom,
3087 ReadResourceRequestParams {
3088 uri: MCP_RESOURCE_URI_SCHEMA.to_string(),
3089 meta: None,
3090 },
3091 )
3092 .await
3093 .expect("schema resource should resolve");
3094 let text = match &schema_read.contents[0] {
3095 ReadResourceContent::TextResourceContents(text) => &text.text,
3096 other => panic!("unexpected content: {other:?}"),
3097 };
3098 assert!(text.contains("\"name\":\"sample\""));
3099
3100 let custom_read = read_resource_result(
3101 "{}",
3102 &custom,
3103 ReadResourceRequestParams {
3104 uri: "test://dynamic".to_string(),
3105 meta: None,
3106 },
3107 )
3108 .await
3109 .expect("custom resource should resolve");
3110 let text = match &custom_read.contents[0] {
3111 ReadResourceContent::TextResourceContents(text) => &text.text,
3112 other => panic!("unexpected content: {other:?}"),
3113 };
3114 assert_eq!(text, "dynamic body");
3115
3116 let missing = read_resource_result(
3117 "{}",
3118 &custom,
3119 ReadResourceRequestParams {
3120 uri: "test://missing".to_string(),
3121 meta: None,
3122 },
3123 )
3124 .await
3125 .expect_err("missing resource should error");
3126 assert!(missing.message.contains("unknown resource uri"));
3127
3128 let failing_resources = vec![content::CustomResource {
3129 uri: "test://broken".to_string(),
3130 name: "broken".to_string(),
3131 title: None,
3132 description: None,
3133 mime_type: None,
3134 content: content::ResourceContent::Dynamic(Arc::new(TestResourceProvider {
3135 response: Err("read failed"),
3136 })),
3137 }];
3138 let failing = read_resource_result(
3139 "{}",
3140 &failing_resources,
3141 ReadResourceRequestParams {
3142 uri: "test://broken".to_string(),
3143 meta: None,
3144 },
3145 )
3146 .await
3147 .expect_err("provider failure should map to rpc error");
3148 assert_eq!(failing.message, "read failed");
3149 }
3150
3151 #[tokio::test]
3152 async fn test_prompt_helpers_cover_logging_custom_and_error_paths() {
3153 let provider = Arc::new(TestPromptProvider {
3154 response: Ok(vec![PromptMessage {
3155 role: Role::User,
3156 content: ContentBlock::text_content("dynamic prompt".to_string()),
3157 }]),
3158 seen: Mutex::new(Vec::new()),
3159 });
3160 let prompts = vec![content::CustomPrompt {
3161 name: "dynamic".to_string(),
3162 title: Some("Dynamic".to_string()),
3163 description: Some("dynamic prompt".to_string()),
3164 arguments: vec![],
3165 content: content::PromptContent::Dynamic(provider.clone()),
3166 }];
3167
3168 let listed = list_prompts_result(true, &prompts);
3169 assert_eq!(listed.prompts.len(), 2);
3170 assert_eq!(listed.prompts[0].name, PROMPT_LOGGING_GUIDE);
3171 assert_eq!(listed.prompts[1].name, "dynamic");
3172
3173 let logging_prompt = get_prompt_result(
3174 true,
3175 &prompts,
3176 GetPromptRequestParams {
3177 name: PROMPT_LOGGING_GUIDE.to_string(),
3178 arguments: None,
3179 meta: None,
3180 },
3181 )
3182 .await
3183 .expect("logging guide should resolve");
3184 assert!(
3185 logging_prompt.messages[0]
3186 .content
3187 .as_text_content()
3188 .expect("logging guide should be text")
3189 .text
3190 .contains("logger")
3191 );
3192
3193 let dynamic_prompt = get_prompt_result(
3194 false,
3195 &prompts,
3196 GetPromptRequestParams {
3197 name: "dynamic".to_string(),
3198 arguments: Some(std::collections::HashMap::from([(
3199 "topic".to_string(),
3200 "coverage".to_string(),
3201 )])),
3202 meta: None,
3203 },
3204 )
3205 .await
3206 .expect("dynamic prompt should resolve");
3207 assert_eq!(
3208 dynamic_prompt.description.as_deref(),
3209 Some("dynamic prompt")
3210 );
3211 assert_eq!(
3212 provider
3213 .seen
3214 .lock()
3215 .expect("provider seen mutex should lock")[0]
3216 .1
3217 .get("topic")
3218 .and_then(|value| value.as_str()),
3219 Some("coverage")
3220 );
3221
3222 let unknown_logging = get_prompt_result(
3223 false,
3224 &prompts,
3225 GetPromptRequestParams {
3226 name: PROMPT_LOGGING_GUIDE.to_string(),
3227 arguments: None,
3228 meta: None,
3229 },
3230 )
3231 .await
3232 .expect_err("logging guide should error when logging disabled");
3233 assert!(unknown_logging.message.contains("unknown prompt"));
3234
3235 let failing_prompts = vec![content::CustomPrompt {
3236 name: "broken".to_string(),
3237 title: None,
3238 description: None,
3239 arguments: vec![],
3240 content: content::PromptContent::Dynamic(Arc::new(TestPromptProvider {
3241 response: Err("prompt failed"),
3242 seen: Mutex::new(Vec::new()),
3243 })),
3244 }];
3245 let failing = get_prompt_result(
3246 false,
3247 &failing_prompts,
3248 GetPromptRequestParams {
3249 name: "broken".to_string(),
3250 arguments: None,
3251 meta: None,
3252 },
3253 )
3254 .await
3255 .expect_err("provider failure should map to rpc error");
3256 assert_eq!(failing.message, "prompt failed");
3257 }
3258
3259 #[test]
3260 fn test_call_tool_result_helpers_cover_text_structured_errors_and_panics() {
3261 let text = call_tool_result_from_output(ClapMcpToolOutput::Text("hello".to_string()));
3262 assert_eq!(text.is_error, None);
3263 assert_eq!(
3264 text.content[0]
3265 .as_text_content()
3266 .expect("text result should be text")
3267 .text,
3268 "hello"
3269 );
3270
3271 let structured = call_tool_result_from_output(ClapMcpToolOutput::Structured(json!({
3272 "sum": 5
3273 })));
3274 assert_eq!(
3275 structured
3276 .structured_content
3277 .as_ref()
3278 .and_then(|content| content.get("sum"))
3279 .and_then(|value| value.as_i64()),
3280 Some(5)
3281 );
3282 assert!(
3283 structured.content[0]
3284 .as_text_content()
3285 .expect("structured result should emit text")
3286 .text
3287 .contains("\"sum\": 5")
3288 );
3289
3290 let non_object = call_tool_result_from_output(ClapMcpToolOutput::Structured(json!(["a"])));
3291 assert!(non_object.structured_content.is_none());
3292
3293 let error = call_tool_result_from_tool_error(ClapMcpToolError::structured(
3294 "bad",
3295 json!({ "code": 7 }),
3296 ));
3297 assert_eq!(error.is_error, Some(true));
3298 assert_eq!(
3299 error
3300 .structured_content
3301 .as_ref()
3302 .and_then(|content| content.get("code"))
3303 .and_then(|value| value.as_i64()),
3304 Some(7)
3305 );
3306
3307 let panic_payload: Box<dyn std::any::Any + Send> = Box::new("boom");
3308 let panic_result = call_tool_result_from_panic(panic_payload.as_ref());
3309 assert_eq!(panic_result.is_error, Some(true));
3310 assert!(
3311 panic_result.content[0]
3312 .as_text_content()
3313 .expect("panic result should be text")
3314 .text
3315 .contains("Tool panicked: boom")
3316 );
3317 }
3318
3319 #[test]
3320 fn test_subprocess_helpers_cover_command_building_logging_and_result_shapes() {
3321 let schema = nested_schema();
3322 let args = serde_json::Map::from_iter([(
3323 "value".to_string(),
3324 serde_json::Value::String("ok".to_string()),
3325 )]);
3326 let command = build_execution_command(
3327 std::path::Path::new("/tmp/example"),
3328 &schema,
3329 "sample",
3330 "child",
3331 &args,
3332 );
3333 assert_eq!(command.get_program(), std::ffi::OsStr::new("/tmp/example"));
3334 let actual_args: Vec<_> = command.get_args().collect();
3335 assert_eq!(
3336 actual_args,
3337 vec![
3338 std::ffi::OsStr::new("parent"),
3339 std::ffi::OsStr::new("child"),
3340 std::ffi::OsStr::new("--value"),
3341 std::ffi::OsStr::new("ok"),
3342 ]
3343 );
3344
3345 let log_params = subprocess_stderr_log_params("child", "warning on stderr\n")
3346 .expect("stderr should produce logging params");
3347 assert_eq!(log_params.logger.as_deref(), Some("stderr"));
3348 assert_eq!(
3349 log_params.meta.as_ref().and_then(|meta| meta.get("tool")),
3350 Some(&serde_json::Value::String("child".to_string()))
3351 );
3352 assert!(subprocess_stderr_log_params("child", " ").is_none());
3353
3354 #[cfg(unix)]
3355 {
3356 let success_output = std::process::Output {
3357 status: std::process::ExitStatus::from_raw(0),
3358 stdout: b"done\n".to_vec(),
3359 stderr: b"note\n".to_vec(),
3360 };
3361 let success = call_tool_result_from_subprocess_output(&success_output);
3362 assert_eq!(success.is_error, None);
3363 assert!(
3364 success.content[0]
3365 .as_text_content()
3366 .expect("success result should be text")
3367 .text
3368 .contains("stderr:\nnote")
3369 );
3370
3371 let failure_output = std::process::Output {
3372 status: std::process::ExitStatus::from_raw(256),
3373 stdout: Vec::new(),
3374 stderr: b"boom\n".to_vec(),
3375 };
3376 let failure = call_tool_result_from_subprocess_output(&failure_output);
3377 assert_eq!(failure.is_error, Some(true));
3378 assert!(
3379 failure.content[0]
3380 .as_text_content()
3381 .expect("failure result should be text")
3382 .text
3383 .contains("non-zero status")
3384 );
3385 }
3386
3387 let launch_error = command_launch_failure_result(&std::io::Error::new(
3388 std::io::ErrorKind::NotFound,
3389 "missing",
3390 ));
3391 assert_eq!(launch_error.is_error, Some(true));
3392 assert!(
3393 launch_error.content[0]
3394 .as_text_content()
3395 .expect("launch error should be text")
3396 .text
3397 .contains("Failed to run command")
3398 );
3399
3400 let placeholder = placeholder_tool_result(
3401 "echo",
3402 &serde_json::Map::from_iter([("message".to_string(), json!("hi"))]),
3403 );
3404 assert!(
3405 placeholder.content[0]
3406 .as_text_content()
3407 .expect("placeholder result should be text")
3408 .text
3409 .contains("Would invoke clap command 'echo'")
3410 );
3411
3412 let parse_failure = schema_parse_failure_result();
3413 assert_eq!(parse_failure.is_error, Some(true));
3414 assert_eq!(
3415 parse_failure.content[0]
3416 .as_text_content()
3417 .expect("schema parse failure should be text")
3418 .text,
3419 "Failed to parse schema"
3420 );
3421 }
3422
3423 #[test]
3424 fn test_validate_tool_argument_names_rejects_unknown_keys() {
3425 let tool = command_to_tool_with_config(
3426 &sample_helper_schema().root,
3427 &ClapMcpConfig::default(),
3428 None,
3429 );
3430 let ok_args = serde_json::Map::from_iter([("input".to_string(), json!("in.txt"))]);
3431 assert!(validate_tool_argument_names(&tool, &tool.name, &ok_args).is_ok());
3432
3433 let bad_args = serde_json::Map::from_iter([("bogus".to_string(), json!(1))]);
3434 let err = validate_tool_argument_names(&tool, &tool.name, &bad_args)
3435 .expect_err("unknown key should error");
3436 assert!(format!("{err:?}").contains("unknown argument: bogus"));
3437 }
3438
3439 #[test]
3440 fn test_into_clap_mcp_result_and_error_impls_cover_basic_conversions() {
3441 assert!(matches!(
3442 String::from("hello")
3443 .into_tool_result()
3444 .expect("string should convert"),
3445 ClapMcpToolOutput::Text(text) if text == "hello"
3446 ));
3447 assert!(matches!(
3448 "world"
3449 .into_tool_result()
3450 .expect("str should convert"),
3451 ClapMcpToolOutput::Text(text) if text == "world"
3452 ));
3453
3454 let structured = AsStructured(json!({ "ok": true }))
3455 .into_tool_result()
3456 .expect("structured value should convert");
3457 assert!(matches!(structured, ClapMcpToolOutput::Structured(_)));
3458
3459 let empty = Option::<String>::None
3460 .into_tool_result()
3461 .expect("none should convert");
3462 assert!(matches!(empty, ClapMcpToolOutput::Text(text) if text.is_empty()));
3463
3464 let some = Some("x").into_tool_result().expect("some should convert");
3465 assert!(matches!(some, ClapMcpToolOutput::Text(text) if text == "x"));
3466
3467 let ok_result: Result<&str, &str> = Ok("done");
3468 assert!(matches!(
3469 ok_result.into_tool_result().expect("ok result should convert"),
3470 ClapMcpToolOutput::Text(text) if text == "done"
3471 ));
3472
3473 let err_result: Result<&str, &str> = Err("boom");
3474 let err = err_result
3475 .into_tool_result()
3476 .expect_err("err result should map to tool error");
3477 assert_eq!(err.message, "boom");
3478
3479 assert_eq!(ClapMcpToolError::from("oops").message, "oops");
3480 assert_eq!(ClapMcpToolError::from(String::from("ouch")).message, "ouch");
3481 assert_eq!(String::from("bad").into_tool_error().message, "bad");
3482 assert_eq!("worse".into_tool_error().message, "worse");
3483 }
3484
3485 #[test]
3486 fn test_merge_captured_stdout_only_changes_text_outputs() {
3487 let merged = merge_captured_stdout(
3488 Ok(ClapMcpToolOutput::Text(String::new())),
3489 "captured only\n".to_string(),
3490 )
3491 .expect("merge should succeed");
3492 assert!(matches!(merged, ClapMcpToolOutput::Text(text) if text == "captured only"));
3493
3494 let appended = merge_captured_stdout(
3495 Ok(ClapMcpToolOutput::Text("returned".to_string())),
3496 "captured\n".to_string(),
3497 )
3498 .expect("append should succeed");
3499 assert!(matches!(appended, ClapMcpToolOutput::Text(text) if text == "returned\ncaptured"));
3500
3501 let structured = merge_captured_stdout(
3502 Ok(ClapMcpToolOutput::Structured(json!({"ok": true}))),
3503 "captured\n".to_string(),
3504 )
3505 .expect("structured output should pass through");
3506 assert!(matches!(structured, ClapMcpToolOutput::Structured(_)));
3507 }
3508
3509 #[test]
3510 fn test_execute_in_process_command_and_handler_cover_capture_stdout_paths() {
3511 let schema = schema_from_command(&ExecCli::command());
3512
3513 let structured = execute_in_process_command::<ExecCli>(
3514 &schema,
3515 "structured",
3516 serde_json::Map::new(),
3517 false,
3518 )
3519 .expect("structured should execute");
3520 assert!(matches!(structured, ClapMcpToolOutput::Structured(_)));
3521
3522 let echo_args = serde_json::Map::from_iter([("value".to_string(), json!("hello"))]);
3523 let handler = make_in_process_handler::<ExecCli>(schema.clone(), false);
3524 let echoed = handler("echo", echo_args).expect("handler should execute");
3525 assert!(matches!(echoed, ClapMcpToolOutput::Text(text) if text == "hello"));
3526
3527 let missing =
3528 execute_in_process_command::<ExecCli>(&schema, "echo", serde_json::Map::new(), false)
3529 .expect_err("missing required arg should fail");
3530 assert!(
3531 missing
3532 .message
3533 .contains("Missing required argument(s): value")
3534 );
3535 }
3536}