spec_kit_mcp/tools/
specify.rs

1//! Spec-Kit Specify Tool
2//!
3//! Defines requirements and user stories.
4
5use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use std::path::PathBuf;
10
11use crate::mcp::types::{ContentBlock, ToolDefinition, ToolResult};
12use crate::speckit::SpecKitCli;
13use crate::tools::Tool;
14
15/// Parameters for the speckit_specify tool
16#[derive(Debug, Deserialize, Serialize)]
17pub struct SpecifyParams {
18    /// Requirements to specify
19    requirements: String,
20
21    /// User stories (optional)
22    #[serde(default)]
23    user_stories: Option<String>,
24
25    /// Output path for specification file
26    #[serde(default = "default_specify_path")]
27    output_path: PathBuf,
28
29    /// Output format
30    #[serde(default = "default_format")]
31    format: String,
32}
33
34fn default_specify_path() -> PathBuf {
35    PathBuf::from("./speckit.specify")
36}
37
38fn default_format() -> String {
39    "markdown".to_string()
40}
41
42/// Tool for creating specifications
43pub struct SpecifyTool {
44    cli: SpecKitCli,
45}
46
47impl SpecifyTool {
48    /// Create a new specify tool
49    pub fn new(cli: SpecKitCli) -> Self {
50        Self { cli }
51    }
52}
53
54#[async_trait]
55impl Tool for SpecifyTool {
56    fn definition(&self) -> ToolDefinition {
57        ToolDefinition {
58            name: "speckit_specify".to_string(),
59            description: "Define what you want to build - requirements, user stories, and acceptance criteria".to_string(),
60            input_schema: json!({
61                "type": "object",
62                "properties": {
63                    "requirements": {
64                        "type": "string",
65                        "description": "The requirements to specify. Can include features, constraints, user needs, etc."
66                    },
67                    "user_stories": {
68                        "type": "string",
69                        "description": "Optional user stories in 'As a... I want... So that...' format"
70                    },
71                    "output_path": {
72                        "type": "string",
73                        "description": "Path where the specification file will be written",
74                        "default": "./speckit.specify"
75                    },
76                    "format": {
77                        "type": "string",
78                        "enum": ["markdown", "yaml", "json"],
79                        "default": "markdown",
80                        "description": "Output format for the specification"
81                    }
82                },
83                "required": ["requirements"]
84            })
85        }
86    }
87
88    async fn execute(&self, params: Value) -> Result<ToolResult> {
89        let params: SpecifyParams =
90            serde_json::from_value(params).context("Failed to parse specify parameters")?;
91
92        tracing::info!(
93            output_path = %params.output_path.display(),
94            format = %params.format,
95            "Creating specification"
96        );
97
98        // Format the specification content
99        let mut content = format!(
100            "# Specification\n\n## Requirements\n\n{}\n",
101            params.requirements
102        );
103
104        if let Some(stories) = params.user_stories {
105            content.push_str(&format!("\n## User Stories\n\n{}\n", stories));
106        }
107
108        // Write specification file
109        let result = self
110            .cli
111            .specify(&content, &params.output_path, &params.format)
112            .await?;
113
114        if !result.is_success() {
115            return Ok(ToolResult {
116                content: vec![ContentBlock::text(format!(
117                    "Failed to create specification: {}",
118                    result.stderr
119                ))],
120                is_error: Some(true),
121            });
122        }
123
124        let message = format!(
125            "Specification created successfully at {}\n\n\
126            The specification defines:\n\
127            - What needs to be built (requirements)\n\
128            - Who it's for and why (user stories)\n\
129            - Success criteria (acceptance criteria)\n\n\
130            Next step: Use speckit_plan tool to create a technical plan",
131            params.output_path.display()
132        );
133
134        Ok(ToolResult {
135            content: vec![ContentBlock::text(message)],
136            is_error: None,
137        })
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use tempfile::tempdir;
145
146    #[tokio::test]
147    async fn test_specify_tool_definition() {
148        let cli = SpecKitCli::new();
149        let tool = SpecifyTool::new(cli);
150        let def = tool.definition();
151
152        assert_eq!(def.name, "speckit_specify");
153        assert!(!def.description.is_empty());
154    }
155
156    #[tokio::test]
157    async fn test_specify_tool_execute() {
158        let cli = SpecKitCli::new_test_mode();
159        let tool = SpecifyTool::new(cli);
160
161        let dir = tempdir().unwrap();
162        let output_path = dir.path().join("specification.md");
163
164        let params = json!({
165            "requirements": "User authentication system with OAuth2 support",
166            "user_stories": "As a user, I want to login with Google, so that I don't need another password",
167            "output_path": output_path.to_str().unwrap()
168        });
169
170        let result = tool.execute(params).await.unwrap();
171        assert!(result.is_error.is_none() || !result.is_error.unwrap());
172        assert!(output_path.exists());
173    }
174}