Skip to main content

clap_mcp/
content.rs

1//! Custom MCP resources, prompts, and agent skills export.
2//!
3//! This module provides types to declare custom resources and prompts (static or
4//! async dynamic), and a function to export [Agent Skills](https://agentskills.io/specification)
5//! (SKILL.md) from the exposed tools, resources, and prompts.
6
7use async_trait::async_trait;
8use rust_mcp_sdk::schema::{Prompt, PromptMessage, Resource};
9use std::sync::Arc;
10
11/// Content of a custom MCP resource: either static text or provided by an async callback.
12#[derive(Clone)]
13pub enum ResourceContent {
14    /// Fixed content known at serve start.
15    Static(String),
16    /// Content provided asynchronously when the resource is read.
17    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 provider for custom resource content.
30/// Implement this trait (or use a closure adapter) for dynamic resources.
31#[async_trait]
32pub trait ResourceContentProvider: Send + Sync {
33    /// Return the resource content for the given URI.
34    async fn read(
35        &self,
36        uri: &str,
37    ) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>>;
38}
39
40/// Descriptor for a custom MCP resource.
41/// Add to [`crate::ClapMcpServeOptions::custom_resources`] to expose it when serving.
42#[derive(Clone, Debug)]
43pub struct CustomResource {
44    /// MCP resource URI (e.g. `myapp://config`). Must be unique.
45    pub uri: String,
46    /// Short name for listing.
47    pub name: String,
48    /// Optional human-readable title.
49    pub title: Option<String>,
50    /// Optional description.
51    pub description: Option<String>,
52    /// Optional MIME type (e.g. `text/plain`, `application/json`).
53    pub mime_type: Option<String>,
54    /// Content: static string or async provider.
55    pub content: ResourceContent,
56}
57
58impl CustomResource {
59    /// Build an MCP `Resource` for list_resources.
60    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/// Content of a custom MCP prompt: either static messages or provided by an async callback.
76#[derive(Clone)]
77pub enum PromptContent {
78    /// Fixed messages known at serve start.
79    Static(Vec<PromptMessage>),
80    /// Messages provided asynchronously when the prompt is requested.
81    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 provider for custom prompt content.
94/// Implement this trait (or use a closure adapter) for dynamic prompts.
95#[async_trait]
96pub trait PromptContentProvider: Send + Sync {
97    /// Return the prompt messages for the given name and optional arguments.
98    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/// Descriptor for a custom MCP prompt.
106/// Add to [`crate::ClapMcpServeOptions::custom_prompts`] to expose it when serving.
107#[derive(Clone, Debug)]
108pub struct CustomPrompt {
109    /// MCP prompt name. Must be unique (built-in uses `clap-mcp-logging-guide`).
110    pub name: String,
111    /// Optional human-readable title.
112    pub title: Option<String>,
113    /// Optional description.
114    pub description: Option<String>,
115    /// Optional prompt arguments (MCP list declares these; get can receive values).
116    pub arguments: Vec<rust_mcp_sdk::schema::PromptArgument>,
117    /// Content: static messages or async provider.
118    pub content: PromptContent,
119}
120
121impl CustomPrompt {
122    /// Build an MCP `Prompt` for list_prompts.
123    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
135/// Resolve custom resource content (static or await dynamic).
136pub 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
148/// Resolve custom prompt content (static or await dynamic).
149pub 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
162/// Export [Agent Skills](https://agentskills.io/specification) (SKILL.md) from the given
163/// schema, tools, custom resources, and prompts.
164///
165/// Writes into `output_dir` (e.g. `.agents/skills/`). Each tool gets its own skill
166/// directory with a SKILL.md; resources and prompts are grouped into a single skill.
167///
168/// Generated files follow the [Agent Skills specification](https://agentskills.io/specification):
169/// YAML frontmatter (`name`, `description`, `allowed-tools`) and a markdown body with
170/// usage instructions. The `name` field matches the parent directory name as required by
171/// the spec. The `allowed-tools` field lists the MCP tool the skill describes; note that
172/// this field is still experimental in the spec with no defined syntax convention.
173pub 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
335/// Sanitize a string into a valid Agent Skills `name` field.
336///
337/// Per the [specification](https://agentskills.io/specification): lowercase alphanumeric
338/// and hyphens only, no leading/trailing/consecutive hyphens, max 64 characters.
339fn 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}