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 }
181 }
182
183 #[test]
184 fn test_render_generation_prompt() {
185 let context = create_test_context();
186 let result = render_generation_prompt(&context);
187
188 match result {
189 Ok(prompt) => {
190 assert!(prompt.contains("test"));
191 assert!(prompt.contains("SKILL.md"));
192 }
193 Err(e) => panic!("Template rendering failed: {e}"),
194 }
195 }
196
197 #[test]
198 fn test_render_skill_md() {
199 let context = create_test_context();
200 let result = render_skill_md(&context);
201
202 match result {
203 Ok(md) => {
204 assert!(md.starts_with("---\n"), "must start with YAML frontmatter");
205 assert!(md.contains("name: test-progressive"));
206 assert!(md.contains("# test-progressive"));
207 assert!(md.contains("~/.claude/servers/test/"));
208 assert!(md.contains("testTool"));
209 }
210 Err(e) => panic!("render_skill_md failed: {e}"),
211 }
212 }
213
214 #[test]
215 fn test_render_skill_md_html_special_chars_not_escaped() {
216 let mut context = create_test_context();
217 context.categories = vec![SkillCategory {
218 name: "test".to_string(),
219 display_name: "Test".to_string(),
220 tools: vec![SkillTool {
221 name: "my_tool".to_string(),
222 typescript_name: "myTool".to_string(),
223 description: "Create & update <items> with \"quotes\"".to_string(),
224 keywords: vec![],
225 required_params: vec![],
226 optional_params: vec![],
227 }],
228 }];
229
230 let md = render_skill_md(&context).unwrap();
231 assert!(md.contains('&'), "& must not be HTML-escaped");
233 assert!(md.contains('<'), "< must not be HTML-escaped");
234 }
235
236 #[test]
237 fn test_render_skill_md_yaml_frontmatter_safe() {
238 let mut context = create_test_context();
240 context.server_description = Some("GitHub: issues & CI\nname: injected".to_string());
241
242 let md = render_skill_md(&context).unwrap();
243
244 let after_open = md.strip_prefix("---\n").expect("must start with ---");
246 let fm_end = after_open.find("\n---").expect("must have closing ---");
247 let frontmatter = &after_open[..fm_end];
248
249 let name_count = frontmatter
251 .lines()
252 .filter(|l| l.starts_with("name:"))
253 .count();
254 assert_eq!(
255 name_count, 1,
256 "YAML key injection detected in: {frontmatter}"
257 );
258
259 let desc_line = frontmatter
261 .lines()
262 .find(|l| l.starts_with("description:"))
263 .expect("description key must be present");
264 assert!(
265 desc_line.contains('"'),
266 "description must be YAML-quoted: {desc_line}"
267 );
268 }
269
270 #[test]
271 fn test_yaml_quote() {
272 assert_eq!(yaml_quote("simple"), "\"simple\"");
273 assert_eq!(yaml_quote("GitHub: issues"), "\"GitHub: issues\"");
274 assert_eq!(yaml_quote("line1\nline2"), "\"line1\\nline2\"");
275 assert_eq!(yaml_quote(r#"has "quotes""#), r#""has \"quotes\"""#);
276 assert_eq!(yaml_quote("has \\backslash"), "\"has \\\\backslash\"");
277 }
278}