task_graph_mcp/resources/
docs.rs1use anyhow::Result;
9use serde_json::{Value, json};
10use std::path::Path;
11
12#[derive(Debug, Clone)]
14pub struct DocInfo {
15 pub relative_path: String,
17 pub name: String,
19 pub size: u64,
21}
22
23fn find_markdown_files(dir: &Path, base: &Path, files: &mut Vec<DocInfo>) -> Result<()> {
25 if !dir.exists() || !dir.is_dir() {
26 return Ok(());
27 }
28
29 for entry in std::fs::read_dir(dir)? {
30 let entry = entry?;
31 let path = entry.path();
32
33 if path.is_dir() {
34 find_markdown_files(&path, base, files)?;
36 } else if path.is_file() {
37 if let Some(ext) = path.extension() {
39 if ext == "md" || ext == "markdown" {
40 let relative = path
41 .strip_prefix(base)
42 .map(|p| p.to_string_lossy().to_string().replace('\\', "/"))
43 .unwrap_or_else(|_| {
44 path.file_name().unwrap().to_string_lossy().to_string()
45 });
46
47 let name = path
48 .file_name()
49 .map(|n| n.to_string_lossy().to_string())
50 .unwrap_or_default();
51
52 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
53
54 files.push(DocInfo {
55 relative_path: relative,
56 name,
57 size,
58 });
59 }
60 }
61 }
62 }
63
64 Ok(())
65}
66
67fn validate_doc_path(path: &str) -> Result<()> {
70 if path.is_empty() {
71 return Err(anyhow::anyhow!("Doc path cannot be empty"));
72 }
73
74 if path.len() > 256 {
75 return Err(anyhow::anyhow!("Doc path too long (max 256 chars)"));
76 }
77
78 if path.contains("..") {
80 return Err(anyhow::anyhow!(
81 "Invalid doc path: path traversal not allowed"
82 ));
83 }
84
85 if !path
87 .chars()
88 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/')
89 {
90 return Err(anyhow::anyhow!(
91 "Invalid doc path: only alphanumeric, hyphen, underscore, dot, and slash allowed"
92 ));
93 }
94
95 if !path.ends_with(".md") && !path.ends_with(".markdown") {
97 return Err(anyhow::anyhow!(
98 "Invalid doc path: must end with .md or .markdown"
99 ));
100 }
101
102 Ok(())
103}
104
105pub fn list_docs(docs_dir: Option<&Path>) -> Result<Value> {
107 let Some(dir) = docs_dir else {
108 return Ok(json!({
109 "docs": [],
110 "count": 0,
111 "docs_dir": null,
112 "error": "No docs directory configured"
113 }));
114 };
115
116 if !dir.exists() {
117 return Ok(json!({
118 "docs": [],
119 "count": 0,
120 "docs_dir": dir.display().to_string(),
121 "error": "Docs directory does not exist"
122 }));
123 }
124
125 let mut files = Vec::new();
126 find_markdown_files(dir, dir, &mut files)?;
127
128 files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
130
131 let docs_list: Vec<Value> = files
132 .iter()
133 .map(|doc| {
134 json!({
135 "name": doc.name,
136 "path": doc.relative_path,
137 "uri": format!("docs://{}", doc.relative_path),
138 "size": doc.size,
139 "mime_type": "text/markdown",
140 })
141 })
142 .collect();
143
144 let count = docs_list.len();
145
146 Ok(json!({
147 "docs": docs_list,
148 "count": count,
149 "docs_dir": dir.display().to_string(),
150 }))
151}
152
153pub fn get_doc_resource(docs_dir: Option<&Path>, path: &str) -> Result<Value> {
155 validate_doc_path(path)?;
156
157 let Some(dir) = docs_dir else {
158 return Err(anyhow::anyhow!("No docs directory configured"));
159 };
160
161 let file_path = dir.join(path);
163
164 if let Ok(canonical_file) = file_path.canonicalize() {
166 if let Ok(canonical_dir) = dir.canonicalize() {
167 if !canonical_file.starts_with(&canonical_dir) {
168 return Err(anyhow::anyhow!("Invalid doc path: outside docs directory"));
169 }
170 }
171 }
172
173 if !file_path.exists() {
174 return Err(anyhow::anyhow!("Documentation file not found: {}", path));
175 }
176
177 if !file_path.is_file() {
178 return Err(anyhow::anyhow!("Not a file: {}", path));
179 }
180
181 let content = std::fs::read_to_string(&file_path)
182 .map_err(|e| anyhow::anyhow!("Failed to read doc file: {}", e))?;
183
184 let size = std::fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
185
186 let name = file_path
187 .file_name()
188 .map(|n| n.to_string_lossy().to_string())
189 .unwrap_or_default();
190
191 Ok(json!({
192 "name": name,
193 "path": path,
194 "uri": format!("docs://{}", path),
195 "content": content,
196 "size": size,
197 "mime_type": "text/markdown",
198 }))
199}
200
201pub fn get_doc_resources(docs_dir: Option<&Path>) -> Vec<(String, String, String)> {
203 let mut resources = Vec::new();
204
205 let Some(dir) = docs_dir else {
206 return resources;
207 };
208
209 if !dir.exists() {
210 return resources;
211 }
212
213 let mut files = Vec::new();
214 if find_markdown_files(dir, dir, &mut files).is_ok() {
215 for doc in files {
216 let uri = format!("docs://{}", doc.relative_path);
217 let name = doc.name.clone();
218 let description = format!("Documentation: {}", doc.relative_path);
219 resources.push((uri, name, description));
220 }
221 }
222
223 resources
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use std::fs;
230 use tempfile::TempDir;
231
232 #[test]
233 fn test_validate_doc_path() {
234 assert!(validate_doc_path("README.md").is_ok());
236 assert!(validate_doc_path("GATES.md").is_ok());
237 assert!(validate_doc_path("diagrams/README.md").is_ok());
238 assert!(validate_doc_path("sub/dir/file.md").is_ok());
239 assert!(validate_doc_path("file_name-test.md").is_ok());
240
241 assert!(validate_doc_path("").is_err());
243 assert!(validate_doc_path("../etc/passwd").is_err());
244 assert!(validate_doc_path("..\\windows\\system32").is_err());
245 assert!(validate_doc_path("file.txt").is_err()); assert!(validate_doc_path("file<script>.md").is_err()); }
248
249 #[test]
250 fn test_list_docs_no_dir() {
251 let result = list_docs(None).unwrap();
252 assert_eq!(result["count"], 0);
253 assert!(result["error"].is_string());
254 }
255
256 #[test]
257 fn test_list_docs_with_files() {
258 let temp_dir = TempDir::new().unwrap();
259 let docs_path = temp_dir.path();
260
261 fs::write(docs_path.join("README.md"), "# Readme").unwrap();
263 fs::write(docs_path.join("GUIDE.md"), "# Guide").unwrap();
264
265 fs::create_dir(docs_path.join("subdir")).unwrap();
267 fs::write(docs_path.join("subdir/NESTED.md"), "# Nested").unwrap();
268
269 let result = list_docs(Some(docs_path)).unwrap();
270 assert_eq!(result["count"], 3);
271
272 let docs = result["docs"].as_array().unwrap();
273 let paths: Vec<&str> = docs.iter().map(|d| d["path"].as_str().unwrap()).collect();
274
275 assert!(paths.contains(&"README.md"));
276 assert!(paths.contains(&"GUIDE.md"));
277 assert!(paths.contains(&"subdir/NESTED.md"));
278 }
279
280 #[test]
281 fn test_get_doc_resource() {
282 let temp_dir = TempDir::new().unwrap();
283 let docs_path = temp_dir.path();
284
285 let content = "# Test Document\n\nThis is a test.";
286 fs::write(docs_path.join("TEST.md"), content).unwrap();
287
288 let result = get_doc_resource(Some(docs_path), "TEST.md").unwrap();
289 assert_eq!(result["name"], "TEST.md");
290 assert_eq!(result["content"], content);
291 assert_eq!(result["mime_type"], "text/markdown");
292 }
293
294 #[test]
295 fn test_get_doc_resource_not_found() {
296 let temp_dir = TempDir::new().unwrap();
297 let result = get_doc_resource(Some(temp_dir.path()), "NONEXISTENT.md");
298 assert!(result.is_err());
299 }
300}