metis_mcp_server/tools/
read_document.rs1use 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 pub project_path: String,
23 pub short_code: String,
25}
26
27impl ReadDocumentTool {
28 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 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 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 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 let content = fs::read_to_string(&full_document_path)
87 .await
88 .map_err(|e| CallToolError::new(e))?;
89
90 let (doc_type, phase, _created, _archived, title) = self.extract_metadata(&content);
92
93 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; }
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 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 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 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 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}