"""MCP server for doc-forge live documentation APIs. The MCP server exposes documentation as queryable resources via the Model Context Protocol. This provides a live, stateless API for accessing documentation data in real-time. The server follows the ADS specification by: - Being read-only and stateless - Exposing documentation as queryable resources - Being backed by the documentation model (not static files) - Supporting the standard MCP resource interface """ from __future__ import annotations import json import logging from typing import Any, Dict, List, Optional, Union from urllib.parse import urlparse from docforge.model import Project, Module, DocObject logger = logging.getLogger(__name__) class MCPServer: """Live MCP server for documentation queries. The MCPServer provides a real-time API for accessing documentation through the Model Context Protocol. Unlike the static MCP exporter, this server works directly with the live documentation model, providing up-to-date access to documentation data. The server exposes these resources: - docs://index - Project metadata - docs://nav - Navigation structure - docs://module/{module} - Individual module data The server is: - Read-only: No modifications allowed - Stateless: No session state maintained - Live: Direct access to documentation model - Queryable: Supports MCP resource queries Attributes: project: The documentation project being served """ def __init__(self, project: Project) -> None: """Initialize the MCP server. Args: project: The documentation project to serve Raises: ValueError: If project is empty """ if project.is_empty(): raise ValueError("Cannot serve empty project") self.project = project self._running = False self._server = None def start(self, host: str = "localhost", port: int = 8080) -> None: """Start the MCP server. Args: host: Host to bind the server to port: Port to bind the server to Raises: RuntimeError: If server is already running """ if self._running: raise RuntimeError("MCP server is already running") logger.info(f"Starting MCP server on {host}:{port}") # Note: This is a simplified implementation # In a full implementation, you would use the actual MCP server library # For now, we'll create a basic HTTP server that handles MCP requests try: self._start_http_server(host, port) self._running = True logger.info(f"MCP server started on http://{host}:{port}") except Exception as e: logger.error(f"Failed to start MCP server: {e}") raise def stop(self) -> None: """Stop the MCP server. Raises: RuntimeError: If server is not running """ if not self._running: raise RuntimeError("MCP server is not running") logger.info("Stopping MCP server") if self._server: self._server.shutdown() self._server = None self._running = False logger.info("MCP server stopped") def handle_request(self, request: Dict[str, Any]) -> Dict[str, Any]: """Handle an MCP request. This method processes incoming MCP requests and returns appropriate responses. It supports the standard MCP resource operations. Args: request: The MCP request dictionary Returns: MCP response dictionary Raises: ValueError: If request is invalid """ method = request.get("method") if method == "resources/list": return self._handle_resources_list() elif method == "resources/read": return self._handle_resources_read(request) else: return { "error": { "code": -32601, "message": f"Method not supported: {method}", } } def get_resource(self, uri: str) -> Optional[Dict[str, Any]]: """Get a resource by URI. Args: uri: The resource URI (e.g., "docs://index") Returns: Resource data or None if not found """ parsed = urlparse(uri) if parsed.scheme != "docs": return None path = parsed.path.lstrip("/") if path == "index": return self._get_index_resource() elif path == "nav": return self._get_nav_resource() elif path.startswith("module/"): module_path = path[7:] # Remove "module/" prefix return self._get_module_resource(module_path) else: return None def _handle_resources_list(self) -> Dict[str, Any]: """Handle resources/list request. Returns: List of available resources """ resources = [ { "uri": "docs://index", "name": "Project Index", "description": "Project metadata and information", "mimeType": "application/json", }, { "uri": "docs://nav", "name": "Navigation", "description": "Documentation navigation structure", "mimeType": "application/json", }, ] # Add module resources for module in self.project.get_all_modules(): resources.append({ "uri": f"docs://module/{module.path}", "name": module.path, "description": f"Documentation for {module.path}", "mimeType": "application/json", }) return { "resources": resources, } def _handle_resources_read(self, request: Dict[str, Any]) -> Dict[str, Any]: """Handle resources/read request. Args: request: The read request Returns: Resource content or error """ uri = request.get("params", {}).get("uri") if not uri: return { "error": { "code": -32602, "message": "Missing URI parameter", } } resource = self.get_resource(uri) if resource is None: return { "error": { "code": -32602, "message": f"Resource not found: {uri}", } } return { "contents": [ { "uri": uri, "mimeType": "application/json", "text": json.dumps(resource, indent=2, ensure_ascii=False), } ], } def _get_index_resource(self) -> Dict[str, Any]: """Get the index resource. Returns: Index resource data """ return { "name": self.project.name, "version": self.project.version, "module_count": len(self.project.get_all_modules()), "total_objects": self.project.get_total_object_count(), "server": "doc-forge MCP server", } def _get_nav_resource(self) -> Dict[str, Any]: """Get the navigation resource. Returns: Navigation resource data """ return { "entries": [ { "title": entry.title, "module": entry.module, "uri": f"docs://module/{entry.module}", } for entry in self.project.nav.entries ] } def _get_module_resource(self, module_path: str) -> Optional[Dict[str, Any]]: """Get a module resource. Args: module_path: The module path Returns: Module resource data or None if not found """ module = self.project.get_module(module_path) if module is None: return None return { "path": module.path, "docstring": module.docstring, "objects": [self._serialize_object(obj) for obj in module.get_public_objects()], } def _serialize_object(self, obj: DocObject) -> Dict[str, Any]: """Serialize a DocObject for MCP response. Args: obj: The DocObject to serialize Returns: Serialized object data """ 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 _start_http_server(self, host: str, port: int) -> None: """Start a simple HTTP server for MCP requests. Args: host: Host to bind to port: Port to bind to """ import http.server import socketserver from threading import Thread class MCPRequestHandler(http.server.SimpleHTTPRequestHandler): def __init__(self, project: Project, *args, **kwargs): self.project = project self.server_instance = MCPServer(project) super().__init__(*args, **kwargs) def do_POST(self): """Handle POST requests (MCP JSON-RPC).""" content_length = int(self.headers.get('Content-Length', 0)) if content_length == 0: self.send_error(400, "Empty request body") return try: # Read request body body = self.rfile.read(content_length) request = json.loads(body.decode('utf-8')) # Handle request response = self.server_instance.handle_request(request) # Send response self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps(response).encode('utf-8')) except json.JSONDecodeError: self.send_error(400, "Invalid JSON") except Exception as e: logger.error(f"Request handling error: {e}") self.send_error(500, "Internal server error") def do_GET(self): """Handle GET requests (health check).""" if self.path == '/health': self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(json.dumps({"status": "ok"}).encode('utf-8')) else: self.send_error(404, "Not found") # Create handler factory def handler_factory(*args, **kwargs): return MCPRequestHandler(self.project, *args, **kwargs) # Start server self._server = socketserver.TCPServer((host, port), handler_factory) # Run server in separate thread server_thread = Thread(target=self._server.serve_forever, daemon=True) server_thread.start() def is_running(self) -> bool: """Check if the server is currently running. Returns: True if server is running, False otherwise """ return self._running