-
Notifications
You must be signed in to change notification settings - Fork 7
feat: MCP server for documentation retrieval #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # MCP Server | ||
|
|
||
| code-to-docs exposes its documentation retrieval layer as a read-only | ||
| [MCP](https://modelcontextprotocol.io/) server. Any MCP-compatible agent | ||
| (Claude Code, Cowork, IDE extensions) can query which docs cover a given | ||
| source file, browse folder indexes, and check for drift. | ||
|
|
||
| ## Tools | ||
|
|
||
| | Tool | Description | | ||
| |------|-------------| | ||
| | `find_docs_for_code` | Given source file paths, returns ranked doc files with reasons | | ||
| | `get_doc_index` | Returns the semantic index summary for a docs folder | | ||
| | `check_doc_drift` | Assesses whether a single doc file is likely stale | | ||
|
|
||
| All tools are **read-only**. The server does not modify any files. | ||
|
|
||
| ## Setup | ||
|
|
||
| ### Claude Code | ||
|
|
||
| Add to your project's `.mcp.json`: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "code-to-docs": { | ||
| "command": "uv", | ||
| "args": ["run", "python", "src/mcp_server.py"], | ||
| "cwd": "/path/to/code-to-docs" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Other MCP Clients | ||
|
|
||
| Run the server via stdio: | ||
|
|
||
| ```bash | ||
| cd /path/to/code-to-docs | ||
| uv run python src/mcp_server.py | ||
| ``` | ||
|
|
||
| The server communicates over stdin/stdout using the MCP protocol. | ||
|
|
||
| ## Example | ||
|
|
||
| Ask your agent: "Which docs cover src/config.py?" | ||
|
|
||
| The agent calls `find_docs_for_code(paths=["src/config.py"])` and gets | ||
| back a list of doc files whose folder index references that module. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| """MCP server exposing read-only documentation retrieval tools. | ||
|
|
||
| Provides three tools for agents and editors: | ||
| - find_docs_for_code: which docs cover given source files | ||
| - get_doc_index: folder-level index summary | ||
| - check_doc_drift: staleness assessment for a single doc | ||
| """ | ||
|
|
||
| import json | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| from mcp.server import Server | ||
| from mcp.server.stdio import stdio_server | ||
| from mcp.types import TextContent, Tool | ||
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | ||
|
|
||
| from doc_index import get_docs_in_folder, get_docs_root, load_manifest | ||
|
|
||
| app = Server("code-to-docs") | ||
|
|
||
|
|
||
| @app.list_tools() | ||
| async def list_tools(): | ||
| return [ | ||
| Tool( | ||
| name="find_docs_for_code", | ||
| description="Find documentation files that cover the given source file paths", | ||
| inputSchema={ | ||
| "type": "object", | ||
| "properties": { | ||
| "paths": { | ||
| "type": "array", | ||
| "items": {"type": "string"}, | ||
| "description": "Source file paths to find docs for", | ||
| }, | ||
| }, | ||
| "required": ["paths"], | ||
| }, | ||
| ), | ||
| Tool( | ||
| name="get_doc_index", | ||
| description="Get the semantic index summary for a documentation folder", | ||
| inputSchema={ | ||
| "type": "object", | ||
| "properties": { | ||
| "folder": { | ||
| "type": "string", | ||
| "description": "Folder path relative to docs root (use '_root' for root-level docs)", | ||
| }, | ||
| }, | ||
| "required": ["folder"], | ||
| }, | ||
| ), | ||
| Tool( | ||
| name="check_doc_drift", | ||
| description="Assess whether a documentation file is likely stale", | ||
| inputSchema={ | ||
| "type": "object", | ||
| "properties": { | ||
| "doc_path": { | ||
| "type": "string", | ||
| "description": "Path to the doc file relative to docs root", | ||
| }, | ||
| }, | ||
| "required": ["doc_path"], | ||
| }, | ||
| ), | ||
| ] | ||
|
|
||
|
|
||
| @app.call_tool() | ||
| async def call_tool(name, arguments): | ||
| if name == "find_docs_for_code": | ||
| return await _find_docs_for_code(arguments.get("paths", [])) | ||
| elif name == "get_doc_index": | ||
| return await _get_doc_index(arguments.get("folder", "")) | ||
| elif name == "check_doc_drift": | ||
| return await _check_doc_drift(arguments.get("doc_path", "")) | ||
| return [TextContent(type="text", text=f"Unknown tool: {name}")] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] fail-open call_tool dispatcher passes arguments to handlers without input validation. Low risk given read-only design and MCP protocol-level schema validation. |
||
|
|
||
|
|
||
| async def _find_docs_for_code(paths): | ||
| """Find docs whose folder index mentions the given source paths.""" | ||
| docs_root = get_docs_root().resolve() | ||
| manifest = load_manifest(docs_root) | ||
| results = [] | ||
|
|
||
| for folder, info in manifest.get("folders", {}).items(): | ||
| index_text = info.get("index", "") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [critical] api-contract Both _find_docs_for_code and _get_doc_index read index content via info.get("index", "") from the manifest. The manifest never stores an "index" key — it stores {"built": ..., "doc_hashes": ...} per folder. Actual index text lives in separate .index.md files loaded via load_index() or load_all_indexes(). Two of three MCP tools always return empty/not-found responses. Suggested fix: Replace manifest-based lookup with load_all_indexes(docs_root) in _find_docs_for_code and load_index(folder, docs_root) in _get_doc_index. Import these from doc_index. |
||
| for path in paths: | ||
| basename = Path(path).stem | ||
| if basename in index_text or path in index_text: | ||
| docs = get_docs_in_folder(folder, docs_root) | ||
| for doc in docs: | ||
| rel = str(doc.relative_to(docs_root)) | ||
| results.append( | ||
| {"file": rel, "folder": folder, "reason": f"Index mentions {path}"} | ||
| ) | ||
| break | ||
|
|
||
| if not results: | ||
| return [TextContent(type="text", text="No documentation files found for the given paths.")] | ||
| return [TextContent(type="text", text=json.dumps(results, indent=2))] | ||
|
|
||
|
|
||
| async def _get_doc_index(folder): | ||
| """Return the index summary for a folder.""" | ||
| docs_root = get_docs_root().resolve() | ||
| manifest = load_manifest(docs_root) | ||
| folder_info = manifest.get("folders", {}).get(folder, {}) | ||
| index_text = folder_info.get("index", "") | ||
| if not index_text: | ||
| return [TextContent(type="text", text=f"No index found for folder: {folder}")] | ||
| return [TextContent(type="text", text=index_text)] | ||
|
|
||
|
|
||
| async def _check_doc_drift(doc_path): | ||
| """Assess staleness of a single doc file.""" | ||
| docs_root = get_docs_root().resolve() | ||
| full_path = docs_root / doc_path | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [high] path-traversal _check_doc_drift constructs full_path = docs_root / doc_path from user-supplied input without validating the resolved path stays within docs root. A path like ../../etc/passwd leaks file metadata (existence, line count, code-block presence) of arbitrary files. The codebase provides validate_file_path() in security_utils.py for this purpose. Suggested fix: Resolve and validate: full_path = (docs_root / doc_path).resolve(); if not full_path.is_relative_to(docs_root): return error. Alternatively, import and use validate_file_path from security_utils. |
||
| if not full_path.exists(): | ||
| return [TextContent(type="text", text=f"File not found: {doc_path}")] | ||
| try: | ||
| content = full_path.read_text(encoding="utf-8") | ||
| line_count = len(content.splitlines()) | ||
| has_code_blocks = "```" in content | ||
| return [ | ||
| TextContent( | ||
| type="text", | ||
| text=json.dumps( | ||
| { | ||
| "file": doc_path, | ||
| "lines": line_count, | ||
| "has_code_blocks": has_code_blocks, | ||
| "assessment": "Run with an LLM endpoint configured for full drift assessment", | ||
| }, | ||
| indent=2, | ||
| ), | ||
| ) | ||
| ] | ||
| except Exception as e: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] data-exposure Exception handler exposes raw exception message in MCP response. Python file-operation exceptions can include full filesystem paths. The codebase consistently uses sanitize_output() from security_utils for error output. Suggested fix: Import sanitize_output from security_utils and wrap: f"Error reading {doc_path}: {sanitize_output(str(e))}" |
||
| return [TextContent(type="text", text=f"Error reading {doc_path}: {e}")] | ||
|
|
||
|
|
||
| async def main(): | ||
| async with stdio_server() as (read_stream, write_stream): | ||
| await app.run(read_stream, write_stream, app.create_initialization_options()) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| import asyncio | ||
|
|
||
| asyncio.run(main()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[medium] pattern-inconsistency
sys.path.insert(0, ...) is not used in any other module in the codebase. As a standalone entry point, consider using uv run python -m invocation or configuring a package entry point instead.