1use clap::{Arg, ArgAction, Command};
36use rmcp::model::{Meta, TaskSupport, Tool, ToolExecution};
37use serde::{Deserialize, Serialize};
38use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
39
40mod server;
41
42#[cfg(feature = "http")]
43mod http;
44
45mod serve;
46
47pub use rmcp::model::ErrorData as ClapMcpErrorData;
48
49pub mod logging;
50
51pub mod content;
53
54#[cfg(feature = "derive")]
55pub use clap_mcp_macros::ClapMcp;
56pub use serve::{ServeMcp, ServeMcpBuilder};
57
58#[macro_export]
77macro_rules! clap_mcp_main {
78 ($root:ty, |$args:ident| $run_expr:expr) => {{
79 let $args = <$root as $crate::ParseOrServeMcp>::parse_or_serve_mcp();
80 $run_expr
81 }};
82 ($root:ty, $run_expr:expr) => {{
83 macro_rules! __clap_mcp_with_args {
84 ($args:ident, $expr:expr) => {{
85 let $args = <$root as $crate::ParseOrServeMcp>::parse_or_serve_mcp();
86 $expr
87 }};
88 }
89 __clap_mcp_with_args!(args, $run_expr)
90 }};
91}
92
93pub const MCP_FLAG_LONG: &str = "mcp";
95
96pub const CLAP_MCP_STDIO_FLAG_ID: &str = "clap_mcp_stdio";
98
99#[cfg(feature = "http")]
101pub const CLAP_MCP_HTTP_FLAG_ID: &str = "clap_mcp_http";
102
103pub const CLAP_MCP_EXPORT_SKILLS_FLAG_ID: &str = "clap_mcp_export_skills";
105
106pub(crate) const CLAP_MCP_STDIO_FLAG_ID_LEGACY: &str = "mcp";
108
109#[cfg(feature = "http")]
111pub const MCP_HTTP_FLAG_LONG: &str = "mcp-http";
112
113#[cfg(feature = "http")]
115pub const MCP_HTTP_BIND_ENV: &str = "CLAP_MCP_HTTP_BIND";
116
117#[cfg(feature = "http")]
119pub const MCP_HTTP_LISTEN_ENV: &str = "CLAP_MCP_HTTP_LISTEN";
120
121#[cfg(feature = "http")]
123pub const MCP_HTTP_PORT_ENV: &str = "CLAP_MCP_HTTP_PORT";
124
125pub const EXPORT_SKILLS_FLAG_LONG: &str = "export-skills";
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct ClapMcpBuiltinFlags {
135 pub stdio_long: &'static str,
137 #[cfg(feature = "http")]
139 pub http_long: &'static str,
140 pub export_skills_long: &'static str,
142}
143
144impl Default for ClapMcpBuiltinFlags {
145 fn default() -> Self {
146 Self {
147 stdio_long: MCP_FLAG_LONG,
148 #[cfg(feature = "http")]
149 http_long: MCP_HTTP_FLAG_LONG,
150 export_skills_long: EXPORT_SKILLS_FLAG_LONG,
151 }
152 }
153}
154
155impl ClapMcpBuiltinFlags {
156 pub const fn with_stdio_long(mut self, long: &'static str) -> Self {
158 self.stdio_long = long;
159 self
160 }
161
162 pub const fn with_export_skills_long(mut self, long: &'static str) -> Self {
164 self.export_skills_long = long;
165 self
166 }
167
168 #[cfg(feature = "http")]
170 pub const fn with_http_long(mut self, long: &'static str) -> Self {
171 self.http_long = long;
172 self
173 }
174}
175
176pub const MCP_RESOURCE_URI_SCHEMA: &str = "clap://schema";
178
179pub trait ClapMcpConfigProvider {
203 fn clap_mcp_config() -> ClapMcpConfig;
204}
205
206pub trait ClapMcpSchemaMetadataProvider {
213 fn clap_mcp_schema_metadata() -> ClapMcpSchemaMetadata;
214}
215
216pub trait ClapMcpRunnable {
220 fn run(self) -> String;
221}
222
223#[derive(Debug, Clone)]
229pub struct ClapMcpToolError {
230 pub message: String,
232 pub structured: Option<serde_json::Value>,
234}
235
236impl ClapMcpToolError {
237 pub fn text(message: impl Into<String>) -> Self {
239 Self {
240 message: message.into(),
241 structured: None,
242 }
243 }
244
245 pub fn structured(message: impl Into<String>, value: serde_json::Value) -> Self {
247 Self {
248 message: message.into(),
249 structured: Some(value),
250 }
251 }
252}
253
254impl From<String> for ClapMcpToolError {
255 fn from(s: String) -> Self {
256 Self::text(s)
257 }
258}
259
260impl From<&str> for ClapMcpToolError {
261 fn from(s: &str) -> Self {
262 Self::text(s)
263 }
264}
265
266pub trait IntoClapMcpResult {
278 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError>;
279}
280
281impl IntoClapMcpResult for String {
282 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
283 Ok(ClapMcpToolOutput::Text(self))
284 }
285}
286
287impl IntoClapMcpResult for &str {
288 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
289 Ok(ClapMcpToolOutput::Text(self.to_string()))
290 }
291}
292
293#[derive(Debug, Clone)]
308pub struct AsStructured<T>(pub T);
309
310impl<T: Serialize> IntoClapMcpResult for AsStructured<T> {
311 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
312 serde_json::to_value(&self.0)
313 .map(ClapMcpToolOutput::Structured)
314 .map_err(|e| ClapMcpToolError::text(e.to_string()))
315 }
316}
317
318impl<O: IntoClapMcpResult> IntoClapMcpResult for Option<O> {
319 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
320 match self {
321 None => Ok(ClapMcpToolOutput::Text(String::new())),
322 Some(o) => o.into_tool_result(),
323 }
324 }
325}
326
327pub trait IntoClapMcpToolError {
333 fn into_tool_error(self) -> ClapMcpToolError;
334}
335
336impl IntoClapMcpToolError for String {
337 fn into_tool_error(self) -> ClapMcpToolError {
338 ClapMcpToolError::text(self)
339 }
340}
341
342impl IntoClapMcpToolError for &str {
343 fn into_tool_error(self) -> ClapMcpToolError {
344 ClapMcpToolError::text(self.to_string())
345 }
346}
347
348impl<O: IntoClapMcpResult, E: IntoClapMcpToolError> IntoClapMcpResult for Result<O, E> {
349 fn into_tool_result(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError> {
350 match self {
351 Ok(o) => o.into_tool_result(),
352 Err(e) => Err(e.into_tool_error()),
353 }
354 }
355}
356
357#[cfg(unix)]
360fn run_with_stdout_capture<R, F>(f: F) -> (R, String)
361where
362 F: FnOnce() -> R,
363{
364 use std::io::{Read, Write};
365 use std::os::unix::io::FromRawFd;
366
367 let mut fds: [libc::c_int; 2] = [0, 0];
372 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
373 return (f(), String::new());
374 }
375 let (read_fd, write_fd) = (fds[0], fds[1]);
376
377 let stdout_fd = libc::STDOUT_FILENO;
378 let saved_stdout = unsafe { libc::dup(stdout_fd) };
379 if saved_stdout < 0 {
380 unsafe {
381 libc::close(read_fd);
382 libc::close(write_fd);
383 }
384 return (f(), String::new());
385 }
386
387 if unsafe { libc::dup2(write_fd, stdout_fd) } < 0 {
388 unsafe {
389 libc::close(saved_stdout);
390 libc::close(read_fd);
391 libc::close(write_fd);
392 }
393 return (f(), String::new());
394 }
395
396 let result = f();
397
398 let _ = std::io::stdout().flush();
399 unsafe {
400 libc::dup2(saved_stdout, stdout_fd);
401 libc::close(saved_stdout);
402 libc::close(write_fd);
403 }
404
405 let mut reader = unsafe { std::fs::File::from_raw_fd(read_fd) };
406 let mut captured = String::new();
407 let _ = reader.read_to_string(&mut captured);
408
409 (result, captured)
410}
411
412#[cfg(not(unix))]
413fn run_with_stdout_capture<R, F>(f: F) -> (R, String)
414where
415 F: FnOnce() -> R,
416{
417 (f(), String::new())
418}
419
420#[derive(Debug, Clone)]
438pub enum ClapMcpToolOutput {
439 Text(String),
441 Structured(serde_json::Value),
443}
444
445impl ClapMcpToolOutput {
446 pub fn into_string(self) -> String {
457 match self {
458 ClapMcpToolOutput::Text(s) => s,
459 ClapMcpToolOutput::Structured(v) => {
460 serde_json::to_string(&v).unwrap_or_else(|_| v.to_string())
461 }
462 }
463 }
464
465 pub fn as_text(&self) -> Option<&str> {
476 match self {
477 ClapMcpToolOutput::Text(s) => Some(s),
478 ClapMcpToolOutput::Structured(_) => None,
479 }
480 }
481
482 pub fn as_structured(&self) -> Option<&serde_json::Value> {
494 match self {
495 ClapMcpToolOutput::Text(_) => None,
496 ClapMcpToolOutput::Structured(v) => Some(v),
497 }
498 }
499}
500
501pub trait ClapMcpToolExecutor {
509 fn execute_for_mcp(self) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError>;
510}
511
512pub trait ClapMcpToolExecutorWithState {
571 type State: Send + Sync + 'static;
573
574 fn execute_for_mcp_with_state(
576 self,
577 state: &Self::State,
578 ) -> std::result::Result<ClapMcpToolOutput, ClapMcpToolError>;
579}
580
581impl<T: ClapMcpToolExecutor> ClapMcpRunnable for T {
582 fn run(self) -> String {
583 self.execute_for_mcp()
584 .unwrap_or_else(|e| ClapMcpToolOutput::Text(e.message))
585 .into_string()
586 }
587}
588
589#[derive(Debug, thiserror::Error)]
591pub enum ClapMcpError {
592 #[error("invalid MCP configuration: {0}")]
593 InvalidConfig(String),
594 #[error("multi-thread tokio runtime required: {reason}")]
597 RequiresMultiThreadRuntime { reason: String },
598 #[error("failed to serialize clap schema to JSON: {0}")]
599 SchemaJson(#[from] serde_json::Error),
600 #[error("MCP service error: {0}")]
601 Mcp(#[from] Box<rmcp::RmcpError>),
602 #[error("MCP service runtime error: {0}")]
603 Service(#[from] rmcp::ServiceError),
604 #[error("MCP join error: {0}")]
605 Join(#[from] tokio::task::JoinError),
606 #[error("I/O error during skill export: {0}")]
607 Io(#[from] std::io::Error),
608 #[error("tokio runtime context: {0}")]
609 RuntimeContext(String),
610 #[error("async tool thread panicked or failed: {0}")]
611 ToolThread(String),
612}
613
614#[derive(Debug, Clone)]
646pub struct ClapMcpConfig {
647 pub reinvocation_safe: bool,
651
652 pub parallel_safe: bool,
655
656 pub share_runtime: bool,
666
667 pub catch_in_process_panics: bool,
674
675 pub allow_mcp_without_subcommand: bool,
683
684 pub builtin_flags: ClapMcpBuiltinFlags,
686}
687
688impl Default for ClapMcpConfig {
689 fn default() -> Self {
690 Self {
691 reinvocation_safe: false,
692 parallel_safe: false,
693 share_runtime: false,
694 catch_in_process_panics: false,
695 allow_mcp_without_subcommand: true,
696 builtin_flags: ClapMcpBuiltinFlags::default(),
697 }
698 }
699}
700
701impl ClapMcpConfig {
702 pub fn needs_multi_thread_runtime(&self) -> bool {
709 self.reinvocation_safe && (self.share_runtime || self.parallel_safe)
710 }
711}
712
713pub(crate) fn build_mcp_blocking_runtime(
714 config: &ClapMcpConfig,
715) -> Result<tokio::runtime::Runtime, ClapMcpError> {
716 let rt = if config.needs_multi_thread_runtime() {
717 tokio::runtime::Builder::new_multi_thread()
718 } else {
719 tokio::runtime::Builder::new_current_thread()
720 }
721 .enable_all()
722 .build()?;
723 Ok(rt)
724}
725
726#[derive(Debug)]
728pub struct ClapMcpRunOptions {
729 pub config: ClapMcpConfig,
730 pub serve: ClapMcpServeOptions,
731}
732
733impl ClapMcpRunOptions {
734 pub fn from_config(config: ClapMcpConfig) -> Self {
736 Self {
737 config,
738 serve: ClapMcpServeOptions::default(),
739 }
740 }
741}
742
743impl From<ClapMcpConfig> for ClapMcpRunOptions {
744 fn from(config: ClapMcpConfig) -> Self {
745 Self::from_config(config)
746 }
747}
748
749#[derive(Debug, Clone, Copy)]
753pub enum McpListen {
754 Stdio,
756 #[cfg(feature = "http")]
758 Http(std::net::SocketAddr),
759}
760
761#[derive(Debug, Default)]
778pub struct ClapMcpServeOptions {
779 pub log_rx: Option<tokio::sync::mpsc::Receiver<logging::LoggingMessageNotificationParams>>,
782
783 #[cfg(unix)]
788 pub capture_stdout: bool,
789
790 pub custom_resources: Vec<content::CustomResource>,
792
793 pub custom_prompts: Vec<content::CustomPrompt>,
795}
796
797pub const LOG_INTERPRETATION_INSTRUCTIONS: &str = r#"When this server emits log messages (notifications/message), the `logger` field indicates the source:
802- "stderr": Subprocess stderr (CLI tools run as subprocesses)
803- "app": In-process application logs
804- Other: Application-defined logger names"#;
805
806pub const PROMPT_LOGGING_GUIDE: &str = "clap-mcp-logging-guide";
808
809pub const LOGGING_GUIDE_CONTENT: &str = r#"# clap-mcp Logging Guide
814
815When this server emits log messages (notifications/message), use the `logger` field to interpret the source:
816
817- **"stderr"**: Output from subprocess stderr (CLI tools run as subprocesses). The `meta` field may include `tool` for the command name.
818- **"app"**: In-process application logs.
819- **Other**: Application-defined logger names.
820
821The `level` field uses RFC 5424 syslog severity: debug, info, notice, warning, error, critical, alert, emergency.
822The `data` field contains the message (string or JSON object)."#;
823
824#[derive(Debug, Clone, Default)]
844pub struct ClapMcpSchemaMetadata {
845 pub skip_commands: Vec<String>,
847 pub skip_args: std::collections::HashMap<String, Vec<String>>,
849 pub requires_args: std::collections::HashMap<String, Vec<String>>,
851 pub skip_root_command_when_subcommands: bool,
855 pub task_tool_names: Vec<String>,
860 pub task_augmented_tools: bool,
863 pub output_schema: Option<serde_json::Value>,
867 pub serialize_tools: std::collections::HashMap<String, ClapMcpSerializeScope>,
871 pub serialize_topic_args: std::collections::HashMap<
876 String,
877 std::collections::HashMap<String, SerializeTopicSegmentFn>,
878 >,
879}
880
881impl ClapMcpSchemaMetadata {
882 pub fn merge_from(&mut self, other: Self) {
887 self.skip_commands.extend(other.skip_commands);
888 for (k, v) in other.skip_args {
889 self.skip_args.entry(k).or_default().extend(v);
890 }
891 for (k, v) in other.requires_args {
892 self.requires_args.entry(k).or_default().extend(v);
893 }
894 self.task_tool_names.extend(other.task_tool_names);
895 self.task_augmented_tools = self.task_augmented_tools || other.task_augmented_tools;
896 self.skip_root_command_when_subcommands |= other.skip_root_command_when_subcommands;
897 for (k, v) in other.serialize_tools {
898 self.serialize_tools.insert(k, v);
899 }
900 for (tool, args) in other.serialize_topic_args {
901 let entry = self.serialize_topic_args.entry(tool).or_default();
902 for (arg, f) in args {
903 entry.insert(arg, f);
904 }
905 }
906 if other.output_schema.is_some() {
907 self.output_schema = other.output_schema;
908 }
909 }
910}
911
912#[doc(hidden)]
914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
915pub enum FlattenSkipKind {
916 Args,
918 Subcommand,
920}
921
922fn collect_flatten_subcommand_names(cmd: &clap::Command, out: &mut Vec<String>) {
923 for sub in cmd.get_subcommands() {
924 out.push(sub.get_name().to_string());
925 collect_flatten_subcommand_names(sub, out);
926 }
927}
928
929#[doc(hidden)]
933pub fn apply_flatten_args_field_skip<T: clap::Args>(
934 skip_commands: &mut Vec<String>,
935 skip_args: &mut std::collections::HashMap<String, Vec<String>>,
936 root_command: &str,
937 explicit: Option<&[String]>,
938 run_bare_probe: bool,
939) {
940 let _ = skip_commands;
941 if run_bare_probe {
942 let probe = T::augment_args(clap::Command::new("_clap_mcp_skip_probe"));
943 let collected: Vec<String> = probe
944 .get_arguments()
945 .map(|a| a.get_id().as_str().to_string())
946 .collect();
947 skip_args
948 .entry(root_command.to_string())
949 .or_default()
950 .extend(collected);
951 }
952 if let Some(ids) = explicit {
953 skip_args
954 .entry(root_command.to_string())
955 .or_default()
956 .extend(ids.iter().cloned());
957 }
958}
959
960#[doc(hidden)]
964pub fn apply_flatten_subcommand_field_skip<T: clap::Subcommand>(
965 skip_commands: &mut Vec<String>,
966 skip_args: &mut std::collections::HashMap<String, Vec<String>>,
967 root_command: &str,
968 explicit: Option<&[String]>,
969 run_bare_probe: bool,
970) {
971 let _ = (skip_args, root_command);
972 if run_bare_probe {
973 let probe = T::augment_subcommands(clap::Command::new("_clap_mcp_skip_probe"));
974 let mut names = Vec::new();
975 collect_flatten_subcommand_names(&probe, &mut names);
976 skip_commands.extend(names);
977 }
978 if let Some(ids) = explicit {
979 skip_commands.extend(ids.iter().cloned());
980 }
981}
982
983pub trait ClapMcpFlattenArgsTopics {
987 const FIELD_IDS: &'static [&'static str];
989
990 const NESTED_FIELD_IDS: &'static [&'static str] = &[];
992
993 fn merge_serialize_topics(
995 tool_name: &str,
996 target: &mut std::collections::HashMap<
997 String,
998 std::collections::HashMap<String, SerializeTopicSegmentFn>,
999 >,
1000 );
1001}
1002
1003const fn str_eq_const(a: &str, b: &str) -> bool {
1004 let a = a.as_bytes();
1005 let b = b.as_bytes();
1006 if a.len() != b.len() {
1007 return false;
1008 }
1009 let mut i = 0usize;
1010 while i < a.len() {
1011 if a[i] != b[i] {
1012 return false;
1013 }
1014 i += 1;
1015 }
1016 true
1017}
1018
1019const fn ids_contain(ids: &[&str], arg: &str) -> bool {
1021 let mut i = 0usize;
1022 while i < ids.len() {
1023 if str_eq_const(ids[i], arg) {
1024 return true;
1025 }
1026 i += 1;
1027 }
1028 false
1029}
1030
1031#[doc(hidden)]
1035pub const fn flatten_args_contains_field<T: ClapMcpFlattenArgsTopics>(arg: &str) -> bool {
1036 ids_contain(T::FIELD_IDS, arg) || ids_contain(T::NESTED_FIELD_IDS, arg)
1037}
1038
1039#[doc(hidden)]
1043pub const fn assert_serialized_in_any_flatten_args(arg: &str, checks: &[bool]) {
1044 let mut i = 0usize;
1045 while i < checks.len() {
1046 if checks[i] {
1047 return;
1048 }
1049 i += 1;
1050 }
1051 let _ = arg;
1052 panic!("serialized arg is not a field on any flattened Args type for this variant");
1053}
1054
1055pub type SerializeTopicSegmentFn = fn(value: &serde_json::Value) -> Option<String>;
1057
1058pub trait ClapMcpSerializeTopic {
1071 fn serialize_topic_segment(value: &serde_json::Value) -> Option<String>;
1074}
1075
1076#[macro_export]
1078macro_rules! impl_serialize_topic_hash_eq {
1079 ($ty:ty) => {
1080 impl $crate::ClapMcpSerializeTopic for $ty {
1081 fn serialize_topic_segment(value: &serde_json::Value) -> Option<String> {
1082 use std::hash::{Hash, Hasher};
1083 let parsed: $ty = serde_json::from_value(value.clone()).ok()?;
1084 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1085 parsed.hash(&mut hasher);
1086 Some(format!("h:{}", hasher.finish()))
1087 }
1088 }
1089 };
1090}
1091
1092#[macro_export]
1095macro_rules! impl_serialize_topic_serde_eq {
1096 ($ty:ty) => {
1097 impl $crate::ClapMcpSerializeTopic for $ty {
1098 fn serialize_topic_segment(value: &serde_json::Value) -> Option<String> {
1099 let parsed: $ty = serde_json::from_value(value.clone()).ok()?;
1100 serde_json::to_string(&parsed).ok()
1101 }
1102 }
1103 };
1104}
1105
1106impl_serialize_topic_serde_eq!(String);
1107impl_serialize_topic_serde_eq!(bool);
1108impl_serialize_topic_serde_eq!(i32);
1109impl_serialize_topic_serde_eq!(i64);
1110impl_serialize_topic_serde_eq!(u32);
1111impl_serialize_topic_serde_eq!(u64);
1112
1113impl<T> ClapMcpSerializeTopic for Option<T>
1114where
1115 T: ClapMcpSerializeTopic,
1116{
1117 fn serialize_topic_segment(value: &serde_json::Value) -> Option<String> {
1118 if value.is_null() {
1119 return None;
1120 }
1121 T::serialize_topic_segment(value)
1122 }
1123}
1124
1125#[derive(Debug, Clone, PartialEq, Eq)]
1126pub enum ClapMcpSerializeScope {
1127 Tool,
1129 Args(Vec<String>),
1131}
1132
1133pub(crate) fn tool_task_eligible(tool_name: &str, metadata: &ClapMcpSchemaMetadata) -> bool {
1134 if !metadata.task_augmented_tools {
1135 return false;
1136 }
1137 if metadata.task_tool_names.is_empty() {
1138 true
1139 } else {
1140 metadata.task_tool_names.iter().any(|n| n == tool_name)
1141 }
1142}
1143
1144#[cfg(feature = "output-schema")]
1147pub fn output_schema_for_type<T: schemars::JsonSchema>() -> Option<serde_json::Value> {
1148 serde_json::to_value(schemars::schema_for!(T)).ok()
1149}
1150
1151#[cfg(not(feature = "output-schema"))]
1152pub fn output_schema_for_type<T>() -> Option<serde_json::Value> {
1153 let _ = std::marker::PhantomData::<T>;
1154 None
1155}
1156
1157#[macro_export]
1161macro_rules! output_schema_one_of {
1162 ($($T:ty),+ $(,)?) => {{
1163 #[cfg(feature = "output-schema")]
1164 {
1165 let mut one_of = vec![];
1166 $( one_of.push(serde_json::to_value(&schemars::schema_for!($T)).unwrap()); )+
1167 Some(serde_json::json!({ "oneOf": one_of }))
1168 }
1169 #[cfg(not(feature = "output-schema"))]
1170 {
1171 None::<serde_json::Value>
1172 }
1173 }};
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize)]
1179pub struct ClapSchema {
1180 pub root: ClapCommand,
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1205pub struct ClapArgGroup {
1206 pub id: String,
1208 pub args: Vec<String>,
1210 pub required: bool,
1212 pub multiple: bool,
1214}
1215
1216#[derive(Debug, Clone, Serialize, Deserialize)]
1218pub struct ClapCommand {
1219 pub name: String,
1220 pub about: Option<String>,
1221 pub long_about: Option<String>,
1222 pub version: Option<String>,
1223 pub args: Vec<ClapArg>,
1224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1230 pub arg_groups: Vec<ClapArgGroup>,
1231 pub subcommands: Vec<ClapCommand>,
1232}
1233
1234impl ClapCommand {
1235 pub fn all_commands(&self) -> Vec<&ClapCommand> {
1237 let mut out = Vec::new();
1238 fn walk<'a>(cmd: &'a ClapCommand, acc: &mut Vec<&'a ClapCommand>) {
1239 acc.push(cmd);
1240 for sub in &cmd.subcommands {
1241 walk(sub, acc);
1242 }
1243 }
1244 walk(self, &mut out);
1245 out
1246 }
1247}
1248
1249pub fn argv_before_end_of_opts(args: &[String]) -> &[String] {
1251 match args.iter().position(|a| a == "--") {
1252 Some(i) => &args[..i],
1253 None => args,
1254 }
1255}
1256
1257pub fn argv_contains_clap_mcp_flags(args: &[String], flags: &ClapMcpBuiltinFlags) -> bool {
1265 let prefix = argv_before_end_of_opts(args);
1266 if argv_has_long_flag(prefix, flags.stdio_long) {
1267 return true;
1268 }
1269 if argv_export_skills_dir_from_args(args, flags).is_some() {
1270 return true;
1271 }
1272 #[cfg(feature = "http")]
1273 if argv_has_long_flag(prefix, flags.http_long) {
1274 return true;
1275 }
1276 false
1277}
1278
1279fn argv_has_long_flag(prefix: &[String], long: &str) -> bool {
1280 let flag = format!("--{long}");
1281 let flag_eq_prefix = format!("--{long}=");
1282 prefix
1283 .iter()
1284 .any(|a| a.as_str() == flag || a.starts_with(&flag_eq_prefix))
1285}
1286
1287fn command_has_arg_id_or_long(cmd: &Command, id: &str, long: &str) -> bool {
1288 cmd.get_arguments().any(|a| {
1289 a.get_id() == id
1290 || a.get_id() == CLAP_MCP_STDIO_FLAG_ID_LEGACY && long == MCP_FLAG_LONG
1291 || a.get_long().is_some_and(|l| l == long)
1292 })
1293}
1294
1295pub(crate) fn matches_stdio_flag(matches: &clap::ArgMatches, _flags: &ClapMcpBuiltinFlags) -> bool {
1296 matches.get_flag(CLAP_MCP_STDIO_FLAG_ID)
1297}
1298
1299#[cfg(feature = "http")]
1300pub(crate) fn matches_http_flag(matches: &clap::ArgMatches, flags: &ClapMcpBuiltinFlags) -> bool {
1301 matches.contains_id(CLAP_MCP_HTTP_FLAG_ID)
1302 || (flags.http_long == MCP_HTTP_FLAG_LONG && matches.contains_id(MCP_HTTP_FLAG_LONG))
1303}
1304
1305pub(crate) fn is_builtin_arg(id: &str) -> bool {
1307 is_clap_mcp_builtin_arg_id(id)
1308}
1309
1310pub(crate) fn is_clap_mcp_builtin_arg_id(id: &str) -> bool {
1311 matches!(
1312 id,
1313 "help"
1314 | "version"
1315 | CLAP_MCP_STDIO_FLAG_ID
1316 | CLAP_MCP_EXPORT_SKILLS_FLAG_ID
1317 | EXPORT_SKILLS_FLAG_LONG
1318 ) || {
1319 #[cfg(feature = "http")]
1320 {
1321 id == CLAP_MCP_HTTP_FLAG_ID || id == MCP_HTTP_FLAG_LONG
1322 }
1323 #[cfg(not(feature = "http"))]
1324 {
1325 let _ = id;
1326 false
1327 }
1328 }
1329}
1330
1331fn is_omitted_schema_clap_arg(arg: &clap::Arg) -> bool {
1332 let id = arg.get_id().as_str();
1333 is_clap_mcp_builtin_arg_id(id)
1334}
1335
1336fn mcp_visible_arg_ids_on_command(
1342 cmd: &Command,
1343 metadata: &ClapMcpSchemaMetadata,
1344) -> std::collections::HashSet<String> {
1345 let cmd_name = cmd.get_name().to_string();
1346 let skip_args: std::collections::HashSet<_> = metadata
1347 .skip_args
1348 .get(&cmd_name)
1349 .map(|v| v.iter().cloned().collect())
1350 .unwrap_or_default();
1351
1352 cmd.get_arguments()
1353 .filter(|a| !is_omitted_schema_clap_arg(a))
1354 .map(|a| a.get_id().to_string())
1355 .filter(|id| !skip_args.contains(id))
1356 .collect()
1357}
1358
1359fn extract_arg_groups(cmd: &Command, metadata: &ClapMcpSchemaMetadata) -> Vec<ClapArgGroup> {
1360 let visible = mcp_visible_arg_ids_on_command(cmd, metadata);
1361 let mut built = cmd.clone();
1362 built.build();
1363 let mut groups = Vec::new();
1364 for group in built.get_groups() {
1365 let mut args: Vec<String> = group
1366 .get_args()
1367 .map(|id| id.to_string())
1368 .filter(|id| visible.contains(id))
1369 .collect();
1370 args.sort();
1371 if args.len() < 2 {
1372 continue;
1373 }
1374 let required = group.is_required_set();
1375 let multiple = {
1376 let mut g = group.clone();
1377 g.is_multiple()
1378 };
1379 groups.push(ClapArgGroup {
1380 id: group.get_id().to_string(),
1381 args,
1382 required,
1383 multiple,
1384 });
1385 }
1386 groups.sort_by(|a, b| a.id.cmp(&b.id));
1387 groups
1388}
1389
1390fn format_arg_groups_description_suffix(groups: &[ClapArgGroup]) -> Option<String> {
1391 if groups.is_empty() {
1392 return None;
1393 }
1394 let parts: Vec<String> = groups
1395 .iter()
1396 .map(|g| {
1397 let args_list = g
1398 .args
1399 .iter()
1400 .map(|a| format!("`{a}`"))
1401 .collect::<Vec<_>>()
1402 .join(", ");
1403 let constraint = if g.required {
1404 "requires one of"
1405 } else {
1406 "at most one of"
1407 };
1408 let mut s = format!("`{}` {constraint}: {args_list}", g.id);
1409 if g.multiple {
1410 s.push_str(" (multiple allowed)");
1411 }
1412 s
1413 })
1414 .collect();
1415 Some(format!("Arg groups (parse-time): {}.", parts.join("; ")))
1416}
1417
1418pub fn tools_from_schema_with_metadata(
1426 schema: &ClapSchema,
1427 config: &ClapMcpConfig,
1428 metadata: &ClapMcpSchemaMetadata,
1429) -> Vec<Tool> {
1430 let commands: Vec<&ClapCommand> =
1431 if metadata.skip_root_command_when_subcommands && !schema.root.subcommands.is_empty() {
1432 schema
1433 .root
1434 .subcommands
1435 .iter()
1436 .flat_map(|c| c.all_commands())
1437 .collect()
1438 } else {
1439 schema.root.all_commands()
1440 };
1441 commands
1442 .into_iter()
1443 .map(|cmd| {
1444 command_to_tool_with_config(
1445 schema,
1446 cmd,
1447 config,
1448 metadata,
1449 metadata.output_schema.as_ref(),
1450 )
1451 })
1452 .collect()
1453}
1454
1455fn effective_args_for_tool(schema: &ClapSchema, command_name: &str) -> Vec<ClapArg> {
1457 let Some(path) = command_path(schema, command_name) else {
1458 return Vec::new();
1459 };
1460 let mut by_id: BTreeMap<String, ClapArg> = BTreeMap::new();
1461 for depth in 0..path.len() {
1462 let subpath = &path[..=depth];
1463 let Some(cmd) = command_at_path(&schema.root, subpath) else {
1464 continue;
1465 };
1466 let is_leaf = depth + 1 == path.len();
1467 for arg in &cmd.args {
1468 if is_builtin_arg(arg.id.as_str()) {
1469 continue;
1470 }
1471 if is_leaf || arg.global {
1472 by_id.insert(arg.id.clone(), arg.clone());
1473 }
1474 }
1475 }
1476 by_id.into_values().collect()
1477}
1478
1479fn command_at_path<'a>(root: &'a ClapCommand, path: &[String]) -> Option<&'a ClapCommand> {
1480 if path.is_empty() || root.name != path[0] {
1481 return None;
1482 }
1483 let mut current = root;
1484 for segment in path.iter().skip(1) {
1485 current = current.subcommands.iter().find(|c| c.name == *segment)?;
1486 }
1487 Some(current)
1488}
1489
1490fn command_to_tool_with_config(
1491 schema: &ClapSchema,
1492 cmd: &ClapCommand,
1493 config: &ClapMcpConfig,
1494 metadata: &ClapMcpSchemaMetadata,
1495 output_schema: Option<&serde_json::Value>,
1496) -> Tool {
1497 let effective_args = effective_args_for_tool(schema, &cmd.name);
1498
1499 let mut properties: BTreeMap<String, serde_json::Map<String, serde_json::Value>> =
1500 BTreeMap::new();
1501 for arg in &effective_args {
1502 let mut prop = serde_json::Map::new();
1503 let (json_type, items) = mcp_type_for_arg(arg);
1504 prop.insert("type".to_string(), json_type);
1505 if let Some(items) = items {
1506 prop.insert("items".to_string(), items);
1507 }
1508 let desc = arg
1509 .long_help
1510 .as_deref()
1511 .or(arg.help.as_deref())
1512 .map(String::from);
1513 let mut desc = desc.unwrap_or_default();
1514 if let Some(hint) = mcp_action_description_hint(arg) {
1515 desc.push_str(&hint);
1516 }
1517 if !desc.is_empty() {
1518 prop.insert("description".to_string(), serde_json::Value::String(desc));
1519 }
1520 properties.insert(arg.id.clone(), prop);
1521 }
1522
1523 let required: Vec<String> = effective_args
1524 .iter()
1525 .filter(|a| a.required)
1526 .map(|a| a.id.clone())
1527 .collect();
1528
1529 let mut input_schema = serde_json::Map::new();
1530 input_schema.insert("type".into(), serde_json::json!("object"));
1531 input_schema.insert(
1532 "properties".into(),
1533 serde_json::Value::Object(
1534 properties
1535 .into_iter()
1536 .map(|(k, v)| (k, serde_json::Value::Object(v)))
1537 .collect(),
1538 ),
1539 );
1540 if !required.is_empty() {
1541 input_schema.insert(
1542 "required".into(),
1543 serde_json::Value::Array(
1544 required
1545 .into_iter()
1546 .map(serde_json::Value::String)
1547 .collect(),
1548 ),
1549 );
1550 }
1551
1552 let mut description = cmd
1553 .long_about
1554 .as_deref()
1555 .or(cmd.about.as_deref())
1556 .map(String::from);
1557 if let Some(suffix) = format_arg_groups_description_suffix(&cmd.arg_groups) {
1558 description = Some(match description {
1559 Some(mut d) => {
1560 if !d.is_empty() {
1561 d.push(' ');
1562 }
1563 d.push_str(&suffix);
1564 d
1565 }
1566 None => suffix,
1567 });
1568 }
1569 let title = cmd.about.as_ref().map(String::from);
1570
1571 let meta = {
1572 let mut clap_mcp = serde_json::Map::new();
1573 clap_mcp.insert(
1574 "reinvocationSafe".into(),
1575 serde_json::Value::Bool(config.reinvocation_safe),
1576 );
1577 clap_mcp.insert(
1578 "parallelSafe".into(),
1579 serde_json::Value::Bool(config.parallel_safe),
1580 );
1581 clap_mcp.insert(
1582 "shareRuntime".into(),
1583 serde_json::Value::Bool(config.share_runtime),
1584 );
1585 if tool_task_eligible(&cmd.name, metadata) {
1586 clap_mcp.insert("taskAugmented".into(), serde_json::Value::Bool(true));
1587 }
1588 if let Some(scope) = metadata.serialize_tools.get(&cmd.name) {
1589 clap_mcp.insert("serialized".into(), serde_json::Value::Bool(true));
1590 match scope {
1591 ClapMcpSerializeScope::Tool => {
1592 clap_mcp.insert(
1593 "serializeScope".into(),
1594 serde_json::Value::String("tool".into()),
1595 );
1596 }
1597 ClapMcpSerializeScope::Args(arg_ids) => {
1598 clap_mcp.insert(
1599 "serializeScope".into(),
1600 serde_json::Value::String("args".into()),
1601 );
1602 clap_mcp.insert(
1603 "serializeArgs".into(),
1604 serde_json::Value::Array(
1605 arg_ids
1606 .iter()
1607 .cloned()
1608 .map(serde_json::Value::String)
1609 .collect(),
1610 ),
1611 );
1612 if let Some(topic_args) = metadata.serialize_topic_args.get(&cmd.name) {
1613 let ids: Vec<_> = topic_args.keys().cloned().collect();
1614 if !ids.is_empty() {
1615 clap_mcp.insert(
1616 "serializeTopicArgs".into(),
1617 serde_json::Value::Array(
1618 ids.into_iter().map(serde_json::Value::String).collect(),
1619 ),
1620 );
1621 }
1622 }
1623 }
1624 }
1625 }
1626 if !cmd.arg_groups.is_empty()
1627 && let Ok(value) = serde_json::to_value(&cmd.arg_groups)
1628 {
1629 clap_mcp.insert("argGroups".into(), value);
1630 }
1631 let mut m = Meta::default();
1632 m.0.insert("clapMcp".into(), serde_json::Value::Object(clap_mcp));
1633 Some(m)
1634 };
1635
1636 let execution = if tool_task_eligible(&cmd.name, metadata) {
1637 Some(ToolExecution::from_raw(Some(TaskSupport::Optional)))
1638 } else {
1639 None
1640 };
1641
1642 let mut tool = Tool::new_with_raw(
1643 cmd.name.clone(),
1644 description.map(|d| d.into()),
1645 Arc::new(input_schema),
1646 );
1647 if let Some(title) = title {
1648 tool = tool.with_title(title);
1649 }
1650 if let Some(meta) = meta {
1651 tool = tool.with_meta(meta);
1652 }
1653 if let Some(execution) = execution {
1654 tool = tool.with_execution(execution);
1655 }
1656 if let Some(output_schema) = output_schema.cloned().and_then(|v| v.as_object().cloned()) {
1657 tool = tool.with_raw_output_schema(Arc::new(output_schema));
1658 }
1659 tool
1660}
1661
1662#[derive(Debug, Clone, Serialize, Deserialize)]
1664pub struct ClapArg {
1665 pub id: String,
1666 pub long: Option<String>,
1667 pub short: Option<char>,
1668 pub help: Option<String>,
1669 pub long_help: Option<String>,
1670 pub required: bool,
1671 pub global: bool,
1672 pub index: Option<usize>,
1673 pub action: Option<String>,
1674 pub value_names: Vec<String>,
1675 pub num_args: Option<String>,
1676}
1677
1678fn mcp_type_for_arg(arg: &ClapArg) -> (serde_json::Value, Option<serde_json::Value>) {
1687 let action = arg.action.as_deref().unwrap_or("Set");
1688 let is_multi = matches!(action, "Append")
1689 || arg
1690 .num_args
1691 .as_deref()
1692 .is_some_and(|n| n.contains("..") && !n.contains("=1"));
1693 let (json_type, items) = if matches!(action, "SetTrue" | "SetFalse") {
1694 (serde_json::json!("boolean"), None)
1695 } else if action == "Count" {
1696 (serde_json::json!("integer"), None)
1697 } else if is_multi {
1698 let item_desc = arg
1699 .value_names
1700 .first()
1701 .map(|name| format!("A {} value", name));
1702 let items_schema = match item_desc {
1703 Some(desc) => serde_json::json!({ "type": "string", "description": desc }),
1704 None => serde_json::json!({ "type": "string" }),
1705 };
1706 (serde_json::json!("array"), Some(items_schema))
1707 } else {
1708 (serde_json::json!("string"), None)
1709 };
1710 (json_type, items)
1711}
1712
1713fn mcp_action_description_hint(arg: &ClapArg) -> Option<String> {
1715 let action = arg.action.as_deref()?;
1716 let hint: String = match action {
1717 "SetTrue" => " Boolean flag: set to true to pass this flag.".into(),
1718 "SetFalse" => " Boolean flag: set to false to pass this flag (e.g. --no-xxx).".into(),
1719 "Count" => " Number of times the flag is passed (e.g. -vvv).".into(),
1720 "Append" => {
1721 if let Some(name) = arg.value_names.first() {
1722 format!(
1723 " List of {} values; pass a JSON array (e.g. [\"a\", \"b\"]).",
1724 name
1725 )
1726 } else {
1727 " List of values; pass a JSON array (e.g. [\"a\", \"b\"]).".into()
1728 }
1729 }
1730 _ => return None,
1731 };
1732 Some(hint)
1733}
1734
1735pub fn command_with_mcp_flag(cmd: Command) -> Command {
1751 command_with_mcp_flag_with_flags(cmd, &ClapMcpBuiltinFlags::default())
1752}
1753
1754pub fn command_with_mcp_flag_with_flags(mut cmd: Command, flags: &ClapMcpBuiltinFlags) -> Command {
1756 if command_has_arg_id_or_long(&cmd, CLAP_MCP_STDIO_FLAG_ID, flags.stdio_long) {
1757 return cmd;
1758 }
1759
1760 cmd = cmd.arg(
1761 Arg::new(CLAP_MCP_STDIO_FLAG_ID)
1762 .long(flags.stdio_long)
1763 .help("Run an MCP server over stdio that exposes this CLI's clap schema")
1764 .action(ArgAction::SetTrue)
1765 .global(true),
1766 );
1767
1768 cmd
1769}
1770
1771pub fn command_with_export_skills_flag(cmd: Command) -> Command {
1786 command_with_export_skills_flag_with_flags(cmd, &ClapMcpBuiltinFlags::default())
1787}
1788
1789pub fn command_with_export_skills_flag_with_flags(
1791 mut cmd: Command,
1792 flags: &ClapMcpBuiltinFlags,
1793) -> Command {
1794 if command_has_arg_id_or_long(
1795 &cmd,
1796 CLAP_MCP_EXPORT_SKILLS_FLAG_ID,
1797 flags.export_skills_long,
1798 ) {
1799 return cmd;
1800 }
1801
1802 cmd = cmd.arg(
1803 Arg::new(CLAP_MCP_EXPORT_SKILLS_FLAG_ID)
1804 .long(flags.export_skills_long)
1805 .value_name("DIR")
1806 .help("Generate Agent Skills (SKILL.md) from tools, resources, and prompts, then exit")
1807 .action(ArgAction::Set)
1808 .required(false)
1809 .global(true),
1810 );
1811
1812 cmd
1813}
1814
1815pub fn command_with_mcp_and_export_skills_flags(cmd: Command) -> Command {
1818 command_with_mcp_and_export_skills_flags_with_flags(cmd, &ClapMcpBuiltinFlags::default())
1819}
1820
1821pub fn command_with_mcp_and_export_skills_flags_with_flags(
1823 mut cmd: Command,
1824 flags: &ClapMcpBuiltinFlags,
1825) -> Command {
1826 cmd = command_with_mcp_flag_with_flags(cmd, flags);
1827 #[cfg(feature = "http")]
1828 {
1829 cmd = command_with_mcp_http_flag_with_flags(cmd, flags);
1830 }
1831 command_with_export_skills_flag_with_flags(cmd, flags)
1832}
1833
1834#[cfg(feature = "http")]
1835pub fn command_with_mcp_http_flag(cmd: Command) -> Command {
1836 command_with_mcp_http_flag_with_flags(cmd, &ClapMcpBuiltinFlags::default())
1837}
1838
1839#[cfg(feature = "http")]
1840pub fn command_with_mcp_http_flag_with_flags(
1841 mut cmd: Command,
1842 flags: &ClapMcpBuiltinFlags,
1843) -> Command {
1844 if command_has_arg_id_or_long(&cmd, CLAP_MCP_HTTP_FLAG_ID, flags.http_long) {
1845 return cmd;
1846 }
1847
1848 cmd = cmd.arg(
1849 Arg::new(CLAP_MCP_HTTP_FLAG_ID)
1850 .long(flags.http_long)
1851 .value_name("ADDR")
1852 .help("Run an MCP server over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080)")
1853 .global(true),
1854 );
1855 cmd
1856}
1857
1858#[cfg(feature = "http")]
1859pub(crate) fn mcp_http_listen_from_env() -> Option<String> {
1860 if let Ok(listen) = std::env::var(MCP_HTTP_LISTEN_ENV)
1861 && !listen.is_empty()
1862 {
1863 return Some(listen);
1864 }
1865 match (
1866 std::env::var(MCP_HTTP_BIND_ENV).ok(),
1867 std::env::var(MCP_HTTP_PORT_ENV).ok(),
1868 ) {
1869 (Some(bind), Some(port)) if !bind.is_empty() && !port.is_empty() => {
1870 Some(format!("{bind}:{port}"))
1871 }
1872 _ => None,
1873 }
1874}
1875
1876#[cfg(feature = "http")]
1877pub(crate) fn argv_mcp_http_listen_from_args(
1878 args: &[String],
1879 flags: &ClapMcpBuiltinFlags,
1880) -> Option<String> {
1881 let prefix = argv_before_end_of_opts(args);
1882 let http_flag = format!("--{}", flags.http_long);
1883 let http_prefix = format!("--{}=", flags.http_long);
1884 for (i, arg) in prefix.iter().enumerate() {
1885 if arg == &http_flag {
1886 if let Some(val) = prefix.get(i + 1).filter(|s| !s.starts_with('-')) {
1887 return Some(val.clone());
1888 }
1889 return mcp_http_listen_from_env();
1890 }
1891 if let Some(addr) = arg.strip_prefix(&http_prefix) {
1892 if addr.is_empty() {
1893 return mcp_http_listen_from_env();
1894 }
1895 return Some(addr.to_string());
1896 }
1897 }
1898 mcp_http_listen_from_env()
1899}
1900
1901#[cfg(feature = "http")]
1902fn parse_mcp_http_listen(raw: &str) -> Result<std::net::SocketAddr, ClapMcpError> {
1903 raw.parse().map_err(|_| {
1904 ClapMcpError::InvalidConfig(format!(
1905 "invalid MCP HTTP listen address `{raw}` (expected host:port)"
1906 ))
1907 })
1908}
1909
1910#[cfg(feature = "http")]
1911fn mcp_http_listen_error_message(flags: &ClapMcpBuiltinFlags) -> String {
1912 format!(
1913 "`--{}` requires HOST:PORT, or set {MCP_HTTP_LISTEN_ENV}, or {MCP_HTTP_BIND_ENV} + {MCP_HTTP_PORT_ENV}",
1914 flags.http_long
1915 )
1916}
1917
1918#[cfg(feature = "http")]
1919fn resolve_mcp_http_listen_from_args(
1920 args: &[String],
1921 flags: &ClapMcpBuiltinFlags,
1922) -> Result<Option<std::net::SocketAddr>, ClapMcpError> {
1923 let prefix = argv_before_end_of_opts(args);
1924 let http_flag = format!("--{}", flags.http_long);
1925 let http_prefix = format!("--{}=", flags.http_long);
1926 let wants_http = prefix
1927 .iter()
1928 .any(|a| a == &http_flag || a.starts_with(&http_prefix));
1929 if !wants_http {
1930 return Ok(None);
1931 }
1932 match argv_mcp_http_listen_from_args(args, flags) {
1933 Some(raw) if !raw.is_empty() => parse_mcp_http_listen(&raw).map(Some),
1934 _ => Err(ClapMcpError::InvalidConfig(mcp_http_listen_error_message(
1935 flags,
1936 ))),
1937 }
1938}
1939
1940pub async fn serve_mcp(
1963 listen: McpListen,
1964 schema_json: String,
1965 executable_path: Option<PathBuf>,
1966 config: ClapMcpConfig,
1967 in_process_handler: Option<InProcessToolHandler>,
1968 serve_options: ClapMcpServeOptions,
1969 metadata: &ClapMcpSchemaMetadata,
1970) -> Result<(), ClapMcpError> {
1971 ServeMcpBuilder::new()
1972 .listen(listen)
1973 .schema_json(schema_json)
1974 .config(config)
1975 .metadata(metadata.clone())
1976 .serve_options(serve_options)
1977 .executable_path(executable_path)
1978 .in_process_handler(in_process_handler)
1979 .serve()
1980 .await
1981}
1982
1983pub fn serve_mcp_blocking(
2002 listen: McpListen,
2003 schema_json: String,
2004 executable_path: Option<PathBuf>,
2005 config: ClapMcpConfig,
2006 in_process_handler: Option<InProcessToolHandler>,
2007 serve_options: ClapMcpServeOptions,
2008 metadata: &ClapMcpSchemaMetadata,
2009) -> Result<(), ClapMcpError> {
2010 ServeMcpBuilder::new()
2011 .listen(listen)
2012 .schema_json(schema_json)
2013 .config(config)
2014 .metadata(metadata.clone())
2015 .serve_options(serve_options)
2016 .executable_path(executable_path)
2017 .in_process_handler(in_process_handler)
2018 .serve_blocking()
2019}
2020
2021#[cfg(feature = "http")]
2022fn serve_prepared_mcp_blocking(
2023 http_listen: Option<std::net::SocketAddr>,
2024 schema_json: String,
2025 executable_path: Option<PathBuf>,
2026 config: ClapMcpConfig,
2027 in_process_handler: Option<InProcessToolHandler>,
2028 serve_options: ClapMcpServeOptions,
2029 metadata: &ClapMcpSchemaMetadata,
2030) -> Result<(), ClapMcpError> {
2031 let listen = match http_listen {
2032 Some(addr) => McpListen::Http(addr),
2033 None => McpListen::Stdio,
2034 };
2035 ServeMcpBuilder::new()
2036 .listen(listen)
2037 .schema_json(schema_json)
2038 .config(config)
2039 .metadata(metadata.clone())
2040 .serve_options(serve_options)
2041 .executable_path(executable_path)
2042 .in_process_handler(in_process_handler)
2043 .serve_blocking()
2044}
2045
2046#[cfg(not(feature = "http"))]
2047fn serve_prepared_mcp_blocking(
2048 _http_listen: Option<std::net::SocketAddr>,
2049 schema_json: String,
2050 executable_path: Option<PathBuf>,
2051 config: ClapMcpConfig,
2052 in_process_handler: Option<InProcessToolHandler>,
2053 serve_options: ClapMcpServeOptions,
2054 metadata: &ClapMcpSchemaMetadata,
2055) -> Result<(), ClapMcpError> {
2056 ServeMcpBuilder::new()
2057 .listen(McpListen::Stdio)
2058 .schema_json(schema_json)
2059 .config(config)
2060 .metadata(metadata.clone())
2061 .serve_options(serve_options)
2062 .executable_path(executable_path)
2063 .in_process_handler(in_process_handler)
2064 .serve_blocking()
2065}
2066
2067#[cfg(feature = "http")]
2068pub(crate) fn argv_requests_mcp_http_without_subcommand_from_args(
2069 args: &[String],
2070 cmd: &Command,
2071 flags: &ClapMcpBuiltinFlags,
2072) -> bool {
2073 let prefix = argv_before_end_of_opts(args);
2074 let subcommand_names: std::collections::HashSet<String> = cmd
2075 .get_subcommands()
2076 .map(|s| s.get_name().to_string())
2077 .collect();
2078 let http_flag = format!("--{}", flags.http_long);
2079 let http_prefix = format!("--{}=", flags.http_long);
2080 let has_http = prefix
2081 .iter()
2082 .any(|a| a == &http_flag || a.starts_with(&http_prefix));
2083 let has_subcommand = prefix.iter().any(|a| subcommand_names.contains(a.as_str()));
2084 has_http && !has_subcommand
2085}
2086
2087fn argv_requests_mcp_without_subcommand(cmd: &Command, flags: &ClapMcpBuiltinFlags) -> bool {
2089 let args: Vec<String> = std::env::args().skip(1).collect();
2090 argv_requests_mcp_without_subcommand_from_args(&args, cmd, flags)
2091}
2092
2093pub(crate) fn argv_requests_mcp_without_subcommand_from_args(
2095 args: &[String],
2096 cmd: &Command,
2097 flags: &ClapMcpBuiltinFlags,
2098) -> bool {
2099 let prefix = argv_before_end_of_opts(args);
2100 let subcommand_names: std::collections::HashSet<String> = cmd
2101 .get_subcommands()
2102 .map(|s| s.get_name().to_string())
2103 .collect();
2104 let has_mcp = argv_has_long_flag(prefix, flags.stdio_long);
2105 let has_subcommand = prefix.iter().any(|a| subcommand_names.contains(a.as_str()));
2106 has_mcp && !has_subcommand
2107}
2108
2109fn argv_export_skills_dir(flags: &ClapMcpBuiltinFlags) -> Option<Option<std::path::PathBuf>> {
2110 let args: Vec<String> = std::env::args().skip(1).collect();
2111 argv_export_skills_dir_from_args(&args, flags)
2112}
2113
2114pub(crate) fn argv_export_skills_dir_from_args(
2116 args: &[String],
2117 flags: &ClapMcpBuiltinFlags,
2118) -> Option<Option<std::path::PathBuf>> {
2119 let prefix = argv_before_end_of_opts(args);
2120 let export_flag = format!("--{}", flags.export_skills_long);
2121 let export_prefix = format!("--{}=", flags.export_skills_long);
2122 for (i, arg) in prefix.iter().enumerate() {
2123 if arg == &export_flag {
2124 return Some(
2125 prefix
2126 .get(i + 1)
2127 .filter(|s| !s.starts_with('-'))
2128 .map(std::path::PathBuf::from),
2129 );
2130 }
2131 if let Some(dir) = arg.strip_prefix(&export_prefix) {
2132 return Some(Some(std::path::PathBuf::from(dir)));
2133 }
2134 }
2135 None
2136}
2137
2138pub fn schema_from_command(cmd: &Command) -> ClapSchema {
2157 schema_from_command_with_metadata(cmd, &ClapMcpSchemaMetadata::default())
2158}
2159
2160pub fn schema_from_command_with_metadata(
2164 cmd: &Command,
2165 metadata: &ClapMcpSchemaMetadata,
2166) -> ClapSchema {
2167 let skip_commands: std::collections::HashSet<_> =
2168 metadata.skip_commands.iter().cloned().collect();
2169 ClapSchema {
2170 root: command_to_schema_with_metadata(cmd, metadata, &skip_commands),
2171 }
2172}
2173
2174fn command_to_schema_with_metadata(
2175 cmd: &Command,
2176 metadata: &ClapMcpSchemaMetadata,
2177 skip_commands: &std::collections::HashSet<String>,
2178) -> ClapCommand {
2179 let visible = mcp_visible_arg_ids_on_command(cmd, metadata);
2180 let mut args: Vec<ClapArg> = cmd
2181 .get_arguments()
2182 .filter(|a| visible.contains(a.get_id().as_str()))
2183 .map(arg_to_schema)
2184 .collect();
2185
2186 let cmd_name = cmd.get_name().to_string();
2187 let requires_args: std::collections::HashSet<_> = metadata
2188 .requires_args
2189 .get(&cmd_name)
2190 .map(|v| v.iter().cloned().collect())
2191 .unwrap_or_default();
2192
2193 for arg in &mut args {
2194 if requires_args.contains(&arg.id) {
2195 arg.required = true;
2196 }
2197 }
2198 args.sort_by(|a, b| a.id.cmp(&b.id));
2199
2200 let arg_groups = extract_arg_groups(cmd, metadata);
2201
2202 let subcommands: Vec<ClapCommand> = cmd
2203 .get_subcommands()
2204 .filter(|s| !skip_commands.contains(&s.get_name().to_string()))
2205 .map(|s| command_to_schema_with_metadata(s, metadata, skip_commands))
2206 .collect();
2207
2208 ClapCommand {
2209 name: cmd.get_name().to_string(),
2210 about: cmd.get_about().map(|s| s.to_string()),
2211 long_about: cmd.get_long_about().map(|s| s.to_string()),
2212 version: cmd.get_version().map(|s| s.to_string()),
2213 args,
2214 arg_groups,
2215 subcommands,
2216 }
2217}
2218
2219pub fn get_matches_or_serve_mcp(cmd: Command) -> clap::ArgMatches {
2236 get_matches_or_serve_mcp_with_config(cmd, ClapMcpConfig::default())
2237}
2238
2239pub fn get_matches_or_serve_mcp_with_config(
2244 cmd: Command,
2245 config: ClapMcpConfig,
2246) -> clap::ArgMatches {
2247 get_matches_or_serve_mcp_with_config_and_metadata(
2248 cmd,
2249 config,
2250 &ClapMcpSchemaMetadata::default(),
2251 )
2252}
2253
2254pub fn get_matches_or_serve_mcp_with_config_and_metadata(
2258 cmd: Command,
2259 config: ClapMcpConfig,
2260 metadata: &ClapMcpSchemaMetadata,
2261) -> clap::ArgMatches {
2262 let schema = schema_from_command_with_metadata(&cmd, metadata);
2263 let flags = config.builtin_flags;
2264 let cmd = command_with_mcp_and_export_skills_flags_with_flags(cmd, &flags);
2265
2266 if let Some(maybe_dir) = argv_export_skills_dir(&flags) {
2267 let tools = tools_from_schema_with_metadata(&schema, &config, metadata);
2268 let output_dir = maybe_dir.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
2269 let app_name = schema.root.name.as_str();
2270 let serve_options = ClapMcpServeOptions::default();
2271 if let Err(e) = content::export_skills(
2272 &schema,
2273 metadata,
2274 &tools,
2275 &serve_options.custom_resources,
2276 &serve_options.custom_prompts,
2277 &output_dir,
2278 app_name,
2279 ) {
2280 eprintln!("export-skills failed: {}", e);
2281 std::process::exit(1);
2282 }
2283 std::process::exit(0);
2284 }
2285
2286 if config.allow_mcp_without_subcommand
2287 && (argv_requests_mcp_without_subcommand(&cmd, &flags) || {
2288 #[cfg(feature = "http")]
2289 {
2290 let args: Vec<String> = std::env::args().skip(1).collect();
2291 argv_requests_mcp_http_without_subcommand_from_args(&args, &cmd, &flags)
2292 }
2293 #[cfg(not(feature = "http"))]
2294 {
2295 false
2296 }
2297 })
2298 {
2299 let schema_json = match serde_json::to_string_pretty(&schema) {
2300 Ok(s) => s,
2301 Err(e) => {
2302 eprintln!("Failed to serialize CLI schema: {}", e);
2303 std::process::exit(1);
2304 }
2305 };
2306 #[cfg(feature = "http")]
2307 let http_listen = {
2308 let args: Vec<String> = std::env::args().skip(1).collect();
2309 resolve_mcp_http_listen_from_args(&args, &flags).unwrap_or_else(|e| {
2310 eprintln!("{e}");
2311 std::process::exit(2);
2312 })
2313 };
2314 #[cfg(not(feature = "http"))]
2315 let http_listen: Option<std::net::SocketAddr> = None;
2316
2317 if let Err(e) = serve_prepared_mcp_blocking(
2318 http_listen,
2319 schema_json,
2320 None,
2321 config,
2322 None,
2323 ClapMcpServeOptions::default(),
2324 metadata,
2325 ) {
2326 eprintln!("MCP server error: {}", e);
2327 std::process::exit(1);
2328 }
2329 std::process::exit(0);
2330 }
2331
2332 let matches = cmd.get_matches();
2333 let mcp_requested = matches_stdio_flag(&matches, &flags);
2334 #[cfg(feature = "http")]
2335 let http_listen = if matches_http_flag(&matches, &flags) {
2336 matches
2337 .get_one::<String>(CLAP_MCP_HTTP_FLAG_ID)
2338 .or_else(|| {
2339 if flags.http_long == MCP_HTTP_FLAG_LONG {
2340 matches.get_one::<String>(MCP_HTTP_FLAG_LONG)
2341 } else {
2342 None
2343 }
2344 })
2345 .map(|s| parse_mcp_http_listen(s))
2346 .transpose()
2347 .unwrap_or_else(|e| {
2348 eprintln!("{e}");
2349 std::process::exit(2);
2350 })
2351 } else {
2352 None
2353 };
2354 #[cfg(not(feature = "http"))]
2355 let http_listen: Option<std::net::SocketAddr> = None;
2356
2357 if mcp_requested && http_listen.is_some() {
2358 #[cfg(feature = "http")]
2359 eprintln!(
2360 "--{} and --{} are mutually exclusive",
2361 flags.stdio_long, flags.http_long
2362 );
2363 #[cfg(not(feature = "http"))]
2364 eprintln!("stdio and HTTP MCP flags are mutually exclusive");
2365 std::process::exit(2);
2366 }
2367
2368 if mcp_requested || http_listen.is_some() {
2369 let schema_json = match serde_json::to_string_pretty(&schema) {
2370 Ok(s) => s,
2371 Err(e) => {
2372 eprintln!("Failed to serialize CLI schema: {}", e);
2373 std::process::exit(1);
2374 }
2375 };
2376 if let Err(e) = serve_prepared_mcp_blocking(
2377 http_listen,
2378 schema_json,
2379 None,
2380 config,
2381 None,
2382 ClapMcpServeOptions::default(),
2383 metadata,
2384 ) {
2385 eprintln!("MCP server error: {}", e);
2386 std::process::exit(1);
2387 }
2388 std::process::exit(0);
2389 }
2390
2391 matches
2392}
2393
2394pub fn get_matches_preserve_cli_or_serve_mcp_with_config_and_metadata(
2397 cmd: Command,
2398 config: ClapMcpConfig,
2399 metadata: &ClapMcpSchemaMetadata,
2400) -> clap::ArgMatches {
2401 let args: Vec<String> = std::env::args().skip(1).collect();
2402 if argv_contains_clap_mcp_flags(&args, &config.builtin_flags) {
2403 get_matches_or_serve_mcp_with_config_and_metadata(cmd, config, metadata)
2404 } else {
2405 cmd.get_matches()
2406 }
2407}
2408
2409pub fn get_matches_preserve_cli_or_serve_mcp_with_config(
2411 cmd: Command,
2412 config: ClapMcpConfig,
2413) -> clap::ArgMatches {
2414 get_matches_preserve_cli_or_serve_mcp_with_config_and_metadata(
2415 cmd,
2416 config,
2417 &ClapMcpSchemaMetadata::default(),
2418 )
2419}
2420
2421pub fn get_matches_preserve_cli_or_serve_mcp(cmd: Command) -> clap::ArgMatches {
2423 get_matches_preserve_cli_or_serve_mcp_with_config(cmd, ClapMcpConfig::default())
2424}
2425
2426pub trait ParseOrServeMcp {
2446 fn parse_or_serve_mcp() -> Self;
2447
2448 fn parse_or_serve_mcp_preserve_cli() -> Self;
2452}
2453
2454impl<T> ParseOrServeMcp for T
2455where
2456 T: ClapMcpConfigProvider
2457 + ClapMcpSchemaMetadataProvider
2458 + ClapMcpToolExecutor
2459 + clap::Parser
2460 + clap::CommandFactory
2461 + clap::FromArgMatches
2462 + 'static,
2463{
2464 fn parse_or_serve_mcp() -> Self {
2465 parse_or_serve_mcp_with(ClapMcpRunOptions {
2466 config: T::clap_mcp_config(),
2467 serve: ClapMcpServeOptions::default(),
2468 })
2469 }
2470
2471 fn parse_or_serve_mcp_preserve_cli() -> Self {
2472 parse_or_serve_mcp_preserve_cli_with(ClapMcpRunOptions {
2473 config: T::clap_mcp_config(),
2474 serve: ClapMcpServeOptions::default(),
2475 })
2476 }
2477}
2478
2479pub fn run_or_serve_mcp<A, F, R, E>(f: F) -> Result<R, E>
2481where
2482 A: ClapMcpConfigProvider
2483 + ClapMcpSchemaMetadataProvider
2484 + ClapMcpToolExecutor
2485 + clap::Parser
2486 + clap::CommandFactory
2487 + clap::FromArgMatches
2488 + 'static,
2489 F: FnOnce(A) -> Result<R, E>,
2490{
2491 let args = A::parse_or_serve_mcp();
2492 f(args)
2493}
2494
2495struct PreparedDeriveMcpServe {
2496 schema_json: String,
2497 in_process_handler: Option<InProcessToolHandler>,
2498 executable_path: Option<PathBuf>,
2499 metadata: ClapMcpSchemaMetadata,
2500}
2501
2502fn capture_stdout_for_serve(serve_options: &ClapMcpServeOptions) -> bool {
2503 #[cfg(unix)]
2504 {
2505 serve_options.capture_stdout
2506 }
2507 #[cfg(not(unix))]
2508 {
2509 let _ = serve_options;
2510 false
2511 }
2512}
2513
2514fn finish_prepared_derive_mcp_serve(
2515 config: &ClapMcpConfig,
2516 metadata: ClapMcpSchemaMetadata,
2517 schema_json: String,
2518 in_process_handler: Option<InProcessToolHandler>,
2519) -> PreparedDeriveMcpServe {
2520 let executable_path = if config.reinvocation_safe {
2521 None
2522 } else {
2523 std::env::current_exe().ok()
2524 };
2525 PreparedDeriveMcpServe {
2526 schema_json,
2527 in_process_handler,
2528 executable_path,
2529 metadata,
2530 }
2531}
2532
2533pub(crate) fn prepare_derive_mcp_serve<T>(
2535 config: &ClapMcpConfig,
2536 serve_options: &ClapMcpServeOptions,
2537) -> PreparedDeriveMcpServe
2538where
2539 T: ClapMcpToolExecutor
2540 + ClapMcpSchemaMetadataProvider
2541 + clap::CommandFactory
2542 + clap::FromArgMatches
2543 + 'static,
2544{
2545 let metadata = T::clap_mcp_schema_metadata();
2546 let schema = schema_from_command_with_metadata(&T::command(), &metadata);
2547 let schema_json = serde_json::to_string_pretty(&schema).expect("schema should serialize");
2548 let capture_stdout = capture_stdout_for_serve(serve_options);
2549 let in_process_handler = if config.reinvocation_safe {
2550 Some(make_in_process_handler::<T>(schema, capture_stdout))
2551 } else {
2552 None
2553 };
2554 finish_prepared_derive_mcp_serve(config, metadata, schema_json, in_process_handler)
2555}
2556
2557pub(crate) fn prepare_derive_mcp_serve_with_state<T>(
2559 config: &ClapMcpConfig,
2560 serve_options: &ClapMcpServeOptions,
2561 state: Arc<T::State>,
2562) -> PreparedDeriveMcpServe
2563where
2564 T: ClapMcpToolExecutorWithState
2565 + ClapMcpSchemaMetadataProvider
2566 + clap::CommandFactory
2567 + clap::FromArgMatches
2568 + 'static,
2569{
2570 let metadata = T::clap_mcp_schema_metadata();
2571 let schema = schema_from_command_with_metadata(&T::command(), &metadata);
2572 let schema_json = serde_json::to_string_pretty(&schema).expect("schema should serialize");
2573 let capture_stdout = capture_stdout_for_serve(serve_options);
2574 let in_process_handler = if config.reinvocation_safe {
2575 Some(make_in_process_handler_with_state::<T>(
2576 schema,
2577 state,
2578 capture_stdout,
2579 ))
2580 } else {
2581 None
2582 };
2583 finish_prepared_derive_mcp_serve(config, metadata, schema_json, in_process_handler)
2584}
2585
2586fn run_prepared_derive_mcp_serve(
2587 prepared: PreparedDeriveMcpServe,
2588 http_listen: Option<std::net::SocketAddr>,
2589 config: ClapMcpConfig,
2590 serve_options: ClapMcpServeOptions,
2591) -> Result<(), ClapMcpError> {
2592 serve_prepared_mcp_blocking(
2593 http_listen,
2594 prepared.schema_json,
2595 prepared.executable_path,
2596 config,
2597 prepared.in_process_handler,
2598 serve_options,
2599 &prepared.metadata,
2600 )
2601}
2602
2603fn exit_on_mcp_serve_error(result: Result<(), ClapMcpError>) -> ! {
2604 if let Err(e) = result {
2605 eprintln!("MCP server error: {}", e);
2606 std::process::exit(1);
2607 }
2608 std::process::exit(0);
2609}
2610
2611#[cfg(feature = "http")]
2612fn resolve_http_listen_from_env_or_exit(
2613 flags: &ClapMcpBuiltinFlags,
2614) -> Option<std::net::SocketAddr> {
2615 let args: Vec<String> = std::env::args().skip(1).collect();
2616 resolve_mcp_http_listen_from_args(&args, flags).unwrap_or_else(|e| {
2617 eprintln!("{e}");
2618 std::process::exit(2);
2619 })
2620}
2621
2622fn parse_or_serve_mcp_common<T>(
2623 options: ClapMcpRunOptions,
2624 prepare: impl FnOnce(&ClapMcpConfig, &ClapMcpServeOptions) -> PreparedDeriveMcpServe,
2625) -> T
2626where
2627 T: ClapMcpSchemaMetadataProvider + clap::Parser + clap::CommandFactory + clap::FromArgMatches,
2628{
2629 let ClapMcpRunOptions {
2630 config,
2631 serve: serve_options,
2632 } = options;
2633 let flags = config.builtin_flags;
2634 let mut cmd = T::command();
2635 cmd = command_with_mcp_and_export_skills_flags_with_flags(cmd, &flags);
2636
2637 if let Some(maybe_dir) = argv_export_skills_dir(&flags) {
2638 let base_cmd = T::command();
2639 let metadata = T::clap_mcp_schema_metadata();
2640 let schema = schema_from_command_with_metadata(&base_cmd, &metadata);
2641 let tools = tools_from_schema_with_metadata(&schema, &config, &metadata);
2642 let output_dir = maybe_dir.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
2643 let app_name = schema.root.name.as_str();
2644 if let Err(e) = content::export_skills(
2645 &schema,
2646 &metadata,
2647 &tools,
2648 &serve_options.custom_resources,
2649 &serve_options.custom_prompts,
2650 &output_dir,
2651 app_name,
2652 ) {
2653 eprintln!("export-skills failed: {}", e);
2654 std::process::exit(1);
2655 }
2656 std::process::exit(0);
2657 }
2658
2659 if config.allow_mcp_without_subcommand
2660 && (argv_requests_mcp_without_subcommand(&cmd, &flags) || {
2661 #[cfg(feature = "http")]
2662 {
2663 let args: Vec<String> = std::env::args().skip(1).collect();
2664 argv_requests_mcp_http_without_subcommand_from_args(&args, &cmd, &flags)
2665 }
2666 #[cfg(not(feature = "http"))]
2667 {
2668 false
2669 }
2670 })
2671 {
2672 #[cfg(feature = "http")]
2673 let http_listen = resolve_http_listen_from_env_or_exit(&flags);
2674 #[cfg(not(feature = "http"))]
2675 let http_listen: Option<std::net::SocketAddr> = None;
2676 let prepared = prepare(&config, &serve_options);
2677 exit_on_mcp_serve_error(run_prepared_derive_mcp_serve(
2678 prepared,
2679 http_listen,
2680 config,
2681 serve_options,
2682 ));
2683 }
2684
2685 let matches = cmd.get_matches();
2686 let mcp_requested = matches_stdio_flag(&matches, &flags);
2687 #[cfg(feature = "http")]
2688 let http_listen = if matches_http_flag(&matches, &flags) {
2689 match matches
2690 .get_one::<String>(CLAP_MCP_HTTP_FLAG_ID)
2691 .or_else(|| {
2692 if flags.http_long == MCP_HTTP_FLAG_LONG {
2693 matches.get_one::<String>(MCP_HTTP_FLAG_LONG)
2694 } else {
2695 None
2696 }
2697 })
2698 .map(|s| parse_mcp_http_listen(s))
2699 .transpose()
2700 {
2701 Ok(v) => v,
2702 Err(e) => {
2703 eprintln!("{e}");
2704 std::process::exit(2);
2705 }
2706 }
2707 } else {
2708 None
2709 };
2710 #[cfg(not(feature = "http"))]
2711 let http_listen: Option<std::net::SocketAddr> = None;
2712
2713 if mcp_requested && http_listen.is_some() {
2714 #[cfg(feature = "http")]
2715 eprintln!(
2716 "--{} and --{} are mutually exclusive",
2717 flags.stdio_long, flags.http_long
2718 );
2719 #[cfg(not(feature = "http"))]
2720 eprintln!("stdio and HTTP MCP flags are mutually exclusive");
2721 std::process::exit(2);
2722 }
2723
2724 if mcp_requested || http_listen.is_some() {
2725 let prepared = prepare(&config, &serve_options);
2726 exit_on_mcp_serve_error(run_prepared_derive_mcp_serve(
2727 prepared,
2728 http_listen,
2729 config,
2730 serve_options,
2731 ));
2732 }
2733
2734 T::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
2735}
2736
2737pub fn parse_or_serve_mcp_with<T>(options: ClapMcpRunOptions) -> T
2742where
2743 T: ClapMcpSchemaMetadataProvider
2744 + ClapMcpToolExecutor
2745 + clap::Parser
2746 + clap::CommandFactory
2747 + clap::FromArgMatches
2748 + 'static,
2749{
2750 parse_or_serve_mcp_common::<T>(options, |config, serve_options| {
2751 prepare_derive_mcp_serve::<T>(config, serve_options)
2752 })
2753}
2754
2755pub fn parse_or_serve_mcp_preserve_cli_with<T>(options: ClapMcpRunOptions) -> T
2758where
2759 T: ClapMcpSchemaMetadataProvider
2760 + ClapMcpToolExecutor
2761 + clap::Parser
2762 + clap::CommandFactory
2763 + clap::FromArgMatches
2764 + 'static,
2765{
2766 let args: Vec<String> = std::env::args().skip(1).collect();
2767 if argv_contains_clap_mcp_flags(&args, &options.config.builtin_flags) {
2768 parse_or_serve_mcp_with(options)
2769 } else {
2770 T::parse()
2771 }
2772}
2773
2774pub fn parse_or_serve_mcp_with_state<T>(options: ClapMcpRunOptions, state: Arc<T::State>) -> T
2783where
2784 T: ClapMcpSchemaMetadataProvider
2785 + ClapMcpToolExecutorWithState
2786 + clap::Parser
2787 + clap::CommandFactory
2788 + clap::FromArgMatches
2789 + 'static,
2790{
2791 parse_or_serve_mcp_common::<T>(options, |config, serve_options| {
2792 prepare_derive_mcp_serve_with_state::<T>(config, serve_options, Arc::clone(&state))
2793 })
2794}
2795
2796pub fn parse_or_serve_mcp_with_state_preserve_cli<T>(
2799 options: ClapMcpRunOptions,
2800 state: Arc<T::State>,
2801) -> T
2802where
2803 T: ClapMcpSchemaMetadataProvider
2804 + ClapMcpToolExecutorWithState
2805 + clap::Parser
2806 + clap::CommandFactory
2807 + clap::FromArgMatches
2808 + 'static,
2809{
2810 let args: Vec<String> = std::env::args().skip(1).collect();
2811 if argv_contains_clap_mcp_flags(&args, &options.config.builtin_flags) {
2812 parse_or_serve_mcp_with_state(options, state)
2813 } else {
2814 T::parse()
2815 }
2816}
2817
2818pub trait ParseOrServeMcpWithState: ClapMcpToolExecutorWithState + Sized {
2823 fn parse_or_serve_mcp_with_state(state: Arc<Self::State>) -> Self;
2825}
2826
2827impl<T> ParseOrServeMcpWithState for T
2828where
2829 T: ClapMcpConfigProvider
2830 + ClapMcpSchemaMetadataProvider
2831 + ClapMcpToolExecutorWithState
2832 + clap::Parser
2833 + clap::CommandFactory
2834 + clap::FromArgMatches
2835 + 'static,
2836{
2837 fn parse_or_serve_mcp_with_state(state: Arc<T::State>) -> Self {
2838 parse_or_serve_mcp_with_state::<T>(
2839 ClapMcpRunOptions {
2840 config: T::clap_mcp_config(),
2841 serve: ClapMcpServeOptions::default(),
2842 },
2843 state,
2844 )
2845 }
2846}
2847
2848fn arg_to_schema(arg: &clap::Arg) -> ClapArg {
2849 let value_names = arg
2850 .get_value_names()
2851 .map(|names| names.iter().map(|n| n.to_string()).collect())
2852 .unwrap_or_default();
2853
2854 ClapArg {
2855 id: arg.get_id().to_string(),
2856 long: arg.get_long().map(|s| s.to_string()),
2857 short: arg.get_short(),
2858 help: arg.get_help().map(|s| s.to_string()),
2859 long_help: arg.get_long_help().map(|s| s.to_string()),
2860 required: arg.is_required_set(),
2861 global: arg.is_global_set(),
2862 index: arg.get_index(),
2863 action: Some(format!("{:?}", arg.get_action())),
2864 value_names,
2865 num_args: arg.get_num_args().map(|r| format!("{r:?}")),
2866 }
2867}
2868
2869pub(crate) fn validate_required_args(
2872 schema: &ClapSchema,
2873 command_name: &str,
2874 arguments: &serde_json::Map<String, serde_json::Value>,
2875) -> Result<(), String> {
2876 if command_path(schema, command_name).is_none() {
2877 return Ok(());
2878 }
2879 let effective_args = effective_args_for_tool(schema, command_name);
2880 let missing: Vec<_> = effective_args
2881 .iter()
2882 .filter(|a| {
2883 if !a.required {
2884 return false;
2885 }
2886 let has_value = arguments.get(&a.id).map(|v| {
2887 let action = a.action.as_deref().unwrap_or("Set");
2888 if matches!(action, "SetTrue" | "SetFalse" | "Count") {
2889 true
2891 } else if action == "Append" || v.is_array() {
2892 !value_to_strings(v).is_some_and(|s| s.is_empty())
2893 } else {
2894 value_to_string(v).is_some_and(|s| !s.is_empty())
2895 }
2896 });
2897 !has_value.unwrap_or(false)
2898 })
2899 .map(|a| a.id.clone())
2900 .collect();
2901 if missing.is_empty() {
2902 Ok(())
2903 } else {
2904 Err(format!(
2905 "Missing required argument(s): {}. The MCP tool schema marks these as required.",
2906 missing.join(", ")
2907 ))
2908 }
2909}
2910
2911fn build_argv_for_clap(
2913 schema: &ClapSchema,
2914 command_name: &str,
2915 arguments: serde_json::Map<String, serde_json::Value>,
2916) -> Vec<String> {
2917 let args = build_tool_argv(schema, command_name, arguments);
2918 let mut argv = vec!["cli".to_string()]; if let Some(path) = command_path(schema, command_name) {
2920 argv.extend(path.into_iter().skip(1));
2921 }
2922 argv.extend(args);
2923 argv
2924}
2925
2926pub(crate) fn command_path(schema: &ClapSchema, command_name: &str) -> Option<Vec<String>> {
2927 fn walk(cmd: &ClapCommand, command_name: &str, path: &mut Vec<String>) -> bool {
2928 path.push(cmd.name.clone());
2929 if cmd.name == command_name {
2930 return true;
2931 }
2932 for subcommand in &cmd.subcommands {
2933 if walk(subcommand, command_name, path) {
2934 return true;
2935 }
2936 }
2937 path.pop();
2938 false
2939 }
2940
2941 let mut path = Vec::new();
2942 if walk(&schema.root, command_name, &mut path) {
2943 Some(path)
2944 } else {
2945 None
2946 }
2947}
2948
2949pub(crate) fn build_tool_argv(
2953 schema: &ClapSchema,
2954 command_name: &str,
2955 arguments: serde_json::Map<String, serde_json::Value>,
2956) -> Vec<String> {
2957 if command_path(schema, command_name).is_none() {
2958 return Vec::new();
2959 }
2960 let effective_args = effective_args_for_tool(schema, command_name);
2961
2962 let mut positionals: Vec<&ClapArg> = effective_args
2963 .iter()
2964 .filter(|a| a.long.is_none() && !a.num_args.as_deref().is_some_and(|n| n.contains("..")))
2965 .collect();
2966 positionals.sort_by_key(|a| a.index.unwrap_or(0));
2967 let trailing_positionals: Vec<&ClapArg> = effective_args
2968 .iter()
2969 .filter(|a| a.long.is_none() && a.num_args.as_deref().is_some_and(|n| n.contains("..")))
2970 .collect();
2971 let optionals: Vec<&ClapArg> = effective_args.iter().filter(|a| a.long.is_some()).collect();
2972
2973 let mut out = Vec::new();
2974
2975 for arg in positionals {
2976 if let Some(v) = arguments.get(&arg.id)
2977 && let Some(strings) = value_to_strings(v)
2978 {
2979 for s in strings {
2980 out.push(s);
2981 }
2982 }
2983 }
2984 for arg in optionals {
2985 if let Some(long) = &arg.long {
2986 let action = arg.action.as_deref().unwrap_or("Set");
2987 let v = arguments.get(&arg.id);
2988 match action {
2989 "SetTrue" => {
2990 if v.and_then(value_to_string).is_some_and(|s| s == "true")
2991 || v.and_then(|x| x.as_bool()).is_some_and(|b| b)
2992 {
2993 out.push(format!("--{long}"));
2994 }
2995 }
2996 "SetFalse" => {
2997 if v.and_then(value_to_string).is_some_and(|s| s == "false")
2998 || v.and_then(|x| x.as_bool()).is_some_and(|b| !b)
2999 {
3000 out.push(format!("--{long}"));
3001 }
3002 }
3003 "Count" => {
3004 let n = v.and_then(|x| x.as_i64()).unwrap_or(0).clamp(0, i64::MAX) as usize;
3005 for _ in 0..n {
3006 out.push(format!("--{long}"));
3007 }
3008 }
3009 "Append" => {
3010 if let Some(v) = v.and_then(value_to_strings) {
3011 for s in v {
3012 if !s.is_empty() {
3013 out.push(format!("--{long}"));
3014 out.push(s);
3015 }
3016 }
3017 } else if let Some(s) = v.and_then(value_to_string)
3018 && !s.is_empty()
3019 {
3020 out.push(format!("--{long}"));
3021 out.push(s);
3022 }
3023 }
3024 _ => {
3025 if let Some(s) = v.and_then(value_to_string)
3026 && !s.is_empty()
3027 {
3028 out.push(format!("--{long}"));
3029 out.push(s);
3030 }
3031 }
3032 }
3033 }
3034 }
3035
3036 let mut trailing_values = Vec::new();
3037 for arg in &trailing_positionals {
3038 if let Some(v) = arguments.get(&arg.id)
3039 && let Some(strings) = value_to_strings(v)
3040 {
3041 trailing_values.extend(strings);
3042 }
3043 }
3044 if !trailing_values.is_empty() {
3045 out.push("--".to_string());
3046 out.extend(trailing_values);
3047 }
3048
3049 out
3050}
3051
3052pub type InProcessToolHandler = Arc<
3057 dyn Fn(
3058 &str,
3059 serde_json::Map<String, serde_json::Value>,
3060 ) -> Result<ClapMcpToolOutput, ClapMcpToolError>
3061 + Send
3062 + Sync,
3063>;
3064
3065fn merge_captured_stdout(
3066 result: Result<ClapMcpToolOutput, ClapMcpToolError>,
3067 captured: String,
3068) -> Result<ClapMcpToolOutput, ClapMcpToolError> {
3069 match result {
3070 Ok(ClapMcpToolOutput::Text(text)) if !captured.is_empty() => {
3071 let merged = if text.is_empty() {
3072 captured.trim().to_string()
3073 } else {
3074 let cap = captured.trim();
3075 if cap.is_empty() {
3076 text
3077 } else {
3078 format!("{text}\n{cap}")
3079 }
3080 };
3081 Ok(ClapMcpToolOutput::Text(merged))
3082 }
3083 other => other,
3084 }
3085}
3086
3087fn parse_cli_from_tool_args<T>(
3088 schema: &ClapSchema,
3089 command_name: &str,
3090 arguments: serde_json::Map<String, serde_json::Value>,
3091) -> Result<T, ClapMcpToolError>
3092where
3093 T: clap::CommandFactory + clap::FromArgMatches,
3094{
3095 validate_required_args(schema, command_name, &arguments).map_err(ClapMcpToolError::text)?;
3096 let argv = build_argv_for_clap(schema, command_name, arguments);
3097 let matches = T::command()
3098 .try_get_matches_from(&argv)
3099 .map_err(|e| ClapMcpToolError::text(e.to_string()))?;
3100 T::from_arg_matches(&matches).map_err(|e| ClapMcpToolError::text(e.to_string()))
3101}
3102
3103fn execute_in_process_command<T>(
3104 schema: &ClapSchema,
3105 command_name: &str,
3106 arguments: serde_json::Map<String, serde_json::Value>,
3107 capture_stdout: bool,
3108 execute: impl FnOnce(T) -> Result<ClapMcpToolOutput, ClapMcpToolError>,
3109) -> Result<ClapMcpToolOutput, ClapMcpToolError>
3110where
3111 T: clap::CommandFactory + clap::FromArgMatches,
3112{
3113 let cli = parse_cli_from_tool_args::<T>(schema, command_name, arguments)?;
3114 if capture_stdout {
3115 let (result, captured) = run_with_stdout_capture(|| execute(cli));
3116 merge_captured_stdout(result, captured)
3117 } else {
3118 execute(cli)
3119 }
3120}
3121
3122fn execute_in_process_command_stateless<T>(
3123 schema: &ClapSchema,
3124 command_name: &str,
3125 arguments: serde_json::Map<String, serde_json::Value>,
3126 capture_stdout: bool,
3127) -> Result<ClapMcpToolOutput, ClapMcpToolError>
3128where
3129 T: ClapMcpToolExecutor + clap::CommandFactory + clap::FromArgMatches,
3130{
3131 execute_in_process_command::<T>(schema, command_name, arguments, capture_stdout, |cli| {
3132 <T as ClapMcpToolExecutor>::execute_for_mcp(cli)
3133 })
3134}
3135
3136fn execute_in_process_command_stateful<T>(
3137 schema: &ClapSchema,
3138 command_name: &str,
3139 arguments: serde_json::Map<String, serde_json::Value>,
3140 state: &T::State,
3141 capture_stdout: bool,
3142) -> Result<ClapMcpToolOutput, ClapMcpToolError>
3143where
3144 T: ClapMcpToolExecutorWithState + clap::CommandFactory + clap::FromArgMatches,
3145{
3146 execute_in_process_command::<T>(schema, command_name, arguments, capture_stdout, |cli| {
3147 <T as ClapMcpToolExecutorWithState>::execute_for_mcp_with_state(cli, state)
3148 })
3149}
3150
3151pub fn in_process_tool_handler_for<T>(
3155 schema: ClapSchema,
3156 capture_stdout: bool,
3157) -> InProcessToolHandler
3158where
3159 T: ClapMcpToolExecutor + clap::CommandFactory + clap::FromArgMatches + 'static,
3160{
3161 make_in_process_handler::<T>(schema, capture_stdout)
3162}
3163
3164pub(crate) fn make_in_process_handler<T>(
3165 schema: ClapSchema,
3166 capture_stdout: bool,
3167) -> InProcessToolHandler
3168where
3169 T: ClapMcpToolExecutor + clap::CommandFactory + clap::FromArgMatches + 'static,
3170{
3171 Arc::new(
3172 move |cmd: &str, args: serde_json::Map<String, serde_json::Value>| {
3173 execute_in_process_command_stateless::<T>(&schema, cmd, args, capture_stdout)
3174 },
3175 ) as InProcessToolHandler
3176}
3177
3178pub(crate) fn make_in_process_handler_with_state<T>(
3179 schema: ClapSchema,
3180 state: Arc<T::State>,
3181 capture_stdout: bool,
3182) -> InProcessToolHandler
3183where
3184 T: ClapMcpToolExecutorWithState + clap::CommandFactory + clap::FromArgMatches + 'static,
3185{
3186 Arc::new(
3187 move |cmd: &str, args: serde_json::Map<String, serde_json::Value>| {
3188 execute_in_process_command_stateful::<T>(
3189 &schema,
3190 cmd,
3191 args,
3192 state.as_ref(),
3193 capture_stdout,
3194 )
3195 },
3196 ) as InProcessToolHandler
3197}
3198
3199pub(crate) fn format_panic_payload(payload: &(dyn std::any::Any + Send)) -> String {
3200 if let Some(s) = payload.downcast_ref::<&str>() {
3201 return (*s).to_string();
3202 }
3203 if let Some(s) = payload.downcast_ref::<String>() {
3204 return s.clone();
3205 }
3206 "<panic>".to_string()
3207}
3208
3209fn value_to_string(v: &serde_json::Value) -> Option<String> {
3210 if v.is_null() {
3211 return None;
3212 }
3213 Some(match v {
3214 serde_json::Value::String(s) => s.clone(),
3215 serde_json::Value::Number(n) => n.to_string(),
3216 serde_json::Value::Bool(b) => b.to_string(),
3217 other => other.to_string(),
3218 })
3219}
3220
3221pub(crate) fn canonical_lock_arg_value(v: &serde_json::Value) -> Option<String> {
3223 if v.is_null() {
3224 return None;
3225 }
3226 match v {
3227 serde_json::Value::Array(arr) => {
3228 let mut parts = Vec::with_capacity(arr.len());
3229 for item in arr {
3230 parts.push(canonical_lock_arg_value(item)?);
3231 }
3232 Some(format!("[{}]", parts.join(",")))
3233 }
3234 serde_json::Value::Object(map) => {
3235 let mut keys: Vec<_> = map.keys().cloned().collect();
3236 keys.sort();
3237 let mut out = String::from("{");
3238 for (i, key) in keys.iter().enumerate() {
3239 if i > 0 {
3240 out.push(',');
3241 }
3242 let val = canonical_lock_arg_value(map.get(key)?)?;
3243 out.push_str(key);
3244 out.push(':');
3245 out.push_str(&val);
3246 }
3247 out.push('}');
3248 Some(out)
3249 }
3250 _ => value_to_string(v),
3251 }
3252}
3253
3254pub(crate) fn serialize_lock_key(
3256 tool_name: &str,
3257 args: &serde_json::Map<String, serde_json::Value>,
3258 scope: &ClapMcpSerializeScope,
3259 topic_fns: Option<&std::collections::HashMap<String, SerializeTopicSegmentFn>>,
3260) -> String {
3261 let tool_prefix = format!("tool:{tool_name}");
3262 match scope {
3263 ClapMcpSerializeScope::Tool => tool_prefix,
3264 ClapMcpSerializeScope::Args(arg_ids) => {
3265 let mut sorted_ids: Vec<_> = arg_ids.clone();
3266 sorted_ids.sort();
3267 let mut segments = Vec::with_capacity(sorted_ids.len());
3268 for id in &sorted_ids {
3269 match args.get(id) {
3270 Some(value) => {
3271 let segment = topic_fns
3272 .and_then(|fns| fns.get(id))
3273 .and_then(|f| f(value))
3274 .or_else(|| canonical_lock_arg_value(value));
3275 match segment {
3276 Some(value) => segments.push(format!("{id}={value}")),
3277 None => return tool_prefix,
3278 }
3279 }
3280 None => return tool_prefix,
3281 }
3282 }
3283 format!("{tool_prefix}:{}", segments.join(":"))
3284 }
3285 }
3286}
3287
3288fn value_to_strings(v: &serde_json::Value) -> Option<Vec<String>> {
3290 if v.is_null() {
3291 return None;
3292 }
3293 match v {
3294 serde_json::Value::Array(arr) => {
3295 let out: Vec<String> = arr
3296 .iter()
3297 .filter_map(value_to_string)
3298 .filter(|s| !s.is_empty())
3299 .collect();
3300 Some(out)
3301 }
3302 _ => value_to_string(v).map(|s| vec![s]),
3303 }
3304}
3305
3306pub fn run_async_tool<Fut, O>(
3353 config: &ClapMcpConfig,
3354 f: impl FnOnce() -> Fut + Send,
3355) -> std::result::Result<O, ClapMcpError>
3356where
3357 Fut: std::future::Future<Output = O> + Send,
3358 O: Send,
3359{
3360 if config.reinvocation_safe && config.share_runtime {
3361 tokio::task::block_in_place(|| {
3362 let handle = tokio::runtime::Handle::try_current()
3363 .map_err(|e| ClapMcpError::RuntimeContext(e.to_string()))?;
3364 let task_id = crate::logging::current_mcp_task_id();
3369 Ok(handle.block_on(async move {
3370 match task_id {
3371 Some(id) => crate::logging::run_with_mcp_task_id(id, f()).await,
3372 None => f().await,
3373 }
3374 }))
3375 })
3376 } else {
3377 let catch_panics = config.catch_in_process_panics;
3378 let run_on_dedicated_thread = || {
3379 let task_id = crate::logging::current_mcp_task_id();
3380 std::thread::scope(|s| {
3381 let join_handle = s.spawn(move || {
3382 let _task_id_guard = task_id.map(crate::logging::McpTaskIdGuard::new);
3383 let rt = tokio::runtime::Builder::new_current_thread()
3384 .enable_all()
3385 .build()?;
3386 Ok(rt.block_on(f()))
3387 });
3388 match join_handle.join() {
3389 Ok(inner) => inner,
3390 Err(payload) if catch_panics => {
3391 let msg = format_panic_payload(payload.as_ref());
3392 Err(ClapMcpError::ToolThread(format!("Tool panicked: {msg}")))
3393 }
3394 Err(payload) => std::panic::resume_unwind(payload),
3395 }
3396 })
3397 };
3398 if config.reinvocation_safe && config.parallel_safe {
3399 tokio::task::block_in_place(run_on_dedicated_thread)
3400 } else {
3401 run_on_dedicated_thread()
3402 }
3403 }
3404}
3405
3406#[cfg(test)]
3407mod tests {
3408 use super::*;
3409 use crate::server::{
3410 ClapMcpServer, build_clap_mcp_server, build_execution_command,
3411 call_tool_result_from_output, call_tool_result_from_panic,
3412 call_tool_result_from_tool_error, command_launch_failure_result, get_prompt_result,
3413 list_prompts_result, list_resources_result, placeholder_tool_result, read_resource_result,
3414 schema_parse_failure_result, subprocess_stderr_log_params, validate_tool_argument_names,
3415 };
3416 use async_trait::async_trait;
3417 use clap::{Arg, ArgAction, ArgGroup, Command, CommandFactory};
3418 use rmcp::ServerHandler;
3419 use rmcp::model::{
3420 Content, GetPromptRequestParams, PromptMessage, PromptMessageContent, PromptMessageRole,
3421 RawContent, ReadResourceRequestParams, ResourceContents, Tool,
3422 };
3423 use serde::Deserialize;
3424 use serde_json::json;
3425 use std::error::Error;
3426 use std::sync::{Arc, Mutex};
3427
3428 fn content_text(content: &Content) -> &str {
3429 match &content.raw {
3430 RawContent::Text(text) => &text.text,
3431 _ => panic!("expected text content"),
3432 }
3433 }
3434
3435 fn prompt_text(content: &PromptMessageContent) -> &str {
3436 match content {
3437 PromptMessageContent::Text { text, .. } => text,
3438 _ => panic!("expected text prompt content"),
3439 }
3440 }
3441
3442 #[cfg(unix)]
3443 use std::os::unix::process::ExitStatusExt;
3444
3445 #[cfg(unix)]
3446 use crate::server::call_tool_result_from_subprocess_output;
3447
3448 fn sample_helper_schema() -> ClapSchema {
3449 schema_from_command(
3450 &Command::new("sample")
3451 .arg(Arg::new("input").help("Input file").required(true).index(1))
3452 .arg(
3453 Arg::new("verbose")
3454 .long("verbose")
3455 .help("Verbose mode")
3456 .action(ArgAction::SetTrue),
3457 )
3458 .arg(
3459 Arg::new("no-cache")
3460 .long("no-cache")
3461 .help("Disable cache")
3462 .action(ArgAction::SetFalse),
3463 )
3464 .arg(
3465 Arg::new("level")
3466 .long("level")
3467 .help("Verbosity level")
3468 .action(ArgAction::Count),
3469 )
3470 .arg(
3471 Arg::new("tag")
3472 .long("tag")
3473 .help("Tags to include")
3474 .action(ArgAction::Append)
3475 .value_name("TAG"),
3476 )
3477 .arg(
3478 Arg::new("mode")
3479 .long("mode")
3480 .help("Execution mode")
3481 .action(ArgAction::Set),
3482 )
3483 .subcommand(Command::new("serve").about("Serve the sample app")),
3484 )
3485 }
3486
3487 fn nested_schema() -> ClapSchema {
3488 schema_from_command(
3489 &Command::new("sample")
3490 .subcommand(
3491 Command::new("parent")
3492 .subcommand(Command::new("child").arg(Arg::new("value").long("value"))),
3493 )
3494 .subcommand(Command::new("echo").arg(Arg::new("message").long("message"))),
3495 )
3496 }
3497
3498 #[derive(Debug)]
3499 struct TestError(&'static str);
3500
3501 impl std::fmt::Display for TestError {
3502 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3503 f.write_str(self.0)
3504 }
3505 }
3506
3507 impl Error for TestError {}
3508
3509 struct TestPromptProvider {
3510 response: Result<Vec<PromptMessage>, &'static str>,
3511 seen: Mutex<Vec<(String, serde_json::Map<String, serde_json::Value>)>>,
3512 }
3513
3514 #[async_trait]
3515 impl content::PromptContentProvider for TestPromptProvider {
3516 async fn get(
3517 &self,
3518 name: &str,
3519 arguments: &serde_json::Map<String, serde_json::Value>,
3520 ) -> std::result::Result<Vec<PromptMessage>, Box<dyn Error + Send + Sync>> {
3521 self.seen
3522 .lock()
3523 .expect("prompt provider mutex should lock")
3524 .push((name.to_string(), arguments.clone()));
3525 match &self.response {
3526 Ok(messages) => Ok(messages.clone()),
3527 Err(message) => Err(Box::new(TestError(message))),
3528 }
3529 }
3530 }
3531
3532 struct TestResourceProvider {
3533 response: Result<String, &'static str>,
3534 }
3535
3536 #[async_trait]
3537 impl content::ResourceContentProvider for TestResourceProvider {
3538 async fn read(
3539 &self,
3540 _uri: &str,
3541 ) -> std::result::Result<String, Box<dyn Error + Send + Sync>> {
3542 match &self.response {
3543 Ok(text) => Ok(text.clone()),
3544 Err(message) => Err(Box::new(TestError(message))),
3545 }
3546 }
3547 }
3548
3549 #[derive(Debug, clap::Parser)]
3550 #[command(name = "exec-cli", subcommand_required = true)]
3551 enum ExecCli {
3552 PrintOnly,
3553 PrintAndText,
3554 Structured,
3555 Echo {
3556 #[arg(long)]
3557 value: String,
3558 },
3559 }
3560
3561 impl ClapMcpToolExecutor for ExecCli {
3562 fn execute_for_mcp(self) -> Result<ClapMcpToolOutput, ClapMcpToolError> {
3563 match self {
3564 Self::PrintOnly => {
3565 print!("captured only");
3566 Ok(ClapMcpToolOutput::Text(String::new()))
3567 }
3568 Self::PrintAndText => {
3569 print!("captured extra");
3570 Ok(ClapMcpToolOutput::Text("returned text".to_string()))
3571 }
3572 Self::Structured => {
3573 print!("ignored capture");
3574 Ok(ClapMcpToolOutput::Structured(json!({ "status": "ok" })))
3575 }
3576 Self::Echo { value } => Ok(ClapMcpToolOutput::Text(value)),
3577 }
3578 }
3579 }
3580
3581 #[test]
3582 fn test_format_panic_payload() {
3583 let s: Box<dyn std::any::Any + Send> = Box::new("hello");
3584 assert_eq!(format_panic_payload(s.as_ref()), "hello");
3585 let s: Box<dyn std::any::Any + Send> = Box::new("world".to_string());
3586 assert_eq!(format_panic_payload(s.as_ref()), "world");
3587 let n: Box<dyn std::any::Any + Send> = Box::new(42i32);
3588 assert_eq!(format_panic_payload(n.as_ref()), "<panic>");
3589 }
3590
3591 #[test]
3592 fn test_mcp_type_for_arg_and_description_hints() {
3593 let boolean_arg = ClapArg {
3594 id: "verbose".to_string(),
3595 long: Some("verbose".to_string()),
3596 short: None,
3597 help: Some("Verbose mode".to_string()),
3598 long_help: None,
3599 required: false,
3600 global: false,
3601 index: None,
3602 action: Some("SetTrue".to_string()),
3603 value_names: vec![],
3604 num_args: None,
3605 };
3606 let (json_type, items) = mcp_type_for_arg(&boolean_arg);
3607 assert_eq!(json_type, json!("boolean"));
3608 assert!(items.is_none());
3609 assert_eq!(
3610 mcp_action_description_hint(&boolean_arg),
3611 Some(" Boolean flag: set to true to pass this flag.".to_string())
3612 );
3613
3614 let false_arg = ClapArg {
3615 action: Some("SetFalse".to_string()),
3616 ..boolean_arg.clone()
3617 };
3618 assert_eq!(mcp_type_for_arg(&false_arg).0, json!("boolean"));
3619 assert_eq!(
3620 mcp_action_description_hint(&false_arg),
3621 Some(" Boolean flag: set to false to pass this flag (e.g. --no-xxx).".to_string())
3622 );
3623
3624 let count_arg = ClapArg {
3625 action: Some("Count".to_string()),
3626 ..boolean_arg.clone()
3627 };
3628 assert_eq!(mcp_type_for_arg(&count_arg).0, json!("integer"));
3629 assert_eq!(
3630 mcp_action_description_hint(&count_arg),
3631 Some(" Number of times the flag is passed (e.g. -vvv).".to_string())
3632 );
3633
3634 let append_arg = ClapArg {
3635 action: Some("Append".to_string()),
3636 value_names: vec!["TAG".to_string()],
3637 ..boolean_arg
3638 };
3639 let (json_type, items) = mcp_type_for_arg(&append_arg);
3640 assert_eq!(json_type, json!("array"));
3641 assert_eq!(
3642 items,
3643 Some(json!({ "type": "string", "description": "A TAG value" }))
3644 );
3645 assert_eq!(
3646 mcp_action_description_hint(&append_arg),
3647 Some(" List of TAG values; pass a JSON array (e.g. [\"a\", \"b\"]).".to_string())
3648 );
3649
3650 let multi_value_arg = ClapArg {
3651 id: "names".to_string(),
3652 long: Some("name".to_string()),
3653 short: None,
3654 help: None,
3655 long_help: None,
3656 required: false,
3657 global: false,
3658 index: None,
3659 action: Some("Set".to_string()),
3660 value_names: vec!["NAME".to_string()],
3661 num_args: Some("1..".to_string()),
3662 };
3663 let (json_type, items) = mcp_type_for_arg(&multi_value_arg);
3664 assert_eq!(json_type, json!("array"));
3665 assert_eq!(
3666 items,
3667 Some(json!({ "type": "string", "description": "A NAME value" }))
3668 );
3669 }
3670
3671 #[test]
3672 fn test_command_to_tool_with_config_reflects_arg_shapes() {
3673 let schema = sample_helper_schema();
3674 let tool = command_to_tool_with_config(
3675 &schema,
3676 &schema.root,
3677 &ClapMcpConfig {
3678 reinvocation_safe: true,
3679 parallel_safe: false,
3680 share_runtime: true,
3681 ..Default::default()
3682 },
3683 &ClapMcpSchemaMetadata::default(),
3684 None,
3685 );
3686
3687 assert_eq!(tool.name, "sample");
3688 assert_eq!(tool.description, None);
3689
3690 let props = tool
3691 .input_schema
3692 .get("properties")
3693 .and_then(|value| value.as_object())
3694 .expect("tool should include input schema properties");
3695 let required = tool
3696 .input_schema
3697 .get("required")
3698 .and_then(|value| value.as_array())
3699 .expect("tool should include required keys");
3700 assert_eq!(
3701 required
3702 .iter()
3703 .filter_map(|value| value.as_str())
3704 .collect::<Vec<_>>(),
3705 vec!["input"]
3706 );
3707 assert_eq!(
3708 props["verbose"]
3709 .get("type")
3710 .and_then(|value| value.as_str()),
3711 Some("boolean")
3712 );
3713 assert!(
3714 props["verbose"]["description"]
3715 .as_str()
3716 .expect("verbose description")
3717 .contains("Boolean flag")
3718 );
3719 assert_eq!(
3720 props["level"].get("type").and_then(|value| value.as_str()),
3721 Some("integer")
3722 );
3723 assert_eq!(
3724 props["tag"].get("type").and_then(|value| value.as_str()),
3725 Some("array")
3726 );
3727 assert_eq!(
3728 props["tag"]["items"]["description"].as_str(),
3729 Some("A TAG value")
3730 );
3731 assert_eq!(
3732 tool.meta
3733 .as_ref()
3734 .and_then(|meta| meta.get("clapMcp"))
3735 .and_then(|value| value.get("shareRuntime"))
3736 .and_then(|value| value.as_bool()),
3737 Some(true)
3738 );
3739 }
3740
3741 #[test]
3742 fn test_validate_required_args_handles_missing_empty_and_flag_values() {
3743 let schema = sample_helper_schema();
3744 let mut provided = serde_json::Map::new();
3745 provided.insert("verbose".to_string(), json!(false));
3746 provided.insert("level".to_string(), json!(0));
3747 provided.insert("input".to_string(), json!("input.txt"));
3748 assert!(validate_required_args(&schema, "sample", &provided).is_ok());
3749
3750 let mut missing_text = serde_json::Map::new();
3751 missing_text.insert("input".to_string(), json!(""));
3752 let error = validate_required_args(&schema, "sample", &missing_text)
3753 .expect_err("empty required string should fail");
3754 assert!(error.contains("Missing required argument(s): input"));
3755
3756 let mut missing_array = serde_json::Map::new();
3757 missing_array.insert("input".to_string(), json!([]));
3758 let error = validate_required_args(&schema, "sample", &missing_array)
3759 .expect_err("empty array should fail");
3760 assert!(error.contains("input"));
3761
3762 assert!(validate_required_args(&schema, "unknown", &serde_json::Map::new()).is_ok());
3763 }
3764
3765 #[test]
3766 fn test_serialize_lock_key_tool_wide() {
3767 let scope = ClapMcpSerializeScope::Tool;
3768 let args = serde_json::Map::new();
3769 assert_eq!(
3770 serialize_lock_key("flush", &args, &scope, None),
3771 "tool:flush"
3772 );
3773 }
3774
3775 #[test]
3776 fn test_serialize_lock_key_arg_scoped() {
3777 let scope = ClapMcpSerializeScope::Args(vec!["output".into()]);
3778 let mut args = serde_json::Map::new();
3779 args.insert("output".into(), json!("abc"));
3780 assert_eq!(
3781 serialize_lock_key("flush", &args, &scope, None),
3782 "tool:flush:output=abc"
3783 );
3784 }
3785
3786 #[test]
3787 fn test_serialize_lock_key_missing_arg_falls_back_to_tool_wide() {
3788 let scope = ClapMcpSerializeScope::Args(vec!["output".into()]);
3789 let args = serde_json::Map::new();
3790 assert_eq!(
3791 serialize_lock_key("flush", &args, &scope, None),
3792 "tool:flush"
3793 );
3794 }
3795
3796 #[test]
3797 fn test_serialize_lock_key_multi_arg_sorted() {
3798 let scope = ClapMcpSerializeScope::Args(vec!["bucket".into(), "region".into()]);
3799 let mut args = serde_json::Map::new();
3800 args.insert("region".into(), json!("us-east"));
3801 args.insert("bucket".into(), json!("logs"));
3802 assert_eq!(
3803 serialize_lock_key("sync", &args, &scope, None),
3804 "tool:sync:bucket=logs:region=us-east"
3805 );
3806 }
3807
3808 #[test]
3809 fn test_serialize_lock_key_typed_topic_fn() {
3810 fn topic(value: &serde_json::Value) -> Option<String> {
3811 String::serialize_topic_segment(value)
3812 }
3813 let scope = ClapMcpSerializeScope::Args(vec!["output".into()]);
3814 let mut fns = std::collections::HashMap::new();
3815 fns.insert("output".to_string(), topic as SerializeTopicSegmentFn);
3816 let mut args = serde_json::Map::new();
3817 args.insert("output".into(), json!("a"));
3818 let key = serialize_lock_key("flush", &args, &scope, Some(&fns));
3819 assert!(key.starts_with("tool:flush:output="));
3820 assert_ne!(key, "tool:flush");
3821 }
3822
3823 #[test]
3824 fn test_serialize_lock_key_typed_topic_fallback_to_json() {
3825 fn bad_parse(_: &serde_json::Value) -> Option<String> {
3826 None
3827 }
3828 let scope = ClapMcpSerializeScope::Args(vec!["output".into()]);
3829 let mut fns = std::collections::HashMap::new();
3830 fns.insert("output".to_string(), bad_parse as SerializeTopicSegmentFn);
3831 let mut args = serde_json::Map::new();
3832 args.insert("output".into(), json!("plain"));
3833 assert_eq!(
3834 serialize_lock_key("flush", &args, &scope, Some(&fns)),
3835 "tool:flush:output=plain"
3836 );
3837 }
3838
3839 #[test]
3840 fn test_serialize_topic_hash_eq_differs_from_json_for_equivalent_values() {
3841 #[derive(Hash, Eq, PartialEq, Deserialize)]
3842 struct Topic(u32);
3843 impl_serialize_topic_hash_eq!(Topic);
3844 let json_key = canonical_lock_arg_value(&json!(1)).unwrap();
3845 let typed_key = Topic::serialize_topic_segment(&json!(1)).unwrap();
3846 assert_ne!(json_key, typed_key);
3847 assert_eq!(
3848 Topic::serialize_topic_segment(&json!(1)),
3849 Topic::serialize_topic_segment(&json!(1))
3850 );
3851 }
3852
3853 #[test]
3854 fn test_canonical_lock_arg_value_object_key_order() {
3855 let a = json!({"b": 2, "a": 1});
3856 let b = json!({"a": 1, "b": 2});
3857 assert_eq!(canonical_lock_arg_value(&a), canonical_lock_arg_value(&b));
3858 }
3859
3860 #[test]
3861 fn test_canonical_lock_arg_value_array_order_matters() {
3862 assert_ne!(
3863 canonical_lock_arg_value(&json!(["a", "b"])),
3864 canonical_lock_arg_value(&json!(["b", "a"]))
3865 );
3866 }
3867
3868 fn command_with_arg_group() -> Command {
3869 Command::new("exec-modes")
3870 .arg(Arg::new("exec").long("exec"))
3871 .arg(Arg::new("exec_batch").long("exec-batch"))
3872 .group(
3873 ArgGroup::new("execs")
3874 .args(["exec", "exec_batch"])
3875 .required(true),
3876 )
3877 }
3878
3879 #[test]
3880 fn test_arg_groups_extracted_from_command() {
3881 let schema = schema_from_command(&command_with_arg_group());
3882 assert_eq!(schema.root.name, "exec-modes");
3883 assert_eq!(schema.root.arg_groups.len(), 1);
3884 let group = &schema.root.arg_groups[0];
3885 assert_eq!(group.id, "execs");
3886 assert_eq!(group.args, vec!["exec", "exec_batch"]);
3887 assert!(group.required);
3888 assert!(!group.multiple);
3889 }
3890
3891 #[test]
3892 fn test_arg_groups_meta_in_list_tools() {
3893 let schema = schema_from_command(&command_with_arg_group());
3894 let tools = tools_from_schema_with_metadata(
3895 &schema,
3896 &ClapMcpConfig::default(),
3897 &ClapMcpSchemaMetadata::default(),
3898 );
3899 let tool = tools
3900 .iter()
3901 .find(|t| t.name == "exec-modes")
3902 .expect("exec-modes tool");
3903 let arg_groups = tool
3904 .meta
3905 .as_ref()
3906 .and_then(|meta| meta.get("clapMcp"))
3907 .and_then(|value| value.get("argGroups"))
3908 .and_then(|value| value.as_array())
3909 .expect("argGroups meta");
3910 assert_eq!(arg_groups.len(), 1);
3911 assert_eq!(
3912 arg_groups[0].get("id").and_then(|v| v.as_str()),
3913 Some("execs")
3914 );
3915 let args = arg_groups[0]
3916 .get("args")
3917 .and_then(|v| v.as_array())
3918 .expect("args array");
3919 assert_eq!(
3920 args.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
3921 vec!["exec", "exec_batch"]
3922 );
3923 }
3924
3925 #[test]
3926 fn test_arg_groups_skip_filters_members() {
3927 let mut metadata = ClapMcpSchemaMetadata::default();
3928 metadata
3929 .skip_args
3930 .insert("exec-modes".into(), vec!["exec_batch".into()]);
3931 let schema = schema_from_command_with_metadata(&command_with_arg_group(), &metadata);
3932 assert!(
3933 schema.root.arg_groups.is_empty(),
3934 "group with one visible member should be omitted"
3935 );
3936 }
3937
3938 #[test]
3939 fn test_arg_groups_omitted_when_empty() {
3940 let schema = sample_helper_schema();
3941 let tools = tools_from_schema_with_metadata(
3942 &schema,
3943 &ClapMcpConfig::default(),
3944 &ClapMcpSchemaMetadata::default(),
3945 );
3946 for tool in &tools {
3947 let has_arg_groups = tool
3948 .meta
3949 .as_ref()
3950 .and_then(|meta| meta.get("clapMcp"))
3951 .and_then(|value| value.get("argGroups"))
3952 .is_some();
3953 assert!(!has_arg_groups, "tool {} should omit argGroups", tool.name);
3954 }
3955 }
3956
3957 #[test]
3958 fn test_arg_group_description_suffix() {
3959 let schema = schema_from_command(&command_with_arg_group());
3960 let tool = command_to_tool_with_config(
3961 &schema,
3962 &schema.root,
3963 &ClapMcpConfig::default(),
3964 &ClapMcpSchemaMetadata::default(),
3965 None,
3966 );
3967 let description = tool
3968 .description
3969 .as_ref()
3970 .map(|d| d.to_string())
3971 .expect("description");
3972 assert!(description.contains("Arg groups (parse-time)"));
3973 assert!(description.contains("`execs` requires one of"));
3974 assert!(description.contains("`exec`"));
3975 assert!(description.contains("`exec_batch`"));
3976 }
3977
3978 #[test]
3979 fn test_arg_groups_per_command_node() {
3980 let cmd = Command::new("root")
3981 .arg(Arg::new("root_a").long("root-a"))
3982 .arg(Arg::new("root_b").long("root-b"))
3983 .group(ArgGroup::new("root_group").args(["root_a", "root_b"]))
3984 .subcommand(
3985 Command::new("leaf")
3986 .arg(Arg::new("leaf_x").long("leaf-x"))
3987 .arg(Arg::new("leaf_y").long("leaf-y"))
3988 .group(ArgGroup::new("leaf_group").args(["leaf_x", "leaf_y"])),
3989 );
3990 let schema = schema_from_command(&cmd);
3991 assert_eq!(schema.root.arg_groups.len(), 1);
3992 assert_eq!(schema.root.arg_groups[0].id, "root_group");
3993 let leaf = schema
3994 .root
3995 .subcommands
3996 .iter()
3997 .find(|c| c.name == "leaf")
3998 .expect("leaf subcommand");
3999 assert_eq!(leaf.arg_groups.len(), 1);
4000 assert_eq!(leaf.arg_groups[0].id, "leaf_group");
4001
4002 let tools = tools_from_schema_with_metadata(
4003 &schema,
4004 &ClapMcpConfig::default(),
4005 &ClapMcpSchemaMetadata::default(),
4006 );
4007 let root_tool = tools.iter().find(|t| t.name == "root").expect("root tool");
4008 let leaf_tool = tools.iter().find(|t| t.name == "leaf").expect("leaf tool");
4009 let root_groups = root_tool
4010 .meta
4011 .as_ref()
4012 .and_then(|m| m.get("clapMcp"))
4013 .and_then(|v| v.get("argGroups"))
4014 .and_then(|v| v.as_array())
4015 .expect("root argGroups");
4016 assert_eq!(
4017 root_groups[0].get("id").and_then(|v| v.as_str()),
4018 Some("root_group")
4019 );
4020 let leaf_groups = leaf_tool
4021 .meta
4022 .as_ref()
4023 .and_then(|m| m.get("clapMcp"))
4024 .and_then(|v| v.get("argGroups"))
4025 .and_then(|v| v.as_array())
4026 .expect("leaf argGroups");
4027 assert_eq!(
4028 leaf_groups[0].get("id").and_then(|v| v.as_str()),
4029 Some("leaf_group")
4030 );
4031 }
4032
4033 #[test]
4034 fn test_tools_from_schema_serializes_meta() {
4035 let schema = sample_helper_schema();
4036 let config = ClapMcpConfig {
4037 reinvocation_safe: true,
4038 parallel_safe: true,
4039 ..Default::default()
4040 };
4041 let mut metadata = ClapMcpSchemaMetadata::default();
4042 metadata.serialize_tools.insert(
4043 "sample".into(),
4044 ClapMcpSerializeScope::Args(vec!["input".into()]),
4045 );
4046 let tools = tools_from_schema_with_metadata(&schema, &config, &metadata);
4047 let tool = tools
4048 .iter()
4049 .find(|t| t.name == "sample")
4050 .expect("sample tool");
4051 let clap_mcp = tool
4052 .meta
4053 .as_ref()
4054 .and_then(|meta| meta.get("clapMcp"))
4055 .and_then(|value| value.as_object())
4056 .expect("clapMcp meta");
4057 assert_eq!(
4058 clap_mcp.get("serialized").and_then(|v| v.as_bool()),
4059 Some(true)
4060 );
4061 assert_eq!(
4062 clap_mcp.get("serializeScope").and_then(|v| v.as_str()),
4063 Some("args")
4064 );
4065 assert_eq!(
4066 clap_mcp.get("serializeArgs").and_then(|v| v.as_array()),
4067 Some(&vec![json!("input")])
4068 );
4069 }
4070
4071 #[test]
4072 fn test_build_tool_argv_handles_positional_flags_and_lists() {
4073 let schema = sample_helper_schema();
4074 let arguments = serde_json::Map::from_iter([
4075 ("input".to_string(), json!("input.txt")),
4076 ("verbose".to_string(), json!(true)),
4077 ("no-cache".to_string(), json!(false)),
4078 ("level".to_string(), json!(2)),
4079 ("tag".to_string(), json!(["alpha", "", "beta"])),
4080 ("mode".to_string(), json!("fast")),
4081 ]);
4082
4083 let argv = build_tool_argv(&schema, "sample", arguments);
4084 assert_eq!(
4085 argv,
4086 vec![
4087 "input.txt",
4088 "--level",
4089 "--level",
4090 "--mode",
4091 "fast",
4092 "--no-cache",
4093 "--tag",
4094 "alpha",
4095 "--tag",
4096 "beta",
4097 "--verbose",
4098 ]
4099 );
4100 }
4101
4102 fn passthrough_exec_command(allow_hyphen_values: bool) -> Command {
4103 let mut trailing = Arg::new("command").last(true).num_args(1..);
4104 if allow_hyphen_values {
4105 trailing = trailing.allow_hyphen_values(true);
4106 }
4107 Command::new("passthrough-args").subcommand(
4108 Command::new("exec")
4109 .arg(
4110 Arg::new("dry_run")
4111 .long("dry-run")
4112 .action(ArgAction::SetTrue),
4113 )
4114 .arg(trailing),
4115 )
4116 }
4117
4118 #[test]
4119 fn test_build_tool_argv_trailing_vec_with_hyphen_tokens() {
4120 let schema = schema_from_command(&passthrough_exec_command(true));
4121 let arguments = serde_json::Map::from_iter([
4122 ("dry_run".to_string(), json!(false)),
4123 ("command".to_string(), json!(["-v", "--mcp", "hello"])),
4124 ]);
4125 let argv = build_tool_argv(&schema, "exec", arguments);
4126 assert_eq!(argv, vec!["--", "-v", "--mcp", "hello"]);
4127 }
4128
4129 #[test]
4130 fn test_build_argv_round_trip_with_hyphen_trailing_vec() {
4131 let cmd = passthrough_exec_command(true);
4132 let schema = schema_from_command(&cmd);
4133 let arguments = serde_json::Map::from_iter([
4134 ("dry_run".to_string(), json!(true)),
4135 ("command".to_string(), json!(["-v", "hello"])),
4136 ]);
4137 let argv = build_argv_for_clap(&schema, "exec", arguments);
4138 let matches = cmd
4139 .try_get_matches_from(argv)
4140 .expect("trailing vec with -- separator should parse hyphen tokens");
4141 let sub = matches.subcommand().expect("exec subcommand");
4142 assert!(sub.1.get_flag("dry_run"));
4143 assert_eq!(
4144 sub.1
4145 .get_many::<String>("command")
4146 .into_iter()
4147 .flatten()
4148 .map(|s| s.as_str())
4149 .collect::<Vec<_>>(),
4150 vec!["-v", "hello"]
4151 );
4152 }
4153
4154 #[test]
4155 fn test_build_argv_round_trip_with_trailing_vec() {
4156 let cmd = passthrough_exec_command(true);
4157 let schema = schema_from_command(&cmd);
4158 let arguments = serde_json::Map::from_iter([
4159 ("dry_run".to_string(), json!(true)),
4160 ("command".to_string(), json!(["echo", "hello"])),
4161 ]);
4162 let argv = build_argv_for_clap(&schema, "exec", arguments);
4163 assert_eq!(
4164 argv,
4165 vec![
4166 "cli".to_string(),
4167 "exec".to_string(),
4168 "--dry-run".to_string(),
4169 "--".to_string(),
4170 "echo".to_string(),
4171 "hello".to_string(),
4172 ]
4173 );
4174 let matches = cmd
4175 .try_get_matches_from(argv)
4176 .expect("trailing vec after flags should parse");
4177 let sub = matches.subcommand().expect("exec subcommand");
4178 assert_eq!(sub.0, "exec");
4179 assert!(sub.1.get_flag("dry_run"));
4180 assert_eq!(
4181 sub.1
4182 .get_many::<String>("command")
4183 .into_iter()
4184 .flatten()
4185 .map(|s| s.as_str())
4186 .collect::<Vec<_>>(),
4187 vec!["echo", "hello"]
4188 );
4189 }
4190
4191 #[test]
4192 fn test_build_argv_hyphen_trailing_fails_without_end_of_opts() {
4193 let cmd = passthrough_exec_command(false);
4194 let err = cmd
4195 .try_get_matches_from(["cli", "exec", "-v", "hello"])
4196 .expect_err("trailing hyphen tokens without -- should fail clap parse");
4197 assert!(
4198 err.to_string().contains("unexpected argument")
4199 || err.to_string().contains("unknown argument")
4200 || err.to_string().contains("found argument"),
4201 "unexpected error: {err}"
4202 );
4203 }
4204
4205 #[test]
4206 fn test_value_to_string_and_value_to_strings_cover_scalar_and_array_inputs() {
4207 assert_eq!(value_to_string(&json!("hello")), Some("hello".to_string()));
4208 assert_eq!(value_to_string(&json!(3)), Some("3".to_string()));
4209 assert_eq!(value_to_string(&json!(false)), Some("false".to_string()));
4210 assert_eq!(value_to_string(&serde_json::Value::Null), None);
4211 assert_eq!(
4212 value_to_string(&json!({"name":"sample"})),
4213 Some("{\"name\":\"sample\"}".to_string())
4214 );
4215
4216 assert_eq!(
4217 value_to_strings(&json!(["alpha", "", 3, null, false])),
4218 Some(vec![
4219 "alpha".to_string(),
4220 "3".to_string(),
4221 "false".to_string()
4222 ])
4223 );
4224 assert_eq!(
4225 value_to_strings(&json!("solo")),
4226 Some(vec!["solo".to_string()])
4227 );
4228 assert_eq!(value_to_strings(&serde_json::Value::Null), None);
4229 }
4230
4231 #[test]
4232 fn test_command_flag_helpers_are_idempotent() {
4233 let cmd = command_with_mcp_flag(command_with_mcp_flag(Command::new("sample")));
4234 let mcp_args = cmd
4235 .get_arguments()
4236 .filter(|arg| arg.get_long() == Some(MCP_FLAG_LONG))
4237 .count();
4238 assert_eq!(mcp_args, 1);
4239
4240 let cmd = command_with_export_skills_flag(command_with_export_skills_flag(Command::new(
4241 "sample",
4242 )));
4243 let export_args = cmd
4244 .get_arguments()
4245 .filter(|arg| arg.get_long() == Some(EXPORT_SKILLS_FLAG_LONG))
4246 .count();
4247 assert_eq!(export_args, 1);
4248
4249 let cmd = command_with_mcp_and_export_skills_flags(Command::new("bare"));
4250 assert_eq!(
4251 cmd.get_arguments()
4252 .filter(|arg| arg.get_long() == Some(MCP_FLAG_LONG))
4253 .count(),
4254 1
4255 );
4256 assert_eq!(
4257 cmd.get_arguments()
4258 .filter(|arg| arg.get_long() == Some(EXPORT_SKILLS_FLAG_LONG))
4259 .count(),
4260 1
4261 );
4262 }
4263
4264 #[test]
4265 fn test_argv_export_skills_dir_from_args() {
4266 let flags = ClapMcpBuiltinFlags::default();
4267 assert!(argv_export_skills_dir_from_args(&[], &flags).is_none());
4268 assert!(argv_export_skills_dir_from_args(&["--other".to_string()], &flags).is_none());
4269 assert_eq!(
4270 argv_export_skills_dir_from_args(&["--export-skills".to_string()], &flags),
4271 Some(None)
4272 );
4273 assert_eq!(
4274 argv_export_skills_dir_from_args(
4275 &["--export-skills".to_string(), "out".to_string()],
4276 &flags
4277 ),
4278 Some(Some(std::path::PathBuf::from("out")))
4279 );
4280 assert_eq!(
4281 argv_export_skills_dir_from_args(
4282 &["--export-skills".to_string(), "--mcp".to_string()],
4283 &flags
4284 ),
4285 Some(None)
4286 );
4287 assert_eq!(
4288 argv_export_skills_dir_from_args(&["--export-skills=out".to_string()], &flags),
4289 Some(Some(std::path::PathBuf::from("out")))
4290 );
4291 assert!(
4292 argv_export_skills_dir_from_args(
4293 &[
4294 "run".to_string(),
4295 "--".to_string(),
4296 "--export-skills".to_string()
4297 ],
4298 &flags
4299 )
4300 .is_none(),
4301 "export-skills after -- must not trigger"
4302 );
4303 }
4304
4305 #[test]
4306 fn test_argv_before_end_of_opts() {
4307 let args = vec!["run".to_string(), "--".to_string(), "--mcp".to_string()];
4308 assert_eq!(
4309 argv_before_end_of_opts(&args),
4310 &["run".to_string()] as &[String]
4311 );
4312 }
4313
4314 #[test]
4315 fn test_argv_contains_clap_mcp_flags() {
4316 let flags = ClapMcpBuiltinFlags::default();
4317 assert!(!argv_contains_clap_mcp_flags(&[], &flags));
4318 assert!(!argv_contains_clap_mcp_flags(
4319 &["run".to_string(), "hello".to_string()],
4320 &flags
4321 ));
4322 assert!(argv_contains_clap_mcp_flags(&["--mcp".to_string()], &flags));
4323 assert!(argv_contains_clap_mcp_flags(
4324 &["--export-skills".to_string()],
4325 &flags
4326 ));
4327 assert!(argv_contains_clap_mcp_flags(
4328 &["run".to_string(), "--mcp".to_string()],
4329 &flags
4330 ));
4331 assert!(!argv_contains_clap_mcp_flags(
4332 &["run".to_string(), "--".to_string(), "--mcp".to_string()],
4333 &flags
4334 ));
4335 let custom = ClapMcpBuiltinFlags::default().with_stdio_long("modelcontextprotocol");
4336 assert!(argv_contains_clap_mcp_flags(
4337 &["--modelcontextprotocol".to_string()],
4338 &custom
4339 ));
4340 assert!(!argv_contains_clap_mcp_flags(
4341 &["--mcp".to_string()],
4342 &custom
4343 ));
4344 #[cfg(feature = "http")]
4345 {
4346 assert!(argv_contains_clap_mcp_flags(
4347 &["--mcp-http".to_string()],
4348 &flags
4349 ));
4350 assert!(argv_contains_clap_mcp_flags(
4351 &["--mcp-http".to_string(), "127.0.0.1:8080".to_string()],
4352 &flags
4353 ));
4354 }
4355 }
4356
4357 #[test]
4358 fn test_argv_requests_mcp_without_subcommand_from_args() {
4359 let cmd = Command::new("app").subcommand(Command::new("run"));
4360 let flags = ClapMcpBuiltinFlags::default();
4361 assert!(argv_requests_mcp_without_subcommand_from_args(
4362 &["--mcp".to_string()],
4363 &cmd,
4364 &flags
4365 ));
4366 assert!(!argv_requests_mcp_without_subcommand_from_args(
4367 &["--mcp".to_string(), "run".to_string()],
4368 &cmd,
4369 &flags
4370 ));
4371 assert!(!argv_requests_mcp_without_subcommand_from_args(
4372 &["run".to_string()],
4373 &cmd,
4374 &flags
4375 ));
4376 assert!(!argv_requests_mcp_without_subcommand_from_args(
4377 &[],
4378 &cmd,
4379 &flags
4380 ));
4381 assert!(!argv_requests_mcp_without_subcommand_from_args(
4382 &["run".to_string(), "--".to_string(), "--mcp".to_string()],
4383 &cmd,
4384 &flags
4385 ));
4386 assert!(!argv_requests_mcp_without_subcommand_from_args(
4387 &["run".to_string(), "--mcp".to_string()],
4388 &cmd,
4389 &flags
4390 ));
4391 assert!(argv_requests_mcp_without_subcommand_from_args(
4392 &["--mcp".to_string(), "--".to_string(), "--mcp".to_string()],
4393 &cmd,
4394 &flags
4395 ));
4396 }
4397
4398 #[test]
4399 fn test_argv_requests_mcp_custom_stdio_long() {
4400 let cmd = Command::new("app");
4401 let flags = ClapMcpBuiltinFlags::default().with_stdio_long("modelcontextprotocol");
4402 assert!(argv_requests_mcp_without_subcommand_from_args(
4403 &["--modelcontextprotocol".to_string()],
4404 &cmd,
4405 &flags
4406 ));
4407 assert!(!argv_requests_mcp_without_subcommand_from_args(
4408 &["--mcp".to_string()],
4409 &cmd,
4410 &flags
4411 ));
4412 assert!(!argv_requests_mcp_without_subcommand_from_args(
4413 &[
4414 "run".to_string(),
4415 "--".to_string(),
4416 "--modelcontextprotocol".to_string(),
4417 ],
4418 &cmd,
4419 &flags
4420 ));
4421 }
4422
4423 #[test]
4424 fn test_is_builtin_arg() {
4425 assert!(is_builtin_arg("help"));
4426 assert!(is_builtin_arg("version"));
4427 assert!(is_builtin_arg(CLAP_MCP_STDIO_FLAG_ID));
4428 assert!(!is_builtin_arg(CLAP_MCP_STDIO_FLAG_ID_LEGACY));
4429 assert!(is_builtin_arg(EXPORT_SKILLS_FLAG_LONG));
4430 assert!(!is_builtin_arg("input"));
4431 assert!(!is_builtin_arg("path"));
4432 assert!(!is_builtin_arg("mcp"), "user mcp field must not be builtin");
4433 }
4434
4435 #[test]
4436 fn test_tools_from_schema_with_metadata() {
4437 let schema = sample_helper_schema();
4438 let tools = tools_from_schema_with_metadata(
4439 &schema,
4440 &ClapMcpConfig::default(),
4441 &ClapMcpSchemaMetadata::default(),
4442 );
4443 assert!(!tools.is_empty());
4444 }
4445
4446 #[cfg(feature = "http")]
4447 #[test]
4448 fn test_mcp_http_listen_from_env_and_flag_alone() {
4449 let listen_key = MCP_HTTP_LISTEN_ENV;
4450 let bind_key = MCP_HTTP_BIND_ENV;
4451 let port_key = MCP_HTTP_PORT_ENV;
4452
4453 let flags = ClapMcpBuiltinFlags::default();
4454 unsafe {
4455 std::env::set_var(listen_key, "127.0.0.1:9090");
4456 }
4457 assert_eq!(
4458 argv_mcp_http_listen_from_args(&["--mcp-http".to_string()], &flags),
4459 Some("127.0.0.1:9090".to_string())
4460 );
4461 unsafe {
4462 std::env::remove_var(listen_key);
4463 }
4464
4465 unsafe {
4466 std::env::set_var(bind_key, "127.0.0.1");
4467 std::env::set_var(port_key, "9091");
4468 }
4469 assert_eq!(
4470 argv_mcp_http_listen_from_args(&["--mcp-http".to_string()], &flags),
4471 Some("127.0.0.1:9091".to_string())
4472 );
4473 unsafe {
4474 std::env::remove_var(bind_key);
4475 std::env::remove_var(port_key);
4476 }
4477 }
4478
4479 #[cfg(feature = "http")]
4480 #[test]
4481 fn test_http_flag_helpers_cover_command_and_argv_shapes() {
4482 use clap::Command;
4483
4484 let cmd = command_with_mcp_http_flag(Command::new("app"));
4485 assert!(
4486 cmd.get_arguments()
4487 .any(|a| a.get_long() == Some(MCP_HTTP_FLAG_LONG))
4488 );
4489
4490 let flags = ClapMcpBuiltinFlags::default();
4491 assert_eq!(
4492 argv_mcp_http_listen_from_args(
4493 &["--mcp-http".to_string(), "127.0.0.1:4242".to_string()],
4494 &flags
4495 ),
4496 Some("127.0.0.1:4242".to_string())
4497 );
4498 assert_eq!(
4499 argv_mcp_http_listen_from_args(&["--mcp-http=10.0.0.1:9".to_string()], &flags),
4500 Some("10.0.0.1:9".to_string())
4501 );
4502
4503 let mut cmd = Command::new("app");
4504 cmd = cmd.arg(
4505 clap::Arg::new(CLAP_MCP_HTTP_FLAG_ID)
4506 .long(MCP_HTTP_FLAG_LONG)
4507 .global(true),
4508 );
4509 let unchanged = command_with_mcp_http_flag_with_flags(cmd, &flags);
4510 assert_eq!(unchanged.get_arguments().count(), 1);
4511
4512 let matches = Command::new("app")
4513 .arg(
4514 clap::Arg::new(CLAP_MCP_HTTP_FLAG_ID)
4515 .long(MCP_HTTP_FLAG_LONG)
4516 .value_name("ADDR")
4517 .global(true),
4518 )
4519 .get_matches_from(["app", "--mcp-http", "127.0.0.1:1"]);
4520 assert!(matches_http_flag(&matches, &flags));
4521 assert!(is_builtin_arg(CLAP_MCP_HTTP_FLAG_ID));
4522 }
4523
4524 #[cfg(feature = "http")]
4525 #[test]
4526 fn test_parse_mcp_http_listen_reports_invalid_addresses() {
4527 let err = parse_mcp_http_listen("not-an-address").expect_err("invalid listen");
4528 assert!(
4529 matches!(err, ClapMcpError::InvalidConfig(message) if message.contains("invalid MCP HTTP listen"))
4530 );
4531 assert!(
4532 mcp_http_listen_error_message(&ClapMcpBuiltinFlags::default())
4533 .contains(MCP_HTTP_LISTEN_ENV)
4534 );
4535 }
4536
4537 #[cfg(feature = "http")]
4538 #[test]
4539 fn test_resolve_mcp_http_listen_from_args_covers_success_and_errors() {
4540 let flags = ClapMcpBuiltinFlags::default();
4541 assert_eq!(
4542 resolve_mcp_http_listen_from_args(
4543 &["--mcp-http".to_string(), "127.0.0.1:4242".to_string()],
4544 &flags
4545 )
4546 .expect("listen should parse")
4547 .map(|addr| addr.to_string()),
4548 Some("127.0.0.1:4242".to_string())
4549 );
4550 assert!(
4551 resolve_mcp_http_listen_from_args(&["--help".to_string()], &flags)
4552 .expect("no http flag")
4553 .is_none()
4554 );
4555 let missing = resolve_mcp_http_listen_from_args(&["--mcp-http".to_string()], &flags)
4556 .expect_err("missing host:port should error");
4557 assert!(
4558 matches!(missing, ClapMcpError::InvalidConfig(message) if message.contains(MCP_HTTP_LISTEN_ENV))
4559 );
4560 }
4561
4562 #[cfg(feature = "http")]
4563 #[test]
4564 fn test_argv_requests_mcp_http_without_subcommand_from_args() {
4565 let cmd = Command::new("app").subcommand(Command::new("run"));
4566 let flags = ClapMcpBuiltinFlags::default();
4567 assert!(argv_requests_mcp_http_without_subcommand_from_args(
4568 &["--mcp-http".to_string(), "127.0.0.1:1".to_string()],
4569 &cmd,
4570 &flags
4571 ));
4572 assert!(!argv_requests_mcp_http_without_subcommand_from_args(
4573 &[
4574 "run".to_string(),
4575 "--mcp-http".to_string(),
4576 "127.0.0.1:1".to_string()
4577 ],
4578 &cmd,
4579 &flags
4580 ));
4581 }
4582
4583 #[tokio::test]
4584 async fn test_serve_mcp_fails_fast_on_invalid_schema_json() {
4585 let err = serve_mcp(
4586 McpListen::Stdio,
4587 "not-json".to_string(),
4588 None,
4589 ClapMcpConfig::default(),
4590 None,
4591 ClapMcpServeOptions::default(),
4592 &ClapMcpSchemaMetadata::default(),
4593 )
4594 .await
4595 .expect_err("invalid schema should fail");
4596 assert!(matches!(err, ClapMcpError::SchemaJson(_)));
4597 }
4598
4599 #[test]
4600 fn test_ambiguous_positional_scalars_build_swapped_argv() {
4601 use clap::{FromArgMatches, Parser, Subcommand};
4602
4603 #[derive(Debug, Subcommand)]
4604 enum Cmd {
4605 Edit { task_id: String, state: String },
4606 }
4607
4608 #[derive(Debug, Parser)]
4609 #[command(subcommand_required = true)]
4610 struct App {
4611 #[command(subcommand)]
4612 cmd: Cmd,
4613 }
4614
4615 let schema = schema_from_command(&App::command());
4616 let args = serde_json::Map::from_iter([
4617 ("task_id".to_string(), json!("done")),
4618 ("state".to_string(), json!("TASK-0")),
4619 ]);
4620 let argv = build_argv_for_clap(&schema, "edit", args);
4621 assert_eq!(argv, vec!["cli", "edit", "TASK-0", "done"]);
4622
4623 let matches = App::command().get_matches_from(argv);
4624 let parsed = App::from_arg_matches(&matches).expect("app should parse");
4625 match parsed.cmd {
4626 Cmd::Edit { task_id, state } => {
4627 assert_eq!(task_id, "TASK-0");
4628 assert_eq!(state, "done");
4629 }
4630 }
4631 }
4632
4633 #[test]
4634 fn test_command_path_and_build_argv_for_clap() {
4635 let schema = nested_schema();
4636 assert_eq!(command_path(&schema, "sample"), Some(vec!["sample".into()]));
4637 assert_eq!(
4638 command_path(&schema, "child"),
4639 Some(vec!["sample".into(), "parent".into(), "child".into()])
4640 );
4641 assert_eq!(command_path(&schema, "nonexistent"), None);
4642
4643 let args = serde_json::Map::from_iter([("value".to_string(), json!("v"))]);
4644 let argv = build_argv_for_clap(&schema, "child", args);
4645 assert_eq!(argv[0], "cli");
4646 assert_eq!(argv[1], "parent");
4647 assert_eq!(argv[2], "child");
4648 assert!(argv.contains(&"--value".to_string()));
4649 assert!(argv.contains(&"v".to_string()));
4650
4651 let empty_argv = build_tool_argv(&schema, "nonexistent", serde_json::Map::new());
4652 assert!(empty_argv.is_empty());
4653 }
4654
4655 #[cfg(not(feature = "output-schema"))]
4656 #[test]
4657 fn test_output_schema_for_type_without_schemars() {
4658 assert!(output_schema_for_type::<()>().is_none());
4659 }
4660
4661 #[cfg(feature = "output-schema")]
4662 #[test]
4663 fn test_output_schema_for_type_with_schemars() {
4664 use schemars::JsonSchema;
4665 #[derive(JsonSchema)]
4666 struct Dummy {
4667 _x: i32,
4668 }
4669 let schema = output_schema_for_type::<Dummy>();
4670 assert!(schema.is_some());
4671 }
4672
4673 #[tokio::test]
4674 async fn test_resource_helpers_cover_builtin_custom_and_error_paths() {
4675 let custom = vec![content::CustomResource {
4676 uri: "test://dynamic".to_string(),
4677 name: "dynamic".to_string(),
4678 title: None,
4679 description: Some("dynamic resource".to_string()),
4680 mime_type: Some("text/plain".to_string()),
4681 content: content::ResourceContent::Dynamic(Arc::new(TestResourceProvider {
4682 response: Ok("dynamic body".to_string()),
4683 })),
4684 }];
4685
4686 let listed = list_resources_result(&custom);
4687 assert_eq!(listed.resources.len(), 2);
4688 assert_eq!(listed.resources[0].uri, MCP_RESOURCE_URI_SCHEMA);
4689 assert_eq!(listed.resources[1].uri, "test://dynamic");
4690
4691 let schema_read = read_resource_result(
4692 "{\"name\":\"sample\"}",
4693 &custom,
4694 ReadResourceRequestParams::new(MCP_RESOURCE_URI_SCHEMA),
4695 )
4696 .await
4697 .expect("schema resource should resolve");
4698 let text = match &schema_read.contents[0] {
4699 ResourceContents::TextResourceContents { text, .. } => text,
4700 other => panic!("unexpected content: {other:?}"),
4701 };
4702 assert!(text.contains("\"name\":\"sample\""));
4703
4704 let custom_read = read_resource_result(
4705 "{}",
4706 &custom,
4707 ReadResourceRequestParams::new("test://dynamic"),
4708 )
4709 .await
4710 .expect("custom resource should resolve");
4711 let text = match &custom_read.contents[0] {
4712 ResourceContents::TextResourceContents { text, .. } => text,
4713 other => panic!("unexpected content: {other:?}"),
4714 };
4715 assert_eq!(text, "dynamic body");
4716
4717 let missing = read_resource_result(
4718 "{}",
4719 &custom,
4720 ReadResourceRequestParams::new("test://missing"),
4721 )
4722 .await
4723 .expect_err("missing resource should error");
4724 assert!(missing.message.contains("unknown resource uri"));
4725
4726 let failing_resources = vec![content::CustomResource {
4727 uri: "test://broken".to_string(),
4728 name: "broken".to_string(),
4729 title: None,
4730 description: None,
4731 mime_type: None,
4732 content: content::ResourceContent::Dynamic(Arc::new(TestResourceProvider {
4733 response: Err("read failed"),
4734 })),
4735 }];
4736 let failing = read_resource_result(
4737 "{}",
4738 &failing_resources,
4739 ReadResourceRequestParams::new("test://broken"),
4740 )
4741 .await
4742 .expect_err("provider failure should map to rpc error");
4743 assert_eq!(failing.message, "read failed");
4744 }
4745
4746 #[tokio::test]
4747 async fn test_prompt_helpers_cover_logging_custom_and_error_paths() {
4748 let provider = Arc::new(TestPromptProvider {
4749 response: Ok(vec![PromptMessage::new_text(
4750 PromptMessageRole::User,
4751 "dynamic prompt",
4752 )]),
4753 seen: Mutex::new(Vec::new()),
4754 });
4755 let prompts = vec![content::CustomPrompt {
4756 name: "dynamic".to_string(),
4757 title: Some("Dynamic".to_string()),
4758 description: Some("dynamic prompt".to_string()),
4759 arguments: vec![],
4760 content: content::PromptContent::Dynamic(provider.clone()),
4761 }];
4762
4763 let listed = list_prompts_result(true, &prompts);
4764 assert_eq!(listed.prompts.len(), 2);
4765 assert_eq!(listed.prompts[0].name, PROMPT_LOGGING_GUIDE);
4766 assert_eq!(listed.prompts[1].name, "dynamic");
4767
4768 let logging_prompt = get_prompt_result(
4769 true,
4770 &prompts,
4771 GetPromptRequestParams::new(PROMPT_LOGGING_GUIDE),
4772 )
4773 .await
4774 .expect("logging guide should resolve");
4775 assert!(prompt_text(&logging_prompt.messages[0].content).contains("logger"));
4776
4777 let mut topic_args = serde_json::Map::new();
4778 topic_args.insert("topic".into(), json!("coverage"));
4779 let dynamic_prompt = get_prompt_result(
4780 false,
4781 &prompts,
4782 GetPromptRequestParams::new("dynamic").with_arguments(topic_args),
4783 )
4784 .await
4785 .expect("dynamic prompt should resolve");
4786 assert_eq!(
4787 dynamic_prompt.description.as_deref(),
4788 Some("dynamic prompt")
4789 );
4790 assert_eq!(
4791 provider
4792 .seen
4793 .lock()
4794 .expect("provider seen mutex should lock")[0]
4795 .1
4796 .get("topic")
4797 .and_then(|value| value.as_str()),
4798 Some("coverage")
4799 );
4800
4801 let unknown_logging = get_prompt_result(
4802 false,
4803 &prompts,
4804 GetPromptRequestParams::new(PROMPT_LOGGING_GUIDE),
4805 )
4806 .await
4807 .expect_err("logging guide should error when logging disabled");
4808 assert!(unknown_logging.message.contains("unknown prompt"));
4809
4810 let failing_prompts = vec![content::CustomPrompt {
4811 name: "broken".to_string(),
4812 title: None,
4813 description: None,
4814 arguments: vec![],
4815 content: content::PromptContent::Dynamic(Arc::new(TestPromptProvider {
4816 response: Err("prompt failed"),
4817 seen: Mutex::new(Vec::new()),
4818 })),
4819 }];
4820 let failing = get_prompt_result(
4821 false,
4822 &failing_prompts,
4823 GetPromptRequestParams::new("broken"),
4824 )
4825 .await
4826 .expect_err("provider failure should map to rpc error");
4827 assert_eq!(failing.message, "prompt failed");
4828 }
4829
4830 #[test]
4831 fn test_call_tool_result_helpers_cover_text_structured_errors_and_panics() {
4832 let text = call_tool_result_from_output(ClapMcpToolOutput::Text("hello".to_string()));
4833 assert_ne!(text.is_error, Some(true));
4834 assert_eq!(content_text(&text.content[0]), "hello");
4835
4836 let structured = call_tool_result_from_output(ClapMcpToolOutput::Structured(json!({
4837 "sum": 5
4838 })));
4839 assert_eq!(
4840 structured
4841 .structured_content
4842 .as_ref()
4843 .and_then(|content| content.get("sum"))
4844 .and_then(|value| value.as_i64()),
4845 Some(5)
4846 );
4847 assert!(content_text(&structured.content[0]).contains("\"sum\": 5"));
4848
4849 let array_value = call_tool_result_from_output(ClapMcpToolOutput::Structured(json!(["a"])));
4850 assert_eq!(array_value.structured_content.as_ref(), Some(&json!(["a"])));
4851
4852 let error = call_tool_result_from_tool_error(ClapMcpToolError::structured(
4853 "bad",
4854 json!({ "code": 7 }),
4855 ));
4856 assert_eq!(error.is_error, Some(true));
4857 assert_eq!(
4858 error
4859 .structured_content
4860 .as_ref()
4861 .and_then(|content| content.get("code"))
4862 .and_then(|value| value.as_i64()),
4863 Some(7)
4864 );
4865
4866 let panic_payload: Box<dyn std::any::Any + Send> = Box::new("boom");
4867 let panic_result = call_tool_result_from_panic(panic_payload.as_ref());
4868 assert_eq!(panic_result.is_error, Some(true));
4869 assert!(content_text(&panic_result.content[0]).contains("Tool panicked: boom"));
4870 }
4871
4872 #[test]
4873 fn test_subprocess_helpers_cover_command_building_logging_and_result_shapes() {
4874 let schema = nested_schema();
4875 let args = serde_json::Map::from_iter([(
4876 "value".to_string(),
4877 serde_json::Value::String("ok".to_string()),
4878 )]);
4879 let command = build_execution_command(
4880 std::path::Path::new("/tmp/example"),
4881 &schema,
4882 "sample",
4883 "child",
4884 &args,
4885 );
4886 assert_eq!(command.get_program(), std::ffi::OsStr::new("/tmp/example"));
4887 let actual_args: Vec<_> = command.get_args().collect();
4888 assert_eq!(
4889 actual_args,
4890 vec![
4891 std::ffi::OsStr::new("parent"),
4892 std::ffi::OsStr::new("child"),
4893 std::ffi::OsStr::new("--value"),
4894 std::ffi::OsStr::new("ok"),
4895 ]
4896 );
4897
4898 let log_params = subprocess_stderr_log_params("child", "warning on stderr\n")
4899 .expect("stderr should produce logging params");
4900 assert_eq!(log_params.logger.as_deref(), Some("stderr"));
4901 assert_eq!(
4902 log_params.meta.as_ref().and_then(|meta| meta.get("tool")),
4903 Some(&serde_json::Value::String("child".to_string()))
4904 );
4905 assert!(subprocess_stderr_log_params("child", " ").is_none());
4906
4907 #[cfg(unix)]
4908 {
4909 let success_output = std::process::Output {
4910 status: std::process::ExitStatus::from_raw(0),
4911 stdout: b"done\n".to_vec(),
4912 stderr: b"note\n".to_vec(),
4913 };
4914 let success = call_tool_result_from_subprocess_output(&success_output);
4915 assert_ne!(success.is_error, Some(true));
4916 assert!(content_text(&success.content[0]).contains("stderr:\nnote"));
4917
4918 let failure_output = std::process::Output {
4919 status: std::process::ExitStatus::from_raw(256),
4920 stdout: Vec::new(),
4921 stderr: b"boom\n".to_vec(),
4922 };
4923 let failure = call_tool_result_from_subprocess_output(&failure_output);
4924 assert_eq!(failure.is_error, Some(true));
4925 assert!(content_text(&failure.content[0]).contains("non-zero status"));
4926 }
4927
4928 let launch_error = command_launch_failure_result(&std::io::Error::new(
4929 std::io::ErrorKind::NotFound,
4930 "missing",
4931 ));
4932 assert_eq!(launch_error.is_error, Some(true));
4933 assert!(content_text(&launch_error.content[0]).contains("Failed to run command"));
4934
4935 let placeholder = placeholder_tool_result(
4936 "echo",
4937 &serde_json::Map::from_iter([("message".to_string(), json!("hi"))]),
4938 );
4939 assert!(content_text(&placeholder.content[0]).contains("Would invoke clap command 'echo'"));
4940
4941 let parse_failure = schema_parse_failure_result();
4942 assert_eq!(parse_failure.is_error, Some(true));
4943 assert_eq!(
4944 content_text(&parse_failure.content[0]),
4945 "Failed to parse schema"
4946 );
4947 }
4948
4949 #[test]
4950 fn test_validate_tool_argument_names_rejects_unknown_keys() {
4951 let schema = sample_helper_schema();
4952 let tool = command_to_tool_with_config(
4953 &schema,
4954 &schema.root,
4955 &ClapMcpConfig::default(),
4956 &ClapMcpSchemaMetadata::default(),
4957 None,
4958 );
4959 let ok_args = serde_json::Map::from_iter([("input".to_string(), json!("in.txt"))]);
4960 assert!(validate_tool_argument_names(&tool, &tool.name, &ok_args).is_ok());
4961
4962 let bad_args = serde_json::Map::from_iter([("bogus".to_string(), json!(1))]);
4963 let err = validate_tool_argument_names(&tool, &tool.name, &bad_args)
4964 .expect_err("unknown key should error");
4965 assert!(format!("{err:?}").contains("unknown argument: bogus"));
4966 }
4967
4968 #[test]
4969 fn test_into_clap_mcp_result_and_error_impls_cover_basic_conversions() {
4970 assert!(matches!(
4971 String::from("hello")
4972 .into_tool_result()
4973 .expect("string should convert"),
4974 ClapMcpToolOutput::Text(text) if text == "hello"
4975 ));
4976 assert!(matches!(
4977 "world"
4978 .into_tool_result()
4979 .expect("str should convert"),
4980 ClapMcpToolOutput::Text(text) if text == "world"
4981 ));
4982
4983 let structured = AsStructured(json!({ "ok": true }))
4984 .into_tool_result()
4985 .expect("structured value should convert");
4986 assert!(matches!(structured, ClapMcpToolOutput::Structured(_)));
4987
4988 let empty = Option::<String>::None
4989 .into_tool_result()
4990 .expect("none should convert");
4991 assert!(matches!(empty, ClapMcpToolOutput::Text(text) if text.is_empty()));
4992
4993 let some = Some("x").into_tool_result().expect("some should convert");
4994 assert!(matches!(some, ClapMcpToolOutput::Text(text) if text == "x"));
4995
4996 let ok_result: Result<&str, &str> = Ok("done");
4997 assert!(matches!(
4998 ok_result.into_tool_result().expect("ok result should convert"),
4999 ClapMcpToolOutput::Text(text) if text == "done"
5000 ));
5001
5002 let err_result: Result<&str, &str> = Err("boom");
5003 let err = err_result
5004 .into_tool_result()
5005 .expect_err("err result should map to tool error");
5006 assert_eq!(err.message, "boom");
5007
5008 assert_eq!(ClapMcpToolError::from("oops").message, "oops");
5009 assert_eq!(ClapMcpToolError::from(String::from("ouch")).message, "ouch");
5010 assert_eq!(String::from("bad").into_tool_error().message, "bad");
5011 assert_eq!("worse".into_tool_error().message, "worse");
5012 }
5013
5014 #[test]
5015 fn test_merge_captured_stdout_only_changes_text_outputs() {
5016 let merged = merge_captured_stdout(
5017 Ok(ClapMcpToolOutput::Text(String::new())),
5018 "captured only\n".to_string(),
5019 )
5020 .expect("merge should succeed");
5021 assert!(matches!(merged, ClapMcpToolOutput::Text(text) if text == "captured only"));
5022
5023 let appended = merge_captured_stdout(
5024 Ok(ClapMcpToolOutput::Text("returned".to_string())),
5025 "captured\n".to_string(),
5026 )
5027 .expect("append should succeed");
5028 assert!(matches!(appended, ClapMcpToolOutput::Text(text) if text == "returned\ncaptured"));
5029
5030 let structured = merge_captured_stdout(
5031 Ok(ClapMcpToolOutput::Structured(json!({"ok": true}))),
5032 "captured\n".to_string(),
5033 )
5034 .expect("structured output should pass through");
5035 assert!(matches!(structured, ClapMcpToolOutput::Structured(_)));
5036 }
5037
5038 #[test]
5039 fn test_execute_in_process_command_and_handler_cover_capture_stdout_paths() {
5040 let schema = schema_from_command(&ExecCli::command());
5041
5042 let structured = execute_in_process_command_stateless::<ExecCli>(
5043 &schema,
5044 "structured",
5045 serde_json::Map::new(),
5046 false,
5047 )
5048 .expect("structured should execute");
5049 assert!(matches!(structured, ClapMcpToolOutput::Structured(_)));
5050
5051 let echo_args = serde_json::Map::from_iter([("value".to_string(), json!("hello"))]);
5052 let handler = make_in_process_handler::<ExecCli>(schema.clone(), false);
5053 let echoed = handler("echo", echo_args).expect("handler should execute");
5054 assert!(matches!(echoed, ClapMcpToolOutput::Text(text) if text == "hello"));
5055
5056 let missing = execute_in_process_command_stateless::<ExecCli>(
5057 &schema,
5058 "echo",
5059 serde_json::Map::new(),
5060 false,
5061 )
5062 .expect_err("missing required arg should fail");
5063 assert!(
5064 missing
5065 .message
5066 .contains("Missing required argument(s): value")
5067 );
5068 }
5069
5070 fn build_test_server(
5071 config: ClapMcpConfig,
5072 metadata: ClapMcpSchemaMetadata,
5073 serve_options: ClapMcpServeOptions,
5074 handler: Option<InProcessToolHandler>,
5075 executable_path: Option<std::path::PathBuf>,
5076 ) -> ClapMcpServer {
5077 let schema = nested_schema();
5078 let schema_json = serde_json::to_string(&schema).expect("schema json");
5079 let tools = tools_from_schema_with_metadata(&schema, &config, &metadata);
5080 build_clap_mcp_server(
5081 schema_json,
5082 tools,
5083 executable_path,
5084 handler,
5085 schema.root.name.clone(),
5086 &config,
5087 &serve_options,
5088 &metadata,
5089 )
5090 .expect("server should build")
5091 }
5092
5093 #[test]
5094 fn test_build_clap_mcp_server_rejects_task_augmented_without_reinvocation_safe() {
5095 let config = ClapMcpConfig {
5096 reinvocation_safe: false,
5097 ..Default::default()
5098 };
5099 let metadata = ClapMcpSchemaMetadata {
5100 task_augmented_tools: true,
5101 ..Default::default()
5102 };
5103 let schema = nested_schema();
5104 let schema_json = serde_json::to_string(&schema).expect("schema json");
5105 let tools = tools_from_schema_with_metadata(&schema, &config, &metadata);
5106 assert!(matches!(
5107 build_clap_mcp_server(
5108 schema_json,
5109 tools,
5110 None,
5111 Some(Arc::new(|_, _| Ok(ClapMcpToolOutput::Text("ok".into())))),
5112 schema.root.name,
5113 &config,
5114 &ClapMcpServeOptions::default(),
5115 &metadata,
5116 ),
5117 Err(ClapMcpError::InvalidConfig(_))
5118 ));
5119 }
5120
5121 #[test]
5122 fn test_build_clap_mcp_server_rejects_task_augmented_without_in_process_handler() {
5123 let config = ClapMcpConfig {
5124 reinvocation_safe: true,
5125 ..Default::default()
5126 };
5127 let metadata = ClapMcpSchemaMetadata {
5128 task_augmented_tools: true,
5129 ..Default::default()
5130 };
5131 let schema = nested_schema();
5132 let schema_json = serde_json::to_string(&schema).expect("schema json");
5133 let tools = tools_from_schema_with_metadata(&schema, &config, &metadata);
5134 assert!(matches!(
5135 build_clap_mcp_server(
5136 schema_json,
5137 tools,
5138 None,
5139 None,
5140 schema.root.name,
5141 &config,
5142 &ClapMcpServeOptions::default(),
5143 &metadata,
5144 ),
5145 Err(ClapMcpError::InvalidConfig(_))
5146 ));
5147 }
5148
5149 #[test]
5150 fn test_build_clap_mcp_server_accepts_parallel_and_serial_configs() {
5151 let handler: InProcessToolHandler =
5152 Arc::new(|_, _| Ok(ClapMcpToolOutput::Text("ok".into())));
5153 for parallel_safe in [true, false] {
5154 let config = ClapMcpConfig {
5155 reinvocation_safe: true,
5156 parallel_safe,
5157 ..Default::default()
5158 };
5159 build_test_server(
5160 config,
5161 ClapMcpSchemaMetadata::default(),
5162 ClapMcpServeOptions::default(),
5163 Some(handler.clone()),
5164 None,
5165 );
5166 }
5167 }
5168
5169 #[test]
5170 fn test_get_info_capability_matrix_and_instructions() {
5171 let handler: InProcessToolHandler =
5172 Arc::new(|_, _| Ok(ClapMcpToolOutput::Text("ok".into())));
5173 let config = ClapMcpConfig {
5174 reinvocation_safe: true,
5175 parallel_safe: true,
5176 ..Default::default()
5177 };
5178
5179 let (tx, rx) = logging::log_channel(4);
5180 drop(tx);
5181 let with_logging = build_test_server(
5182 config.clone(),
5183 ClapMcpSchemaMetadata::default(),
5184 ClapMcpServeOptions {
5185 log_rx: Some(rx),
5186 ..Default::default()
5187 },
5188 Some(handler.clone()),
5189 None,
5190 );
5191 let info = with_logging.get_info();
5192 assert!(info.capabilities.logging.is_some());
5193 assert!(info.capabilities.tasks.is_none());
5194 assert_eq!(
5195 info.instructions.as_deref(),
5196 Some(LOG_INTERPRETATION_INSTRUCTIONS)
5197 );
5198
5199 let with_tasks = build_test_server(
5200 config.clone(),
5201 ClapMcpSchemaMetadata {
5202 task_augmented_tools: true,
5203 ..Default::default()
5204 },
5205 ClapMcpServeOptions::default(),
5206 Some(handler.clone()),
5207 None,
5208 );
5209 let info = with_tasks.get_info();
5210 assert!(info.capabilities.logging.is_none());
5211 assert!(info.capabilities.tasks.is_some());
5212 assert!(info.instructions.is_none());
5213
5214 let with_both = build_test_server(
5215 config,
5216 ClapMcpSchemaMetadata {
5217 task_augmented_tools: true,
5218 ..Default::default()
5219 },
5220 ClapMcpServeOptions {
5221 log_rx: Some(logging::log_channel(4).1),
5222 ..Default::default()
5223 },
5224 Some(handler),
5225 None,
5226 );
5227 let info = with_both.get_info();
5228 assert!(info.capabilities.logging.is_some());
5229 assert!(info.capabilities.tasks.is_some());
5230 assert_eq!(
5231 info.instructions.as_deref(),
5232 Some(LOG_INTERPRETATION_INSTRUCTIONS)
5233 );
5234 }
5235
5236 #[test]
5237 fn test_build_execution_command_root_tool_skips_extra_segment() {
5238 let schema =
5239 schema_from_command(&Command::new("sample").arg(Arg::new("value").long("value")));
5240 let args = serde_json::Map::from_iter([("value".to_string(), json!("ok"))]);
5241 let command = build_execution_command(
5242 std::path::Path::new("/tmp/example"),
5243 &schema,
5244 "sample",
5245 "sample",
5246 &args,
5247 );
5248 let actual_args: Vec<_> = command.get_args().collect();
5249 assert_eq!(
5250 actual_args,
5251 vec![std::ffi::OsStr::new("--value"), std::ffi::OsStr::new("ok"),]
5252 );
5253 }
5254
5255 #[test]
5256 fn test_validate_tool_argument_names_allows_extra_when_no_properties() {
5257 let tool = Tool::new(
5258 "freeform",
5259 "no schema properties",
5260 Arc::new(serde_json::Map::new()),
5261 );
5262 let args = serde_json::Map::from_iter([("anything".to_string(), json!(1))]);
5263 assert!(validate_tool_argument_names(&tool, "freeform", &args).is_ok());
5264 }
5265}