Files
doc-forge/docforge/exporters/mcp.py

281 lines
9.3 KiB
Python

"""MCP exporter for doc-forge static documentation bundles.
The MCP exporter creates static JSON bundles that can be consumed by
MCP (Model Context Protocol) clients. This follows the ADS specification
by providing a machine-consumable representation of the documentation
model.
The exporter bypasses renderers entirely and works directly with the
documentation model to create alias-safe, deterministic output.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from docforge.model import Project, Module, DocObject
logger = logging.getLogger(__name__)
class MCPExporter:
"""Exports documentation model to MCP JSON format.
The MCPExporter creates static JSON bundles that represent the
documentation model in a machine-consumable format. These bundles
can be loaded by MCP clients to provide structured access to
documentation.
Output structure:
mcp/
├── index.json # Project metadata
├── nav.json # Navigation structure
└── modules/
├── package.module.json # Individual module data
└── ...
The exported data is:
- Alias-safe: Uses canonical paths for all references
- Deterministic: Same input always produces same output
- Self-contained: No external dependencies
- Queryable: Structured for easy machine consumption
"""
def __init__(self) -> None:
"""Initialize the MCP exporter."""
self._version = "1.0.0"
def export(self, project: Project, out_dir: Path) -> None:
"""Export the complete project to MCP format.
This is the main entry point for exporting. It creates the full
MCP bundle structure including index, navigation, and all module
files.
Args:
project: The documentation project to export
out_dir: Directory where MCP bundle should be written
"""
if project.is_empty():
raise ValueError("Cannot export empty project")
# Create output directory structure
modules_dir = out_dir / "modules"
modules_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"Exporting MCP bundle to {out_dir}")
# Export components
self.export_index(project, out_dir)
self.export_nav(project, out_dir)
self.export_modules(project, modules_dir)
logger.info(f"MCP export completed: {len(project.get_all_modules())} modules")
def export_index(self, project: Project, out_dir: Path) -> None:
"""Export the project index metadata.
The index.json file contains project-level metadata and
serves as the entry point for the MCP bundle.
Args:
project: The documentation project
out_dir: Output directory
"""
index_path = out_dir / "index.json"
index_data = {
"name": project.name,
"version": project.version,
"docforge_version": self._version,
"export_format": "mcp",
"export_timestamp": self._get_timestamp(),
"module_count": len(project.get_all_modules()),
"total_objects": project.get_total_object_count(),
}
with open(index_path, "w", encoding="utf-8") as f:
json.dump(index_data, f, indent=2, ensure_ascii=False)
logger.debug(f"Exported index: {index_path}")
def export_nav(self, project: Project, out_dir: Path) -> None:
"""Export the navigation structure.
The nav.json file contains the hierarchical navigation structure
for browsing the documentation.
Args:
project: The documentation project
out_dir: Output directory
"""
nav_path = out_dir / "nav.json"
nav_data = {
"entries": [
{
"title": entry.title,
"module": entry.module,
"path": f"modules/{entry.module.replace('.', '/')}.json",
}
for entry in project.nav.entries
]
}
with open(nav_path, "w", encoding="utf-8") as f:
json.dump(nav_data, f, indent=2, ensure_ascii=False)
logger.debug(f"Exported navigation: {nav_path}")
def export_modules(self, project: Project, modules_dir: Path) -> None:
"""Export all modules to individual JSON files.
Each module is exported to its own JSON file in the modules/
directory. The filename follows the pattern: package.module.json
Args:
project: The documentation project
modules_dir: Directory for module files
"""
for module in project.get_all_modules():
self._export_module(module, modules_dir)
def _export_module(self, module: Module, modules_dir: Path) -> None:
"""Export a single module to JSON.
Args:
module: The module to export
modules_dir: Directory for module files
"""
# Create subdirectories for nested packages
module_file_path = modules_dir / f"{module.path}.json"
module_file_path.parent.mkdir(parents=True, exist_ok=True)
module_data = {
"path": module.path,
"docstring": module.docstring,
"objects": [self._serialize_object(obj) for obj in module.get_public_objects()],
}
with open(module_file_path, "w", encoding="utf-8") as f:
json.dump(module_data, f, indent=2, ensure_ascii=False)
logger.debug(f"Exported module: {module_file_path}")
def _serialize_object(self, obj: DocObject) -> Dict[str, Any]:
"""Serialize a DocObject to MCP format.
Args:
obj: The DocObject to serialize
Returns:
Dictionary representation of the object
"""
data = {
"name": obj.name,
"kind": obj.kind,
"path": obj.path,
"docstring": obj.docstring,
}
if obj.signature:
data["signature"] = obj.signature
if obj.members:
data["members"] = [
self._serialize_object(member)
for member in obj.get_public_members()
]
return data
def export_module(self, module: Module, out_dir: Path) -> None:
"""Export a single module (convenience method).
Args:
module: The module to export
out_dir: Output directory
"""
modules_dir = out_dir / "modules"
modules_dir.mkdir(parents=True, exist_ok=True)
self._export_module(module, modules_dir)
def validate_export(self, out_dir: Path) -> bool:
"""Validate that the MCP export is complete and valid.
Args:
out_dir: Directory containing the exported bundle
Returns:
True if export is valid, False otherwise
"""
try:
# Check required files exist
index_path = out_dir / "index.json"
nav_path = out_dir / "nav.json"
modules_dir = out_dir / "modules"
if not (index_path.exists() and nav_path.exists() and modules_dir.exists()):
return False
# Load and validate index
with open(index_path, "r", encoding="utf-8") as f:
index = json.load(f)
required_fields = ["name", "docforge_version", "export_format"]
if not all(field in index for field in required_fields):
return False
# Load and validate nav
with open(nav_path, "r", encoding="utf-8") as f:
nav = json.load(f)
if "entries" not in nav:
return False
# Check that all nav entries have corresponding module files
for entry in nav["entries"]:
module_path = modules_dir / f"{entry['module']}.json"
if not module_path.exists():
return False
return True
except Exception as e:
logger.error(f"Export validation failed: {e}")
return False
def _get_timestamp(self) -> str:
"""Get current timestamp in ISO format.
Returns:
ISO format timestamp string
"""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
def get_export_info(self, out_dir: Path) -> Optional[Dict[str, Any]]:
"""Get information about an existing export.
Args:
out_dir: Directory containing the exported bundle
Returns:
Export information dictionary or None if invalid
"""
index_path = out_dir / "index.json"
if not index_path.exists():
return None
try:
with open(index_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None