Skip to main content

metis_mcp_server/tools/
create_document.rs

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    /// Path to the .metis folder (e.g., "/Users/me/my-project/.metis"). Must end with .metis
29    pub project_path: String,
30    /// Document type: vision, strategy, initiative, task, adr
31    pub document_type: String,
32    /// Title of the document
33    pub title: String,
34    /// Parent document short code (required for strategy, initiative, task). Omit for backlog items.
35    pub parent_id: Option<String>,
36    /// Risk level for strategies (low, medium, high)
37    pub risk_level: Option<String>,
38    /// Complexity for initiatives (xs, s, m, l, xl)
39    pub complexity: Option<String>,
40    /// Stakeholders involved
41    pub stakeholders: Option<Vec<String>>,
42    /// Decision maker for ADRs
43    pub decision_maker: Option<String>,
44    /// Backlog category for standalone tasks not tied to initiatives (bug, feature, tech-debt). When specified, creates a backlog item instead of a regular task.
45    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        // Prepare workspace (validates, creates/updates database, syncs)
53        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        // Parse document type
65        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        // Validate document type is enabled in current configuration
87        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        // Create the document creation service
104        let creation_service = DocumentCreationService::new(metis_dir);
105
106        // Parse complexity if provided
107        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        // Parse risk level if provided
120        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        // Pass parent_id as-is (short codes are now handled directly by core services)
133        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, // Will use defaults
143            complexity,
144            risk_level,
145        };
146
147        // Create the document based on type
148        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                // Determine parent strategy ID based on configuration
167                let parent_strategy_id = if flight_config.strategies_enabled {
168                    // Full configuration: require explicit strategy parent
169                    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                    // Streamlined/Direct configuration: use NULL strategy placeholder
177                    "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                // Check if this should be a backlog item
187                if let Some(category) = &self.backlog_category {
188                    // Creating a backlog item - add category tag
189                    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                    // Task with parent initiative
217                    let strategy_id = if flight_config.strategies_enabled {
218                        // Full configuration: use actual strategy short code from initiative location
219                        self.find_strategy_short_code_for_initiative(&database, initiative_id)?
220                    } else {
221                        // Streamlined/Direct: use NULL as strategy placeholder
222                        "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                    // Initiatives enabled but no parent provided - suggest backlog
236                    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                    // Direct configuration: create task without parents (use NULL for both)
242                    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        // Find the initiative document in the database by short code
291        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        // Get the strategy ID from the initiative, then find the strategy's short code
307        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        // Find the strategy by its short code (strategy_id now contains short codes)
315        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}