Skip to main content

mcp_execution_skill/
context.rs

1//! Context builder for skill generation.
2//!
3//! Transforms parsed tool files into structured context
4//! that the LLM uses to generate SKILL.md content.
5
6use crate::parser::ParsedToolFile;
7use crate::types::{GenerateSkillResult, SkillCategory, SkillTool, ToolExample};
8use std::collections::HashMap;
9
10/// Build skill generation context from parsed tools.
11///
12/// # Arguments
13///
14/// * `server_id` - Server identifier (e.g., "github")
15/// * `tools` - Parsed tool files from `scan_tools_directory`
16/// * `use_case_hints` - Optional hints about intended use cases
17///
18/// # Returns
19///
20/// `GenerateSkillResult` with all context needed for skill generation.
21///
22/// # Examples
23///
24/// ```
25/// use mcp_execution_skill::{build_skill_context, ParsedToolFile, ParsedParameter};
26///
27/// let tools: Vec<ParsedToolFile> = vec![]; // Parsed from scan_tools_directory
28/// let context = build_skill_context("github", &tools, None);
29///
30/// assert_eq!(context.server_id, "github");
31/// ```
32#[must_use]
33pub fn build_skill_context(
34    server_id: &str,
35    tools: &[ParsedToolFile],
36    use_case_hints: Option<&[String]>,
37) -> GenerateSkillResult {
38    let tool_count = tools.len();
39
40    // Group tools by category
41    let categories = group_by_category(tools);
42
43    // Select representative examples
44    let example_tools = select_example_tools(tools, 5);
45
46    // Generate skill name
47    let skill_name = format!("{server_id}-progressive");
48
49    // Build output path
50    let output_path = format!("~/.claude/skills/{server_id}/SKILL.md");
51
52    // Render generation prompt
53    let generation_prompt = build_generation_prompt(
54        server_id,
55        &skill_name,
56        &categories,
57        &example_tools,
58        use_case_hints,
59    );
60
61    GenerateSkillResult {
62        server_id: server_id.to_string(),
63        skill_name,
64        server_description: infer_server_description(tools),
65        categories,
66        tool_count,
67        example_tools,
68        generation_prompt,
69        output_path,
70    }
71}
72
73/// Group tools by category.
74///
75/// Tools without a category are placed in "uncategorized".
76fn group_by_category(tools: &[ParsedToolFile]) -> Vec<SkillCategory> {
77    let mut category_map: HashMap<String, Vec<SkillTool>> = HashMap::new();
78
79    for tool in tools {
80        let category = tool
81            .category
82            .clone()
83            .unwrap_or_else(|| "uncategorized".to_string());
84
85        let skill_tool = SkillTool {
86            name: tool.name.clone(),
87            typescript_name: tool.typescript_name.clone(),
88            description: tool
89                .description
90                .clone()
91                .unwrap_or_else(|| format!("{} tool", tool.name)),
92            keywords: tool.keywords.clone(),
93            required_params: tool
94                .parameters
95                .iter()
96                .filter(|p| p.required)
97                .map(|p| p.name.clone())
98                .collect(),
99            optional_params: tool
100                .parameters
101                .iter()
102                .filter(|p| !p.required)
103                .map(|p| p.name.clone())
104                .collect(),
105        };
106
107        category_map.entry(category).or_default().push(skill_tool);
108    }
109
110    // Convert to sorted vector
111    let mut categories: Vec<SkillCategory> = category_map
112        .into_iter()
113        .map(|(name, tools)| {
114            let display_name = humanize_category(&name);
115            SkillCategory {
116                name,
117                display_name,
118                tools,
119            }
120        })
121        .collect();
122
123    // Sort categories alphabetically, but put "uncategorized" last
124    categories.sort_by(|a, b| {
125        if a.name == "uncategorized" {
126            std::cmp::Ordering::Greater
127        } else if b.name == "uncategorized" {
128            std::cmp::Ordering::Less
129        } else {
130            a.name.cmp(&b.name)
131        }
132    });
133
134    categories
135}
136
137/// Convert category slug to human-readable name.
138fn humanize_category(name: &str) -> String {
139    name.split('-')
140        .map(|word| {
141            let mut chars = word.chars();
142            chars.next().map_or_else(String::new, |first| {
143                first.to_uppercase().chain(chars).collect()
144            })
145        })
146        .collect::<Vec<_>>()
147        .join(" ")
148}
149
150/// Select representative example tools.
151///
152/// Prioritizes common CRUD operations and picks one per category.
153fn select_example_tools(tools: &[ParsedToolFile], max_examples: usize) -> Vec<ToolExample> {
154    // Priority keywords for example selection
155    let priority_prefixes = ["create", "list", "get", "search", "update"];
156
157    let mut examples = Vec::new();
158    let mut seen_categories = std::collections::HashSet::new();
159
160    // First pass: pick priority operations from different categories
161    for prefix in priority_prefixes {
162        if examples.len() >= max_examples {
163            break;
164        }
165
166        for tool in tools {
167            if examples.len() >= max_examples {
168                break;
169            }
170
171            let category = tool.category.as_deref().unwrap_or("uncategorized");
172
173            if tool.name.starts_with(prefix) && !seen_categories.contains(category) {
174                examples.push(build_tool_example(tool));
175                seen_categories.insert(category.to_string());
176            }
177        }
178    }
179
180    // Second pass: fill remaining slots
181    for tool in tools {
182        if examples.len() >= max_examples {
183            break;
184        }
185
186        let category = tool.category.as_deref().unwrap_or("uncategorized");
187
188        if !seen_categories.contains(category) {
189            examples.push(build_tool_example(tool));
190            seen_categories.insert(category.to_string());
191        }
192    }
193
194    examples
195}
196
197/// Build example for a single tool.
198fn build_tool_example(tool: &ParsedToolFile) -> ToolExample {
199    // Build example params
200    let params: HashMap<&str, &str> = tool
201        .parameters
202        .iter()
203        .filter(|p| p.required)
204        .map(|p| (p.name.as_str(), get_example_value(&p.typescript_type)))
205        .collect();
206
207    let params_json = serde_json::to_string_pretty(&params).unwrap_or_else(|_| "{}".to_string());
208
209    // Build CLI command
210    let cli_command = format!(
211        "node ~/.claude/servers/{}/{}.ts '{}'",
212        tool.server_id,
213        tool.typescript_name,
214        params_json.replace('\n', " ").replace("  ", "")
215    );
216
217    ToolExample {
218        tool_name: tool.name.clone(),
219        description: tool
220            .description
221            .clone()
222            .unwrap_or_else(|| format!("Execute {}", tool.name)),
223        cli_command,
224        params_json,
225    }
226}
227
228/// Get example value for TypeScript type.
229fn get_example_value(ts_type: &str) -> &'static str {
230    match ts_type.trim() {
231        "string" => "\"example\"",
232        "number" => "42",
233        "boolean" => "true",
234        t if t.starts_with("string[]") => "[\"item1\", \"item2\"]",
235        t if t.starts_with("number[]") => "[1, 2, 3]",
236        _ => "\"...\"",
237    }
238}
239
240/// Infer server description from tool metadata.
241fn infer_server_description(tools: &[ParsedToolFile]) -> Option<String> {
242    if tools.is_empty() {
243        return None;
244    }
245
246    // Get unique categories
247    let categories: std::collections::HashSet<_> =
248        tools.iter().filter_map(|t| t.category.as_ref()).collect();
249
250    if categories.is_empty() {
251        return Some(format!("MCP server with {} tools", tools.len()));
252    }
253
254    let category_list: Vec<_> = categories.iter().map(|s| s.as_str()).collect();
255    Some(format!(
256        "MCP server for {} operations ({} tools)",
257        category_list.join(", "),
258        tools.len()
259    ))
260}
261
262/// Build the generation prompt.
263#[allow(clippy::format_push_string)]
264fn build_generation_prompt(
265    server_id: &str,
266    skill_name: &str,
267    categories: &[SkillCategory],
268    examples: &[ToolExample],
269    use_case_hints: Option<&[String]>,
270) -> String {
271    // Pre-allocate String capacity to reduce reallocations
272    // Estimate: 500 base + 100/category + 200/example
273    let estimated_size = 500 + (categories.len() * 100) + (examples.len() * 200);
274    let mut prompt = String::with_capacity(estimated_size);
275
276    prompt.push_str(&format!(
277        r#"You are generating a Claude Code skill file (SKILL.md) for the "{server_id}" MCP server.
278
279## Context
280
281**Server ID**: {server_id}
282**Skill Name**: {skill_name}
283**Total Tools**: {}
284
285### Categories and Tools
286
287"#,
288        categories.iter().map(|c| c.tools.len()).sum::<usize>()
289    ));
290
291    for category in categories {
292        prompt.push_str(&format!(
293            "#### {} ({} tools)\n",
294            category.display_name,
295            category.tools.len()
296        ));
297
298        for tool in &category.tools {
299            prompt.push_str(&format!("- **{}**: {}\n", tool.name, tool.description));
300
301            if !tool.keywords.is_empty() {
302                prompt.push_str(&format!("  - Keywords: {}\n", tool.keywords.join(", ")));
303            }
304
305            if !tool.required_params.is_empty() {
306                prompt.push_str(&format!(
307                    "  - Required params: {}\n",
308                    tool.required_params.join(", ")
309                ));
310            }
311        }
312
313        prompt.push('\n');
314    }
315
316    prompt.push_str("### Example Tool Usages\n\n");
317
318    for example in examples {
319        prompt.push_str(&format!(
320            "**{}**\n```bash\n{}\n```\n\n",
321            example.description, example.cli_command
322        ));
323    }
324
325    if let Some(hints) = use_case_hints {
326        prompt.push_str("### Use Case Hints\n\n");
327        for hint in hints {
328            prompt.push_str(&format!("- {hint}\n"));
329        }
330        prompt.push('\n');
331    }
332
333    prompt.push_str(GENERATION_INSTRUCTIONS);
334
335    prompt
336}
337
338const GENERATION_INSTRUCTIONS: &str = r#"
339## Instructions
340
341Generate a SKILL.md file with the following structure:
342
3431. **YAML Frontmatter** (required):
344   ```yaml
345   ---
346   name: {skill_name}
347   description: [One-sentence description of what this skill enables]
348   ---
349   ```
350
3512. **Introduction** (1-2 paragraphs):
352   - What this server/skill does
353   - Key capabilities in bullet points
354   - When to use this skill
355
3563. **Quick Start** (numbered steps):
357   - How to discover available tools
358   - How to execute a tool
359   - Example with a common use case
360
3614. **Common Tasks** (3-5 sections):
362   - Organize by USE CASE, not by tool
363   - Each section should solve a real problem
364   - Include natural language examples that trigger tool usage
365   - Show CLI commands where helpful
366
3675. **Tool Reference** (organized by category):
368   - List all tools by category
369   - Brief description of each
370   - Key parameters
371
3726. **Troubleshooting** (3-5 items):
373   - Common errors and solutions
374   - Authentication issues
375   - Connection problems
376
377## Guidelines
378
379- Write for AI agents (Claude), not humans
380- Focus on WHEN to use tools, not just HOW
381- Use natural language examples: "Create an issue about the login bug"
382- Keep descriptions concise but informative
383- Include path references: ~/.claude/servers/{server_id}/
384
385## Output Format
386
387Return ONLY the SKILL.md content, starting with the YAML frontmatter.
388Do not include any explanation or commentary outside the file content.
389"#;
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::parser::ParsedParameter;
395
396    fn create_test_tool(name: &str, category: Option<&str>) -> ParsedToolFile {
397        ParsedToolFile {
398            name: name.to_string(),
399            typescript_name: name.to_string(),
400            server_id: "test".to_string(),
401            category: category.map(ToString::to_string),
402            keywords: vec!["test".to_string()],
403            description: Some(format!("{name} description")),
404            parameters: vec![ParsedParameter {
405                name: "param1".to_string(),
406                typescript_type: "string".to_string(),
407                required: true,
408                description: None,
409            }],
410        }
411    }
412
413    #[test]
414    fn test_build_skill_context() {
415        let tools = vec![
416            create_test_tool("create_issue", Some("issues")),
417            create_test_tool("list_repos", Some("repos")),
418        ];
419
420        let context = build_skill_context("github", &tools, None);
421
422        assert_eq!(context.server_id, "github");
423        assert_eq!(context.skill_name, "github-progressive");
424        assert_eq!(context.tool_count, 2);
425        assert_eq!(context.categories.len(), 2);
426        assert!(!context.generation_prompt.is_empty());
427    }
428
429    #[test]
430    fn test_group_by_category() {
431        let tools = vec![
432            create_test_tool("tool1", Some("cat-a")),
433            create_test_tool("tool2", Some("cat-b")),
434            create_test_tool("tool3", Some("cat-a")),
435            create_test_tool("tool4", None),
436        ];
437
438        let categories = group_by_category(&tools);
439
440        assert_eq!(categories.len(), 3);
441
442        // cat-a should have 2 tools
443        let cat_a = categories.iter().find(|c| c.name == "cat-a").unwrap();
444        assert_eq!(cat_a.tools.len(), 2);
445
446        // uncategorized should be last
447        assert_eq!(categories.last().unwrap().name, "uncategorized");
448    }
449
450    #[test]
451    fn test_humanize_category() {
452        assert_eq!(humanize_category("issues"), "Issues");
453        assert_eq!(humanize_category("pull-requests"), "Pull Requests");
454        assert_eq!(humanize_category("user-management"), "User Management");
455    }
456
457    #[test]
458    fn test_select_example_tools() {
459        let tools = vec![
460            create_test_tool("create_issue", Some("issues")),
461            create_test_tool("list_repos", Some("repos")),
462            create_test_tool("get_user", Some("users")),
463            create_test_tool("update_pr", Some("prs")),
464            create_test_tool("delete_branch", Some("branches")),
465        ];
466
467        let examples = select_example_tools(&tools, 3);
468
469        assert_eq!(examples.len(), 3);
470        // Should prioritize create, list, get
471        assert!(examples.iter().any(|e| e.tool_name == "create_issue"));
472        assert!(examples.iter().any(|e| e.tool_name == "list_repos"));
473        assert!(examples.iter().any(|e| e.tool_name == "get_user"));
474    }
475
476    #[test]
477    fn test_get_example_value() {
478        assert_eq!(get_example_value("string"), "\"example\"");
479        assert_eq!(get_example_value("number"), "42");
480        assert_eq!(get_example_value("boolean"), "true");
481        assert_eq!(get_example_value("string[]"), "[\"item1\", \"item2\"]");
482    }
483}