Skip to main content

task_graph_mcp/resources/
docs.rs

1//! Documentation resources - expose markdown files from docs/ directory via MCP resources.
2//!
3//! Provides access to project documentation through docs:// URIs:
4//! - `docs://index` - Lists all available documentation files
5//! - `docs://FILENAME.md` - Returns content of specific doc file
6//! - `docs://subdir/FILENAME.md` - Supports recursive subdirectories
7
8use anyhow::Result;
9use serde_json::{Value, json};
10use std::path::Path;
11
12/// Metadata for a documentation file.
13#[derive(Debug, Clone)]
14pub struct DocInfo {
15    /// Relative path from docs/ directory (e.g., "GATES.md" or "diagrams/README.md")
16    pub relative_path: String,
17    /// File name only
18    pub name: String,
19    /// Size in bytes
20    pub size: u64,
21}
22
23/// Recursively find all markdown files in a directory.
24fn 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            // Recurse into subdirectories
35            find_markdown_files(&path, base, files)?;
36        } else if path.is_file() {
37            // Check if it's a markdown file
38            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
67/// Validate a doc path to prevent path traversal attacks.
68/// Only allows alphanumeric characters, hyphens, underscores, dots, and forward slashes.
69fn 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    // Check for path traversal attempts
79    if path.contains("..") {
80        return Err(anyhow::anyhow!(
81            "Invalid doc path: path traversal not allowed"
82        ));
83    }
84
85    // Only allow safe characters
86    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    // Must end with .md or .markdown
96    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
105/// List all documentation files as JSON.
106pub 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    // Sort by relative path for consistent ordering
129    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
153/// Get a specific documentation file's content as JSON.
154pub 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    // Construct the full path
162    let file_path = dir.join(path);
163
164    // Security check: ensure resolved path is within docs_dir
165    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
201/// Get resources for all documentation files (for get_resources registration).
202pub 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        // Valid paths
235        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        // Invalid paths
242        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()); // wrong extension
246        assert!(validate_doc_path("file<script>.md").is_err()); // invalid chars
247    }
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        // Create test files
262        fs::write(docs_path.join("README.md"), "# Readme").unwrap();
263        fs::write(docs_path.join("GUIDE.md"), "# Guide").unwrap();
264
265        // Create subdirectory with file
266        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}