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

1#!/usr/bin/env python3 

2 

3import sys 

4from pathlib import Path 

5from typing import Dict, Set, Optional 

6 

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 

23 

24# Initialize FastMCP server 

25mcp = FastMCP("docs-server") 

26 

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 

32 

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 

40 

41 # Modular components (dependency injection) 

42 self.doc_api = DocumentAPI(self) 

43 self.webserver = WebserverManager(self) 

44 

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() 

50 

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() 

54 

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() 

61 

62 # Webserver thread will stop automatically (daemon thread) 

63 

64 except Exception as e: 

65 print(f"Exception in cleanup: {e}", file=sys.stderr) 

66 

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() 

72 

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 

76 

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'} 

81 

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) 

93 

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) 

101 

102 # ============================================================================ 

103 # DocumentAPI and WebserverManager methods have been extracted to modules 

104 # Delegation methods below provide backward compatibility 

105 # ============================================================================ 

106 

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) 

111 

112 def get_main_chapters(self): 

113 """Delegate to DocumentAPI""" 

114 return self.doc_api.get_main_chapters() 

115 

116 def get_root_files_structure(self): 

117 """Delegate to DocumentAPI""" 

118 return self.doc_api.get_root_files_structure() 

119 

120 def get_section(self, path: str): 

121 """Delegate to DocumentAPI""" 

122 return self.doc_api.get_section(path) 

123 

124 def get_sections(self, level: int): 

125 """Delegate to DocumentAPI""" 

126 return self.doc_api.get_sections(level) 

127 

128 def get_sections_by_level(self, level: int): 

129 """Delegate to DocumentAPI""" 

130 return self.doc_api.get_sections_by_level(level) 

131 

132 def search_content(self, query: str): 

133 """Delegate to DocumentAPI""" 

134 return self.doc_api.search_content(query) 

135 

136 def get_metadata(self, path: str = None): 

137 """Delegate to DocumentAPI""" 

138 return self.doc_api.get_metadata(path) 

139 

140 def get_dependencies(self): 

141 """Delegate to DocumentAPI""" 

142 return self.doc_api.get_dependencies() 

143 

144 def validate_structure(self): 

145 """Delegate to DocumentAPI""" 

146 return self.doc_api.validate_structure() 

147 

148 def refresh_index(self): 

149 """Delegate to DocumentAPI""" 

150 return self.doc_api.refresh_index() 

151 

152 def update_section_content(self, path: str, content: str): 

153 """Delegate to DocumentAPI""" 

154 return self.doc_api.update_section_content(path, content) 

155 

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) 

159 

160 # WebserverManager delegation methods 

161 def get_webserver_status(self): 

162 """Delegate to WebserverManager""" 

163 return self.webserver.get_webserver_status() 

164 

165 def restart_webserver(self): 

166 """Delegate to WebserverManager""" 

167 return self.webserver.restart_webserver() 

168 

169 

170# Global server instance (initialized in main()) 

171_server: Optional[MCPDocumentationServer] = None 

172 

173 

174# ============================================================================ 

175# MCP Tools - Registered with FastMCP decorators 

176# ============================================================================ 

177 

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) 

184 

185 

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) 

192 

193 

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) 

200 

201 

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() 

208 

209 

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() 

216 

217 

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() 

224 

225 

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) 

232 

233 

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) 

240 

241 

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) 

248 

249 

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) 

256 

257 

258def main(): 

259 global _server 

260 import signal 

261 import atexit 

262 import os 

263 

264 if len(sys.argv) != 2: 

265 print("Usage: python mcp_server.py <project_root>", file=sys.stderr) 

266 sys.exit(1) 

267 

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) 

272 

273 # Check environment variable to disable webserver for MCP mode 

274 enable_webserver = os.environ.get('DISABLE_WEBSERVER', '').lower() not in ('1', 'true', 'yes') 

275 

276 # Initialize server 

277 _server = MCPDocumentationServer(project_root, enable_webserver=enable_webserver) 

278 

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() 

284 

285 with open(log_file, "a") as f: 

286 f.write(f"[{timestamp}] Signal {signum} received, cleaning up...\n") 

287 

288 _server.cleanup() 

289 sys.exit(0) 

290 

291 # Register signal handlers 

292 signal.signal(signal.SIGTERM, signal_handler) 

293 signal.signal(signal.SIGINT, signal_handler) 

294 

295 # Register atexit handler as backup 

296 atexit.register(_server.cleanup) 

297 

298 # Run FastMCP server (replaces manual stdin/stdout loop) 

299 mcp.run() 

300 

301 

302if __name__ == '__main__': 

303 main()