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
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-16 08:08 -0700
1"""CLI interface for excalidraw-mcp server management."""
3import asyncio
4import signal
5import subprocess
6import sys
7import time
8from pathlib import Path
9from typing import Any
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
18from excalidraw_mcp.config import Config
19from excalidraw_mcp.monitoring.supervisor import MonitoringSupervisor
20from excalidraw_mcp.process_manager import CanvasProcessManager
22console = Console()
24# Global process manager instance
25_process_manager: CanvasProcessManager | None = None
26_monitoring_supervisor: MonitoringSupervisor | None = None
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
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
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
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
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
81 rprint("[green]Starting Excalidraw MCP server...[/green]")
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 )
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()
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)
120 signal.signal(signal.SIGINT, signal_handler)
121 signal.signal(signal.SIGTERM, signal_handler)
123 # Start monitoring
124 await supervisor.start()
126 # Keep the process running
127 try:
128 # Import and run the main server
129 from excalidraw_mcp.server import main
131 await main() # type: ignore
132 finally:
133 await supervisor.stop()
135 asyncio.run(run_with_monitoring())
136 else:
137 # Start without monitoring
138 from excalidraw_mcp.server import main
140 asyncio.run(main()) # type: ignore
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)
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}"
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()
179 if not mcp_proc and not canvas_proc:
180 rprint("[yellow]No MCP server processes found running[/yellow]")
181 return
183 rprint("[yellow]Stopping Excalidraw MCP server...[/yellow]")
185 stopped_procs = []
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)
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)
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]")
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]")
216 # Stop existing servers
217 stop_mcp_server_impl()
219 # Wait a moment for processes to fully stop
220 time.sleep(2)
222 # Start server again
223 start_mcp_server_impl(background=background)
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")
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]", "-", "-")
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]", "-", "-")
268 console.print(table)
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)
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 ]
293 for path in log_paths:
294 if path.exists():
295 return path
296 return None
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]")
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)
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())
329def logs_impl(lines: int = 50, follow: bool = False) -> None:
330 """Implementation for showing logs."""
331 log_file = _find_log_file()
333 if not log_file:
334 _show_missing_log_message()
335 return
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]")
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."""
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)
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)
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()
416app = typer.Typer()
417app.command()(main)
419if __name__ == "__main__":
420 app()