281 lines
9.3 KiB
Python
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 |