mcp_execution_skill/
template.rs1use std::sync::LazyLock;
7
8use handlebars::Handlebars;
9use thiserror::Error;
10
11use crate::types::GenerateSkillResult;
12
13#[derive(Debug, Error)]
15pub enum TemplateError {
16 #[error("template rendering failed: {0}")]
18 RenderFailed(#[from] handlebars::RenderError),
19
20 #[error("template registration failed: {0}")]
22 RegistrationFailed(#[from] handlebars::TemplateError),
23}
24
25const SKILL_GENERATION_TEMPLATE: &str = include_str!("templates/skill-generation.hbs");
27
28const SKILL_MD_TEMPLATE: &str = include_str!("templates/skill-md.hbs");
30
31static 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
44fn 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
58pub fn render_generation_prompt(context: &GenerateSkillResult) -> Result<String, TemplateError> {
84 let rendered = HANDLEBARS.render("skill", context)?;
85 Ok(rendered)
86}
87
88pub fn render_skill_md(context: &GenerateSkillResult) -> Result<String, TemplateError> {
129 let mut value =
132 serde_json::to_value(context).expect("GenerateSkillResult serialization is infallible");
133
134 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 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 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 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 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 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 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 #[test]
272 fn test_yaml_quote() {
273 assert_eq!(yaml_quote("simple"), "\"simple\"");
274 assert_eq!(yaml_quote("GitHub: issues"), "\"GitHub: issues\"");
275 assert_eq!(yaml_quote("line1\nline2"), "\"line1\\nline2\"");
276 assert_eq!(yaml_quote(r#"has "quotes""#), r#""has \"quotes\"""#);
277 assert_eq!(yaml_quote("has \\backslash"), "\"has \\\\backslash\"");
278 }
279}