#
tokens: 5580/50000 8/8 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── .python-version
├── examples
│   ├── claude_desktop_config_windows.json
│   └── claude_desktop_config.json
├── LICENSE
├── pyproject.toml
├── README.md
├── src
│   └── filesystem
│       ├── __init__.py
│       └── server.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
1 | 3.12
2 | 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Python
 2 | __pycache__/
 3 | *.py[cod]
 4 | *$py.class
 5 | .Python
 6 | *.so
 7 | .env
 8 | .venv/
 9 | venv/
10 | .env/
11 | ENV/
12 | 
13 | # UV
14 | .uv/
15 | 
16 | # Distribution / packaging
17 | dist/
18 | build/
19 | *.egg-info/
20 | 
21 | # IDE
22 | .vscode/
23 | .idea/
24 | *.swp
25 | *.swo
26 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
 1 | # MCP Filesystem Python
 2 | 
 3 | A Model Context Protocol (MCP) server that provides secure, read-only access to files in a specified directory.
 4 | 
 5 | ## Features
 6 | 
 7 | - Exposes files as MCP resources using \`file://\` URI scheme
 8 | - Provides file search capabilities through MCP tools
 9 | - Respects .gitignore patterns
10 | - Security features including path traversal protection
11 | - MIME type detection
12 | 
13 | ## Installation
14 | 
15 | Using UV:
16 | 
17 | ```bash
18 | uv add mcp-filesystem-python
19 | ```
20 | 
21 | ## Usage
22 | 
23 | Run the server:
24 | 
25 | ```bash
26 | uv run src/filesystem/server.py /path/to/directory
27 | ```
28 | 
29 | ## Claude Desktop Integration
30 | 
31 | ### Configuration Examples
32 | 
33 | Example configurations for Claude Desktop can be found in the \`examples\` directory:
34 | 
35 | - \`examples/claude_desktop_config.json\`: Example for macOS/Linux
36 | - \`examples/claude_desktop_config_windows.json\`: Example for Windows
37 | 
38 | These files should be placed at:
39 | - macOS: \`~/Library/Application Support/Claude/claude_desktop_config.json\`
40 | - Windows: \`%AppData%\\Claude\\claude_desktop_config.json\`
41 | 
42 | Make sure to:
43 | 1. Replace the paths with your actual paths
44 | 2. Use forward slashes (\`/\`) for macOS/Linux and backslashes (\`\\\\\`) for Windows
45 | 3. Use absolute paths (not relative paths)
46 | 
47 | ## Development
48 | 
49 | 1. Clone the repository
50 | 2. Create virtual environment and sync requirements, ```uv sync```
51 | 
52 | ## License
53 | 
54 | [MIT](LICENSE)
55 | 
```

--------------------------------------------------------------------------------
/src/filesystem/__init__.py:
--------------------------------------------------------------------------------

```python
1 | 
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "mcp-filesystem-python"
 3 | version = "0.1.0"
 4 | description = "MCP server for filesystem access"
 5 | readme = "README.md"
 6 | requires-python = ">=3.12"
 7 | dependencies = [
 8 |     "mcp>=1.2.0",
 9 |     "pathspec>=0.12.1",
10 |     "pydantic>=2.10.4",
11 | ]
12 | 
13 | [project.scripts]
14 | mcp-filesystem-python = "filesystem.server:main"
15 | 
```

--------------------------------------------------------------------------------
/examples/claude_desktop_config.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |     "mcpServers": {
 3 |         "filesystem": {
 4 |             "command": "uv",
 5 |             "args": [
 6 |                 "run",
 7 |                 "--directory", "/Users/example/projects/mcp-filesystem-python",
 8 |                 "src/filesystem/server.py",
 9 |                 "/Users/example/Documents"
10 |             ]
11 |         }
12 |     }
13 | }
14 | 
```

--------------------------------------------------------------------------------
/examples/claude_desktop_config_windows.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |     "mcpServers": {
 3 |         "filesystem": {
 4 |             "command": "uv",
 5 |             "args": [
 6 |                 "run",
 7 |                 "--directory", "C:\Users\example\projects\mcp-filesystem-python",
 8 |                 "src/filesystem/server.py",
 9 |                 "C:\Users\example\Documents"
10 |             ]
11 |         }
12 |     }
13 | }
14 | 
```

--------------------------------------------------------------------------------
/src/filesystem/server.py:
--------------------------------------------------------------------------------

```python
  1 | from typing import Annotated, Optional
  2 | import os
  3 | import pathspec
  4 | import asyncio
  5 | from mcp.server import Server, NotificationOptions
  6 | from mcp.server.models import InitializationOptions
  7 | from mcp.shared.exceptions import McpError
  8 | import mcp.types as types
  9 | import mcp.server.stdio
 10 | from pydantic import BaseModel, Field
 11 | 
 12 | # Configuration constants
 13 | DEFAULT_IGNORE_PATTERNS = [
 14 |     ".git",
 15 |     "__pycache__",
 16 |     "*.pyc",
 17 |     ".venv",
 18 |     "venv",
 19 |     ".env",
 20 |     ".idea",
 21 |     ".vscode",
 22 |     "*.egg-info",
 23 |     "dist",
 24 |     "build",
 25 |     ".pytest_cache",
 26 |     ".coverage",
 27 |     "htmlcov",
 28 |     ".DS_Store",  # macOS
 29 |     "Thumbs.db",  # Windows
 30 | ]
 31 | 
 32 | 
 33 | class FileAccess(BaseModel):
 34 |     """Parameters for accessing a file."""
 35 | 
 36 |     path: Annotated[str, Field(description="Path to the file")]
 37 |     pattern: Annotated[
 38 |         str,
 39 |         Field(
 40 |             default="*",
 41 |             description="File pattern for search (e.g., *.py for Python files)",
 42 |         ),
 43 |     ]
 44 | 
 45 | 
 46 | class FileSearch(BaseModel):
 47 |     """Parameters for searching files."""
 48 | 
 49 |     query: Annotated[str, Field(description="Text to search for")]
 50 |     file_pattern: Annotated[
 51 |         str,
 52 |         Field(
 53 |             default="*",
 54 |             description="File pattern to filter search (e.g., *.py for Python files)",
 55 |         ),
 56 |     ]
 57 | 
 58 | 
 59 | class FileWrite(BaseModel):
 60 |     """Parameters for writing to a file."""
 61 | 
 62 |     path: Annotated[str, Field(description="Path to the file")]
 63 |     content: Annotated[str, Field(description="Content to write")]
 64 |     create_dirs: Annotated[
 65 |         bool,
 66 |         Field(
 67 |             default=False, description="Create parent directories if they don't exist"
 68 |         ),
 69 |     ]
 70 | 
 71 | 
 72 | class FileDelete(BaseModel):
 73 |     """Parameters for deleting a file or directory."""
 74 | 
 75 |     path: Annotated[str, Field(description="Path to delete")]
 76 |     recursive: Annotated[
 77 |         bool, Field(default=False, description="Recursively delete directories")
 78 |     ]
 79 | 
 80 | 
 81 | def is_safe_path(root_path: str, path: str) -> bool:
 82 |     """Check if a path is safe to access.
 83 | 
 84 |     Args:
 85 |         root_path: Base directory path.
 86 |         path: Path to check.
 87 | 
 88 |     Returns:
 89 |         True if path is within root directory.
 90 |     """
 91 |     if not root_path:
 92 |         return False
 93 | 
 94 |     abs_path = os.path.abspath(os.path.join(root_path, path))
 95 |     return abs_path.startswith(root_path)
 96 | 
 97 | 
 98 | def is_ignored(
 99 |     root_path: str, path: str, ignore_patterns: Optional[pathspec.PathSpec]
100 | ) -> bool:
101 |     """Check if path matches ignore patterns.
102 | 
103 |     Args:
104 |         root_path: Base directory path.
105 |         path: Path to check
106 |         ignore_patterns: PathSpec patterns to check against
107 | 
108 |     Returns:
109 |         True if path should be ignored
110 |     """
111 |     if not ignore_patterns:
112 |         return False
113 |     relative_path = os.path.relpath(path, root_path)
114 |     return ignore_patterns.match_file(relative_path)
115 | 
116 | 
117 | def get_mime_type(file_path: str) -> str:
118 |     """Get MIME type based on file extension.
119 | 
120 |     Args:
121 |         file_path: Path to the file
122 | 
123 |     Returns:
124 |         MIME type string
125 |     """
126 |     ext = os.path.splitext(file_path)[1].lower()
127 |     mime_types = {
128 |         ".txt": "text/plain",
129 |         ".md": "text/markdown",
130 |         ".py": "text/x-python",
131 |         ".js": "text/javascript",
132 |         ".json": "application/json",
133 |         ".html": "text/html",
134 |         ".css": "text/css",
135 |         ".csv": "text/csv",
136 |         ".xml": "application/xml",
137 |         ".yaml": "application/x-yaml",
138 |         ".yml": "application/x-yaml",
139 |     }
140 |     return mime_types.get(ext, "application/octet-stream")
141 | 
142 | 
143 | async def serve(
144 |     root_path: str, custom_ignore_patterns: Optional[list[str]] = None
145 | ) -> None:
146 |     """Run the filesystem MCP server.
147 | 
148 |     Args:
149 |         root_path: Base directory to serve files from
150 |         custom_ignore_patterns: Optional list of patterns to ignore
151 |     """
152 |     if not os.path.exists(root_path):
153 |         raise ValueError(f"Directory does not exist: {root_path}")
154 | 
155 |     root_path = os.path.abspath(root_path)
156 |     ignore_patterns = None
157 | 
158 |     # Initialize ignore patterns
159 |     gitignore_path = os.path.join(root_path, ".gitignore")
160 |     if os.path.exists(gitignore_path):
161 |         with open(gitignore_path, "r") as f:
162 |             patterns = f.readlines()
163 |     else:
164 |         patterns = DEFAULT_IGNORE_PATTERNS
165 | 
166 |     if custom_ignore_patterns:
167 |         patterns.extend(custom_ignore_patterns)
168 | 
169 |     ignore_patterns = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
170 | 
171 |     server = Server("filesystem")
172 | 
173 |     @server.list_resources()
174 |     async def handle_list_resources() -> list[types.Resource]:
175 |         """List all files in the root directory."""
176 |         resources = []
177 | 
178 |         for root, _, files in os.walk(root_path):
179 |             for file in files:
180 |                 full_path = os.path.join(root, file)
181 | 
182 |                 if is_ignored(root_path, full_path, ignore_patterns):
183 |                     continue
184 | 
185 |                 rel_path = os.path.relpath(full_path, root_path)
186 |                 uri = f"file://{rel_path}"
187 | 
188 |                 resources.append(
189 |                     types.Resource(
190 |                         uri=uri,
191 |                         name=rel_path,
192 |                         description=f"File: {rel_path}",
193 |                         mimeType=get_mime_type(full_path),
194 |                     )
195 |                 )
196 | 
197 |         return resources
198 | 
199 |     @server.read_resource()
200 |     async def handle_read_resource(uri: types.AnyUrl) -> str:
201 |         """Read contents of a specific file.
202 | 
203 |         Args:
204 |             uri: The URI of the file to read
205 | 
206 |         Returns:
207 |             The contents of the file as a string
208 | 
209 |         Raises:
210 |             McpError: If file access fails
211 |         """
212 |         if uri.scheme != "file":
213 |             raise McpError(
214 |                 types.ErrorData(
215 |                     code=types.INVALID_PARAMS,
216 |                     message="Invalid URI scheme - only file:// URIs are supported",
217 |                 )
218 |             )
219 | 
220 |         path = str(uri).replace("file://", "", 1)
221 | 
222 |         if not is_safe_path(root_path, path):
223 |             raise McpError(
224 |                 types.ErrorData(
225 |                     code=types.INVALID_PARAMS, message="Path is outside root directory"
226 |                 )
227 |             )
228 | 
229 |         full_path = os.path.join(root_path, path)
230 | 
231 |         if not os.path.exists(full_path) or not os.path.isfile(full_path):
232 |             raise McpError(
233 |                 types.ErrorData(
234 |                     code=types.INVALID_PARAMS, message="File not found: {}".format(path)
235 |                 )
236 |             )
237 | 
238 |         if is_ignored(root_path, full_path, ignore_patterns):
239 |             raise McpError(
240 |                 types.ErrorData(
241 |                     code=types.INVALID_PARAMS,
242 |                     message="File is ignored: {}".format(path),
243 |                 )
244 |             )
245 | 
246 |         try:
247 |             with open(full_path, "r", encoding="utf-8") as f:
248 |                 return f.read()
249 |         except UnicodeDecodeError:
250 |             raise McpError(
251 |                 types.ErrorData(
252 |                     code=types.INVALID_PARAMS,
253 |                     message="File is not text-based: {}".format(path),
254 |                 )
255 |             )
256 |         except IOError as e:
257 |             raise McpError(
258 |                 types.ErrorData(
259 |                     code=types.INTERNAL_ERROR,
260 |                     message="Failed to read file {}: {}".format(path, str(e)),
261 |                 )
262 |             )
263 | 
264 |     @server.list_prompts()
265 |     async def handle_list_prompts() -> list[types.Prompt]:
266 |         """List available prompts."""
267 |         return [
268 |             types.Prompt(
269 |                 name="analyze-file",
270 |                 description="Get a summary analysis of a file's contents",
271 |                 arguments=[
272 |                     types.PromptArgument(
273 |                         name="path",
274 |                         description="Path to the file to analyze",
275 |                         required=True,
276 |                     )
277 |                 ],
278 |             )
279 |         ]
280 | 
281 |     @server.get_prompt()
282 |     async def handle_get_prompt(
283 |         name: str, arguments: dict[str, str] | None
284 |     ) -> types.GetPromptResult:
285 |         """Get a specific prompt template.
286 | 
287 |         Args:
288 |             name: Name of the prompt to retrieve
289 |             arguments: Optional arguments for the prompt
290 | 
291 |         Returns:
292 |             The prompt template with arguments filled in
293 | 
294 |         Raises:
295 |             McpError: If prompt or arguments are invalid
296 |         """
297 |         if name != "analyze-file":
298 |             raise McpError(
299 |                 types.ErrorData(
300 |                     code=types.INVALID_PARAMS, message="Unknown prompt: {}".format(name)
301 |                 )
302 |             )
303 | 
304 |         if not arguments or "path" not in arguments:
305 |             raise McpError(
306 |                 types.ErrorData(
307 |                     code=types.INVALID_PARAMS, message="Path argument is required"
308 |                 )
309 |             )
310 | 
311 |         path = arguments["path"]
312 |         if not is_safe_path(root_path, path):
313 |             raise McpError(
314 |                 types.ErrorData(
315 |                     code=types.INVALID_PARAMS, message="Path is outside root directory"
316 |                 )
317 |             )
318 | 
319 |         full_path = os.path.join(root_path, path)
320 |         if not os.path.exists(full_path) or not os.path.isfile(full_path):
321 |             raise McpError(
322 |                 types.ErrorData(
323 |                     code=types.INVALID_PARAMS, message=f"File not found: {path}"
324 |                 )
325 |             )
326 | 
327 |         try:
328 |             with open(full_path, "r", encoding="utf-8") as f:
329 |                 content = f.read()
330 | 
331 |             return types.GetPromptResult(
332 |                 messages=[
333 |                     types.PromptMessage(
334 |                         role="user",
335 |                         content=types.TextContent(
336 |                             type="text",
337 |                             text=f"Please analyze this file ({path}):\n\n{content}",
338 |                         ),
339 |                     )
340 |                 ]
341 |             )
342 |         except UnicodeDecodeError:
343 |             raise McpError(
344 |                 types.ErrorData(
345 |                     code=types.INVALID_PARAMS, message=f"File is not text-based:{path}"
346 |                 )
347 |             )
348 |         except IOError:
349 |             raise McpError(
350 |                 types.ErrorData(
351 |                     code=types.INTERNAL_ERROR,
352 |                     message="Failed to read file {path}: {str(e)}",
353 |                 )
354 |             )
355 | 
356 |     @server.list_tools()
357 |     async def handle_list_tools() -> list[types.Tool]:
358 |         """List available tools."""
359 |         return [
360 |             types.Tool(
361 |                 name="search-files",
362 |                 description="Search for files containing specific text",
363 |                 inputSchema=FileSearch.model_json_schema(),
364 |             ),
365 |             types.Tool(
366 |                 name="write-file",
367 |                 description="Write content to a file",
368 |                 inputSchema=FileWrite.model_json_schema(),
369 |             ),
370 |             types.Tool(
371 |                 name="delete-file",
372 |                 description="Delete a file or directory",
373 |                 inputSchema=FileDelete.model_json_schema(),
374 |             ),
375 |         ]
376 | 
377 |     @server.call_tool()
378 |     async def handle_call_tool(
379 |         name: str, arguments: dict | None
380 |     ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
381 |         """Handle tool execution."""
382 |         if not arguments:
383 |             raise McpError(
384 |                 types.ErrorData(code=types.INVALID_PARAMS, message="Missing arguments")
385 |             )
386 | 
387 |         if name == "search-files":
388 |             try:
389 |                 args = FileSearch(**arguments)
390 |             except ValueError as e:
391 |                 raise McpError(
392 |                     types.ErrorData(code=types.INVALID_PARAMS, message=str(e))
393 |                 )
394 | 
395 |             results = []
396 | 
397 |             for root, _, files in os.walk(root_path):
398 |                 for file in files:
399 |                     full_path = os.path.join(root, file)
400 | 
401 |                     if is_ignored(root_path, full_path, ignore_patterns):
402 |                         continue
403 | 
404 |                     if not pathspec.Pattern(args.file_pattern).match_file(file):
405 |                         continue
406 | 
407 |                     try:
408 |                         with open(full_path, "r", encoding="utf-8") as f:
409 |                             content = f.read()
410 |                             if args.query.lower() in content.lower():
411 |                                 rel_path = os.path.relpath(full_path, root_path)
412 |                                 results.append(f"Found in {rel_path}")
413 |                     except (UnicodeDecodeError, IOError):
414 |                         continue
415 | 
416 |             if not results:
417 |                 return [types.TextContent(type="text", text="No matches found")]
418 | 
419 |             return [
420 |                 types.TextContent(
421 |                     type="text", text="Search results:\n" + "\n".join(results)
422 |                 )
423 |             ]
424 | 
425 |         elif name == "write-file":
426 |             try:
427 |                 args = FileWrite(**arguments)
428 |             except ValueError as e:
429 |                 raise McpError(
430 |                     types.ErrorData(code=types.INVALID_PARAMS, message=str(e))
431 |                 )
432 | 
433 |             if not is_safe_path(root_path, args.path):
434 |                 raise McpError(
435 |                     types.ErrorData(
436 |                         code=types.INVALID_PARAMS,
437 |                         message="Path is outside root directory",
438 |                     )
439 |                 )
440 | 
441 |             full_path = os.path.join(root_path, args.path)
442 | 
443 |             try:
444 |                 if args.create_dirs:
445 |                     os.makedirs(os.path.dirname(full_path), exist_ok=True)
446 | 
447 |                 with open(full_path, "w", encoding="utf-8") as f:
448 |                     f.write(args.content)
449 | 
450 |                 return [
451 |                     types.TextContent(
452 |                         type="text", text=f"Successfully wrote to {args.path}"
453 |                     )
454 |                 ]
455 |             except IOError as e:
456 |                 raise McpError(
457 |                     types.ErrorData(
458 |                         code=types.INTERNAL_ERROR,
459 |                         message=f"Failed to write file {args.path}: {str(e)}",
460 |                     )
461 |                 )
462 | 
463 |         elif name == "delete-file":
464 |             try:
465 |                 args = FileDelete(**arguments)
466 |             except ValueError as e:
467 |                 raise McpError(
468 |                     types.ErrorData(code=types.INVALID_PARAMS, message=str(e))
469 |                 )
470 | 
471 |             if not is_safe_path(root_path, args.path):
472 |                 raise McpError(
473 |                     types.ErrorData(
474 |                         code=types.INVALID_PARAMS,
475 |                         message="Path is outside root directory",
476 |                     )
477 |                 )
478 | 
479 |             full_path = os.path.join(root_path, args.path)
480 | 
481 |             try:
482 |                 if os.path.isdir(full_path):
483 |                     if args.recursive:
484 |                         import shutil
485 | 
486 |                         shutil.rmtree(full_path)
487 |                     else:
488 |                         os.rmdir(full_path)  # Only removes empty directories
489 |                 else:
490 |                     os.remove(full_path)
491 | 
492 |                 return [
493 |                     types.TextContent(
494 |                         type="text", text=f"Successfully deleted {args.path}"
495 |                     )
496 |                 ]
497 |             except IOError as e:
498 |                 raise McpError(
499 |                     types.ErrorData(
500 |                         code=types.INTERNAL_ERROR,
501 |                         message=f"Failed to delete {args.path}: {str(e)}",
502 |                     )
503 |                 )
504 | 
505 |         raise McpError(
506 |             types.ErrorData(code=types.INVALID_PARAMS, message=f"Unknown tool: {name}")
507 |         )
508 | 
509 |     # Run the server
510 |     async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
511 |         await server.run(
512 |             read_stream,
513 |             write_stream,
514 |             InitializationOptions(
515 |                 server_name="filesystem",
516 |                 server_version="0.1.0",
517 |                 capabilities=server.get_capabilities(
518 |                     notification_options=NotificationOptions(),
519 |                     experimental_capabilities={},
520 |                 ),
521 |             ),
522 |         )
523 | 
524 | 
525 | if __name__ == "__main__":
526 |     import sys
527 | 
528 |     if len(sys.argv) != 2:
529 |         print("Usage: python server.py <root_directory>", file=sys.stderr)
530 |         sys.exit(1)
531 | 
532 |     asyncio.run(serve(sys.argv[1]))
533 | 
```