1use async_trait::async_trait;
8use rust_mcp_sdk::schema::{Prompt, PromptMessage, Resource};
9use std::sync::Arc;
10
11#[derive(Clone)]
13pub enum ResourceContent {
14 Static(String),
16 Dynamic(Arc<dyn ResourceContentProvider>),
18}
19
20impl std::fmt::Debug for ResourceContent {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 ResourceContent::Static(s) => f.debug_tuple("Static").field(&s.len()).finish(),
24 ResourceContent::Dynamic(_) => f.write_str("Dynamic(_)"),
25 }
26 }
27}
28
29#[async_trait]
32pub trait ResourceContentProvider: Send + Sync {
33 async fn read(
35 &self,
36 uri: &str,
37 ) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>>;
38}
39
40#[derive(Clone, Debug)]
43pub struct CustomResource {
44 pub uri: String,
46 pub name: String,
48 pub title: Option<String>,
50 pub description: Option<String>,
52 pub mime_type: Option<String>,
54 pub content: ResourceContent,
56}
57
58impl CustomResource {
59 pub fn to_list_resource(&self) -> Resource {
61 Resource {
62 name: self.name.clone(),
63 uri: self.uri.clone(),
64 title: self.title.clone(),
65 description: self.description.clone(),
66 mime_type: self.mime_type.clone(),
67 annotations: None,
68 icons: vec![],
69 meta: None,
70 size: None,
71 }
72 }
73}
74
75#[derive(Clone)]
77pub enum PromptContent {
78 Static(Vec<PromptMessage>),
80 Dynamic(Arc<dyn PromptContentProvider>),
82}
83
84impl std::fmt::Debug for PromptContent {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 PromptContent::Static(msgs) => f.debug_tuple("Static").field(&msgs.len()).finish(),
88 PromptContent::Dynamic(_) => f.write_str("Dynamic(_)"),
89 }
90 }
91}
92
93#[async_trait]
96pub trait PromptContentProvider: Send + Sync {
97 async fn get(
99 &self,
100 name: &str,
101 arguments: &serde_json::Map<String, serde_json::Value>,
102 ) -> std::result::Result<Vec<PromptMessage>, Box<dyn std::error::Error + Send + Sync>>;
103}
104
105#[derive(Clone, Debug)]
108pub struct CustomPrompt {
109 pub name: String,
111 pub title: Option<String>,
113 pub description: Option<String>,
115 pub arguments: Vec<rust_mcp_sdk::schema::PromptArgument>,
117 pub content: PromptContent,
119}
120
121impl CustomPrompt {
122 pub fn to_list_prompt(&self) -> Prompt {
124 Prompt {
125 name: self.name.clone(),
126 description: self.description.clone(),
127 arguments: self.arguments.clone(),
128 icons: vec![],
129 meta: None,
130 title: self.title.clone(),
131 }
132 }
133}
134
135pub async fn resolve_resource_content(
137 r: &CustomResource,
138 uri: &str,
139) -> std::result::Result<String, rust_mcp_sdk::schema::RpcError> {
140 match &r.content {
141 ResourceContent::Static(s) => Ok(s.clone()),
142 ResourceContent::Dynamic(provider) => provider.read(uri).await.map_err(|e| {
143 rust_mcp_sdk::schema::RpcError::internal_error().with_message(e.to_string())
144 }),
145 }
146}
147
148pub async fn resolve_prompt_content(
150 p: &CustomPrompt,
151 name: &str,
152 arguments: &serde_json::Map<String, serde_json::Value>,
153) -> std::result::Result<Vec<PromptMessage>, rust_mcp_sdk::schema::RpcError> {
154 match &p.content {
155 PromptContent::Static(msgs) => Ok(msgs.clone()),
156 PromptContent::Dynamic(provider) => provider.get(name, arguments).await.map_err(|e| {
157 rust_mcp_sdk::schema::RpcError::internal_error().with_message(e.to_string())
158 }),
159 }
160}
161
162pub fn export_skills(
174 _schema: &crate::ClapSchema,
175 _metadata: &crate::ClapMcpSchemaMetadata,
176 tools: &[Tool],
177 custom_resources: &[CustomResource],
178 custom_prompts: &[CustomPrompt],
179 output_dir: &std::path::Path,
180 app_name: &str,
181) -> std::result::Result<(), crate::ClapMcpError> {
182 use std::io::Write;
183 std::fs::create_dir_all(output_dir)?;
184 let app_dir = output_dir.join(sanitize_skill_name(app_name));
185 std::fs::create_dir_all(&app_dir)?;
186
187 if tools.len() == 1 {
188 let tool = &tools[0];
189 let dir_name = sanitize_skill_name(app_name);
190 let description = build_tool_description(tool);
191 let body = build_tool_body(tool);
192 let content = format_skill_md(&dir_name, &description, Some(&tool.name), &body);
193 std::fs::File::create(app_dir.join("SKILL.md"))?.write_all(content.as_bytes())?;
194 } else {
195 for tool in tools {
196 let dir_name = sanitize_skill_name(&tool.name);
197 let description = build_tool_description(tool);
198 let body = build_tool_body(tool);
199 let content = format_skill_md(&dir_name, &description, Some(&tool.name), &body);
200 let tool_dir = app_dir.join(&dir_name);
201 std::fs::create_dir_all(&tool_dir)?;
202 std::fs::File::create(tool_dir.join("SKILL.md"))?.write_all(content.as_bytes())?;
203 }
204 }
205
206 if !custom_resources.is_empty() || !custom_prompts.is_empty() {
207 let mut sections = Vec::new();
208 if !custom_resources.is_empty() {
209 sections.push("## Resources\n".to_string());
210 for r in custom_resources {
211 sections.push(format!(
212 "- **{}** (`{}`): {}",
213 r.name,
214 r.uri,
215 r.description.as_deref().unwrap_or("Custom resource")
216 ));
217 }
218 sections.push(String::new());
219 }
220 if !custom_prompts.is_empty() {
221 sections.push("## Prompts\n".to_string());
222 for p in custom_prompts {
223 let mut line = format!(
224 "- **{}**: {}",
225 p.name,
226 p.description.as_deref().unwrap_or("Custom prompt")
227 );
228 if !p.arguments.is_empty() {
229 let arg_names: Vec<_> = p.arguments.iter().map(|a| a.name.as_str()).collect();
230 line.push_str(&format!(" (arguments: {})", arg_names.join(", ")));
231 }
232 sections.push(line);
233 }
234 sections.push(String::new());
235 }
236 let dir_name = "resources-and-prompts";
237 let description = format!(
238 "Custom MCP resources and prompts exposed by {}. Use when interacting with this server's non-tool capabilities.",
239 app_name
240 );
241 let body = format!(
242 "# Resources and Prompts\n\nThis skill describes the custom MCP resources and prompts provided by `{}`.\n\n{}",
243 app_name,
244 sections.join("\n")
245 );
246 let content = format_skill_md(dir_name, &description, None, &body);
247 let res_dir = app_dir.join(dir_name);
248 std::fs::create_dir_all(&res_dir)?;
249 std::fs::File::create(res_dir.join("SKILL.md"))?.write_all(content.as_bytes())?;
250 }
251
252 Ok(())
253}
254
255fn build_tool_description(tool: &Tool) -> String {
256 let base = tool
257 .description
258 .as_deref()
259 .unwrap_or("MCP tool from clap-mcp");
260 let sanitized = base.replace('\n', " ");
261 let desc = format!(
262 "{}. Use when invoking the `{}` tool via MCP.",
263 sanitized.trim_end_matches('.'),
264 tool.name
265 );
266 truncate_to_char_boundary(&desc, 1024)
267}
268
269fn build_tool_body(tool: &Tool) -> String {
270 let description = tool
271 .description
272 .as_deref()
273 .unwrap_or("MCP tool from clap-mcp");
274 let mut body = format!("# {}\n\n{}\n", tool.name, description);
275
276 if let Some(ref props) = tool.input_schema.properties
277 && !props.is_empty()
278 {
279 body.push_str("\n## Arguments\n\n");
280 let required_set: std::collections::HashSet<&str> = tool
281 .input_schema
282 .required
283 .iter()
284 .map(|s| s.as_str())
285 .collect();
286 let mut names: Vec<_> = props.keys().collect();
287 names.sort();
288 for name in names {
289 let prop = &props[name];
290 let type_str = prop
291 .get("type")
292 .and_then(|v| v.as_str())
293 .unwrap_or("string");
294 let required = if required_set.contains(name.as_str()) {
295 "required"
296 } else {
297 "optional"
298 };
299 let desc = prop
300 .get("description")
301 .and_then(|v| v.as_str())
302 .unwrap_or("");
303 if desc.is_empty() {
304 body.push_str(&format!("- `{}` ({}, {})\n", name, type_str, required));
305 } else {
306 body.push_str(&format!(
307 "- `{}` ({}, {}): {}\n",
308 name, type_str, required, desc
309 ));
310 }
311 }
312 }
313
314 body
315}
316
317fn format_skill_md(
318 name: &str,
319 description: &str,
320 allowed_tools: Option<&str>,
321 body: &str,
322) -> String {
323 let mut frontmatter = format!(
324 "---\nname: {}\ndescription: {}",
325 name,
326 description.replace('\n', " "),
327 );
328 if let Some(tools) = allowed_tools {
329 frontmatter.push_str(&format!("\nallowed-tools: {}", tools));
330 }
331 frontmatter.push_str("\n---");
332 format!("{}\n\n{}\n", frontmatter, body.trim_end())
333}
334
335fn sanitize_skill_name(s: &str) -> String {
340 let raw: String = s
341 .to_lowercase()
342 .chars()
343 .map(|c| {
344 if c.is_ascii_alphanumeric() || c == '-' {
345 c
346 } else {
347 '-'
348 }
349 })
350 .collect();
351 let name = raw
352 .split('-')
353 .filter(|p| !p.is_empty())
354 .collect::<Vec<_>>()
355 .join("-");
356 truncate_to_char_boundary(&name, 64)
357}
358
359fn truncate_to_char_boundary(s: &str, max: usize) -> String {
360 if s.len() <= max {
361 s.to_string()
362 } else {
363 s[..s.floor_char_boundary(max)].to_string()
364 }
365}
366
367use rust_mcp_sdk::schema::Tool;
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use clap::{Arg, Command};
373 use rust_mcp_sdk::schema::{ContentBlock, PromptArgument, ToolInputSchema};
374 use std::error::Error;
375 use std::path::PathBuf;
376 use std::sync::Mutex;
377 use std::time::{SystemTime, UNIX_EPOCH};
378
379 #[derive(Debug)]
380 struct TestError(&'static str);
381
382 impl std::fmt::Display for TestError {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 f.write_str(self.0)
385 }
386 }
387
388 impl Error for TestError {}
389
390 struct TestResourceProvider {
391 response: Result<String, &'static str>,
392 seen_uri: Mutex<Vec<String>>,
393 }
394
395 #[async_trait]
396 impl ResourceContentProvider for TestResourceProvider {
397 async fn read(
398 &self,
399 uri: &str,
400 ) -> std::result::Result<String, Box<dyn Error + Send + Sync>> {
401 self.seen_uri.lock().unwrap().push(uri.to_string());
402 match &self.response {
403 Ok(text) => Ok(text.clone()),
404 Err(message) => Err(Box::new(TestError(message))),
405 }
406 }
407 }
408
409 struct TestPromptProvider {
410 response: Result<Vec<PromptMessage>, &'static str>,
411 seen: Mutex<Vec<(String, serde_json::Map<String, serde_json::Value>)>>,
412 }
413
414 #[async_trait]
415 impl PromptContentProvider for TestPromptProvider {
416 async fn get(
417 &self,
418 name: &str,
419 arguments: &serde_json::Map<String, serde_json::Value>,
420 ) -> std::result::Result<Vec<PromptMessage>, Box<dyn Error + Send + Sync>> {
421 self.seen
422 .lock()
423 .unwrap()
424 .push((name.to_string(), arguments.clone()));
425 match &self.response {
426 Ok(messages) => Ok(messages.clone()),
427 Err(message) => Err(Box::new(TestError(message))),
428 }
429 }
430 }
431
432 fn temp_output_dir(test_name: &str) -> PathBuf {
433 let nanos = SystemTime::now()
434 .duration_since(UNIX_EPOCH)
435 .expect("time should be monotonic enough for test tempdirs")
436 .as_nanos();
437 std::env::temp_dir().join(format!(
438 "clap-mcp-content-tests-{test_name}-{}-{nanos}",
439 std::process::id()
440 ))
441 }
442
443 fn sample_messages() -> Vec<PromptMessage> {
444 vec![PromptMessage {
445 role: rust_mcp_sdk::schema::Role::User,
446 content: ContentBlock::text_content("hello from prompt".to_string()),
447 }]
448 }
449
450 fn sample_tool(name: &str, description: Option<&str>) -> Tool {
451 let mut count_property = serde_json::Map::new();
452 count_property.insert("type".to_string(), serde_json::json!("integer"));
453 count_property.insert(
454 "description".to_string(),
455 serde_json::json!("How many items to process"),
456 );
457 let mut properties = std::collections::HashMap::new();
458 properties.insert("count".to_string(), count_property);
459 Tool {
460 name: name.to_string(),
461 title: None,
462 description: description.map(str::to_string),
463 input_schema: ToolInputSchema::new(vec!["count".to_string()], Some(properties), None),
464 annotations: None,
465 execution: None,
466 icons: vec![],
467 meta: None,
468 output_schema: None,
469 }
470 }
471
472 fn sample_schema() -> crate::ClapSchema {
473 crate::schema_from_command(
474 &Command::new("sample-app").arg(
475 Arg::new("verbose")
476 .long("verbose")
477 .help("Enable verbose logging"),
478 ),
479 )
480 }
481
482 #[tokio::test]
483 async fn resolve_resource_content_handles_static_and_dynamic() {
484 let static_resource = CustomResource {
485 uri: "test://static".to_string(),
486 name: "static".to_string(),
487 title: None,
488 description: None,
489 mime_type: Some("text/plain".to_string()),
490 content: ResourceContent::Static("fixed text".to_string()),
491 };
492 assert_eq!(
493 resolve_resource_content(&static_resource, &static_resource.uri)
494 .await
495 .expect("static content should resolve"),
496 "fixed text"
497 );
498
499 let provider = Arc::new(TestResourceProvider {
500 response: Ok("dynamic text".to_string()),
501 seen_uri: Mutex::new(Vec::new()),
502 });
503 let dynamic_resource = CustomResource {
504 uri: "test://dynamic".to_string(),
505 name: "dynamic".to_string(),
506 title: Some("Dynamic".to_string()),
507 description: Some("dynamic provider".to_string()),
508 mime_type: None,
509 content: ResourceContent::Dynamic(provider.clone()),
510 };
511 assert_eq!(
512 resolve_resource_content(&dynamic_resource, &dynamic_resource.uri)
513 .await
514 .expect("dynamic content should resolve"),
515 "dynamic text"
516 );
517 assert_eq!(
518 provider.seen_uri.lock().unwrap().as_slice(),
519 ["test://dynamic"]
520 );
521 }
522
523 #[tokio::test]
524 async fn resolve_resource_content_maps_provider_errors() {
525 let provider = Arc::new(TestResourceProvider {
526 response: Err("resource boom"),
527 seen_uri: Mutex::new(Vec::new()),
528 });
529 let resource = CustomResource {
530 uri: "test://broken".to_string(),
531 name: "broken".to_string(),
532 title: None,
533 description: None,
534 mime_type: None,
535 content: ResourceContent::Dynamic(provider),
536 };
537
538 let error = resolve_resource_content(&resource, &resource.uri)
539 .await
540 .expect_err("dynamic error should map to rpc error");
541 assert_eq!(error.message, "resource boom");
542 }
543
544 #[tokio::test]
545 async fn resolve_prompt_content_handles_static_and_dynamic() {
546 let static_prompt = CustomPrompt {
547 name: "static-prompt".to_string(),
548 title: None,
549 description: Some("static prompt".to_string()),
550 arguments: vec![],
551 content: PromptContent::Static(sample_messages()),
552 };
553 assert_eq!(
554 resolve_prompt_content(&static_prompt, &static_prompt.name, &serde_json::Map::new())
555 .await
556 .expect("static prompt should resolve")
557 .len(),
558 1
559 );
560
561 let mut arguments = serde_json::Map::new();
562 arguments.insert(
563 "topic".to_string(),
564 serde_json::Value::String("coverage".into()),
565 );
566 let provider = Arc::new(TestPromptProvider {
567 response: Ok(sample_messages()),
568 seen: Mutex::new(Vec::new()),
569 });
570 let dynamic_prompt = CustomPrompt {
571 name: "dynamic-prompt".to_string(),
572 title: Some("Dynamic Prompt".to_string()),
573 description: Some("dynamic prompt".to_string()),
574 arguments: vec![PromptArgument {
575 name: "topic".to_string(),
576 title: None,
577 description: Some("Topic to discuss".to_string()),
578 required: Some(true),
579 }],
580 content: PromptContent::Dynamic(provider.clone()),
581 };
582
583 let messages = resolve_prompt_content(&dynamic_prompt, &dynamic_prompt.name, &arguments)
584 .await
585 .expect("dynamic prompt should resolve");
586 assert_eq!(messages.len(), 1);
587
588 let seen = provider.seen.lock().unwrap();
589 assert_eq!(seen.len(), 1);
590 assert_eq!(seen[0].0, "dynamic-prompt");
591 assert_eq!(
592 seen[0].1.get("topic").and_then(|value| value.as_str()),
593 Some("coverage")
594 );
595 }
596
597 #[tokio::test]
598 async fn resolve_prompt_content_maps_provider_errors() {
599 let provider = Arc::new(TestPromptProvider {
600 response: Err("prompt boom"),
601 seen: Mutex::new(Vec::new()),
602 });
603 let prompt = CustomPrompt {
604 name: "broken-prompt".to_string(),
605 title: None,
606 description: None,
607 arguments: vec![],
608 content: PromptContent::Dynamic(provider),
609 };
610
611 let error = resolve_prompt_content(&prompt, &prompt.name, &serde_json::Map::new())
612 .await
613 .expect_err("prompt provider error should map to rpc error");
614 assert_eq!(error.message, "prompt boom");
615 }
616
617 #[test]
618 fn export_skills_writes_single_tool_skill() {
619 let output_dir = temp_output_dir("single");
620 let schema = sample_schema();
621 let tools = vec![sample_tool(
622 "Run-Task",
623 Some("Runs the task.\nWith details."),
624 )];
625
626 export_skills(
627 &schema,
628 &crate::ClapMcpSchemaMetadata::default(),
629 &tools,
630 &[],
631 &[],
632 &output_dir,
633 "My App",
634 )
635 .expect("single-tool export should succeed");
636
637 let skill_path = output_dir.join("my-app").join("SKILL.md");
638 let content = std::fs::read_to_string(&skill_path).expect("skill file should exist");
639 assert!(content.contains("name: my-app"));
640 assert!(content.contains("allowed-tools: Run-Task"));
641 assert!(content.contains(
642 "Runs the task. With details. Use when invoking the `Run-Task` tool via MCP."
643 ));
644 assert!(content.contains("- `count` (integer, required): How many items to process"));
645
646 std::fs::remove_dir_all(output_dir).expect("temp output dir should be removable");
647 }
648
649 #[test]
650 fn export_skills_writes_multi_tool_and_resources_skill() {
651 let output_dir = temp_output_dir("multi");
652 let schema = sample_schema();
653 let tools = vec![
654 sample_tool("First Tool", Some("First tool.")),
655 sample_tool("Second/Tool", None),
656 ];
657 let resources = vec![CustomResource {
658 uri: "app://config".to_string(),
659 name: "Config".to_string(),
660 title: None,
661 description: Some("Configuration snapshot".to_string()),
662 mime_type: Some("application/json".to_string()),
663 content: ResourceContent::Static("{\"ok\":true}".to_string()),
664 }];
665 let prompts = vec![CustomPrompt {
666 name: "guidance".to_string(),
667 title: Some("Guidance".to_string()),
668 description: Some("Prompt guidance".to_string()),
669 arguments: vec![PromptArgument {
670 name: "audience".to_string(),
671 title: None,
672 description: None,
673 required: Some(false),
674 }],
675 content: PromptContent::Static(sample_messages()),
676 }];
677
678 export_skills(
679 &schema,
680 &crate::ClapMcpSchemaMetadata::default(),
681 &tools,
682 &resources,
683 &prompts,
684 &output_dir,
685 "App With Extras",
686 )
687 .expect("multi export should succeed");
688
689 let first_tool_path = output_dir
690 .join("app-with-extras")
691 .join("first-tool")
692 .join("SKILL.md");
693 let second_tool_path = output_dir
694 .join("app-with-extras")
695 .join("second-tool")
696 .join("SKILL.md");
697 let resources_path = output_dir
698 .join("app-with-extras")
699 .join("resources-and-prompts")
700 .join("SKILL.md");
701
702 let first_tool = std::fs::read_to_string(first_tool_path).expect("first tool skill exists");
703 let second_tool =
704 std::fs::read_to_string(second_tool_path).expect("second tool skill exists");
705 let resource_prompt_skill =
706 std::fs::read_to_string(resources_path).expect("resource prompt skill exists");
707
708 assert!(first_tool.contains("name: first-tool"));
709 assert!(second_tool.contains("name: second-tool"));
710 assert!(second_tool.contains("allowed-tools: Second/Tool"));
711 assert!(resource_prompt_skill.contains("## Resources"));
712 assert!(
713 resource_prompt_skill.contains("**Config** (`app://config`): Configuration snapshot")
714 );
715 assert!(resource_prompt_skill.contains("## Prompts"));
716 assert!(
717 resource_prompt_skill.contains("**guidance**: Prompt guidance (arguments: audience)")
718 );
719
720 std::fs::remove_dir_all(output_dir).expect("temp output dir should be removable");
721 }
722
723 #[test]
724 fn sanitize_and_truncate_helpers_follow_skill_rules() {
725 let long = format!("{}{}", "A".repeat(80), "! invalid suffix");
726 let sanitized = sanitize_skill_name(&long);
727 assert_eq!(sanitized.len(), 64);
728 assert!(
729 sanitized
730 .chars()
731 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
732 );
733 assert!(!sanitized.starts_with('-'));
734 assert!(!sanitized.ends_with('-'));
735 assert_eq!(sanitize_skill_name("My Fancy__Tool"), "my-fancy-tool");
736
737 let formatted = format_skill_md(
738 "tool-name",
739 "First line.\nSecond line.",
740 Some("tool-name"),
741 "# Body\n\nDetails\n",
742 );
743 assert!(formatted.contains("description: First line. Second line."));
744 assert!(formatted.ends_with("# Body\n\nDetails\n"));
745 }
746
747 #[test]
748 fn custom_resource_and_prompt_list_items_preserve_metadata() {
749 let resource = CustomResource {
750 uri: "test://resource".to_string(),
751 name: "Resource".to_string(),
752 title: Some("Resource Title".to_string()),
753 description: Some("Helpful resource".to_string()),
754 mime_type: Some("text/plain".to_string()),
755 content: ResourceContent::Static("resource body".to_string()),
756 };
757 let prompt = CustomPrompt {
758 name: "prompt-name".to_string(),
759 title: Some("Prompt Title".to_string()),
760 description: Some("Helpful prompt".to_string()),
761 arguments: vec![PromptArgument {
762 name: "subject".to_string(),
763 title: Some("Subject".to_string()),
764 description: Some("What to discuss".to_string()),
765 required: Some(true),
766 }],
767 content: PromptContent::Static(sample_messages()),
768 };
769
770 let listed_resource = resource.to_list_resource();
771 let listed_prompt = prompt.to_list_prompt();
772 assert_eq!(listed_resource.uri, "test://resource");
773 assert_eq!(listed_resource.mime_type.as_deref(), Some("text/plain"));
774 assert_eq!(listed_prompt.name, "prompt-name");
775 assert_eq!(listed_prompt.arguments.len(), 1);
776 assert_eq!(listed_prompt.arguments[0].name, "subject");
777 }
778}