Coverage for excalidraw_mcp/cli.py: 0%

219 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-16 08:08 -0700

1"""CLI interface for excalidraw-mcp server management.""" 

2 

3import asyncio 

4import signal 

5import subprocess 

6import sys 

7import time 

8from pathlib import Path 

9from typing import Any 

10 

11import psutil 

12import typer 

13from rich import print as rprint 

14from rich.console import Console 

15from rich.panel import Panel 

16from rich.table import Table 

17 

18from excalidraw_mcp.config import Config 

19from excalidraw_mcp.monitoring.supervisor import MonitoringSupervisor 

20from excalidraw_mcp.process_manager import CanvasProcessManager 

21 

22console = Console() 

23 

24# Global process manager instance 

25_process_manager: CanvasProcessManager | None = None 

26_monitoring_supervisor: MonitoringSupervisor | None = None 

27 

28 

29def get_process_manager() -> CanvasProcessManager: 

30 """Get or create process manager instance.""" 

31 global _process_manager 

32 if _process_manager is None: 

33 _process_manager = CanvasProcessManager() 

34 return _process_manager 

35 

36 

37def get_monitoring_supervisor() -> MonitoringSupervisor: 

38 """Get or create monitoring supervisor instance.""" 

39 global _monitoring_supervisor 

40 if _monitoring_supervisor is None: 

41 _monitoring_supervisor = MonitoringSupervisor() 

42 return _monitoring_supervisor 

43 

44 

45def find_mcp_server_process() -> psutil.Process | None: 

46 """Find running MCP server process.""" 

47 for proc in psutil.process_iter(["pid", "name", "cmdline"]): 

48 try: 

49 cmdline = proc.info["cmdline"] 

50 if cmdline and any("excalidraw_mcp.server" in arg for arg in cmdline): 

51 return proc 

52 except (psutil.NoSuchProcess, psutil.AccessDenied): 

53 continue 

54 return None 

55 

56 

57def find_canvas_server_process() -> psutil.Process | None: 

58 """Find running canvas server process.""" 

59 for proc in psutil.process_iter(["pid", "name", "cmdline"]): 

60 try: 

61 cmdline = proc.info["cmdline"] 

62 if cmdline and any( 

63 "src/server.js" in arg or "dist/server.js" in arg for arg in cmdline 

64 ): 

65 return proc 

66 except (psutil.NoSuchProcess, psutil.AccessDenied): 

67 continue 

68 return None 

69 

70 

71def start_mcp_server_impl(background: bool = False, monitoring: bool = True) -> None: 

72 """Implementation for starting MCP server.""" 

73 # Check if already running 

74 existing_proc = find_mcp_server_process() 

75 if existing_proc: 

76 rprint( 

77 f"[yellow]MCP server already running (PID: {existing_proc.pid})[/yellow]" 

78 ) 

79 return 

80 

81 rprint("[green]Starting Excalidraw MCP server...[/green]") 

82 

83 try: 

84 if background: 

85 # Start in background 

86 subprocess.Popen( 

87 [sys.executable, "-m", "excalidraw_mcp.server"], 

88 stdout=subprocess.DEVNULL, 

89 stderr=subprocess.DEVNULL, 

90 start_new_session=True, 

91 ) 

92 

93 # Wait a moment and check if it started 

94 time.sleep(2) 

95 proc = find_mcp_server_process() 

96 if proc: 

97 rprint( 

98 f"[green]✓ MCP server started in background (PID: {proc.pid})[/green]" 

99 ) 

100 else: 

101 rprint("[red]✗ Failed to start MCP server in background[/red]") 

102 sys.exit(1) 

103 else: 

104 # Start in foreground with optional monitoring 

105 if monitoring: 

106 # Start with monitoring supervisor 

107 async def run_with_monitoring() -> None: 

108 supervisor = get_monitoring_supervisor() 

109 process_manager = get_process_manager() 

110 

111 # Set up signal handlers for graceful shutdown 

112 def signal_handler(signum: int, frame: Any) -> None: 

113 rprint( 

114 "\n[yellow]Received shutdown signal, stopping servers...[/yellow]" 

115 ) 

116 asyncio.create_task(supervisor.stop()) 

117 asyncio.create_task(process_manager.stop()) 

118 sys.exit(0) 

119 

120 signal.signal(signal.SIGINT, signal_handler) 

121 signal.signal(signal.SIGTERM, signal_handler) 

122 

123 # Start monitoring 

124 await supervisor.start() 

125 

126 # Keep the process running 

127 try: 

128 # Import and run the main server 

129 from excalidraw_mcp.server import main 

130 

131 await main() # type: ignore 

132 finally: 

133 await supervisor.stop() 

134 

135 asyncio.run(run_with_monitoring()) 

136 else: 

137 # Start without monitoring 

138 from excalidraw_mcp.server import main 

139 

140 asyncio.run(main()) # type: ignore 

141 

142 except KeyboardInterrupt: 

143 rprint("\n[yellow]Shutting down MCP server...[/yellow]") 

144 # Clean up any running processes 

145 process_manager = get_process_manager() 

146 asyncio.run(process_manager.stop()) 

147 except Exception as e: 

148 rprint(f"[red]Failed to start MCP server: {e}[/red]") 

149 sys.exit(1) 

150 

151 

152def _stop_process( 

153 process: psutil.Process, process_name: str, force: bool, timeout: int 

154) -> str: 

155 """Stop a single process and return a status message.""" 

156 try: 

157 if force: 

158 process.kill() 

159 return f"{process_name} (PID: {process.pid}) - killed" 

160 else: 

161 process.terminate() 

162 try: 

163 process.wait(timeout=timeout) 

164 return f"{process_name} (PID: {process.pid}) - terminated" 

165 except psutil.TimeoutExpired: 

166 process.kill() 

167 return f"{process_name} (PID: {process.pid}) - force killed" 

168 except psutil.NoSuchProcess: 

169 return f"{process_name} - already stopped" 

170 except Exception as e: 

171 return f"{process_name} - failed to stop: {e}" 

172 

173 

174def stop_mcp_server_impl(force: bool = False) -> None: 

175 """Implementation for stopping MCP server.""" 

176 mcp_proc = find_mcp_server_process() 

177 canvas_proc = find_canvas_server_process() 

178 

179 if not mcp_proc and not canvas_proc: 

180 rprint("[yellow]No MCP server processes found running[/yellow]") 

181 return 

182 

183 rprint("[yellow]Stopping Excalidraw MCP server...[/yellow]") 

184 

185 stopped_procs = [] 

186 

187 # Stop MCP server 

188 if mcp_proc: 

189 status = _stop_process(mcp_proc, "MCP server", force, 10) 

190 if "failed to stop" in status: 

191 rprint(f"[red]Failed to stop MCP server: {status.split(': ')[-1]}[/red]") 

192 else: 

193 stopped_procs.append(status) 

194 

195 # Stop canvas server 

196 if canvas_proc: 

197 status = _stop_process(canvas_proc, "Canvas server", force, 5) 

198 if "failed to stop" in status: 

199 rprint(f"[red]Failed to stop canvas server: {status.split(': ')[-1]}[/red]") 

200 else: 

201 stopped_procs.append(status) 

202 

203 # Display results 

204 if stopped_procs: 

205 rprint("[green]✓ Stopped processes:[/green]") 

206 for proc_info in stopped_procs: 

207 rprint(f"{proc_info}") 

208 else: 

209 rprint("[yellow]No processes were stopped[/yellow]") 

210 

211 

212def restart_mcp_server_impl(background: bool = False, monitoring: bool = True) -> None: 

213 """Implementation for restarting MCP server.""" 

214 rprint("[yellow]Restarting Excalidraw MCP server...[/yellow]") 

215 

216 # Stop existing servers 

217 stop_mcp_server_impl() 

218 

219 # Wait a moment for processes to fully stop 

220 time.sleep(2) 

221 

222 # Start server again 

223 start_mcp_server_impl(background=background) 

224 

225 

226def status_impl() -> None: 

227 """Implementation for showing status.""" 

228 table = Table(title="Excalidraw MCP Server Status") 

229 table.add_column("Component", style="cyan") 

230 table.add_column("Status", style="green") 

231 table.add_column("PID", style="yellow") 

232 table.add_column("Details", style="white") 

233 

234 # Check MCP server 

235 mcp_proc = find_mcp_server_process() 

236 if mcp_proc: 

237 try: 

238 cpu_percent = mcp_proc.cpu_percent() 

239 memory_mb = mcp_proc.memory_info().rss / 1024 / 1024 

240 table.add_row( 

241 "MCP Server", 

242 "[green]Running[/green]", 

243 str(mcp_proc.pid), 

244 f"CPU: {cpu_percent:.1f}%, Memory: {memory_mb:.1f}MB", 

245 ) 

246 except psutil.NoSuchProcess: 

247 table.add_row("MCP Server", "[red]Stopped[/red]", "-", "-") 

248 else: 

249 table.add_row("MCP Server", "[red]Stopped[/red]", "-", "-") 

250 

251 # Check canvas server 

252 canvas_proc = find_canvas_server_process() 

253 if canvas_proc: 

254 try: 

255 cpu_percent = canvas_proc.cpu_percent() 

256 memory_mb = canvas_proc.memory_info().rss / 1024 / 1024 

257 table.add_row( 

258 "Canvas Server", 

259 "[green]Running[/green]", 

260 str(canvas_proc.pid), 

261 f"CPU: {cpu_percent:.1f}%, Memory: {memory_mb:.1f}MB", 

262 ) 

263 except psutil.NoSuchProcess: 

264 table.add_row("Canvas Server", "[red]Stopped[/red]", "-", "-") 

265 else: 

266 table.add_row("Canvas Server", "[red]Stopped[/red]", "-", "-") 

267 

268 console.print(table) 

269 

270 # Show configuration info 

271 config = Config() 

272 config_panel = Panel.fit( 

273 f"[bold]Configuration[/bold]\n" 

274 f"Canvas URL: {config.server.express_url}\n" 

275 f"Canvas Auto-start: {config.server.canvas_auto_start}\n" 

276 f"Monitoring: {config.monitoring.enabled}\n" 

277 f"Health Check Interval: {config.monitoring.health_check_interval_seconds}s", 

278 title="Server Configuration", 

279 ) 

280 console.print("\n") 

281 console.print(config_panel) 

282 

283 

284def _find_log_file() -> Path | None: 

285 """Find the log file in common locations.""" 

286 log_paths = [ 

287 Path("excalidraw-mcp.log"), 

288 Path("logs/excalidraw-mcp.log"), 

289 Path.home() / "tmp" / "excalidraw-mcp.log", 

290 Path.home() / ".local" / "state" / "excalidraw-mcp" / "server.log", 

291 ] 

292 

293 for path in log_paths: 

294 if path.exists(): 

295 return path 

296 return None 

297 

298 

299def _show_missing_log_message() -> None: 

300 """Show message when log file is not found.""" 

301 rprint("[yellow]No log file found. Logs may be going to stdout/stderr.[/yellow]") 

302 rprint("Try running the server with output redirection:") 

303 rprint(" [cyan]excalidraw-mcp --start-mcp-server > server.log 2>&1[/cyan]") 

304 

305 

306def _follow_log_output(log_file: Path) -> None: 

307 """Follow log output (basic implementation).""" 

308 with log_file.open("r") as f: 

309 # Move to end of file 

310 f.seek(0, 2) 

311 while True: 

312 line = f.readline() 

313 if line: 

314 print(line.rstrip()) 

315 else: 

316 time.sleep(0.1) 

317 

318 

319def _show_recent_log_lines(log_file: Path, lines: int) -> None: 

320 """Show recent lines from log file.""" 

321 with log_file.open("r") as f: 

322 # Read all lines and show last N 

323 all_lines = f.readlines() 

324 recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines 

325 for line in recent_lines: 

326 print(line.rstrip()) 

327 

328 

329def logs_impl(lines: int = 50, follow: bool = False) -> None: 

330 """Implementation for showing logs.""" 

331 log_file = _find_log_file() 

332 

333 if not log_file: 

334 _show_missing_log_message() 

335 return 

336 

337 try: 

338 if follow: 

339 _follow_log_output(log_file) 

340 else: 

341 _show_recent_log_lines(log_file, lines) 

342 except KeyboardInterrupt: 

343 rprint("\n[yellow]Stopped following logs[/yellow]") 

344 except Exception as e: 

345 rprint(f"[red]Error reading logs: {e}[/red]") 

346 

347 

348def main( 

349 start_mcp_server: bool = typer.Option( 

350 False, "--start-mcp-server", help="Start the Excalidraw MCP server" 

351 ), 

352 stop_mcp_server: bool = typer.Option( 

353 False, "--stop-mcp-server", help="Stop the Excalidraw MCP server" 

354 ), 

355 restart_mcp_server: bool = typer.Option( 

356 False, "--restart-mcp-server", help="Restart the Excalidraw MCP server" 

357 ), 

358 status: bool = typer.Option( 

359 False, "--status", help="Show status of MCP server and canvas server" 

360 ), 

361 logs: bool = typer.Option(False, "--logs", help="Show server logs (if available)"), 

362 background: bool = typer.Option( 

363 False, 

364 "--background", 

365 "-b", 

366 help="Run MCP server in background (for start/restart commands)", 

367 ), 

368 force: bool = typer.Option( 

369 False, "--force", "-f", help="Force kill server processes (for stop command)" 

370 ), 

371 monitoring: bool = typer.Option( 

372 True, 

373 "--monitoring/--no-monitoring", 

374 help="Enable monitoring supervisor (for start/restart commands)", 

375 ), 

376 lines: int = typer.Option( 

377 50, 

378 "--lines", 

379 "-n", 

380 help="Number of recent log lines to show (for logs command)", 

381 ), 

382 follow: bool = typer.Option( 

383 False, "--follow", help="Follow log output (for logs command)" 

384 ), 

385) -> None: 

386 """CLI for managing Excalidraw MCP server.""" 

387 

388 # Count how many main actions were requested 

389 actions = [start_mcp_server, stop_mcp_server, restart_mcp_server, status, logs] 

390 action_count = sum(actions) 

391 

392 if action_count == 0: 

393 # No action specified, show help 

394 rprint( 

395 "[yellow]No action specified. Use --help to see available options.[/yellow]" 

396 ) 

397 return 

398 elif action_count > 1: 

399 # Multiple actions specified 

400 rprint("[red]Error: Only one action can be specified at a time.[/red]") 

401 sys.exit(1) 

402 

403 # Execute the requested action 

404 if start_mcp_server: 

405 start_mcp_server_impl(background=background, monitoring=monitoring) 

406 elif stop_mcp_server: 

407 stop_mcp_server_impl(force=force) 

408 elif restart_mcp_server: 

409 restart_mcp_server_impl(background=background, monitoring=monitoring) 

410 elif status: 

411 status_impl() 

412 elif logs: 

413 logs_impl() 

414 

415 

416app = typer.Typer() 

417app.command()(main) 

418 

419if __name__ == "__main__": 

420 app()