aiomcp-server-filesystem 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiomcp-server-filesystem
3
+ Version: 0.0.1
4
+ Summary: Filesystem MCP server powered by aiomcp
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiomcp
9
+ Requires-Dist: pydantic
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest; extra == "test"
12
+ Requires-Dist: pytest-asyncio; extra == "test"
13
+
14
+ # aiomcp-server-filesystem
15
+
16
+ Filesystem MCP server powered by `aiomcp`.
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ aiomcp-server-filesystem /path/to/allowed-directory [/path/to/another-directory]
22
+ ```
23
+
24
+ The server intentionally uses command-line allowed directories for access control. Dynamic MCP Roots updates from the reference server are not implemented here.
25
+
26
+ ## Tools
27
+
28
+ - `read_file`
29
+ - `read_text_file`
30
+ - `read_media_file`
31
+ - `read_multiple_files`
32
+ - `write_file`
33
+ - `edit_file`
34
+ - `create_directory`
35
+ - `list_directory`
36
+ - `list_directory_with_sizes`
37
+ - `directory_tree`
38
+ - `move_file`
39
+ - `search_files`
40
+ - `get_file_info`
41
+ - `list_allowed_directories`
@@ -0,0 +1,28 @@
1
+ # aiomcp-server-filesystem
2
+
3
+ Filesystem MCP server powered by `aiomcp`.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ aiomcp-server-filesystem /path/to/allowed-directory [/path/to/another-directory]
9
+ ```
10
+
11
+ The server intentionally uses command-line allowed directories for access control. Dynamic MCP Roots updates from the reference server are not implemented here.
12
+
13
+ ## Tools
14
+
15
+ - `read_file`
16
+ - `read_text_file`
17
+ - `read_media_file`
18
+ - `read_multiple_files`
19
+ - `write_file`
20
+ - `edit_file`
21
+ - `create_directory`
22
+ - `list_directory`
23
+ - `list_directory_with_sizes`
24
+ - `directory_tree`
25
+ - `move_file`
26
+ - `search_files`
27
+ - `get_file_info`
28
+ - `list_allowed_directories`
@@ -0,0 +1,32 @@
1
+ def main() -> None:
2
+ import argparse
3
+ import asyncio
4
+
5
+ from aiomcp_server_filesystem.server import host_http, host_stdio
6
+
7
+ parser = argparse.ArgumentParser(description="Filesystem MCP server.")
8
+ parser.add_argument(
9
+ "allowed_directories",
10
+ nargs="+",
11
+ metavar="allowed-directory",
12
+ help="Directory this server is allowed to access.",
13
+ )
14
+ parser.add_argument(
15
+ "--http",
16
+ metavar="URL",
17
+ help="Host an HTTP MCP endpoint at URL instead of using stdio.",
18
+ )
19
+
20
+ args = parser.parse_args()
21
+
22
+ try:
23
+ if args.http:
24
+ asyncio.run(host_http(args.http, args.allowed_directories))
25
+ else:
26
+ asyncio.run(host_stdio(args.allowed_directories))
27
+ except KeyboardInterrupt:
28
+ pass
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,651 @@
1
+ import base64
2
+ import difflib
3
+ import fnmatch
4
+ import json
5
+ import os
6
+ import shutil
7
+ import stat
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from secrets import token_hex
11
+ from typing import Annotated, Any, Literal, Sequence
12
+
13
+ from aiomcp import McpServer
14
+ from aiomcp.transports.stdio import McpStdioServerTransport
15
+ from pydantic import BaseModel, Field
16
+
17
+ SERVER_NAME = "aiomcp-filesystem-server"
18
+
19
+ READ_ONLY_ANNOTATIONS = {"readOnlyHint": True}
20
+ CREATE_DIRECTORY_ANNOTATIONS = {
21
+ "readOnlyHint": False,
22
+ "idempotentHint": True,
23
+ "destructiveHint": False,
24
+ }
25
+ WRITE_FILE_ANNOTATIONS = {
26
+ "readOnlyHint": False,
27
+ "idempotentHint": True,
28
+ "destructiveHint": True,
29
+ }
30
+ MUTATING_FILE_ANNOTATIONS = {
31
+ "readOnlyHint": False,
32
+ "idempotentHint": False,
33
+ "destructiveHint": True,
34
+ }
35
+
36
+
37
+ class EditOperation(BaseModel):
38
+ oldText: Annotated[
39
+ str,
40
+ Field(description="Text to search for - must match exactly"),
41
+ ]
42
+ newText: Annotated[str, Field(description="Text to replace with")]
43
+
44
+
45
+ TailLineCount = Annotated[
46
+ float | None,
47
+ Field(description="If provided, returns only the last N lines of the file"),
48
+ ]
49
+ HeadLineCount = Annotated[
50
+ float | None,
51
+ Field(description="If provided, returns only the first N lines of the file"),
52
+ ]
53
+ MultipleFilePaths = Annotated[
54
+ list[str],
55
+ Field(
56
+ description="Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.",
57
+ json_schema_extra={"minItems": 1},
58
+ ),
59
+ ]
60
+ DryRun = Annotated[
61
+ bool,
62
+ Field(False, description="Preview changes using git-style diff format"),
63
+ ]
64
+ SizeSort = Annotated[
65
+ Literal["name", "size"],
66
+ Field("name", description="Sort entries by name or size"),
67
+ ]
68
+ ExcludePatterns = Annotated[list[str] | None, Field(default=[])]
69
+
70
+
71
+ class FilesystemServer:
72
+ @staticmethod
73
+ def _normalize_line_endings(text: str) -> str:
74
+ return text.replace("\r\n", "\n")
75
+
76
+ @staticmethod
77
+ def _format_size(byte_count: int) -> str:
78
+ units = ["B", "KB", "MB", "GB", "TB"]
79
+ if byte_count == 0:
80
+ return "0 B"
81
+
82
+ unit_index = 0
83
+ size = float(byte_count)
84
+ while size >= 1024 and unit_index < len(units) - 1:
85
+ size /= 1024
86
+ unit_index += 1
87
+
88
+ if unit_index == 0:
89
+ return f"{byte_count} B"
90
+ return f"{size:.2f} {units[unit_index]}"
91
+
92
+ @staticmethod
93
+ def _as_json_time(timestamp: float) -> str:
94
+ return datetime.fromtimestamp(timestamp).isoformat()
95
+
96
+ @staticmethod
97
+ def _line_count(value: float | None, name: str) -> int | None:
98
+ if value is None:
99
+ return None
100
+ if value < 0:
101
+ raise ValueError(f"{name} must be greater than or equal to 0")
102
+ if not float(value).is_integer():
103
+ raise ValueError(f"{name} must be an integer")
104
+ return int(value)
105
+
106
+ def __init__(self, allowed_directories: Sequence[str]) -> None:
107
+ self._allowed_paths = self._prepare_allowed_directories(allowed_directories)
108
+
109
+ @property
110
+ def allowed_directories(self) -> list[str]:
111
+ return [str(path) for path in self._allowed_paths]
112
+
113
+ def _prepare_allowed_directories(self, directories: Sequence[str]) -> list[Path]:
114
+ allowed_paths: list[Path] = []
115
+ for directory in directories:
116
+ expanded = Path(os.path.expanduser(directory.strip().strip("'\"")))
117
+ path = expanded if expanded.is_absolute() else Path.cwd() / expanded
118
+ if not path.exists():
119
+ raise ValueError(f"Allowed directory does not exist: {directory}")
120
+ if not path.is_dir():
121
+ raise ValueError(f"Allowed path is not a directory: {directory}")
122
+ resolved = path.resolve(strict=True)
123
+ if resolved not in allowed_paths:
124
+ allowed_paths.append(resolved)
125
+
126
+ if not allowed_paths:
127
+ raise ValueError("At least one allowed directory must be provided")
128
+ return allowed_paths
129
+
130
+ def _path_key(self, path: Path) -> str:
131
+ return os.path.normcase(os.path.abspath(os.fspath(path)))
132
+
133
+ def _is_within_allowed(self, path: Path) -> bool:
134
+ requested = self._path_key(path)
135
+ for allowed_path in self._allowed_paths:
136
+ allowed = self._path_key(allowed_path)
137
+ try:
138
+ if os.path.commonpath([requested, allowed]) == allowed:
139
+ return True
140
+ except ValueError:
141
+ continue
142
+ return False
143
+
144
+ def _candidate_path(self, requested_path: str) -> Path:
145
+ cleaned = requested_path.strip().strip("'\"")
146
+ expanded = os.path.expanduser(cleaned)
147
+ candidate = Path(expanded)
148
+ if candidate.is_absolute():
149
+ return candidate
150
+
151
+ for allowed_path in self._allowed_paths:
152
+ joined = (allowed_path / candidate).resolve(strict=False)
153
+ if self._is_within_allowed(joined):
154
+ return joined
155
+ return (self._allowed_paths[0] / candidate).resolve(strict=False)
156
+
157
+ def _validate_path(
158
+ self,
159
+ requested_path: str,
160
+ *,
161
+ must_exist: bool = False,
162
+ allow_missing_ancestors: bool = False,
163
+ ) -> Path:
164
+ candidate = self._candidate_path(requested_path).resolve(strict=False)
165
+ if not self._is_within_allowed(candidate):
166
+ raise ValueError(
167
+ "Access denied - path outside allowed directories: "
168
+ f"{candidate} not in {', '.join(self.allowed_directories)}"
169
+ )
170
+
171
+ if candidate.exists():
172
+ real_path = candidate.resolve(strict=True)
173
+ if not self._is_within_allowed(real_path):
174
+ raise ValueError(
175
+ "Access denied - symlink target outside allowed directories: "
176
+ f"{real_path} not in {', '.join(self.allowed_directories)}"
177
+ )
178
+ return real_path
179
+
180
+ if must_exist:
181
+ raise FileNotFoundError(f"Path does not exist: {candidate}")
182
+
183
+ parent = candidate.parent
184
+ if not allow_missing_ancestors and not parent.exists():
185
+ raise FileNotFoundError(f"Parent directory does not exist: {parent}")
186
+
187
+ ancestor = parent
188
+ while not ancestor.exists() and ancestor != ancestor.parent:
189
+ ancestor = ancestor.parent
190
+ real_ancestor = ancestor.resolve(strict=True)
191
+ if not self._is_within_allowed(real_ancestor):
192
+ raise ValueError(
193
+ "Access denied - parent directory outside allowed directories: "
194
+ f"{real_ancestor} not in {', '.join(self.allowed_directories)}"
195
+ )
196
+ return candidate
197
+
198
+ def _read_text(self, path: Path) -> str:
199
+ return path.read_text(encoding="utf-8")
200
+
201
+ def _write_text_atomic(self, path: Path, content: str) -> None:
202
+ temp_path = path.with_name(f"{path.name}.{token_hex(16)}.tmp")
203
+ try:
204
+ temp_path.write_text(content, encoding="utf-8")
205
+ os.replace(temp_path, path)
206
+ finally:
207
+ if temp_path.exists():
208
+ temp_path.unlink()
209
+
210
+ def _matches_pattern(self, relative_path: str, pattern: str) -> bool:
211
+ normalized_path = relative_path.replace(os.sep, "/")
212
+ normalized_pattern = pattern.replace(os.sep, "/")
213
+ return fnmatch.fnmatchcase(normalized_path, normalized_pattern)
214
+
215
+ def _is_excluded(self, relative_path: str, exclude_patterns: Sequence[str]) -> bool:
216
+ normalized_path = relative_path.replace(os.sep, "/")
217
+ name = normalized_path.rsplit("/", 1)[-1]
218
+ for pattern in exclude_patterns:
219
+ normalized_pattern = pattern.replace(os.sep, "/")
220
+ if fnmatch.fnmatchcase(normalized_path, normalized_pattern):
221
+ return True
222
+ if fnmatch.fnmatchcase(name, normalized_pattern):
223
+ return True
224
+ if fnmatch.fnmatchcase(normalized_path, f"**/{normalized_pattern}"):
225
+ return True
226
+ if fnmatch.fnmatchcase(normalized_path, f"**/{normalized_pattern}/**"):
227
+ return True
228
+ return False
229
+
230
+ def read_text_file(
231
+ self,
232
+ path: str,
233
+ tail: TailLineCount = None,
234
+ head: HeadLineCount = None,
235
+ ) -> Any:
236
+ valid_path = self._validate_path(path, must_exist=True)
237
+ if head is not None and tail is not None:
238
+ raise ValueError(
239
+ "Cannot specify both head and tail parameters simultaneously"
240
+ )
241
+
242
+ head_count = self._line_count(head, "head")
243
+ tail_count = self._line_count(tail, "tail")
244
+
245
+ content = self._read_text(valid_path)
246
+ if head_count is not None:
247
+ content = "\n".join(content.splitlines()[:head_count])
248
+ elif tail_count is not None:
249
+ content = "\n".join(
250
+ content.splitlines()[-tail_count:] if tail_count else []
251
+ )
252
+ return [{"type": "text", "text": content}]
253
+
254
+ def read_media_file(self, path: str) -> Any:
255
+ valid_path = self._validate_path(path, must_exist=True)
256
+ mime_types = {
257
+ ".png": "image/png",
258
+ ".jpg": "image/jpeg",
259
+ ".jpeg": "image/jpeg",
260
+ ".gif": "image/gif",
261
+ ".webp": "image/webp",
262
+ ".bmp": "image/bmp",
263
+ ".svg": "image/svg+xml",
264
+ ".mp3": "audio/mpeg",
265
+ ".wav": "audio/wav",
266
+ ".ogg": "audio/ogg",
267
+ ".flac": "audio/flac",
268
+ }
269
+ mime_type = mime_types.get(
270
+ valid_path.suffix.lower(), "application/octet-stream"
271
+ )
272
+ if mime_type.startswith("image/"):
273
+ content_type = "image"
274
+ elif mime_type.startswith("audio/"):
275
+ content_type = "audio"
276
+ else:
277
+ raise ValueError(f"Unsupported media file type: {valid_path.suffix}")
278
+ return [
279
+ {
280
+ "type": content_type,
281
+ "data": base64.b64encode(valid_path.read_bytes()).decode("ascii"),
282
+ "mimeType": mime_type,
283
+ }
284
+ ]
285
+
286
+ def read_multiple_files(self, paths: MultipleFilePaths) -> Any:
287
+ if not paths:
288
+ raise ValueError("At least one file path must be provided")
289
+
290
+ results: list[str] = []
291
+ for file_path in paths:
292
+ try:
293
+ valid_path = self._validate_path(file_path, must_exist=True)
294
+ results.append(f"{file_path}:\n{self._read_text(valid_path)}\n")
295
+ except Exception as exc:
296
+ results.append(f"{file_path}: Error - {exc}")
297
+ return [{"type": "text", "text": "\n---\n".join(results)}]
298
+
299
+ def write_file(self, path: str, content: str) -> Any:
300
+ valid_path = self._validate_path(path)
301
+ self._write_text_atomic(valid_path, content)
302
+ return [{"type": "text", "text": f"Successfully wrote to {path}"}]
303
+
304
+ def edit_file(
305
+ self,
306
+ path: str,
307
+ edits: list[EditOperation],
308
+ dryRun: DryRun = False,
309
+ ) -> Any:
310
+ valid_path = self._validate_path(path, must_exist=True)
311
+ original_content = self._normalize_line_endings(self._read_text(valid_path))
312
+ modified_content = original_content
313
+
314
+ for edit in edits:
315
+ if isinstance(edit, dict):
316
+ old_text_raw = edit["oldText"]
317
+ new_text_raw = edit["newText"]
318
+ else:
319
+ old_text_raw = edit.oldText
320
+ new_text_raw = edit.newText
321
+
322
+ old_text = self._normalize_line_endings(old_text_raw)
323
+ new_text = self._normalize_line_endings(new_text_raw)
324
+
325
+ if old_text in modified_content:
326
+ modified_content = modified_content.replace(old_text, new_text, 1)
327
+ continue
328
+
329
+ old_lines = old_text.split("\n")
330
+ content_lines = modified_content.split("\n")
331
+ match_found = False
332
+
333
+ for index in range(0, len(content_lines) - len(old_lines) + 1):
334
+ potential_match = content_lines[index : index + len(old_lines)]
335
+ if all(
336
+ old_line.strip() == content_line.strip()
337
+ for old_line, content_line in zip(old_lines, potential_match)
338
+ ):
339
+ original_indent = content_lines[index][
340
+ : len(content_lines[index]) - len(content_lines[index].lstrip())
341
+ ]
342
+ new_lines: list[str] = []
343
+ for line_index, line in enumerate(new_text.split("\n")):
344
+ if line_index == 0:
345
+ new_lines.append(original_indent + line.lstrip())
346
+ continue
347
+ old_indent = (
348
+ old_lines[line_index][
349
+ : len(old_lines[line_index])
350
+ - len(old_lines[line_index].lstrip())
351
+ ]
352
+ if line_index < len(old_lines)
353
+ else ""
354
+ )
355
+ new_indent = line[: len(line) - len(line.lstrip())]
356
+ if old_indent and new_indent:
357
+ relative_indent = max(0, len(new_indent) - len(old_indent))
358
+ new_lines.append(
359
+ original_indent + " " * relative_indent + line.lstrip()
360
+ )
361
+ else:
362
+ new_lines.append(line)
363
+
364
+ content_lines[index : index + len(old_lines)] = new_lines
365
+ modified_content = "\n".join(content_lines)
366
+ match_found = True
367
+ break
368
+
369
+ if not match_found:
370
+ raise ValueError(
371
+ f"Could not find exact match for edit:\n{old_text_raw}"
372
+ )
373
+
374
+ diff = "\n".join(
375
+ difflib.unified_diff(
376
+ original_content.splitlines(),
377
+ modified_content.splitlines(),
378
+ fromfile=str(valid_path),
379
+ tofile=str(valid_path),
380
+ fromfiledate="original",
381
+ tofiledate="modified",
382
+ lineterm="",
383
+ )
384
+ )
385
+ formatted_diff = f"```diff\n{diff}\n```\n\n"
386
+
387
+ if not dryRun:
388
+ self._write_text_atomic(valid_path, modified_content)
389
+ return [{"type": "text", "text": formatted_diff}]
390
+
391
+ def create_directory(self, path: str) -> Any:
392
+ valid_path = self._validate_path(path, allow_missing_ancestors=True)
393
+ valid_path.mkdir(parents=True, exist_ok=True)
394
+ return [{"type": "text", "text": f"Successfully created directory {path}"}]
395
+
396
+ def list_directory(self, path: str) -> Any:
397
+ valid_path = self._validate_path(path, must_exist=True)
398
+ entries = sorted(valid_path.iterdir(), key=lambda entry: entry.name.lower())
399
+ text = "\n".join(
400
+ f"{'[DIR]' if entry.is_dir() else '[FILE]'} {entry.name}"
401
+ for entry in entries
402
+ )
403
+ return [{"type": "text", "text": text}]
404
+
405
+ def list_directory_with_sizes(self, path: str, sortBy: SizeSort = "name") -> Any:
406
+ valid_path = self._validate_path(path, must_exist=True)
407
+ detailed_entries = []
408
+ for entry in valid_path.iterdir():
409
+ try:
410
+ entry_stats = entry.stat()
411
+ size = entry_stats.st_size
412
+ except OSError:
413
+ size = 0
414
+ detailed_entries.append(
415
+ {"name": entry.name, "isDirectory": entry.is_dir(), "size": size}
416
+ )
417
+
418
+ if sortBy == "size":
419
+ sorted_entries = sorted(
420
+ detailed_entries, key=lambda item: item["size"], reverse=True
421
+ )
422
+ else:
423
+ sorted_entries = sorted(
424
+ detailed_entries, key=lambda item: str(item["name"]).lower()
425
+ )
426
+
427
+ formatted_entries = []
428
+ for entry in sorted_entries:
429
+ prefix = "[DIR]" if entry["isDirectory"] else "[FILE]"
430
+ size_text = (
431
+ "" if entry["isDirectory"] else self._format_size(int(entry["size"]))
432
+ )
433
+ formatted_entries.append(
434
+ f"{prefix} {str(entry['name']):<30} {size_text:>10}"
435
+ )
436
+
437
+ total_files = sum(1 for entry in detailed_entries if not entry["isDirectory"])
438
+ total_dirs = sum(1 for entry in detailed_entries if entry["isDirectory"])
439
+ total_size = sum(
440
+ int(entry["size"]) for entry in detailed_entries if not entry["isDirectory"]
441
+ )
442
+ summary = [
443
+ "",
444
+ f"Total: {total_files} files, {total_dirs} directories",
445
+ f"Combined size: {self._format_size(total_size)}",
446
+ ]
447
+ return [{"type": "text", "text": "\n".join([*formatted_entries, *summary])}]
448
+
449
+ def directory_tree(self, path: str, excludePatterns: ExcludePatterns = None) -> Any:
450
+ root_path = self._validate_path(path, must_exist=True)
451
+ exclude_patterns = excludePatterns or []
452
+
453
+ def build_tree(current_path: Path) -> list[dict[str, object]]:
454
+ valid_path = self._validate_path(str(current_path), must_exist=True)
455
+ entries: list[dict[str, object]] = []
456
+ for entry in sorted(
457
+ valid_path.iterdir(), key=lambda item: item.name.lower()
458
+ ):
459
+ relative_path = os.path.relpath(entry, root_path)
460
+ if self._is_excluded(relative_path, exclude_patterns):
461
+ continue
462
+ entry_data: dict[str, object] = {
463
+ "name": entry.name,
464
+ "type": "directory" if entry.is_dir() else "file",
465
+ }
466
+ if entry.is_dir():
467
+ entry_data["children"] = build_tree(entry)
468
+ entries.append(entry_data)
469
+ return entries
470
+
471
+ return [{"type": "text", "text": json.dumps(build_tree(root_path), indent=2)}]
472
+
473
+ def move_file(self, source: str, destination: str) -> Any:
474
+ valid_source = self._validate_path(source, must_exist=True)
475
+ valid_destination = self._validate_path(destination)
476
+ if valid_destination.exists():
477
+ raise FileExistsError(f"Destination already exists: {destination}")
478
+ shutil.move(str(valid_source), str(valid_destination))
479
+ return [
480
+ {"type": "text", "text": f"Successfully moved {source} to {destination}"}
481
+ ]
482
+
483
+ def search_files(
484
+ self, path: str, pattern: str, excludePatterns: ExcludePatterns = None
485
+ ) -> Any:
486
+ root_path = self._validate_path(path, must_exist=True)
487
+ exclude_patterns = excludePatterns or []
488
+ results: list[str] = []
489
+
490
+ for current_root, dirnames, filenames in os.walk(root_path):
491
+ current_path = Path(current_root)
492
+ dirnames[:] = [
493
+ dirname
494
+ for dirname in dirnames
495
+ if not self._is_excluded(
496
+ os.path.relpath(current_path / dirname, root_path), exclude_patterns
497
+ )
498
+ ]
499
+ for name in [*dirnames, *filenames]:
500
+ full_path = current_path / name
501
+ try:
502
+ self._validate_path(str(full_path), must_exist=True)
503
+ except Exception:
504
+ continue
505
+ relative_path = os.path.relpath(full_path, root_path)
506
+ if self._is_excluded(relative_path, exclude_patterns):
507
+ continue
508
+ if self._matches_pattern(relative_path, pattern):
509
+ results.append(str(full_path))
510
+
511
+ return [
512
+ {
513
+ "type": "text",
514
+ "text": "\n".join(results) if results else "No matches found",
515
+ }
516
+ ]
517
+
518
+ def get_file_info(self, path: str) -> Any:
519
+ valid_path = self._validate_path(path, must_exist=True)
520
+ stats = valid_path.stat()
521
+ info = {
522
+ "size": stats.st_size,
523
+ "created": self._as_json_time(stats.st_ctime),
524
+ "modified": self._as_json_time(stats.st_mtime),
525
+ "accessed": self._as_json_time(stats.st_atime),
526
+ "isDirectory": valid_path.is_dir(),
527
+ "isFile": valid_path.is_file(),
528
+ "permissions": oct(stat.S_IMODE(stats.st_mode))[-3:],
529
+ }
530
+ return [
531
+ {
532
+ "type": "text",
533
+ "text": "\n".join(f"{key}: {value}" for key, value in info.items()),
534
+ }
535
+ ]
536
+
537
+ def list_allowed_directories(self) -> Any:
538
+ return [
539
+ {
540
+ "type": "text",
541
+ "text": "Allowed directories:\n" + "\n".join(self.allowed_directories),
542
+ }
543
+ ]
544
+
545
+
546
+ async def register_tools(
547
+ server: McpServer, allowed_directories: Sequence[str]
548
+ ) -> FilesystemServer:
549
+ state = FilesystemServer(allowed_directories)
550
+
551
+ await server.register_tool(
552
+ func=state.read_text_file,
553
+ alias="read_file",
554
+ title="Read File (Deprecated)",
555
+ description="Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
556
+ annotations=READ_ONLY_ANNOTATIONS,
557
+ )
558
+ await server.register_tool(
559
+ func=state.read_text_file,
560
+ title="Read Text File",
561
+ description="Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories.",
562
+ annotations=READ_ONLY_ANNOTATIONS,
563
+ )
564
+ await server.register_tool(
565
+ func=state.read_media_file,
566
+ title="Read Media File",
567
+ description="Read an image or audio file. Returns the base64 encoded data and MIME type. Only works within allowed directories.",
568
+ annotations=READ_ONLY_ANNOTATIONS,
569
+ )
570
+ await server.register_tool(
571
+ func=state.read_multiple_files,
572
+ title="Read Multiple Files",
573
+ description="Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories.",
574
+ annotations=READ_ONLY_ANNOTATIONS,
575
+ )
576
+ await server.register_tool(
577
+ func=state.write_file,
578
+ title="Write File",
579
+ description="Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories.",
580
+ annotations=WRITE_FILE_ANNOTATIONS,
581
+ )
582
+ await server.register_tool(
583
+ func=state.edit_file,
584
+ title="Edit File",
585
+ description="Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories.",
586
+ annotations=MUTATING_FILE_ANNOTATIONS,
587
+ )
588
+ await server.register_tool(
589
+ func=state.create_directory,
590
+ title="Create Directory",
591
+ description="Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories.",
592
+ annotations=CREATE_DIRECTORY_ANNOTATIONS,
593
+ )
594
+ await server.register_tool(
595
+ func=state.list_directory,
596
+ title="List Directory",
597
+ description="Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories.",
598
+ annotations=READ_ONLY_ANNOTATIONS,
599
+ )
600
+ await server.register_tool(
601
+ func=state.list_directory_with_sizes,
602
+ title="List Directory with Sizes",
603
+ description="Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories.",
604
+ annotations=READ_ONLY_ANNOTATIONS,
605
+ )
606
+ await server.register_tool(
607
+ func=state.directory_tree,
608
+ title="Directory Tree",
609
+ description="Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
610
+ annotations=READ_ONLY_ANNOTATIONS,
611
+ )
612
+ await server.register_tool(
613
+ func=state.move_file,
614
+ title="Move File",
615
+ description="Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories.",
616
+ annotations=MUTATING_FILE_ANNOTATIONS,
617
+ )
618
+ await server.register_tool(
619
+ func=state.search_files,
620
+ title="Search Files",
621
+ description="Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories.",
622
+ annotations=READ_ONLY_ANNOTATIONS,
623
+ )
624
+ await server.register_tool(
625
+ func=state.get_file_info,
626
+ title="Get File Info",
627
+ description="Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories.",
628
+ annotations=READ_ONLY_ANNOTATIONS,
629
+ )
630
+ await server.register_tool(
631
+ func=state.list_allowed_directories,
632
+ title="List Allowed Directories",
633
+ description="Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files.",
634
+ annotations=READ_ONLY_ANNOTATIONS,
635
+ )
636
+ return state
637
+
638
+
639
+ async def host_stdio(allowed_directories: Sequence[str]) -> None:
640
+ server = McpServer(SERVER_NAME)
641
+ await register_tools(server, allowed_directories)
642
+
643
+ transport = McpStdioServerTransport()
644
+ await server.host(transport)
645
+
646
+
647
+ async def host_http(url: str, allowed_directories: Sequence[str]) -> None:
648
+ server = McpServer(SERVER_NAME)
649
+ await register_tools(server, allowed_directories)
650
+
651
+ await server.host(url)
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiomcp-server-filesystem
3
+ Version: 0.0.1
4
+ Summary: Filesystem MCP server powered by aiomcp
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: aiomcp
9
+ Requires-Dist: pydantic
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest; extra == "test"
12
+ Requires-Dist: pytest-asyncio; extra == "test"
13
+
14
+ # aiomcp-server-filesystem
15
+
16
+ Filesystem MCP server powered by `aiomcp`.
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ aiomcp-server-filesystem /path/to/allowed-directory [/path/to/another-directory]
22
+ ```
23
+
24
+ The server intentionally uses command-line allowed directories for access control. Dynamic MCP Roots updates from the reference server are not implemented here.
25
+
26
+ ## Tools
27
+
28
+ - `read_file`
29
+ - `read_text_file`
30
+ - `read_media_file`
31
+ - `read_multiple_files`
32
+ - `write_file`
33
+ - `edit_file`
34
+ - `create_directory`
35
+ - `list_directory`
36
+ - `list_directory_with_sizes`
37
+ - `directory_tree`
38
+ - `move_file`
39
+ - `search_files`
40
+ - `get_file_info`
41
+ - `list_allowed_directories`
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ aiomcp_server_filesystem/__main__.py
4
+ aiomcp_server_filesystem/server.py
5
+ aiomcp_server_filesystem.egg-info/PKG-INFO
6
+ aiomcp_server_filesystem.egg-info/SOURCES.txt
7
+ aiomcp_server_filesystem.egg-info/dependency_links.txt
8
+ aiomcp_server_filesystem.egg-info/entry_points.txt
9
+ aiomcp_server_filesystem.egg-info/requires.txt
10
+ aiomcp_server_filesystem.egg-info/top_level.txt
11
+ tests/test_filesystem_server.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aiomcp-server-filesystem = aiomcp_server_filesystem.__main__:main
@@ -0,0 +1,6 @@
1
+ aiomcp
2
+ pydantic
3
+
4
+ [test]
5
+ pytest
6
+ pytest-asyncio
@@ -0,0 +1 @@
1
+ aiomcp_server_filesystem
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "aiomcp-server-filesystem"
3
+ description = "Filesystem MCP server powered by aiomcp"
4
+ version = "0.0.1"
5
+ requires-python = ">=3.11"
6
+ readme = "README.md"
7
+ license = { text = "MIT" }
8
+ dependencies = [
9
+ "aiomcp",
10
+ "pydantic",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ test = [
15
+ "pytest",
16
+ "pytest-asyncio",
17
+ ]
18
+
19
+ [project.scripts]
20
+ aiomcp-server-filesystem = "aiomcp_server_filesystem.__main__:main"
21
+
22
+ [build-system]
23
+ requires = ["setuptools"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [tool.setuptools.packages.find]
27
+ include = ["aiomcp_server_filesystem*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,138 @@
1
+ import base64
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from aiomcp import McpClient, McpServer
8
+ from aiomcp_server_filesystem.server import SERVER_NAME, register_tools
9
+
10
+
11
+ async def create_test_server(root: Path) -> McpServer:
12
+ server = McpServer(SERVER_NAME)
13
+ await register_tools(server, [str(root)])
14
+ return server
15
+
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_aiomcp_server_exposes_reference_filesystem_tools(tmp_path):
19
+ server = await create_test_server(tmp_path)
20
+ tools = {tool.name: tool for tool in await server.list_tools()}
21
+
22
+ assert set(tools) == {
23
+ "read_file",
24
+ "read_text_file",
25
+ "read_media_file",
26
+ "read_multiple_files",
27
+ "write_file",
28
+ "edit_file",
29
+ "create_directory",
30
+ "list_directory",
31
+ "list_directory_with_sizes",
32
+ "directory_tree",
33
+ "move_file",
34
+ "search_files",
35
+ "get_file_info",
36
+ "list_allowed_directories",
37
+ }
38
+ assert tools["read_file"].description == (
39
+ "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead."
40
+ )
41
+ assert tools["read_text_file"].description.startswith(
42
+ "Read the complete contents of a file from the file system as text."
43
+ )
44
+ assert tools["read_text_file"].outputSchema is None
45
+ read_schema = tools["read_text_file"].inputSchema.model_dump(exclude_none=True)
46
+ assert read_schema["properties"]["tail"]["description"] == (
47
+ "If provided, returns only the last N lines of the file"
48
+ )
49
+ assert read_schema["properties"]["head"]["description"] == (
50
+ "If provided, returns only the first N lines of the file"
51
+ )
52
+ assert tools["write_file"].annotations.model_dump(exclude_none=True) == {
53
+ "readOnlyHint": False,
54
+ "destructiveHint": True,
55
+ "idempotentHint": True,
56
+ }
57
+ assert tools["edit_file"].annotations.model_dump(exclude_none=True) == {
58
+ "readOnlyHint": False,
59
+ "destructiveHint": True,
60
+ "idempotentHint": False,
61
+ }
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_aiomcp_client_can_use_filesystem_tools(tmp_path):
66
+ (tmp_path / "notes.txt").write_text("alpha\nbeta\ngamma\n", encoding="utf-8")
67
+ (tmp_path / "image.png").write_bytes(b"png bytes")
68
+ (tmp_path / "nested").mkdir()
69
+ (tmp_path / "nested" / "match.txt").write_text("needle", encoding="utf-8")
70
+
71
+ server = await create_test_server(tmp_path)
72
+ client = McpClient()
73
+ await client.initialize(server)
74
+
75
+ try:
76
+ read_result = await client.invoke(
77
+ "read_text_file",
78
+ {"path": "notes.txt", "head": 2},
79
+ )
80
+ media_result = await client.invoke("read_media_file", {"path": "image.png"})
81
+ list_result = await client.invoke("list_directory", {"path": "."})
82
+ search_result = await client.invoke(
83
+ "search_files",
84
+ {"path": ".", "pattern": "nested/*.txt"},
85
+ )
86
+ tree_result = await client.invoke("directory_tree", {"path": "."})
87
+ edit_result = await client.invoke(
88
+ "edit_file",
89
+ {
90
+ "path": "notes.txt",
91
+ "edits": [{"oldText": "beta", "newText": "delta"}],
92
+ "dryRun": True,
93
+ },
94
+ )
95
+ write_result = await client.invoke(
96
+ "write_file",
97
+ {"path": "created.txt", "content": "created"},
98
+ )
99
+ finally:
100
+ await client.close()
101
+
102
+ assert read_result == [{"type": "text", "text": "alpha\nbeta"}]
103
+ assert media_result[0] == {
104
+ "type": "image",
105
+ "data": base64.b64encode(b"png bytes").decode("ascii"),
106
+ "mimeType": "image/png",
107
+ }
108
+ assert "[FILE] notes.txt" in list_result[0]["text"]
109
+ assert str(tmp_path / "nested" / "match.txt") in search_result[0]["text"]
110
+ tree = json.loads(tree_result[0]["text"])
111
+ assert {entry["name"] for entry in tree} >= {"notes.txt", "nested"}
112
+ assert "-beta" in edit_result[0]["text"]
113
+ assert write_result == [
114
+ {"type": "text", "text": "Successfully wrote to created.txt"}
115
+ ]
116
+ assert (tmp_path / "created.txt").read_text(encoding="utf-8") == "created"
117
+
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_path_validation_rejects_paths_outside_allowed_directory(tmp_path):
121
+ outside = tmp_path.parent / "outside.txt"
122
+ outside.write_text("outside", encoding="utf-8")
123
+ server = await create_test_server(tmp_path)
124
+ client = McpClient()
125
+ await client.initialize(server)
126
+
127
+ try:
128
+ result = await client.invoke_result(
129
+ "read_text_file", {"path": str(outside)}, timeout=1
130
+ )
131
+ finally:
132
+ await client.close()
133
+ outside.unlink(missing_ok=True)
134
+
135
+ assert result.isError is True
136
+ assert (
137
+ "Access denied - path outside allowed directories" in result.content[0]["text"]
138
+ )