oxify_mcp/servers/
filesystem.rs

1//! Filesystem MCP server - provides file operations
2
3use crate::{McpServer, Result};
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::{Path, PathBuf};
7use tokio::fs;
8
9/// Built-in MCP server for filesystem operations
10pub struct FilesystemServer {
11    /// Root directory for operations (security boundary)
12    root_dir: PathBuf,
13    /// Whether to allow operations outside root_dir
14    allow_absolute_paths: bool,
15}
16
17impl FilesystemServer {
18    /// Create a new filesystem server with a root directory
19    pub fn new(root_dir: PathBuf) -> Self {
20        Self {
21            root_dir,
22            allow_absolute_paths: false,
23        }
24    }
25
26    /// Allow operations with absolute paths (security risk)
27    pub fn allow_absolute_paths(mut self, allow: bool) -> Self {
28        self.allow_absolute_paths = allow;
29        self
30    }
31
32    /// Resolve a path relative to root_dir
33    fn resolve_path(&self, path: &str) -> Result<PathBuf> {
34        let requested_path = Path::new(path);
35
36        if !self.allow_absolute_paths && requested_path.is_absolute() {
37            return Err(crate::McpError::InvalidRequest(
38                "Absolute paths not allowed".to_string(),
39            ));
40        }
41
42        let full_path = if requested_path.is_absolute() {
43            requested_path.to_path_buf()
44        } else {
45            self.root_dir.join(requested_path)
46        };
47
48        // Try to canonicalize the path if it exists, otherwise validate the parent directory
49        let canonical = if full_path.exists() {
50            full_path
51                .canonicalize()
52                .map_err(|e| crate::McpError::InvalidRequest(format!("Invalid path: {}", e)))?
53        } else {
54            // For non-existent paths, canonicalize the root dir and append the relative path
55            let canonical_root = self.root_dir.canonicalize().map_err(|e| {
56                crate::McpError::InvalidRequest(format!("Invalid root directory: {}", e))
57            })?;
58
59            if requested_path.is_absolute() {
60                full_path
61            } else {
62                canonical_root.join(requested_path)
63            }
64        };
65
66        // Ensure the path is within root_dir (after canonicalization of root)
67        if !self.allow_absolute_paths {
68            let canonical_root = self.root_dir.canonicalize().map_err(|e| {
69                crate::McpError::InvalidRequest(format!("Invalid root directory: {}", e))
70            })?;
71
72            if !canonical.starts_with(&canonical_root) {
73                return Err(crate::McpError::InvalidRequest(
74                    "Path outside allowed directory".to_string(),
75                ));
76            }
77        }
78
79        Ok(canonical)
80    }
81}
82
83#[async_trait]
84impl McpServer for FilesystemServer {
85    async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value> {
86        match name {
87            "fs_read" => {
88                let path = arguments["path"]
89                    .as_str()
90                    .ok_or_else(|| crate::McpError::InvalidRequest("Missing 'path'".to_string()))?;
91
92                let resolved = self.resolve_path(path)?;
93                let content = fs::read_to_string(&resolved)
94                    .await
95                    .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
96
97                Ok(json!({
98                    "content": content,
99                    "path": resolved.to_string_lossy(),
100                }))
101            }
102
103            "fs_write" => {
104                let path = arguments["path"]
105                    .as_str()
106                    .ok_or_else(|| crate::McpError::InvalidRequest("Missing 'path'".to_string()))?;
107                let content = arguments["content"].as_str().ok_or_else(|| {
108                    crate::McpError::InvalidRequest("Missing 'content'".to_string())
109                })?;
110
111                let resolved = self.resolve_path(path)?;
112
113                // Create parent directories if needed
114                if let Some(parent) = resolved.parent() {
115                    fs::create_dir_all(parent)
116                        .await
117                        .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
118                }
119
120                fs::write(&resolved, content)
121                    .await
122                    .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
123
124                Ok(json!({
125                    "success": true,
126                    "path": resolved.to_string_lossy(),
127                    "bytes_written": content.len(),
128                }))
129            }
130
131            "fs_list" => {
132                let path = arguments["path"]
133                    .as_str()
134                    .ok_or_else(|| crate::McpError::InvalidRequest("Missing 'path'".to_string()))?;
135
136                let resolved = self.resolve_path(path)?;
137                let mut entries = Vec::new();
138
139                let mut dir = fs::read_dir(&resolved)
140                    .await
141                    .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
142
143                while let Some(entry) = dir
144                    .next_entry()
145                    .await
146                    .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?
147                {
148                    let metadata = entry
149                        .metadata()
150                        .await
151                        .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
152
153                    entries.push(json!({
154                        "name": entry.file_name().to_string_lossy(),
155                        "is_dir": metadata.is_dir(),
156                        "is_file": metadata.is_file(),
157                        "size": metadata.len(),
158                    }));
159                }
160
161                Ok(json!({
162                    "path": resolved.to_string_lossy(),
163                    "entries": entries,
164                }))
165            }
166
167            "fs_delete" => {
168                let path = arguments["path"]
169                    .as_str()
170                    .ok_or_else(|| crate::McpError::InvalidRequest("Missing 'path'".to_string()))?;
171
172                let resolved = self.resolve_path(path)?;
173                let metadata = fs::metadata(&resolved)
174                    .await
175                    .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
176
177                if metadata.is_dir() {
178                    fs::remove_dir_all(&resolved)
179                        .await
180                        .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
181                } else {
182                    fs::remove_file(&resolved)
183                        .await
184                        .map_err(|e| crate::McpError::ToolExecutionError(e.to_string()))?;
185                }
186
187                Ok(json!({
188                    "success": true,
189                    "deleted": resolved.to_string_lossy(),
190                }))
191            }
192
193            "fs_exists" => {
194                let path = arguments["path"]
195                    .as_str()
196                    .ok_or_else(|| crate::McpError::InvalidRequest("Missing 'path'".to_string()))?;
197
198                let resolved = self.resolve_path(path)?;
199                let exists = resolved.exists();
200
201                Ok(json!({
202                    "exists": exists,
203                    "path": resolved.to_string_lossy(),
204                }))
205            }
206
207            _ => Err(crate::McpError::ToolNotFound(name.to_string())),
208        }
209    }
210
211    async fn list_tools(&self) -> Result<Vec<Value>> {
212        Ok(vec![
213            json!({
214                "name": "fs_read",
215                "description": "Read file contents",
216                "inputSchema": {
217                    "type": "object",
218                    "properties": {
219                        "path": {
220                            "type": "string",
221                            "description": "Path to file"
222                        }
223                    },
224                    "required": ["path"]
225                }
226            }),
227            json!({
228                "name": "fs_write",
229                "description": "Write content to file",
230                "inputSchema": {
231                    "type": "object",
232                    "properties": {
233                        "path": {
234                            "type": "string",
235                            "description": "Path to file"
236                        },
237                        "content": {
238                            "type": "string",
239                            "description": "Content to write"
240                        }
241                    },
242                    "required": ["path", "content"]
243                }
244            }),
245            json!({
246                "name": "fs_list",
247                "description": "List directory contents",
248                "inputSchema": {
249                    "type": "object",
250                    "properties": {
251                        "path": {
252                            "type": "string",
253                            "description": "Directory path"
254                        }
255                    },
256                    "required": ["path"]
257                }
258            }),
259            json!({
260                "name": "fs_delete",
261                "description": "Delete file or directory",
262                "inputSchema": {
263                    "type": "object",
264                    "properties": {
265                        "path": {
266                            "type": "string",
267                            "description": "Path to delete"
268                        }
269                    },
270                    "required": ["path"]
271                }
272            }),
273            json!({
274                "name": "fs_exists",
275                "description": "Check if path exists",
276                "inputSchema": {
277                    "type": "object",
278                    "properties": {
279                        "path": {
280                            "type": "string",
281                            "description": "Path to check"
282                        }
283                    },
284                    "required": ["path"]
285                }
286            }),
287        ])
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use serde_json::json;
295    use std::fs;
296
297    #[tokio::test]
298    async fn test_fs_write_and_read() {
299        let temp_dir = std::env::temp_dir().join("oxify-mcp-test");
300        fs::create_dir_all(&temp_dir).unwrap();
301
302        let server = FilesystemServer::new(temp_dir.clone());
303
304        // Write a file
305        let write_result = server
306            .call_tool(
307                "fs_write",
308                json!({
309                    "path": "test.txt",
310                    "content": "Hello, OxiFY!"
311                }),
312            )
313            .await
314            .unwrap();
315
316        assert_eq!(write_result["success"], true);
317
318        // Read the file
319        let read_result = server
320            .call_tool(
321                "fs_read",
322                json!({
323                    "path": "test.txt"
324                }),
325            )
326            .await
327            .unwrap();
328
329        assert_eq!(read_result["content"], "Hello, OxiFY!");
330
331        // Cleanup
332        fs::remove_dir_all(&temp_dir).unwrap();
333    }
334
335    #[tokio::test]
336    async fn test_fs_list() {
337        let temp_dir = std::env::temp_dir().join("oxify-mcp-test-list");
338        fs::create_dir_all(&temp_dir).unwrap();
339
340        let server = FilesystemServer::new(temp_dir.clone());
341
342        // Write some files
343        server
344            .call_tool(
345                "fs_write",
346                json!({
347                    "path": "file1.txt",
348                    "content": "File 1"
349                }),
350            )
351            .await
352            .unwrap();
353
354        server
355            .call_tool(
356                "fs_write",
357                json!({
358                    "path": "file2.txt",
359                    "content": "File 2"
360                }),
361            )
362            .await
363            .unwrap();
364
365        // List directory
366        let list_result = server
367            .call_tool(
368                "fs_list",
369                json!({
370                    "path": "."
371                }),
372            )
373            .await
374            .unwrap();
375
376        let entries = list_result["entries"].as_array().unwrap();
377        assert!(entries.len() >= 2);
378
379        // Cleanup
380        fs::remove_dir_all(&temp_dir).unwrap();
381    }
382
383    #[tokio::test]
384    async fn test_fs_exists() {
385        let temp_dir = std::env::temp_dir().join("oxify-mcp-test-exists");
386        fs::create_dir_all(&temp_dir).unwrap();
387
388        let server = FilesystemServer::new(temp_dir.clone());
389
390        // Test non-existent file
391        let exists_result = server
392            .call_tool(
393                "fs_exists",
394                json!({
395                    "path": "nonexistent.txt"
396                }),
397            )
398            .await
399            .unwrap();
400
401        assert_eq!(exists_result["exists"], false);
402
403        // Write a file
404        server
405            .call_tool(
406                "fs_write",
407                json!({
408                    "path": "exists.txt",
409                    "content": "I exist!"
410                }),
411            )
412            .await
413            .unwrap();
414
415        // Test existing file
416        let exists_result = server
417            .call_tool(
418                "fs_exists",
419                json!({
420                    "path": "exists.txt"
421                }),
422            )
423            .await
424            .unwrap();
425
426        assert_eq!(exists_result["exists"], true);
427
428        // Cleanup
429        fs::remove_dir_all(&temp_dir).unwrap();
430    }
431
432    #[tokio::test]
433    async fn test_fs_delete() {
434        let temp_dir = std::env::temp_dir().join("oxify-mcp-test-delete");
435        fs::create_dir_all(&temp_dir).unwrap();
436
437        let server = FilesystemServer::new(temp_dir.clone());
438
439        // Write a file
440        server
441            .call_tool(
442                "fs_write",
443                json!({
444                    "path": "delete_me.txt",
445                    "content": "Delete this!"
446                }),
447            )
448            .await
449            .unwrap();
450
451        // Delete the file
452        let delete_result = server
453            .call_tool(
454                "fs_delete",
455                json!({
456                    "path": "delete_me.txt"
457                }),
458            )
459            .await
460            .unwrap();
461
462        assert_eq!(delete_result["success"], true);
463
464        // Verify file is gone
465        let exists_result = server
466            .call_tool(
467                "fs_exists",
468                json!({
469                    "path": "delete_me.txt"
470                }),
471            )
472            .await
473            .unwrap();
474
475        assert_eq!(exists_result["exists"], false);
476
477        // Cleanup
478        fs::remove_dir_all(&temp_dir).unwrap();
479    }
480
481    #[tokio::test]
482    async fn test_fs_list_tools() {
483        let temp_dir = std::env::temp_dir().join("oxify-mcp-test-tools");
484        fs::create_dir_all(&temp_dir).unwrap();
485
486        let server = FilesystemServer::new(temp_dir.clone());
487
488        let tools = server.list_tools().await.unwrap();
489
490        assert_eq!(tools.len(), 5);
491        assert!(tools.iter().any(|t| t["name"] == "fs_read"));
492        assert!(tools.iter().any(|t| t["name"] == "fs_write"));
493        assert!(tools.iter().any(|t| t["name"] == "fs_list"));
494        assert!(tools.iter().any(|t| t["name"] == "fs_delete"));
495        assert!(tools.iter().any(|t| t["name"] == "fs_exists"));
496
497        // Cleanup
498        fs::remove_dir_all(&temp_dir).unwrap();
499    }
500
501    #[tokio::test]
502    async fn test_absolute_path_security() {
503        let temp_dir = std::env::temp_dir().join("oxify-mcp-test-security");
504        fs::create_dir_all(&temp_dir).unwrap();
505
506        let server = FilesystemServer::new(temp_dir.clone());
507
508        // Try to read with absolute path (should fail)
509        let result = server
510            .call_tool(
511                "fs_read",
512                json!({
513                    "path": "/etc/passwd"
514                }),
515            )
516            .await;
517
518        assert!(result.is_err());
519
520        // Cleanup
521        fs::remove_dir_all(&temp_dir).unwrap();
522    }
523}