Skip to main content

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}