Skip to main content

metis_mcp_server/tools/
read_document.rs

1use crate::formatting::{error_result, ToolOutput};
2use metis_core::{application::services::workspace::WorkspaceDetectionService, dal::Database};
3use rust_mcp_sdk::{
4    macros::{mcp_tool, JsonSchema},
5    schema::{schema_utils::CallToolError, CallToolResult},
6};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use tokio::fs;
10
11#[mcp_tool(
12    name = "read_document",
13    description = "Read a document's content and structure using its short code (e.g., PROJ-V-0001).",
14    idempotent_hint = true,
15    destructive_hint = false,
16    open_world_hint = false,
17    read_only_hint = true
18)]
19#[derive(Debug, Serialize, Deserialize, JsonSchema)]
20pub struct ReadDocumentTool {
21    /// Path to the .metis folder (e.g., "/Users/me/my-project/.metis"). Must end with .metis
22    pub project_path: String,
23    /// Document short code (e.g., PROJ-V-0001) to identify the document
24    pub short_code: String,
25}
26
27impl ReadDocumentTool {
28    /// Resolve short code to file path
29    fn resolve_short_code_to_path(&self, metis_dir: &Path) -> Result<String, CallToolError> {
30        let db_path = metis_dir.join("metis.db");
31        let db = Database::new(db_path.to_str().unwrap()).map_err(|e| {
32            CallToolError::new(std::io::Error::new(
33                std::io::ErrorKind::Other,
34                format!("Database error: {}", e),
35            ))
36        })?;
37
38        let mut repo = db.repository().map_err(|e| {
39            CallToolError::new(std::io::Error::new(
40                std::io::ErrorKind::Other,
41                format!("Repository error: {}", e),
42            ))
43        })?;
44
45        // Use the core DAL method
46        repo.resolve_short_code_to_filepath(&self.short_code)
47            .map_err(|e| {
48                CallToolError::new(std::io::Error::new(
49                    std::io::ErrorKind::Other,
50                    format!("Resolution error: {}", e),
51                ))
52            })
53    }
54
55    pub async fn call_tool(&self) -> std::result::Result<CallToolResult, CallToolError> {
56        let metis_dir = Path::new(&self.project_path);
57
58        // Prepare workspace (validates, creates/updates database, syncs)
59        let detection_service = WorkspaceDetectionService::new();
60        let _db = detection_service
61            .prepare_workspace(metis_dir)
62            .await
63            .map_err(|e| {
64                CallToolError::new(std::io::Error::new(
65                    std::io::ErrorKind::Other,
66                    e.to_string(),
67                ))
68            })?;
69
70        // Resolve short code to document path
71        let document_path = self.resolve_short_code_to_path(metis_dir)?;
72        let full_document_path = metis_dir.join(&document_path);
73
74        if !full_document_path.exists() {
75            return Ok(error_result(
76                &format!("Document not found: {}", self.short_code),
77                &format!(
78                    "No document with identifier \"{}\" exists in this project.",
79                    self.short_code
80                ),
81                Some("Use `list_documents` to see available documents."),
82            ));
83        }
84
85        // Read the document content
86        let content = fs::read_to_string(&full_document_path)
87            .await
88            .map_err(|e| CallToolError::new(e))?;
89
90        // Extract metadata from frontmatter
91        let (doc_type, phase, _created, _archived, title) = self.extract_metadata(&content);
92
93        // Build simplified output with inline metadata
94        let output = ToolOutput::new()
95            .header(&format!("{}: {} ({}, {})", self.short_code, title, doc_type, phase))
96            .text(&content);
97
98        Ok(output.build_result())
99    }
100
101    fn extract_metadata(&self, content: &str) -> (String, String, String, String, String) {
102        let mut doc_type = "unknown".to_string();
103        let mut phase = "unknown".to_string();
104        let mut created = "unknown".to_string();
105        let mut archived = "No".to_string();
106        let mut title = "Untitled".to_string();
107
108        let mut in_frontmatter = false;
109
110        for line in content.lines() {
111            let trimmed = line.trim();
112            if trimmed == "---" {
113                if in_frontmatter {
114                    break; // End of frontmatter
115                }
116                in_frontmatter = true;
117                continue;
118            }
119
120            if in_frontmatter {
121                if let Some((key, value)) = trimmed.split_once(':') {
122                    let key = key.trim();
123                    let value = value.trim().trim_matches('"');
124                    match key {
125                        "level" => doc_type = value.to_string(),
126                        "title" => title = value.to_string(),
127                        "created_at" => {
128                            // Parse and format date
129                            if let Some(date_part) = value.split('T').next() {
130                                created = date_part.to_string();
131                            } else {
132                                created = value.to_string();
133                            }
134                        }
135                        "archived" => {
136                            archived = if value == "true" {
137                                "Yes".to_string()
138                            } else {
139                                "No".to_string()
140                            };
141                        }
142                        _ => {}
143                    }
144                }
145                // Extract phase from tags
146                if trimmed.contains("#phase/") {
147                    if let Some(start) = trimmed.find("#phase/") {
148                        let phase_start = start + 7;
149                        let phase_end = trimmed[phase_start..]
150                            .find(|c: char| !c.is_alphanumeric() && c != '_')
151                            .map(|i| phase_start + i)
152                            .unwrap_or(trimmed.len());
153                        phase = trimmed[phase_start..phase_end].trim_matches('"').to_string();
154                    }
155                }
156            }
157        }
158
159        (doc_type, phase, created, archived, title)
160    }
161
162    #[allow(dead_code)]
163    fn extract_sections(&self, content: &str) -> Vec<String> {
164        let mut sections = Vec::new();
165
166        for line in content.lines() {
167            let trimmed = line.trim();
168            if trimmed.starts_with("## ") && !trimmed.starts_with("### ") {
169                let section_name = trimmed[3..].trim().to_string();
170                sections.push(section_name);
171            }
172        }
173
174        sections
175    }
176
177    fn extract_exit_criteria(&self, content: &str) -> Vec<ExitCriterion> {
178        let mut criteria = Vec::new();
179
180        for line in content.lines() {
181            let trimmed = line.trim();
182
183            // Look for markdown checkbox patterns
184            if trimmed.starts_with("- [") {
185                if let Some(checkbox_end) = trimmed.find(']') {
186                    if checkbox_end >= 3 {
187                        let checkbox_content = &trimmed[3..checkbox_end];
188                        let completed =
189                            checkbox_content.trim() == "x" || checkbox_content.trim() == "X";
190
191                        // Extract the criterion text after the checkbox
192                        let criterion_text = if trimmed.len() > checkbox_end + 1 {
193                            trimmed[checkbox_end + 1..].trim().to_string()
194                        } else {
195                            "".to_string()
196                        };
197
198                        if !criterion_text.is_empty() {
199                            criteria.push(ExitCriterion {
200                                text: criterion_text,
201                                completed,
202                            });
203                        }
204                    }
205                }
206            }
207        }
208
209        criteria
210    }
211}
212
213#[derive(Debug, Serialize, Deserialize)]
214struct ExitCriterion {
215    text: String,
216    completed: bool,
217}