Skip to main content

construct/mcp_server/
skills_tools.rs

1//! Skill meta-tools exposed by the in-process MCP server.
2//!
3//! Instead of exposing every user skill as a standalone MCP tool (which could
4//! easily balloon to 50+ entries), this module provides three compact
5//! meta-tools that let external CLIs discover and invoke skills on demand:
6//!
7//! - `skills_list`     → no args; returns a JSON array of skill summaries.
8//! - `skills_describe` → `{ skill_id }`; returns the full skill body + metadata.
9//! - `skills_execute`  → `{ skill_id, arguments? }`; dispatches to the skill's
10//!                       named `[[tools]]` entry OR returns the body for
11//!                       markdown-only skills for the calling model to follow.
12//!
13//! All three are backed by the shared `crate::skills::load_skills_*` helpers
14//! (the same code path `ReadSkillTool` uses) so they stay consistent with
15//! the CLI-side view and don't duplicate the on-disk skill lookup.
16//!
17//! # Testability
18//!
19//! The tools accept a `SkillSource` so unit tests can hand in a tmp dir or a
20//! mocked executor without needing to reach into the real `~/.construct` tree.
21
22use crate::security::SecurityPolicy;
23use crate::skills::Skill;
24use crate::tools::traits::{Tool, ToolResult};
25use async_trait::async_trait;
26use serde_json::{Value, json};
27use std::path::PathBuf;
28use std::sync::Arc;
29
30/// Abstraction over the skill store so we can inject a mock in tests.
31///
32/// The real implementation walks `workspace_dir/skills/` (and optionally the
33/// open-skills mirror); tests can substitute a pre-built `Vec<Skill>`.
34pub trait SkillSource: Send + Sync {
35    fn load(&self) -> Vec<Skill>;
36}
37
38/// Production skill source: reads from disk using the same entry points as
39/// `ReadSkillTool` + the gateway's `skills_to_prompt` path.
40pub struct DiskSkillSource {
41    workspace_dir: PathBuf,
42    open_skills_enabled: bool,
43    open_skills_dir: Option<String>,
44}
45
46impl DiskSkillSource {
47    pub fn new(
48        workspace_dir: PathBuf,
49        open_skills_enabled: bool,
50        open_skills_dir: Option<String>,
51    ) -> Self {
52        Self {
53            workspace_dir,
54            open_skills_enabled,
55            open_skills_dir,
56        }
57    }
58}
59
60impl SkillSource for DiskSkillSource {
61    fn load(&self) -> Vec<Skill> {
62        crate::skills::load_skills_with_open_skills_settings(
63            &self.workspace_dir,
64            self.open_skills_enabled,
65            self.open_skills_dir.as_deref(),
66        )
67    }
68}
69
70fn summarize_skill(skill: &Skill) -> Value {
71    let location = skill
72        .location
73        .as_ref()
74        .map(|p| p.to_string_lossy().into_owned());
75    json!({
76        "id": skill.name,
77        "name": skill.name,
78        "description": skill.description,
79        "version": skill.version,
80        "author": skill.author,
81        "tags": skill.tags,
82        "location": location,
83        "tool_count": skill.tools.len(),
84    })
85}
86
87fn find_skill<'a>(skills: &'a [Skill], id: &str) -> Option<&'a Skill> {
88    skills
89        .iter()
90        .find(|s| s.name.eq_ignore_ascii_case(id.trim()))
91}
92
93// ── skills_list ──────────────────────────────────────────────────────────
94
95/// List every skill visible to the daemon.
96pub struct SkillsListTool {
97    source: Arc<dyn SkillSource>,
98}
99
100impl SkillsListTool {
101    pub fn new(source: Arc<dyn SkillSource>) -> Self {
102        Self { source }
103    }
104}
105
106#[async_trait]
107impl Tool for SkillsListTool {
108    fn name(&self) -> &str {
109        "skills_list"
110    }
111
112    fn description(&self) -> &str {
113        "List all Construct skills available to the local daemon. Returns a JSON array of { id, name, description, version, tags, tool_count, location } objects."
114    }
115
116    fn parameters_schema(&self) -> Value {
117        json!({ "type": "object", "properties": {}, "additionalProperties": false })
118    }
119
120    async fn execute(&self, _args: Value) -> anyhow::Result<ToolResult> {
121        let skills = self.source.load();
122        let payload: Vec<Value> = skills.iter().map(summarize_skill).collect();
123        Ok(ToolResult {
124            success: true,
125            output: serde_json::to_string_pretty(&payload)?,
126            error: None,
127        })
128    }
129}
130
131// ── skills_describe ──────────────────────────────────────────────────────
132
133/// Return the full markdown/manifest body of a single skill.
134pub struct SkillsDescribeTool {
135    source: Arc<dyn SkillSource>,
136}
137
138impl SkillsDescribeTool {
139    pub fn new(source: Arc<dyn SkillSource>) -> Self {
140        Self { source }
141    }
142}
143
144#[async_trait]
145impl Tool for SkillsDescribeTool {
146    fn name(&self) -> &str {
147        "skills_describe"
148    }
149
150    fn description(&self) -> &str {
151        "Return the full body (markdown / SKILL.toml) plus metadata for a single Construct skill by id."
152    }
153
154    fn parameters_schema(&self) -> Value {
155        json!({
156            "type": "object",
157            "properties": {
158                "skill_id": {
159                    "type": "string",
160                    "description": "Exact skill name/id as returned by skills_list."
161                }
162            },
163            "required": ["skill_id"]
164        })
165    }
166
167    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
168        let id = args
169            .get("skill_id")
170            .and_then(Value::as_str)
171            .map(str::trim)
172            .filter(|s| !s.is_empty());
173        let Some(id) = id else {
174            return Ok(ToolResult {
175                success: false,
176                output: String::new(),
177                error: Some("skills_describe requires `skill_id`".into()),
178            });
179        };
180
181        let skills = self.source.load();
182        let Some(skill) = find_skill(&skills, id) else {
183            let mut names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
184            names.sort_unstable();
185            return Ok(ToolResult {
186                success: false,
187                output: String::new(),
188                error: Some(format!(
189                    "Unknown skill '{id}'. Available: {}",
190                    if names.is_empty() {
191                        "none".into()
192                    } else {
193                        names.join(", ")
194                    }
195                )),
196            });
197        };
198
199        let body = if let Some(loc) = &skill.location {
200            tokio::fs::read_to_string(loc).await.unwrap_or_default()
201        } else {
202            String::new()
203        };
204
205        let payload = json!({
206            "id": skill.name,
207            "description": skill.description,
208            "version": skill.version,
209            "author": skill.author,
210            "tags": skill.tags,
211            "tools": skill.tools.iter().map(|t| json!({
212                "name": t.name,
213                "description": t.description,
214                "kind": t.kind,
215            })).collect::<Vec<_>>(),
216            "body": body,
217            "location": skill.location.as_ref().map(|p| p.to_string_lossy().into_owned()),
218        });
219
220        Ok(ToolResult {
221            success: true,
222            output: serde_json::to_string_pretty(&payload)?,
223            error: None,
224        })
225    }
226}
227
228// ── skills_execute ───────────────────────────────────────────────────────
229
230/// Executor abstraction — produced by tests to capture calls.
231#[async_trait]
232pub trait SkillExecutor: Send + Sync {
233    async fn run(
234        &self,
235        skill: &Skill,
236        sub_tool: Option<&str>,
237        arguments: Value,
238    ) -> anyhow::Result<ToolResult>;
239}
240
241/// Default executor: delegates to the same `SkillShellTool` / `SkillHttpTool`
242/// wrappers the agent loop uses. For markdown-only skills (no `[[tools]]`),
243/// returns the skill body so the calling model can follow the instructions.
244pub struct DefaultSkillExecutor {
245    security: Arc<SecurityPolicy>,
246}
247
248impl DefaultSkillExecutor {
249    pub fn new(security: Arc<SecurityPolicy>) -> Self {
250        Self { security }
251    }
252}
253
254#[async_trait]
255impl SkillExecutor for DefaultSkillExecutor {
256    async fn run(
257        &self,
258        skill: &Skill,
259        sub_tool: Option<&str>,
260        arguments: Value,
261    ) -> anyhow::Result<ToolResult> {
262        // Markdown-only skill: return body verbatim.
263        if skill.tools.is_empty() {
264            let body = if let Some(loc) = &skill.location {
265                tokio::fs::read_to_string(loc).await.unwrap_or_default()
266            } else {
267                String::new()
268            };
269            return Ok(ToolResult {
270                success: true,
271                output: body,
272                error: None,
273            });
274        }
275
276        // Select the sub-tool: explicit arg wins; otherwise the first entry.
277        let tool = if let Some(name) = sub_tool {
278            skill.tools.iter().find(|t| t.name == name)
279        } else {
280            skill.tools.first()
281        };
282
283        let Some(tool) = tool else {
284            return Ok(ToolResult {
285                success: false,
286                output: String::new(),
287                error: Some(format!(
288                    "Skill '{}' has no [[tools]] entry matching '{}'",
289                    skill.name,
290                    sub_tool.unwrap_or("(first)")
291                )),
292            });
293        };
294
295        match tool.kind.as_str() {
296            "shell" | "script" => {
297                let t = crate::tools::skill_tool::SkillShellTool::new(
298                    &skill.name,
299                    tool,
300                    self.security.clone(),
301                );
302                t.execute(arguments).await
303            }
304            "http" => {
305                let t = crate::tools::skill_http::SkillHttpTool::new(&skill.name, tool);
306                t.execute(arguments).await
307            }
308            other => Ok(ToolResult {
309                success: false,
310                output: String::new(),
311                error: Some(format!("Unsupported skill tool kind: {other}")),
312            }),
313        }
314    }
315}
316
317/// MCP tool: `skills_execute`.
318pub struct SkillsExecuteTool {
319    source: Arc<dyn SkillSource>,
320    executor: Arc<dyn SkillExecutor>,
321}
322
323impl SkillsExecuteTool {
324    pub fn new(source: Arc<dyn SkillSource>, executor: Arc<dyn SkillExecutor>) -> Self {
325        Self { source, executor }
326    }
327}
328
329#[async_trait]
330impl Tool for SkillsExecuteTool {
331    fn name(&self) -> &str {
332        "skills_execute"
333    }
334
335    fn description(&self) -> &str {
336        "Execute a Construct skill by id. For markdown skills this returns the skill body; for skills with [[tools]] entries it invokes the named sub-tool (or the first one) with the supplied `arguments` object."
337    }
338
339    fn parameters_schema(&self) -> Value {
340        json!({
341            "type": "object",
342            "properties": {
343                "skill_id": { "type": "string", "description": "Skill id/name." },
344                "tool": { "type": "string", "description": "Optional sub-tool name within the skill's [[tools]]." },
345                "arguments": { "type": "object", "description": "Arguments for the skill sub-tool." }
346            },
347            "required": ["skill_id"]
348        })
349    }
350
351    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
352        let id = args
353            .get("skill_id")
354            .and_then(Value::as_str)
355            .map(str::trim)
356            .filter(|s| !s.is_empty());
357        let Some(id) = id else {
358            return Ok(ToolResult {
359                success: false,
360                output: String::new(),
361                error: Some("skills_execute requires `skill_id`".into()),
362            });
363        };
364        let sub = args.get("tool").and_then(Value::as_str);
365        let arguments = args.get("arguments").cloned().unwrap_or_else(|| json!({}));
366
367        let skills = self.source.load();
368        let Some(skill) = find_skill(&skills, id) else {
369            return Ok(ToolResult {
370                success: false,
371                output: String::new(),
372                error: Some(format!("Unknown skill '{id}'")),
373            });
374        };
375
376        self.executor.run(skill, sub, arguments).await
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use std::path::PathBuf;
384    use std::sync::Mutex;
385
386    struct StaticSource(Vec<Skill>);
387    impl SkillSource for StaticSource {
388        fn load(&self) -> Vec<Skill> {
389            self.0.clone()
390        }
391    }
392
393    fn skill(name: &str) -> Skill {
394        Skill {
395            name: name.to_string(),
396            description: format!("desc-{name}"),
397            version: "0.1.0".into(),
398            author: None,
399            tags: vec!["t1".into()],
400            tools: vec![],
401            prompts: vec![],
402            location: None,
403        }
404    }
405
406    #[tokio::test]
407    async fn skills_list_returns_store_contents() {
408        let source: Arc<dyn SkillSource> =
409            Arc::new(StaticSource(vec![skill("alpha"), skill("beta")]));
410        let tool = SkillsListTool::new(source);
411        let res = tool.execute(json!({})).await.unwrap();
412        assert!(res.success);
413        let v: Value = serde_json::from_str(&res.output).unwrap();
414        let arr = v.as_array().unwrap();
415        assert_eq!(arr.len(), 2);
416        assert_eq!(arr[0]["id"], "alpha");
417        assert_eq!(arr[1]["id"], "beta");
418    }
419
420    #[tokio::test]
421    async fn skills_list_empty_returns_empty_array() {
422        let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![]));
423        let tool = SkillsListTool::new(source);
424        let res = tool.execute(json!({})).await.unwrap();
425        assert!(res.success);
426        assert_eq!(res.output.trim(), "[]");
427    }
428
429    #[tokio::test]
430    async fn skills_describe_unknown_skill_errors_with_available_list() {
431        let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![skill("alpha")]));
432        let tool = SkillsDescribeTool::new(source);
433        let res = tool.execute(json!({ "skill_id": "zeta" })).await.unwrap();
434        assert!(!res.success);
435        assert!(res.error.as_deref().unwrap().contains("alpha"));
436    }
437
438    struct RecordingExecutor {
439        calls: Mutex<Vec<(String, Option<String>, Value)>>,
440        response: String,
441    }
442    #[async_trait]
443    impl SkillExecutor for RecordingExecutor {
444        async fn run(
445            &self,
446            skill: &Skill,
447            sub: Option<&str>,
448            arguments: Value,
449        ) -> anyhow::Result<ToolResult> {
450            self.calls.lock().unwrap().push((
451                skill.name.clone(),
452                sub.map(str::to_string),
453                arguments,
454            ));
455            Ok(ToolResult {
456                success: true,
457                output: self.response.clone(),
458                error: None,
459            })
460        }
461    }
462
463    #[tokio::test]
464    async fn skills_execute_dispatches_to_executor_with_arguments() {
465        let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![skill("deploy")]));
466        let exec = Arc::new(RecordingExecutor {
467            calls: Mutex::new(Vec::new()),
468            response: "shipped!".into(),
469        });
470        let tool = SkillsExecuteTool::new(source, exec.clone());
471        let res = tool
472            .execute(json!({
473                "skill_id": "deploy",
474                "tool": "run",
475                "arguments": { "env": "prod" }
476            }))
477            .await
478            .unwrap();
479        assert!(res.success);
480        assert_eq!(res.output, "shipped!");
481        let calls = exec.calls.lock().unwrap();
482        assert_eq!(calls.len(), 1);
483        assert_eq!(calls[0].0, "deploy");
484        assert_eq!(calls[0].1.as_deref(), Some("run"));
485        assert_eq!(calls[0].2["env"], "prod");
486    }
487
488    #[tokio::test]
489    async fn skills_execute_markdown_skill_returns_body() {
490        let tmp = tempfile::TempDir::new().unwrap();
491        let skill_path = tmp.path().join("DEPLOY.md");
492        std::fs::write(&skill_path, "# Deploy\nmarkdown body").unwrap();
493        let mut s = skill("deploy");
494        s.location = Some(skill_path);
495        let source: Arc<dyn SkillSource> = Arc::new(StaticSource(vec![s]));
496        let executor = Arc::new(DefaultSkillExecutor::new(Arc::new(
497            SecurityPolicy::default(),
498        )));
499        let tool = SkillsExecuteTool::new(source, executor);
500        let res = tool.execute(json!({ "skill_id": "deploy" })).await.unwrap();
501        assert!(res.success);
502        assert!(res.output.contains("markdown body"));
503    }
504
505    #[tokio::test]
506    async fn disk_skill_source_reads_from_workspace_skills_dir() {
507        let tmp = tempfile::TempDir::new().unwrap();
508        let skill_dir = tmp.path().join("skills/widget");
509        std::fs::create_dir_all(&skill_dir).unwrap();
510        std::fs::write(skill_dir.join("SKILL.md"), "# Widget\nbody").unwrap();
511        let source = DiskSkillSource::new(tmp.path().to_path_buf(), false, None);
512        let loaded = source.load();
513        assert!(loaded.iter().any(|s| s.name == "widget"));
514    }
515
516    // Suppress unused-import warnings in non-test builds.
517    #[allow(dead_code)]
518    fn _path_ref() -> PathBuf {
519        PathBuf::new()
520    }
521}