Last active
August 21, 2025 23:27
-
-
Save umputun/e47c3305e734ea2c06d19ad955370c5c to your computer and use it in GitHub Desktop.
Local Documentation MCP Server for Claude
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
""" | |
Local Documentation MCP Server for Claude | |
This MCP (Model Context Protocol) server provides access to local markdown documentation | |
files, allowing Claude to search, read, and list documentation stored in a specified | |
directory. By default, it serves documentation from the ~/.claude/commands directory. | |
## Features | |
- search_docs(query): Search for documentation files by name with fuzzy matching | |
- read_doc(path): Read a specific documentation file | |
- list_all_docs(): List all available documentation files | |
## Installation | |
1. Install required Python package: | |
pip3 install --user --break-system-packages mcp | |
Or if you prefer using a virtual environment: | |
python3 -m venv ~/mcp-env | |
source ~/mcp-env/bin/activate | |
pip install mcp | |
2. Add to your Claude MCP configuration (~/.claude.json or project .mcp.json): | |
{ | |
"mcpServers": { | |
"local-docs": { | |
"command": "python3", | |
"args": [ | |
"/path/to/local-docs-mcp.py" | |
] | |
} | |
} | |
} | |
3. Restart Claude Code to load the new MCP server | |
## Usage | |
Once configured, you can query documentation naturally in Claude: | |
- "Show me docs for routegroup" | |
- "Find documentation about testing" | |
- "List all available commands" | |
- "What's in the go-architect command?" | |
Claude will automatically use the appropriate MCP tools to search and retrieve | |
the documentation from your configured directory. | |
""" | |
import sys | |
import logging | |
from pathlib import Path | |
import difflib | |
from typing import Dict, List, Any, Optional | |
from mcp.server.fastmcp import FastMCP | |
# Type definitions | |
class FileInfo(Dict[str, str]): | |
"""Type for file info dictionaries.""" | |
pass | |
class DocInfo(Dict[str, Any]): | |
"""Type for document info dictionaries.""" | |
pass | |
# Constants | |
DOCS_DIR = Path.home() / ".claude" / "commands" | |
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB safety limit | |
# Setup logging | |
logging.basicConfig( | |
level=logging.INFO, | |
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', | |
stream=sys.stderr | |
) | |
logger = logging.getLogger("local-docs-mcp") | |
# Initialize FastMCP server | |
mcp = FastMCP(name="local-docs") | |
# Validate directory at startup | |
if not DOCS_DIR.exists(): | |
logger.error(f"Documentation directory does not exist: {DOCS_DIR}") | |
elif not DOCS_DIR.is_dir(): | |
logger.error(f"Path is not a directory: {DOCS_DIR}") | |
else: | |
logger.info(f"Documentation directory: {DOCS_DIR}") | |
try: | |
md_count = sum(1 for _ in DOCS_DIR.glob("*.md")) | |
logger.info(f"Found {md_count} .md files") | |
except Exception as e: | |
logger.error(f"Error counting files: {e}") | |
def safe_resolve_path(base_dir: Path, user_path: str) -> Optional[Path]: | |
""" | |
Safely resolve a user-provided path within the base directory. | |
Returns None if the path is invalid or escapes the base directory. | |
""" | |
try: | |
# reject absolute paths | |
if Path(user_path).is_absolute(): | |
return None | |
# add .md extension if not present | |
if not user_path.endswith('.md'): | |
user_path = f"{user_path}.md" | |
# construct the path without resolving symlinks in base_dir | |
candidate = base_dir / user_path | |
# ensure path doesn't escape via ../ | |
if ".." in str(user_path): | |
return None | |
# check if file exists and is not too large | |
if not candidate.is_file(): | |
return None | |
if candidate.stat().st_size > MAX_FILE_SIZE: | |
logger.warning(f"File too large: {candidate}") | |
return None | |
return candidate | |
except Exception: | |
return None | |
def get_file_list() -> List[FileInfo]: | |
"""Get list of documentation files.""" | |
files: List[FileInfo] = [] | |
try: | |
for file_path in sorted(DOCS_DIR.glob("*.md")): | |
# skip hidden files | |
if file_path.name.startswith('.'): | |
continue | |
files.append(FileInfo({ | |
"name": file_path.stem, | |
"filename": file_path.name, | |
"normalized": file_path.stem.lower() | |
})) | |
except Exception as e: | |
logger.error(f"Error listing files: {e}") | |
return files | |
@mcp.tool() | |
def search_docs(query: str) -> Dict[str, Any]: | |
"""Search for documentation files matching the query""" | |
if not DOCS_DIR.is_dir(): | |
return {"error": {"code": "DIR_NOT_FOUND", "message": "Documentation directory not found"}} | |
try: | |
query_lower = query.lower().replace(" ", "-") | |
files = get_file_list() | |
matches: List[Dict[str, Any]] = [] | |
for file_info in files: | |
# check for exact or substring match first | |
if query_lower in file_info["normalized"]: | |
score = 1.0 | |
else: | |
# use fuzzy matching for others | |
score = difflib.SequenceMatcher(None, query_lower, file_info["normalized"]).ratio() | |
if score > 0.3: # reasonable threshold | |
matches.append({ | |
"path": file_info["filename"], | |
"name": file_info["name"], | |
"score": score | |
}) | |
# sort by score | |
matches.sort(key=lambda x: x['score'], reverse=True) | |
return {"success": True, "data": {"results": matches[:10], "total": len(matches)}} | |
except Exception as e: | |
logger.error(f"Error searching docs: {e}") | |
return {"error": {"code": "SEARCH_ERROR", "message": str(e)}} | |
@mcp.tool() | |
def read_doc(path: str) -> Dict[str, Any]: | |
"""Read a documentation file by path""" | |
if not DOCS_DIR.is_dir(): | |
return {"error": {"code": "DIR_NOT_FOUND", "message": "Documentation directory not found"}} | |
# safely resolve the path | |
resolved_path = safe_resolve_path(DOCS_DIR, path) | |
if resolved_path is None: | |
return {"error": {"code": "FILE_NOT_FOUND", "message": f"File not found: {path}"}} | |
try: | |
content = resolved_path.read_text(encoding='utf-8', errors='strict') | |
relative_path = resolved_path.relative_to(DOCS_DIR) | |
return { | |
"success": True, | |
"data": { | |
"path": str(relative_path), | |
"content": content, | |
"size": len(content) | |
} | |
} | |
except UnicodeDecodeError: | |
return {"error": {"code": "DECODE_ERROR", "message": "Failed to decode file content"}} | |
except PermissionError: | |
return {"error": {"code": "PERMISSION_DENIED", "message": "Permission denied"}} | |
except Exception as e: | |
logger.error(f"Error reading {path}: {e}") | |
return {"error": {"code": "READ_ERROR", "message": str(e)}} | |
@mcp.tool() | |
def list_all_docs() -> Dict[str, Any]: | |
"""List all available documentation files""" | |
if not DOCS_DIR.is_dir(): | |
return {"error": {"code": "DIR_NOT_FOUND", "message": "Documentation directory not found"}} | |
try: | |
files = get_file_list() | |
docs: List[DocInfo] = [] | |
for file_info in files: | |
doc_info = DocInfo({ | |
"name": file_info["name"], | |
"filename": file_info["filename"] | |
}) | |
# try to add file size | |
try: | |
file_path = DOCS_DIR / file_info["filename"] | |
doc_info["size"] = file_path.stat().st_size | |
except Exception: | |
pass # skip size if we can't get it | |
docs.append(doc_info) | |
return {"success": True, "data": {"docs": docs, "total": len(docs)}} | |
except Exception as e: | |
logger.error(f"Error listing docs: {e}") | |
return {"error": {"code": "LIST_ERROR", "message": str(e)}} | |
if __name__ == "__main__": | |
logger.info(f"Starting local-docs MCP server with directory: {DOCS_DIR}") | |
mcp.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment