# 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 | ```