1use crate::{McpServer, Result};
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::{Path, PathBuf};
7use tokio::fs;
8
9pub struct FilesystemServer {
11 root_dir: PathBuf,
13 allow_absolute_paths: bool,
15}
16
17impl FilesystemServer {
18 pub fn new(root_dir: PathBuf) -> Self {
20 Self {
21 root_dir,
22 allow_absolute_paths: false,
23 }
24 }
25
26 pub fn allow_absolute_paths(mut self, allow: bool) -> Self {
28 self.allow_absolute_paths = allow;
29 self
30 }
31
32 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fs::remove_dir_all(&temp_dir).unwrap();
522 }
523}