Skip to main content

mcp_execution_skill/
template.rs

1//! Template rendering for skill generation.
2//!
3//! Uses Handlebars templates to render the skill generation prompt and
4//! the final SKILL.md file. Both templates are embedded at compile time.
5
6use std::sync::LazyLock;
7
8use handlebars::Handlebars;
9use thiserror::Error;
10
11use crate::types::GenerateSkillResult;
12
13/// Errors that can occur during template rendering.
14#[derive(Debug, Error)]
15pub enum TemplateError {
16    /// Template rendering failed.
17    #[error("template rendering failed: {0}")]
18    RenderFailed(#[from] handlebars::RenderError),
19
20    /// Template registration failed.
21    #[error("template registration failed: {0}")]
22    RegistrationFailed(#[from] handlebars::TemplateError),
23}
24
25/// Embedded Handlebars template for the LLM skill generation prompt.
26const SKILL_GENERATION_TEMPLATE: &str = include_str!("templates/skill-generation.hbs");
27
28/// Embedded Handlebars template that renders SKILL.md directly (no LLM required).
29const SKILL_MD_TEMPLATE: &str = include_str!("templates/skill-md.hbs");
30
31/// Handlebars instance with pre-registered templates.
32///
33/// Initialized once per process using `LazyLock` for optimal performance.
34/// Templates are parsed and validated on first access.
35static HANDLEBARS: LazyLock<Handlebars<'static>> = LazyLock::new(|| {
36    let mut hb = Handlebars::new();
37    hb.register_template_string("skill", SKILL_GENERATION_TEMPLATE)
38        .expect("embedded skill-generation template must be valid Handlebars syntax");
39    hb.register_template_string("skill-md", SKILL_MD_TEMPLATE)
40        .expect("embedded skill-md template must be valid Handlebars syntax");
41    hb
42});
43
44/// Wraps a string in YAML double-quote scalars, escaping `\`, `"`, and newlines.
45///
46/// Produces a value that can be embedded directly after a YAML key as a
47/// quoted scalar — safe against `:` in the middle, leading `-`, and
48/// newline injection.
49fn yaml_quote(s: &str) -> String {
50    let escaped = s
51        .replace('\\', "\\\\")
52        .replace('"', "\\\"")
53        .replace('\n', "\\n")
54        .replace('\r', "");
55    format!("\"{escaped}\"")
56}
57
58/// Render the skill generation prompt.
59///
60/// Takes the `GenerateSkillResult` context and renders it using
61/// the embedded Handlebars template.
62///
63/// # Arguments
64///
65/// * `context` - Skill generation context from `build_skill_context`
66///
67/// # Returns
68///
69/// Rendered prompt string for the LLM.
70///
71/// # Errors
72///
73/// Returns `TemplateError` if template rendering fails.
74///
75/// # Examples
76///
77/// ```no_run
78/// use mcp_execution_skill::{build_skill_context, render_generation_prompt};
79///
80/// let context = build_skill_context("github", &[], None);
81/// let prompt = render_generation_prompt(&context).unwrap();
82/// ```
83pub fn render_generation_prompt(context: &GenerateSkillResult) -> Result<String, TemplateError> {
84    let rendered = HANDLEBARS.render("skill", context)?;
85    Ok(rendered)
86}
87
88/// Render SKILL.md content directly from skill context.
89///
90/// Produces the final SKILL.md file content without requiring an LLM. Uses the
91/// embedded `skill-md.hbs` template with the same [`GenerateSkillResult`] context
92/// as [`render_generation_prompt`].
93///
94/// Tool descriptions are rendered with triple-stash (`{{{...}}}`) to avoid
95/// HTML-escaping characters such as `<`, `>`, and `&`.
96///
97/// YAML frontmatter scalars (`description`) are pre-quoted so that special
98/// characters in MCP server metadata (`:`, newlines, leading `-`) cannot
99/// corrupt the frontmatter or inject additional YAML keys (S3).
100///
101/// # Arguments
102///
103/// * `context` - Skill generation context from [`crate::build_skill_context`]
104///
105/// # Returns
106///
107/// Rendered SKILL.md string ready to write to disk.
108///
109/// # Panics
110///
111/// Does not panic in practice: `serde_json::to_value` is infallible for
112/// `GenerateSkillResult` because all fields are standard Rust types with
113/// derived `Serialize` implementations.
114///
115/// # Errors
116///
117/// Returns [`TemplateError`] if Handlebars rendering fails.
118///
119/// # Examples
120///
121/// ```no_run
122/// use mcp_execution_skill::{build_skill_context, render_skill_md};
123///
124/// let context = build_skill_context("github", &[], None);
125/// let md = render_skill_md(&context).unwrap();
126/// assert!(md.starts_with("---\n"));
127/// ```
128pub fn render_skill_md(context: &GenerateSkillResult) -> Result<String, TemplateError> {
129    // SAFETY: `GenerateSkillResult` derives `Serialize` with only primitive and
130    // standard-library types — `to_value` is infallible for this type.
131    let mut value =
132        serde_json::to_value(context).expect("GenerateSkillResult serialization is infallible");
133
134    // YAML-quote server_description so that `:`, newlines, and leading `-` in
135    // MCP server metadata cannot corrupt the frontmatter or inject keys (S3).
136    if let Some(desc) = value
137        .get("server_description")
138        .and_then(|v| v.as_str())
139        .map(yaml_quote)
140    {
141        value["server_description"] = serde_json::Value::String(desc);
142    }
143
144    let rendered = HANDLEBARS.render("skill-md", &value)?;
145    // Normalize CRLF → LF so output is consistent across platforms (Windows CI).
146    Ok(rendered.replace("\r\n", "\n"))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::types::{SkillCategory, SkillTool, ToolExample};
153
154    fn create_test_context() -> GenerateSkillResult {
155        GenerateSkillResult {
156            server_id: "test".to_string(),
157            skill_name: "test-progressive".to_string(),
158            server_description: Some("Test server".to_string()),
159            categories: vec![SkillCategory {
160                name: "test".to_string(),
161                display_name: "Test".to_string(),
162                tools: vec![SkillTool {
163                    name: "test_tool".to_string(),
164                    typescript_name: "testTool".to_string(),
165                    description: "Test tool description".to_string(),
166                    keywords: vec!["test".to_string()],
167                    required_params: vec!["param1".to_string()],
168                    optional_params: vec![],
169                }],
170            }],
171            tool_count: 1,
172            example_tools: vec![ToolExample {
173                tool_name: "test_tool".to_string(),
174                description: "Test tool".to_string(),
175                cli_command: "node test.ts".to_string(),
176                params_json: "{}".to_string(),
177            }],
178            generation_prompt: "Pre-built prompt".to_string(),
179            output_path: "~/.claude/skills/test/SKILL.md".to_string(),
180            warnings: vec![],
181        }
182    }
183
184    #[test]
185    fn test_render_generation_prompt() {
186        let context = create_test_context();
187        let result = render_generation_prompt(&context);
188
189        match result {
190            Ok(prompt) => {
191                assert!(prompt.contains("test"));
192                assert!(prompt.contains("SKILL.md"));
193            }
194            Err(e) => panic!("Template rendering failed: {e}"),
195        }
196    }
197
198    #[test]
199    fn test_render_skill_md() {
200        let context = create_test_context();
201        let result = render_skill_md(&context);
202
203        match result {
204            Ok(md) => {
205                assert!(md.starts_with("---\n"), "must start with YAML frontmatter");
206                assert!(md.contains("name: test-progressive"));
207                assert!(md.contains("# test-progressive"));
208                assert!(md.contains("~/.claude/servers/test/"));
209                assert!(md.contains("testTool"));
210            }
211            Err(e) => panic!("render_skill_md failed: {e}"),
212        }
213    }
214
215    #[test]
216    fn test_render_skill_md_html_special_chars_not_escaped() {
217        let mut context = create_test_context();
218        context.categories = vec![SkillCategory {
219            name: "test".to_string(),
220            display_name: "Test".to_string(),
221            tools: vec![SkillTool {
222                name: "my_tool".to_string(),
223                typescript_name: "myTool".to_string(),
224                description: "Create & update <items> with \"quotes\"".to_string(),
225                keywords: vec![],
226                required_params: vec![],
227                optional_params: vec![],
228            }],
229        }];
230
231        let md = render_skill_md(&context).unwrap();
232        // Triple-stash in template must prevent HTML escaping.
233        assert!(md.contains('&'), "& must not be HTML-escaped");
234        assert!(md.contains('<'), "< must not be HTML-escaped");
235    }
236
237    #[test]
238    fn test_render_skill_md_yaml_frontmatter_safe() {
239        // S3: malicious server_description must not inject YAML keys or corrupt frontmatter.
240        let mut context = create_test_context();
241        context.server_description = Some("GitHub: issues & CI\nname: injected".to_string());
242
243        let md = render_skill_md(&context).unwrap();
244
245        // Extract frontmatter block (between the two "---" markers).
246        let after_open = md.strip_prefix("---\n").expect("must start with ---");
247        let fm_end = after_open.find("\n---").expect("must have closing ---");
248        let frontmatter = &after_open[..fm_end];
249
250        // There must be exactly one `name:` key — no injected sibling.
251        let name_count = frontmatter
252            .lines()
253            .filter(|l| l.starts_with("name:"))
254            .count();
255        assert_eq!(
256            name_count, 1,
257            "YAML key injection detected in: {frontmatter}"
258        );
259
260        // The description value must be quoted (YAML double-quoted scalar).
261        let desc_line = frontmatter
262            .lines()
263            .find(|l| l.starts_with("description:"))
264            .expect("description key must be present");
265        assert!(
266            desc_line.contains('"'),
267            "description must be YAML-quoted: {desc_line}"
268        );
269    }
270
271    /// Issue #298 (end-to-end): a tool description containing embedded line breaks
272    /// that mimic Markdown structure must not be able to inject a heading, a fenced
273    /// code block, or an extra list item into the rendered SKILL.md body. The
274    /// sanitization happens in `build_skill_context` (`context::group_by_category`),
275    /// so this exercises the real production pipeline rather than a hand-built
276    /// `GenerateSkillResult`.
277    #[test]
278    fn test_render_skill_md_end_to_end_flattens_injected_markdown_structure() {
279        use crate::build_skill_context;
280        use crate::parser::ParsedToolFile;
281
282        let hostile = ParsedToolFile {
283            name: "evil_tool".to_string(),
284            typescript_name: "evilTool".to_string(),
285            server_id: "test".to_string(),
286            category: Some("test".to_string()),
287            keywords: vec![],
288            description: Some(
289                "safe text\n### Injected Heading\n```\ninjected fenced block\n```\n\
290                 - fake list item"
291                    .to_string(),
292            ),
293            parameters: vec![],
294        };
295
296        let context = build_skill_context("test", std::slice::from_ref(&hostile), None);
297        let md = render_skill_md(&context).unwrap();
298
299        // The hostile description must be flattened to a single line before reaching
300        // the template: no new heading line (only the one legitimate "### Test"
301        // category heading may start a line), and no new fenced-code-block *delimiter
302        // line* beyond the 4 static ```bash ... ``` pairs already present in the
303        // "Usage" section. A literal "```" surviving mid-line (not at line start) is
304        // inert Markdown-wise — a fence only opens/closes at the start of a line —
305        // so the check must look at line starts, not raw substring counts.
306        assert_eq!(
307            md.lines().filter(|l| l.starts_with("###")).count(),
308            1,
309            "tool description must not introduce a new heading line: {md}"
310        );
311        assert_eq!(
312            md.lines()
313                .filter(|l| l.trim_start().starts_with("```"))
314                .count(),
315            8,
316            "tool description must not introduce a new fenced-code-block delimiter line: {md}"
317        );
318        assert!(
319            md.contains("Injected Heading"),
320            "sanitized content must still render, just inert"
321        );
322    }
323
324    /// S2 (end-to-end): `category` is exactly as untrusted as `description` — the
325    /// `_meta.json` sidecar stores it raw — and it reaches SKILL.md as a `###
326    /// {{display_name}}` heading via `humanize_category`. A malicious category must
327    /// not be able to inject its own heading line.
328    #[test]
329    fn test_render_skill_md_end_to_end_flattens_injected_category_heading() {
330        use crate::build_skill_context;
331        use crate::parser::ParsedToolFile;
332
333        let hostile = ParsedToolFile {
334            name: "evil_tool".to_string(),
335            typescript_name: "evilTool".to_string(),
336            server_id: "test".to_string(),
337            category: Some("issues\n### Injected Heading".to_string()),
338            keywords: vec![],
339            description: Some("safe description".to_string()),
340            parameters: vec![],
341        };
342
343        let context = build_skill_context("test", std::slice::from_ref(&hostile), None);
344        let md = render_skill_md(&context).unwrap();
345
346        // Only the one legitimate category heading may start a line; the injected
347        // "### Injected Heading" must have been flattened into that same line.
348        assert_eq!(
349            md.lines().filter(|l| l.starts_with("###")).count(),
350            1,
351            "hostile category must not introduce a new heading line: {md}"
352        );
353        assert!(
354            md.contains("Injected Heading"),
355            "sanitized content must still render, just inert"
356        );
357    }
358
359    #[test]
360    fn test_yaml_quote() {
361        assert_eq!(yaml_quote("simple"), "\"simple\"");
362        assert_eq!(yaml_quote("GitHub: issues"), "\"GitHub: issues\"");
363        assert_eq!(yaml_quote("line1\nline2"), "\"line1\\nline2\"");
364        assert_eq!(yaml_quote(r#"has "quotes""#), r#""has \"quotes\"""#);
365        assert_eq!(yaml_quote("has \\backslash"), "\"has \\\\backslash\"");
366    }
367}