Skip to content

Instantly share code, notes, and snippets.

@umputun
Last active August 21, 2025 23:27
Show Gist options
  • Save umputun/e47c3305e734ea2c06d19ad955370c5c to your computer and use it in GitHub Desktop.
Save umputun/e47c3305e734ea2c06d19ad955370c5c to your computer and use it in GitHub Desktop.
Local Documentation MCP Server for Claude
#!/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