mcp_execution_skill/types.rs
1//! Type definitions for skill generation.
2//!
3//! This module defines all parameter and result types for skill generation:
4//! - `GenerateSkillParams`: Parameters for generating a skill
5//! - `GenerateSkillResult`: Result from skill generation
6//! - `SaveSkillParams`: Parameters for saving a skill
7//! - `SaveSkillResult`: Result from saving a skill
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12use thiserror::Error;
13
14/// Maximum `server_id` length (denial-of-service protection).
15///
16/// `pub` (rather than private) so downstream crates' schemars drift-guard tests — e.g.
17/// `mcp-execution-server`'s, for `IntrospectServerParams::server_id` — can assert the declared
18/// schema length against this real constant instead of a hardcoded literal (issue #198 S3).
19pub const MAX_SERVER_ID_LENGTH: usize = 64;
20
21// ============================================================================
22// generate_skill types
23// ============================================================================
24
25/// Parameters for generating a skill.
26///
27/// # Examples
28///
29/// ```
30/// use mcp_execution_skill::types::GenerateSkillParams;
31///
32/// let params = GenerateSkillParams {
33/// server_id: "github".to_string(),
34/// servers_dir: None,
35/// skill_name: None,
36/// use_case_hints: None,
37/// };
38/// ```
39#[derive(Debug, Clone, Deserialize, JsonSchema)]
40pub struct GenerateSkillParams {
41 /// Server identifier (e.g., "github").
42 ///
43 /// Must be 1-64 lowercase letters, digits, or hyphens (see `validate_server_id`'s
44 /// `MAX_SERVER_ID_LENGTH`, mirrored here as a literal since schemars attributes cannot
45 /// reference a `const`).
46 #[schemars(length(max = 64), regex(pattern = r"^[a-z0-9-]+$"))]
47 pub server_id: String,
48
49 /// Base directory for generated servers.
50 ///
51 /// Default: `~/.claude/servers`
52 pub servers_dir: Option<PathBuf>,
53
54 /// Custom skill name.
55 ///
56 /// Default: `{server_id}-progressive`
57 pub skill_name: Option<String>,
58
59 /// Additional context about intended use cases.
60 ///
61 /// Helps generate more relevant documentation.
62 pub use_case_hints: Option<Vec<String>>,
63}
64
65/// Result from `generate_skill` tool.
66///
67/// Contains all context Claude needs to generate optimal SKILL.md content.
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct GenerateSkillResult {
70 /// Server identifier.
71 pub server_id: String,
72
73 /// Suggested skill name.
74 pub skill_name: String,
75
76 /// Server description (inferred from tools).
77 pub server_description: Option<String>,
78
79 /// Tools grouped by category.
80 pub categories: Vec<SkillCategory>,
81
82 /// Total tool count.
83 pub tool_count: usize,
84
85 /// Example tool usages (for documentation).
86 pub example_tools: Vec<ToolExample>,
87
88 /// Prompt template for skill generation.
89 ///
90 /// Claude uses this prompt to generate SKILL.md content.
91 pub generation_prompt: String,
92
93 /// Output path for the skill file.
94 pub output_path: String,
95
96 /// Non-fatal drift warnings, e.g. `.ts` files on disk excluded from
97 /// `categories`/`tool_count` because `_meta.json` has no matching entry
98 /// for them. Empty when the scanned directory has no drift.
99 #[serde(default)]
100 pub warnings: Vec<String>,
101}
102
103/// A category of tools for the skill.
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
105pub struct SkillCategory {
106 /// Category name (e.g., "issues", "repositories").
107 pub name: String,
108
109 /// Human-readable display name.
110 pub display_name: String,
111
112 /// Tools in this category.
113 pub tools: Vec<SkillTool>,
114}
115
116/// Tool information for skill generation.
117#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
118pub struct SkillTool {
119 /// Original tool name.
120 pub name: String,
121
122 /// TypeScript function name.
123 pub typescript_name: String,
124
125 /// Short description.
126 pub description: String,
127
128 /// Keywords for discovery.
129 pub keywords: Vec<String>,
130
131 /// Required parameters.
132 pub required_params: Vec<String>,
133
134 /// Optional parameters.
135 pub optional_params: Vec<String>,
136}
137
138/// Example tool usage for documentation.
139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
140pub struct ToolExample {
141 /// Tool name.
142 pub tool_name: String,
143
144 /// Natural language description of what this does.
145 pub description: String,
146
147 /// Example CLI command.
148 pub cli_command: String,
149
150 /// Example parameters as JSON.
151 pub params_json: String,
152}
153
154// ============================================================================
155// save_skill types
156// ============================================================================
157
158/// Parameters for saving a skill.
159///
160/// # Examples
161///
162/// ```
163/// use mcp_execution_skill::types::SaveSkillParams;
164///
165/// let params = SaveSkillParams {
166/// server_id: "github".to_string(),
167/// content: "---\nname: github\n---\n# GitHub".to_string(),
168/// output_path: None,
169/// overwrite: false,
170/// };
171/// ```
172#[derive(Debug, Clone, Deserialize, JsonSchema)]
173pub struct SaveSkillParams {
174 /// Server identifier.
175 ///
176 /// Must be 1-64 lowercase letters, digits, or hyphens (see `validate_server_id`'s
177 /// `MAX_SERVER_ID_LENGTH`, mirrored here as a literal since schemars attributes cannot
178 /// reference a `const`).
179 #[schemars(length(max = 64), regex(pattern = r"^[a-z0-9-]+$"))]
180 pub server_id: String,
181
182 /// SKILL.md content (markdown with YAML frontmatter).
183 ///
184 /// Capped at 100KB (`MAX_SKILL_CONTENT_SIZE` in `mcp_execution_server::service`) at
185 /// runtime; mirrored here as a literal since schemars attributes require literals and
186 /// this crate does not depend on `mcp-execution-server` (the dependency runs the other
187 /// way). Note: JSON Schema's `maxLength` counts Unicode code points, not bytes, so the two
188 /// bounds only coincide exactly for ASCII input — for legitimate multi-byte UTF-8 content,
189 /// the runtime byte check can reject content the declared schema would still accept
190 /// (never the reverse), since bytes-per-char >= 1 (issue #198 M2).
191 #[schemars(length(max = 102_400))]
192 pub content: String,
193
194 /// Custom output path.
195 ///
196 /// Default: `~/.claude/skills/{server_id}/SKILL.md`
197 pub output_path: Option<PathBuf>,
198
199 /// Overwrite if exists.
200 #[serde(default)]
201 pub overwrite: bool,
202}
203
204/// Result from saving a skill.
205#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
206pub struct SaveSkillResult {
207 /// Whether save was successful.
208 pub success: bool,
209
210 /// Path where skill was saved.
211 pub output_path: String,
212
213 /// Whether an existing file was overwritten.
214 pub overwritten: bool,
215
216 /// Skill metadata extracted from content.
217 pub metadata: SkillMetadata,
218}
219
220/// Metadata extracted from saved skill.
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
222pub struct SkillMetadata {
223 /// Skill name from frontmatter.
224 pub name: String,
225
226 /// Description from frontmatter.
227 pub description: String,
228
229 /// Section count (H2 headers).
230 pub section_count: usize,
231
232 /// Approximate word count.
233 pub word_count: usize,
234}
235
236// ============================================================================
237// Validation functions
238// ============================================================================
239
240/// Errors returned by [`validate_server_id`].
241#[derive(Debug, Clone, PartialEq, Eq, Error)]
242pub enum SkillServerIdError {
243 /// `server_id` was empty.
244 ///
245 /// An empty `server_id` would collapse a per-server confinement
246 /// directory back to its shared parent, so it is rejected explicitly
247 /// rather than falling through the (vacuously true) character-class
248 /// check below.
249 #[error("server_id must not be empty")]
250 Empty,
251
252 /// `server_id` exceeded the maximum allowed length.
253 #[error("server_id too long: {len} chars exceeds {limit} limit")]
254 TooLong {
255 /// Actual length of the rejected `server_id`, in bytes (`str::len`,
256 /// not `chars().count()`).
257 len: usize,
258 /// Maximum allowed length (`MAX_SERVER_ID_LENGTH`).
259 limit: usize,
260 },
261
262 /// `server_id` contained a character other than a lowercase letter,
263 /// digit, or hyphen.
264 #[error("server_id must contain only lowercase letters, digits, and hyphens")]
265 InvalidCharacters,
266}
267
268/// Validate `server_id` format and length.
269///
270/// # Arguments
271///
272/// * `server_id` - Server identifier to validate
273///
274/// # Returns
275///
276/// `Ok(())` if valid.
277///
278/// # Errors
279///
280/// Returns [`SkillServerIdError`] if:
281/// - Empty
282/// - Length exceeds 64 characters
283/// - Contains characters other than lowercase letters, digits, and hyphens
284///
285/// # Validation Rules
286///
287/// - Must not be empty
288/// - Length must not exceed 64 characters
289/// - Must contain only lowercase letters, digits, and hyphens
290///
291/// # Examples
292///
293/// ```
294/// use mcp_execution_skill::validate_server_id;
295///
296/// assert!(validate_server_id("github").is_ok());
297/// assert!(validate_server_id("my-server-123").is_ok());
298/// assert!(validate_server_id("").is_err()); // empty
299/// assert!(validate_server_id("GitHub").is_err()); // uppercase
300/// assert!(validate_server_id("my_server").is_err()); // underscore
301/// ```
302pub fn validate_server_id(server_id: &str) -> Result<(), SkillServerIdError> {
303 // Check emptiness (the character-class check below is vacuously true for
304 // an empty string, and an empty server_id would collapse a per-server
305 // confinement directory back to its shared parent)
306 if server_id.is_empty() {
307 return Err(SkillServerIdError::Empty);
308 }
309
310 // Check length
311 if server_id.len() > MAX_SERVER_ID_LENGTH {
312 return Err(SkillServerIdError::TooLong {
313 len: server_id.len(),
314 limit: MAX_SERVER_ID_LENGTH,
315 });
316 }
317
318 // Check format
319 if !server_id
320 .chars()
321 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
322 {
323 return Err(SkillServerIdError::InvalidCharacters);
324 }
325
326 Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 // ── schemars bounds (issue #205) ──────────────────────────────────────
334
335 #[test]
336 fn test_generate_skill_params_schema_declares_server_id_bounds() {
337 let schema = schemars::schema_for!(GenerateSkillParams);
338 let props = schema.get("properties").unwrap().as_object().unwrap();
339
340 // Asserted against the real runtime constant (not a hardcoded literal), so bumping
341 // `MAX_SERVER_ID_LENGTH` without updating the `#[schemars(length(max = ..))]` literal
342 // above fails this test instead of leaving the declared schema silently stale
343 // (issue #198 S3).
344 assert_eq!(props["server_id"]["maxLength"], MAX_SERVER_ID_LENGTH);
345 assert_eq!(props["server_id"]["pattern"], "^[a-z0-9-]+$");
346 }
347
348 #[test]
349 fn test_save_skill_params_schema_declares_bounds() {
350 let schema = schemars::schema_for!(SaveSkillParams);
351 let props = schema.get("properties").unwrap().as_object().unwrap();
352
353 assert_eq!(props["server_id"]["maxLength"], MAX_SERVER_ID_LENGTH);
354 assert_eq!(props["server_id"]["pattern"], "^[a-z0-9-]+$");
355 // `content`'s bound (`MAX_SKILL_CONTENT_SIZE`) lives in `mcp-execution-server`, a
356 // reverse dependency this crate cannot reference — see
357 // `mcp-server::service::tests::test_save_skill_params_content_schema_matches_max_skill_content_size`
358 // for the drift-proof version of this specific assertion.
359 assert_eq!(props["content"]["maxLength"], 102_400);
360 }
361
362 #[test]
363 fn test_validate_server_id_valid() {
364 assert!(validate_server_id("github").is_ok());
365 assert!(validate_server_id("my-server").is_ok());
366 assert!(validate_server_id("server123").is_ok());
367 assert!(validate_server_id("my-server-123").is_ok());
368 }
369
370 #[test]
371 fn test_validate_server_id_empty() {
372 let result = validate_server_id("");
373 assert_eq!(result, Err(SkillServerIdError::Empty));
374 }
375
376 #[test]
377 fn test_validate_server_id_uppercase() {
378 let result = validate_server_id("GitHub");
379 assert_eq!(result, Err(SkillServerIdError::InvalidCharacters));
380 }
381
382 #[test]
383 fn test_validate_server_id_underscore() {
384 let result = validate_server_id("my_server");
385 assert_eq!(result, Err(SkillServerIdError::InvalidCharacters));
386 }
387
388 #[test]
389 fn test_validate_server_id_special_chars() {
390 let result = validate_server_id("my@server");
391 assert_eq!(result, Err(SkillServerIdError::InvalidCharacters));
392 }
393
394 #[test]
395 fn test_validate_server_id_too_long() {
396 let long_id = "a".repeat(65);
397 let result = validate_server_id(&long_id);
398 assert_eq!(
399 result,
400 Err(SkillServerIdError::TooLong {
401 len: 65,
402 limit: MAX_SERVER_ID_LENGTH
403 })
404 );
405 }
406
407 #[test]
408 fn test_validate_server_id_max_length() {
409 let max_id = "a".repeat(64);
410 assert!(validate_server_id(&max_id).is_ok());
411 }
412}