#!/usr/bin/env python3 """ MCP Server for serving local documents via SSE using FastMCP. This server provides file reading capabilities for the localdocs directory. """ import os import base64 import shutil from pathlib import Path from fastmcp import FastMCP import sentry_sdk sentry_sdk.init( dsn="https://fc33f2349050895a6cf1e2153a5d5f2c@sentry.eroomai.com/2", send_default_pii=True, traces_sample_rate=1.0, ) from mcp_docparser.json_utils import safe_json_dumps # Get docs directory from environment DOCS_DIR = Path(os.environ.get("DOCS_DIR", "/home/eroom/Works/mcp-localdocs")) # Create FastMCP server mcp = FastMCP("mcp-localdocs") # Binary file extensions that should be read as base64 BINARY_EXTENSIONS = { '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.zip', '.tar', '.gz', '.rar', '.7z', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.mp3', '.mp4', '.wav', '.avi', '.mov', '.bin', '.exe', '.dll', '.so', '.dylib', } def is_binary_file(file_path: Path) -> bool: """Check if a file should be treated as binary based on extension.""" return file_path.suffix.lower() in BINARY_EXTENSIONS def get_mime_type(file_path: Path) -> str: """Get MIME type based on file extension.""" mime_types = { '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.zip': 'application/zip', '.tar': 'application/x-tar', '.gz': 'application/gzip', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.wav': 'audio/wav', '.json': 'application/json', '.xml': 'application/xml', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.txt': 'text/plain', '.md': 'text/markdown', } return mime_types.get(file_path.suffix.lower(), 'application/octet-stream') @mcp.tool() def read_doc(doc_name: str) -> str: """Read content from a document in the localdocs directory. For text files, returns the content as plain text. For binary files (PDF, images, etc.), returns base64 encoded content with metadata. Args: doc_name: Name of the document to read (e.g., 'client_meeting.md', 'contract.pdf') """ try: file_path = DOCS_DIR / doc_name # Security check if not file_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" if not file_path.exists(): return f"Error: Document not found: {doc_name}" # Check if binary file if is_binary_file(file_path): with open(file_path, 'rb') as f: content = f.read() mime_type = get_mime_type(file_path) encoded = base64.b64encode(content).decode('utf-8') # Return as JSON-like structure for easy parsing return safe_json_dumps({ "type": "binary", "filename": doc_name, "mime_type": mime_type, "size": len(content), "content_base64": encoded }) # Text file with open(file_path, 'r', encoding='utf-8') as f: content = f.read() return content except UnicodeDecodeError: # If text reading fails, try as binary try: with open(file_path, 'rb') as f: content = f.read() mime_type = get_mime_type(file_path) encoded = base64.b64encode(content).decode('utf-8') return safe_json_dumps({ "type": "binary", "filename": doc_name, "mime_type": mime_type, "size": len(content), "content_base64": encoded }) except Exception as e: return f"Error reading document: {str(e)}" except Exception as e: return f"Error reading document: {str(e)}" @mcp.tool() def read_binary_doc(doc_name: str) -> str: """Read a binary file (PDF, image, etc.) from the localdocs directory as base64. Always returns base64 encoded content regardless of file type. Args: doc_name: Name of the document to read (e.g., 'contract.pdf', 'receipt.png') Returns: JSON string with filename, mime_type, size, and content_base64 """ try: file_path = DOCS_DIR / doc_name # Security check if not file_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" if not file_path.exists(): return f"Error: Document not found: {doc_name}" with open(file_path, 'rb') as f: content = f.read() mime_type = get_mime_type(file_path) encoded = base64.b64encode(content).decode('utf-8') return safe_json_dumps({ "type": "binary", "filename": doc_name, "mime_type": mime_type, "size": len(content), "content_base64": encoded }) except Exception as e: return f"Error reading document: {str(e)}" @mcp.tool() def list_docs(pattern: str = "*", path: str = "", recursive: bool = False) -> str: """List all documents in the localdocs directory. Args: pattern: Optional glob pattern to filter documents (e.g., '*.json', '*.pdf') path: Relative path within localdocs to list from (default: root) recursive: If true, search in all subdirectories (default: false) """ try: target_dir = DOCS_DIR / path if path else DOCS_DIR # Security check if not target_dir.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" if not target_dir.exists(): return f"Error: Directory not found: {path}" if not target_dir.is_dir(): return f"Error: Path is not a directory: {path}" files = [] glob_pattern = f"**/{pattern}" if recursive else pattern for file_path in target_dir.glob(glob_pattern): if file_path.is_file(): rel_path = str(file_path.relative_to(DOCS_DIR)) stat = file_path.stat() files.append({ "name": file_path.name, "path": rel_path, "size": stat.st_size, "modified": stat.st_mtime, }) # Sort by path files.sort(key=lambda x: x["path"]) if not files: return safe_json_dumps({ "path": path or "/", "pattern": pattern, "recursive": recursive, "files": [], "total": 0 }) return safe_json_dumps({ "path": path or "/", "pattern": pattern, "recursive": recursive, "files": files, "total": len(files) }) except Exception as e: return f"Error listing documents: {str(e)}" @mcp.tool() def write_file(path: str, content: str, overwrite: bool = True) -> str: """Write content to a file in the localdocs directory. Args: path: File path to write to content: Content to write overwrite: Whether to overwrite existing file (default: true) """ try: file_path = DOCS_DIR / path # Security check if not file_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" # Check if file exists if file_path.exists() and not overwrite: return f"Error: File already exists: {path}. Use overwrite=true to replace." # Create parent directories if needed file_path.parent.mkdir(parents=True, exist_ok=True) with open(file_path, 'w', encoding='utf-8') as f: f.write(content) return f"Successfully wrote {len(content)} characters to {path}" except Exception as e: return f"Error writing file: {str(e)}" @mcp.tool() def list_folders(path: str = "", recursive: bool = False) -> str: """List all folders in the localdocs directory. Args: path: Relative path within localdocs to list folders from (default: root) recursive: If true, list all nested folders recursively (default: false) Returns: JSON string with folder structure including name, path, and file/folder counts """ try: target_dir = DOCS_DIR / path if path else DOCS_DIR # Security check if not target_dir.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" if not target_dir.exists(): return f"Error: Directory not found: {path}" if not target_dir.is_dir(): return f"Error: Path is not a directory: {path}" def get_folder_info(folder_path: Path) -> dict: """Get information about a folder.""" rel_path = str(folder_path.relative_to(DOCS_DIR)) if rel_path == ".": rel_path = "" files = [f for f in folder_path.iterdir() if f.is_file()] subdirs = [d for d in folder_path.iterdir() if d.is_dir()] return { "name": folder_path.name or "root", "path": rel_path, "file_count": len(files), "folder_count": len(subdirs), } def list_folders_recursive(folder_path: Path) -> list: """Recursively list all folders.""" result = [] for item in sorted(folder_path.iterdir()): if item.is_dir(): folder_info = get_folder_info(item) folder_info["subfolders"] = list_folders_recursive(item) result.append(folder_info) return result if recursive: root_info = get_folder_info(target_dir) root_info["subfolders"] = list_folders_recursive(target_dir) return safe_json_dumps(root_info) else: folders = [] for item in sorted(target_dir.iterdir()): if item.is_dir(): folders.append(get_folder_info(item)) return safe_json_dumps({ "current_path": path or "/", "folders": folders, "total": len(folders) }) except Exception as e: return f"Error listing folders: {str(e)}" @mcp.tool() def create_folder(path: str) -> str: """Create a new folder in the localdocs directory. Args: path: Relative path of the folder to create (e.g., 'projects/2024' or 'invoices') Returns: Success or error message """ try: folder_path = DOCS_DIR / path # Security check if not folder_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" if folder_path.exists(): if folder_path.is_dir(): return f"Folder already exists: {path}" else: return f"Error: A file with that name already exists: {path}" folder_path.mkdir(parents=True, exist_ok=True) return f"Successfully created folder: {path}" except Exception as e: return f"Error creating folder: {str(e)}" @mcp.tool() def delete_folder(path: str, force: bool = False) -> str: """Delete a folder from the localdocs directory. Args: path: Relative path of the folder to delete force: If true, delete non-empty folders recursively (default: false) Returns: Success or error message """ try: folder_path = DOCS_DIR / path # Security check if not folder_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" # Prevent deleting root if folder_path.resolve() == DOCS_DIR.resolve(): return "Error: Cannot delete the root directory" if not folder_path.exists(): return f"Error: Folder not found: {path}" if not folder_path.is_dir(): return f"Error: Path is not a folder: {path}" # Check if folder is empty contents = list(folder_path.iterdir()) if contents and not force: return f"Error: Folder is not empty ({len(contents)} items). Use force=true to delete recursively." if force: shutil.rmtree(folder_path) else: folder_path.rmdir() return f"Successfully deleted folder: {path}" except Exception as e: return f"Error deleting folder: {str(e)}" @mcp.tool() def move_item(source: str, destination: str) -> str: """Move or rename a file or folder within the localdocs directory. Args: source: Relative path of the file/folder to move destination: New relative path (can be a new name or different location) Returns: Success or error message """ try: source_path = DOCS_DIR / source dest_path = DOCS_DIR / destination # Security checks if not source_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - source path outside allowed directory" if not dest_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - destination path outside allowed directory" if not source_path.exists(): return f"Error: Source not found: {source}" if dest_path.exists(): return f"Error: Destination already exists: {destination}" # Create parent directories if needed dest_path.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(source_path), str(dest_path)) item_type = "folder" if dest_path.is_dir() else "file" return f"Successfully moved {item_type}: {source} -> {destination}" except Exception as e: return f"Error moving item: {str(e)}" @mcp.tool() def copy_item(source: str, destination: str) -> str: """Copy a file or folder within the localdocs directory. Args: source: Relative path of the file/folder to copy destination: Destination relative path Returns: Success or error message """ try: source_path = DOCS_DIR / source dest_path = DOCS_DIR / destination # Security checks if not source_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - source path outside allowed directory" if not dest_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - destination path outside allowed directory" if not source_path.exists(): return f"Error: Source not found: {source}" if dest_path.exists(): return f"Error: Destination already exists: {destination}" # Create parent directories if needed dest_path.parent.mkdir(parents=True, exist_ok=True) if source_path.is_dir(): shutil.copytree(str(source_path), str(dest_path)) return f"Successfully copied folder: {source} -> {destination}" else: shutil.copy2(str(source_path), str(dest_path)) return f"Successfully copied file: {source} -> {destination}" except Exception as e: return f"Error copying item: {str(e)}" @mcp.tool() def delete_file(path: str) -> str: """Delete a file from the localdocs directory. Args: path: Relative path of the file to delete Returns: Success or error message """ try: file_path = DOCS_DIR / path # Security check if not file_path.resolve().is_relative_to(DOCS_DIR.resolve()): return f"Error: Access denied - path outside allowed directory" if not file_path.exists(): return f"Error: File not found: {path}" if file_path.is_dir(): return f"Error: Path is a directory, not a file. Use delete_folder instead: {path}" file_path.unlink() return f"Successfully deleted file: {path}" except Exception as e: return f"Error deleting file: {str(e)}" if __name__ == "__main__": print(f"Starting MCP LocalDocs Server with FastMCP...") print(f"Serving files from: {DOCS_DIR}") print(f"HTTP endpoint: http://localhost:8012/mcp") # Use streamable-http instead of SSE to avoid MemoryObjectReceiveStream leak mcp.run(transport="streamable-http", host="0.0.0.0", port=8012)