spec_kit_mcp/tools/
plan.rs1use 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#[derive(Debug, Deserialize, Serialize)]
17pub struct PlanParams {
18 spec_file: PathBuf,
20
21 #[serde(default)]
23 tech_stack: Option<String>,
24
25 #[serde(default = "default_plan_path")]
27 output_path: PathBuf,
28}
29
30fn default_plan_path() -> PathBuf {
31 PathBuf::from("./speckit.plan")
32}
33
34pub struct PlanTool {
36 cli: SpecKitCli,
37}
38
39impl PlanTool {
40 pub fn new(cli: SpecKitCli) -> Self {
42 Self { cli }
43 }
44}
45
46#[async_trait]
47impl Tool for PlanTool {
48 fn definition(&self) -> ToolDefinition {
49 ToolDefinition {
50 name: "speckit_plan".to_string(),
51 description: "Create a technical implementation plan based on the specification, including architecture, tech stack, and approach".to_string(),
52 input_schema: json!({
53 "type": "object",
54 "properties": {
55 "spec_file": {
56 "type": "string",
57 "description": "Path to the specification file (speckit.specify)"
58 },
59 "tech_stack": {
60 "type": "string",
61 "description": "Technology stack to use (e.g., 'Rust + Tokio', 'Python + FastAPI')"
62 },
63 "output_path": {
64 "type": "string",
65 "description": "Path where the plan file will be written",
66 "default": "./speckit.plan"
67 }
68 },
69 "required": ["spec_file"]
70 })
71 }
72 }
73
74 async fn execute(&self, params: Value) -> Result<ToolResult> {
75 let params: PlanParams =
76 serde_json::from_value(params).context("Failed to parse plan parameters")?;
77
78 tracing::info!(
79 spec_file = %params.spec_file.display(),
80 output_path = %params.output_path.display(),
81 "Creating technical plan"
82 );
83
84 let result = self
86 .cli
87 .plan(¶ms.spec_file, ¶ms.output_path)
88 .await?;
89
90 if !result.is_success() {
91 return Ok(ToolResult {
92 content: vec![ContentBlock::text(format!(
93 "Failed to create plan: {}",
94 result.stderr
95 ))],
96 is_error: Some(true),
97 });
98 }
99
100 let message = format!(
101 "Technical plan created successfully at {}\n\n\
102 The plan includes:\n\
103 - Architecture and system design\n\
104 - Technology stack and frameworks\n\
105 - Implementation approach\n\
106 - Module breakdown\n\n\
107 Next step: Use speckit_tasks tool to generate actionable tasks",
108 params.output_path.display()
109 );
110
111 Ok(ToolResult {
112 content: vec![ContentBlock::text(message)],
113 is_error: None,
114 })
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use tempfile::tempdir;
122 use tokio::fs;
123
124 #[tokio::test]
125 async fn test_plan_tool_definition() {
126 let cli = SpecKitCli::new();
127 let tool = PlanTool::new(cli);
128 let def = tool.definition();
129
130 assert_eq!(def.name, "speckit_plan");
131 assert!(!def.description.is_empty());
132 }
133
134 #[tokio::test]
135 async fn test_plan_tool_execute() {
136 let cli = SpecKitCli::new_test_mode();
137 let tool = PlanTool::new(cli);
138
139 let dir = tempdir().unwrap();
140 let spec_file = dir.path().join("spec.md");
141 let output_path = dir.path().join("plan.md");
142
143 fs::write(&spec_file, "Test specification").await.unwrap();
145
146 let params = json!({
147 "spec_file": spec_file.to_str().unwrap(),
148 "tech_stack": "Rust + Tokio",
149 "output_path": output_path.to_str().unwrap()
150 });
151
152 let result = tool.execute(params).await.unwrap();
153 assert!(result.is_error.is_none() || !result.is_error.unwrap());
154 }
155}