1use crate::formatting::ToolOutput;
2use metis_core::{
3 application::services::{
4 document::{creation::DocumentCreationConfig, DocumentCreationService},
5 workspace::WorkspaceDetectionService,
6 },
7 domain::documents::types::DocumentType,
8 Database,
9};
10use rust_mcp_sdk::{
11 macros::{mcp_tool, JsonSchema},
12 schema::{schema_utils::CallToolError, CallToolResult},
13};
14use serde::{Deserialize, Serialize};
15use std::path::Path;
16use std::str::FromStr;
17
18#[mcp_tool(
19 name = "create_document",
20 description = "Create a new Metis document (vision, strategy, initiative, task, adr). Each document gets a unique short code in format PREFIX-TYPE-NNNN (e.g., PROJ-V-0001). Parent documents should be referenced by their short code (e.g., PROJ-V-0001). Document type availability depends on current flight level configuration. For standalone work items not tied to initiatives, use document_type='task' with backlog_category to create a backlog item.",
21 idempotent_hint = false,
22 destructive_hint = false,
23 open_world_hint = false,
24 read_only_hint = false
25)]
26#[derive(Debug, Serialize, Deserialize, JsonSchema)]
27pub struct CreateDocumentTool {
28 pub project_path: String,
30 pub document_type: String,
32 pub title: String,
34 pub parent_id: Option<String>,
36 pub risk_level: Option<String>,
38 pub complexity: Option<String>,
40 pub stakeholders: Option<Vec<String>>,
42 pub decision_maker: Option<String>,
44 pub backlog_category: Option<String>,
46}
47
48impl CreateDocumentTool {
49 pub async fn call_tool(&self) -> std::result::Result<CallToolResult, CallToolError> {
50 let metis_dir = Path::new(&self.project_path);
51
52 let detection_service = WorkspaceDetectionService::new();
54 let database = detection_service
55 .prepare_workspace(metis_dir)
56 .await
57 .map_err(|e| {
58 CallToolError::new(std::io::Error::new(
59 std::io::ErrorKind::Other,
60 e.to_string(),
61 ))
62 })?;
63
64 let doc_type = DocumentType::from_str(&self.document_type).map_err(|_| {
66 CallToolError::new(std::io::Error::new(
67 std::io::ErrorKind::InvalidInput,
68 format!("Invalid document type: {}", self.document_type),
69 ))
70 })?;
71
72 let mut config_repo = database.configuration_repository().map_err(|e| {
73 CallToolError::new(std::io::Error::new(
74 std::io::ErrorKind::Other,
75 format!("Failed to access configuration repository: {}", e),
76 ))
77 })?;
78
79 let flight_config = config_repo.get_flight_level_config().map_err(|e| {
80 CallToolError::new(std::io::Error::new(
81 std::io::ErrorKind::Other,
82 format!("Failed to load configuration: {}", e),
83 ))
84 })?;
85
86 let enabled_types = flight_config.enabled_document_types();
88 if !enabled_types.contains(&doc_type) {
89 let available_types: Vec<String> =
90 enabled_types.iter().map(|t| t.to_string()).collect();
91 return Err(CallToolError::new(std::io::Error::new(
92 std::io::ErrorKind::InvalidInput,
93 format!(
94 "{} creation is disabled in current configuration ({} mode). Available document types: {}. To enable {}, use 'metis config set --preset full' or configure individually with 'metis config set --strategies true --initiatives true'",
95 doc_type,
96 flight_config.preset_name(),
97 available_types.join(", "),
98 doc_type
99 ),
100 )));
101 }
102
103 let creation_service = DocumentCreationService::new(metis_dir);
105
106 let complexity = self
108 .complexity
109 .as_ref()
110 .map(|c| c.parse())
111 .transpose()
112 .map_err(|e| {
113 CallToolError::new(std::io::Error::new(
114 std::io::ErrorKind::InvalidInput,
115 format!("Invalid complexity: {}", e),
116 ))
117 })?;
118
119 let risk_level = self
121 .risk_level
122 .as_ref()
123 .map(|r| r.parse())
124 .transpose()
125 .map_err(|e| {
126 CallToolError::new(std::io::Error::new(
127 std::io::ErrorKind::InvalidInput,
128 format!("Invalid risk level: {}", e),
129 ))
130 })?;
131
132 let resolved_parent_id = self.parent_id.clone();
134
135 let config = DocumentCreationConfig {
136 title: self.title.clone(),
137 description: None,
138 parent_id: resolved_parent_id
139 .as_ref()
140 .map(|id| metis_core::domain::documents::types::DocumentId::from(id.clone())),
141 tags: vec![],
142 phase: None, complexity,
144 risk_level,
145 };
146
147 let result = match doc_type {
149 DocumentType::Vision => {
150 if self.parent_id.is_some() {
151 return Err(CallToolError::new(std::io::Error::new(
152 std::io::ErrorKind::InvalidInput,
153 "Vision documents cannot have a parent",
154 )));
155 }
156 creation_service
157 .create_vision(config)
158 .await
159 .map_err(|e| CallToolError::new(e))?
160 }
161 DocumentType::Strategy => creation_service
162 .create_strategy(config)
163 .await
164 .map_err(|e| CallToolError::new(e))?,
165 DocumentType::Initiative => {
166 let parent_strategy_id = if flight_config.strategies_enabled {
168 resolved_parent_id.as_ref().ok_or_else(|| {
170 CallToolError::new(std::io::Error::new(
171 std::io::ErrorKind::InvalidInput,
172 "Initiative requires a parent strategy short code in full configuration",
173 ))
174 })?.clone()
175 } else {
176 "NULL".to_string()
178 };
179
180 creation_service
181 .create_initiative_with_config(config, &parent_strategy_id, &flight_config)
182 .await
183 .map_err(|e| CallToolError::new(e))?
184 }
185 DocumentType::Task => {
186 if let Some(category) = &self.backlog_category {
188 let category_tag = match category.to_lowercase().as_str() {
190 "bug" => metis_core::domain::documents::types::Tag::Label("bug".to_string()),
191 "feature" => metis_core::domain::documents::types::Tag::Label("feature".to_string()),
192 "tech-debt" | "techdebt" | "tech_debt" => metis_core::domain::documents::types::Tag::Label("tech-debt".to_string()),
193 _ => {
194 return Err(CallToolError::new(std::io::Error::new(
195 std::io::ErrorKind::InvalidInput,
196 format!("Invalid backlog category '{}'. Valid options: bug, feature, tech-debt", category),
197 )));
198 }
199 };
200
201 let backlog_config = DocumentCreationConfig {
202 title: self.title.clone(),
203 description: None,
204 parent_id: None,
205 tags: vec![category_tag],
206 phase: None,
207 complexity: None,
208 risk_level: None,
209 };
210
211 creation_service
212 .create_backlog_item(backlog_config)
213 .await
214 .map_err(|e| CallToolError::new(e))?
215 } else if let Some(initiative_id) = resolved_parent_id.as_ref() {
216 let strategy_id = if flight_config.strategies_enabled {
218 self.find_strategy_short_code_for_initiative(&database, initiative_id)?
220 } else {
221 "NULL".to_string()
223 };
224
225 creation_service
226 .create_task_with_config(
227 config,
228 &strategy_id,
229 initiative_id,
230 &flight_config,
231 )
232 .await
233 .map_err(|e| CallToolError::new(e))?
234 } else if flight_config.initiatives_enabled {
235 return Err(CallToolError::new(std::io::Error::new(
237 std::io::ErrorKind::InvalidInput,
238 format!("Task requires a parent initiative ID in {} configuration. Either provide parent_id with an initiative short code, or use backlog_category (bug, feature, tech-debt) to create a standalone backlog item.", flight_config.preset_name()),
239 )));
240 } else {
241 creation_service
243 .create_task_with_config(config, "NULL", "NULL", &flight_config)
244 .await
245 .map_err(|e| CallToolError::new(e))?
246 }
247 }
248 DocumentType::Adr => creation_service
249 .create_adr(config)
250 .await
251 .map_err(|e| CallToolError::new(e))?,
252 };
253
254 let parent_display = self
255 .parent_id
256 .as_ref()
257 .map(|s| s.as_str())
258 .unwrap_or("-");
259
260 let result_output = ToolOutput::new()
261 .header("Document Created")
262 .text(&format!("{} created successfully", result.short_code))
263 .table(
264 &["Field", "Value"],
265 vec![
266 vec!["Title".to_string(), self.title.clone()],
267 vec!["Type".to_string(), self.document_type.clone()],
268 vec!["Short Code".to_string(), result.short_code.clone()],
269 vec!["Parent".to_string(), parent_display.to_string()],
270 ],
271 )
272 .text(&format!("Path: `{}`", result.file_path.to_string_lossy()))
273 .build_result();
274
275 Ok(result_output)
276 }
277
278 fn find_strategy_short_code_for_initiative(
279 &self,
280 database: &Database,
281 initiative_id: &str,
282 ) -> Result<String, CallToolError> {
283 let mut repo = database.repository().map_err(|e| {
284 CallToolError::new(std::io::Error::new(
285 std::io::ErrorKind::Other,
286 format!("Repository error: {}", e),
287 ))
288 })?;
289
290 let initiative = repo
292 .find_by_short_code(initiative_id)
293 .map_err(|e| {
294 CallToolError::new(std::io::Error::new(
295 std::io::ErrorKind::Other,
296 format!("Database lookup error: {}", e),
297 ))
298 })?
299 .ok_or_else(|| {
300 CallToolError::new(std::io::Error::new(
301 std::io::ErrorKind::NotFound,
302 format!("Initiative '{}' not found in database", initiative_id),
303 ))
304 })?;
305
306 let strategy_id = initiative.strategy_id.ok_or_else(|| {
308 CallToolError::new(std::io::Error::new(
309 std::io::ErrorKind::InvalidData,
310 format!("Initiative '{}' has no parent strategy", initiative_id),
311 ))
312 })?;
313
314 let strategy = repo
316 .find_by_short_code(&strategy_id)
317 .map_err(|e| {
318 CallToolError::new(std::io::Error::new(
319 std::io::ErrorKind::Other,
320 format!("Database lookup error: {}", e),
321 ))
322 })?
323 .ok_or_else(|| {
324 CallToolError::new(std::io::Error::new(
325 std::io::ErrorKind::NotFound,
326 format!("Strategy '{}' not found in database", strategy_id),
327 ))
328 })?;
329
330 Ok(strategy.short_code)
331 }
332}