Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/mcp.md
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.
155 changes: 155 additions & 0 deletions src/mcp_server.py
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))

Copy link
Copy Markdown

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.


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}")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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", "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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())
Loading