"""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