Coverage for src/mcp_server.py: 64%
172 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-08 05:40 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-08 05:40 +0000
1#!/usr/bin/env python3
3import sys
4from pathlib import Path
5from typing import Dict, Set, Optional
7try:
8 from src.document_parser import DocumentParser
9 from src.file_watcher import FileWatcher
10 from src.content_editor import ContentEditor
11 from src.diff_engine import DiffEngine
12 from src.mcp_internal.document_api import DocumentAPI
13 from src.mcp_internal.webserver_manager import WebserverManager
14except ImportError:
15 # Fallback for when run as script without src module in path
16 from document_parser import DocumentParser
17 from file_watcher import FileWatcher
18 from content_editor import ContentEditor
19 from diff_engine import DiffEngine
20 from mcp_internal.document_api import DocumentAPI
21 from mcp_internal.webserver_manager import WebserverManager
22from fastmcp import FastMCP
24# Initialize FastMCP server
25mcp = FastMCP("docs-server")
27class MCPDocumentationServer:
28 def __init__(self, project_root: Path, enable_webserver: bool = True):
29 # Convert to Path if string is passed
30 self.project_root = Path(project_root) if isinstance(project_root, str) else project_root
31 self.enable_webserver = enable_webserver
33 # Core components
34 self.parser = DocumentParser()
35 self.editor = ContentEditor(project_root)
36 self.diff_engine = DiffEngine()
37 self.sections = {}
38 self.root_files = []
39 self.included_files = set() # Track files that are included by other files
41 # Modular components (dependency injection)
42 self.doc_api = DocumentAPI(self)
43 self.webserver = WebserverManager(self)
45 # File watching
46 self.file_watcher = FileWatcher(project_root, self._on_files_changed)
47 self._discover_root_files()
48 self._parse_project()
49 self.file_watcher.start()
51 # Start webserver after initialization (moved from protocol_handler.py:23)
52 if self.enable_webserver and not self.webserver.webserver_started:
53 self.webserver.start_webserver_thread()
55 def cleanup(self):
56 """Clean up resources and stop webserver"""
57 try:
58 # Stop file watcher
59 if hasattr(self, 'file_watcher') and self.file_watcher:
60 self.file_watcher.stop()
62 # Webserver thread will stop automatically (daemon thread)
64 except Exception as e:
65 print(f"Exception in cleanup: {e}", file=sys.stderr)
67 def _on_files_changed(self, changed_files: Set[str]):
68 """Handle file change notifications"""
69 print(f"Files changed: {len(changed_files)} files", file=sys.stderr)
70 self._discover_root_files()
71 self._parse_project()
73 def _discover_root_files(self):
74 """Find main documentation files (AsciiDoc and Markdown, including subdirectories)"""
75 self.root_files = [] # Clear list before discovering to prevent duplicates
77 # Directories to exclude from search
78 exclude_dirs = {'.venv', 'venv', '.git', '.pytest_cache', '__pycache__',
79 'node_modules', '.tox', '.mypy_cache', '.ruff_cache',
80 '.amazonq', '.serena', '.vibe', 'build'}
82 # Extended patterns for AsciiDoc and Markdown files (recursive search)
83 patterns = ['**/*.adoc', '**/*.ad', '**/*.asciidoc', '**/*.md', '**/*.markdown']
84 for pattern in patterns:
85 for file in self.project_root.glob(pattern):
86 # Skip files in excluded directories
87 if any(excluded in file.parts for excluded in exclude_dirs):
88 continue
89 # Skip include files (starting with _)
90 if file.name.startswith('_'):
91 continue
92 self.root_files.append(file)
94 def _parse_project(self):
95 """Parse all root files and build section index"""
96 self.included_files.clear() # Clear before re-parsing
97 for root_file in self.root_files:
98 file_sections, included = self.parser.parse_project(root_file)
99 self.sections.update(file_sections)
100 self.included_files.update(included)
102 # ============================================================================
103 # DocumentAPI and WebserverManager methods have been extracted to modules
104 # Delegation methods below provide backward compatibility
105 # ============================================================================
107 # DocumentAPI delegation methods
108 def get_structure(self, start_level: int = 1, parent_id: str = None, limit: int = None, offset: int = 0):
109 """Delegate to DocumentAPI"""
110 return self.doc_api.get_structure(start_level, parent_id, limit, offset)
112 def get_main_chapters(self):
113 """Delegate to DocumentAPI"""
114 return self.doc_api.get_main_chapters()
116 def get_root_files_structure(self):
117 """Delegate to DocumentAPI"""
118 return self.doc_api.get_root_files_structure()
120 def get_section(self, path: str):
121 """Delegate to DocumentAPI"""
122 return self.doc_api.get_section(path)
124 def get_sections(self, level: int):
125 """Delegate to DocumentAPI"""
126 return self.doc_api.get_sections(level)
128 def get_sections_by_level(self, level: int):
129 """Delegate to DocumentAPI"""
130 return self.doc_api.get_sections_by_level(level)
132 def search_content(self, query: str):
133 """Delegate to DocumentAPI"""
134 return self.doc_api.search_content(query)
136 def get_metadata(self, path: str = None):
137 """Delegate to DocumentAPI"""
138 return self.doc_api.get_metadata(path)
140 def get_dependencies(self):
141 """Delegate to DocumentAPI"""
142 return self.doc_api.get_dependencies()
144 def validate_structure(self):
145 """Delegate to DocumentAPI"""
146 return self.doc_api.validate_structure()
148 def refresh_index(self):
149 """Delegate to DocumentAPI"""
150 return self.doc_api.refresh_index()
152 def update_section_content(self, path: str, content: str):
153 """Delegate to DocumentAPI"""
154 return self.doc_api.update_section_content(path, content)
156 def insert_section(self, parent_path: str, title: str, content: str, position: str = "append"):
157 """Delegate to DocumentAPI"""
158 return self.doc_api.insert_section(parent_path, title, content, position)
160 # WebserverManager delegation methods
161 def get_webserver_status(self):
162 """Delegate to WebserverManager"""
163 return self.webserver.get_webserver_status()
165 def restart_webserver(self):
166 """Delegate to WebserverManager"""
167 return self.webserver.restart_webserver()
170# Global server instance (initialized in main())
171_server: Optional[MCPDocumentationServer] = None
174# ============================================================================
175# MCP Tools - Registered with FastMCP decorators
176# ============================================================================
178@mcp.tool()
179def get_section(path: str) -> dict:
180 """Get specific section content"""
181 if _server is None:
182 raise RuntimeError("Server not initialized")
183 return _server.doc_api.get_section(path)
186@mcp.tool()
187def get_metadata(path: str | None = None) -> dict:
188 """Get metadata for section or entire project"""
189 if _server is None:
190 raise RuntimeError("Server not initialized")
191 return _server.doc_api.get_metadata(path)
194@mcp.tool()
195def get_sections(level: int) -> list:
196 """Get all sections at specific level"""
197 if _server is None:
198 raise RuntimeError("Server not initialized")
199 return _server.doc_api.get_sections(level)
202@mcp.tool()
203def get_dependencies() -> dict:
204 """Get include tree and cross-references"""
205 if _server is None:
206 raise RuntimeError("Server not initialized")
207 return _server.doc_api.get_dependencies()
210@mcp.tool()
211def validate_structure() -> dict:
212 """Validate document structure consistency"""
213 if _server is None:
214 raise RuntimeError("Server not initialized")
215 return _server.doc_api.validate_structure()
218@mcp.tool()
219def refresh_index() -> dict:
220 """Refresh document index to detect new files"""
221 if _server is None:
222 raise RuntimeError("Server not initialized")
223 return _server.doc_api.refresh_index()
226@mcp.tool()
227def get_structure(start_level: int = 1, parent_id: str | None = None) -> dict:
228 """Get sections at a specific hierarchy level (depth=1 to avoid token limits). Use start_level to navigate through levels progressively."""
229 if _server is None:
230 raise RuntimeError("Server not initialized")
231 return _server.doc_api.get_structure(start_level, parent_id)
234@mcp.tool()
235def search_content(query: str) -> list:
236 """Search for content in documentation"""
237 if _server is None:
238 raise RuntimeError("Server not initialized")
239 return _server.doc_api.search_content(query)
242@mcp.tool()
243def update_section(path: str, content: str) -> bool:
244 """Update section content"""
245 if _server is None:
246 raise RuntimeError("Server not initialized")
247 return _server.doc_api.update_section_content(path, content)
250@mcp.tool()
251def insert_section(parent_path: str, title: str, content: str, position: str = 'append') -> bool:
252 """Insert new section"""
253 if _server is None:
254 raise RuntimeError("Server not initialized")
255 return _server.doc_api.insert_section(parent_path, title, content, position)
258def main():
259 global _server
260 import signal
261 import atexit
262 import os
264 if len(sys.argv) != 2:
265 print("Usage: python mcp_server.py <project_root>", file=sys.stderr)
266 sys.exit(1)
268 project_root = Path(sys.argv[1])
269 if not project_root.exists():
270 print(f"Project root does not exist: {project_root}", file=sys.stderr)
271 sys.exit(1)
273 # Check environment variable to disable webserver for MCP mode
274 enable_webserver = os.environ.get('DISABLE_WEBSERVER', '').lower() not in ('1', 'true', 'yes')
276 # Initialize server
277 _server = MCPDocumentationServer(project_root, enable_webserver=enable_webserver)
279 # Setup signal handlers for graceful shutdown
280 def signal_handler(signum, frame):
281 import datetime
282 log_file = "browser_debug.log"
283 timestamp = datetime.datetime.now().isoformat()
285 with open(log_file, "a") as f:
286 f.write(f"[{timestamp}] Signal {signum} received, cleaning up...\n")
288 _server.cleanup()
289 sys.exit(0)
291 # Register signal handlers
292 signal.signal(signal.SIGTERM, signal_handler)
293 signal.signal(signal.SIGINT, signal_handler)
295 # Register atexit handler as backup
296 atexit.register(_server.cleanup)
298 # Run FastMCP server (replaces manual stdin/stdout loop)
299 mcp.run()
302if __name__ == '__main__':
303 main()