kmcp 0.1.5.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. kali_mcp/__init__.py +3 -0
  2. kali_mcp/__main__.py +28 -0
  3. kali_mcp/_version.py +24 -0
  4. kali_mcp/cli.py +169 -0
  5. kali_mcp/config.py +95 -0
  6. kali_mcp/db/__init__.py +1 -0
  7. kali_mcp/db/manager.py +248 -0
  8. kali_mcp/execution.py +439 -0
  9. kali_mcp/logging_utils.py +74 -0
  10. kali_mcp/models.py +51 -0
  11. kali_mcp/monitor/__init__.py +105 -0
  12. kali_mcp/monitor/sessions.py +70 -0
  13. kali_mcp/monitor/sse_client.py +87 -0
  14. kali_mcp/monitor/tui.py +364 -0
  15. kali_mcp/notifications.py +130 -0
  16. kali_mcp/prompts.py +184 -0
  17. kali_mcp/registry.py +33 -0
  18. kali_mcp/resources.py +263 -0
  19. kali_mcp/responses.py +39 -0
  20. kali_mcp/security.py +15 -0
  21. kali_mcp/server.py +535 -0
  22. kali_mcp/tools/__init__.py +125 -0
  23. kali_mcp/tools/amass.py +83 -0
  24. kali_mcp/tools/arjun.py +86 -0
  25. kali_mcp/tools/base.py +100 -0
  26. kali_mcp/tools/bettercap.py +99 -0
  27. kali_mcp/tools/bloodhound.py +68 -0
  28. kali_mcp/tools/commix.py +73 -0
  29. kali_mcp/tools/crackmapexec.py +100 -0
  30. kali_mcp/tools/dirb.py +75 -0
  31. kali_mcp/tools/dursgo.py +156 -0
  32. kali_mcp/tools/enum4linux.py +59 -0
  33. kali_mcp/tools/farsight.py +127 -0
  34. kali_mcp/tools/ffuf.py +85 -0
  35. kali_mcp/tools/file_download.py +119 -0
  36. kali_mcp/tools/file_read.py +144 -0
  37. kali_mcp/tools/file_write.py +141 -0
  38. kali_mcp/tools/flowlyt.py +88 -0
  39. kali_mcp/tools/generic_command.py +95 -0
  40. kali_mcp/tools/gobuster.py +84 -0
  41. kali_mcp/tools/hashcat.py +101 -0
  42. kali_mcp/tools/httpx.py +85 -0
  43. kali_mcp/tools/hydra.py +99 -0
  44. kali_mcp/tools/john.py +100 -0
  45. kali_mcp/tools/katana.py +81 -0
  46. kali_mcp/tools/masscan.py +85 -0
  47. kali_mcp/tools/medusa.py +102 -0
  48. kali_mcp/tools/msfconsole.py +59 -0
  49. kali_mcp/tools/naabu.py +100 -0
  50. kali_mcp/tools/netexec.py +86 -0
  51. kali_mcp/tools/nmap.py +94 -0
  52. kali_mcp/tools/normalization.py +18 -0
  53. kali_mcp/tools/nuclei.py +87 -0
  54. kali_mcp/tools/parallel_execute.py +246 -0
  55. kali_mcp/tools/persist.py +436 -0
  56. kali_mcp/tools/playbook_ad.py +99 -0
  57. kali_mcp/tools/playbook_engine.py +142 -0
  58. kali_mcp/tools/playbook_engine_tool.py +72 -0
  59. kali_mcp/tools/playbook_recon.py +115 -0
  60. kali_mcp/tools/playbook_tools.py +125 -0
  61. kali_mcp/tools/playbook_webapp.py +105 -0
  62. kali_mcp/tools/python_command.py +100 -0
  63. kali_mcp/tools/responder.py +85 -0
  64. kali_mcp/tools/searchsploit.py +123 -0
  65. kali_mcp/tools/spiderfoot.py +75 -0
  66. kali_mcp/tools/sqlmap.py +73 -0
  67. kali_mcp/tools/subfinder.py +69 -0
  68. kali_mcp/tools/theharvester.py +80 -0
  69. kali_mcp/tools/vault_tool.py +489 -0
  70. kali_mcp/tools/wfuzz.py +87 -0
  71. kali_mcp/tools/whatweb.py +82 -0
  72. kali_mcp/tools/wpscan.py +76 -0
  73. kali_mcp/tools/zap.py +79 -0
  74. kali_mcp/tools/zighound.py +139 -0
  75. kali_mcp/tools/zizmor.py +98 -0
  76. kali_mcp/utils/__init__.py +0 -0
  77. kali_mcp/utils/process.py +36 -0
  78. kali_mcp/utils/vault.py +471 -0
  79. kali_mcp/validation.py +82 -0
  80. kmcp-0.1.5.dev0.dist-info/METADATA +251 -0
  81. kmcp-0.1.5.dev0.dist-info/RECORD +84 -0
  82. kmcp-0.1.5.dev0.dist-info/WHEEL +5 -0
  83. kmcp-0.1.5.dev0.dist-info/entry_points.txt +4 -0
  84. kmcp-0.1.5.dev0.dist-info/top_level.txt +1 -0
kali_mcp/execution.py ADDED
@@ -0,0 +1,439 @@
1
+ """Single execution engine — hardened for resilience.
2
+
3
+ 3-layer defense:
4
+ 1. asyncio.wait_for with enforced timeout (tool can't hang server)
5
+ 2. Watchdog thread kills stuck executions
6
+ 3. Semaphore prevents resource exhaustion from concurrent calls
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextvars
13
+ import gc
14
+ import inspect
15
+ import logging
16
+ import os
17
+ import threading
18
+ import time
19
+ import uuid
20
+
21
+ from kali_mcp.config import config
22
+ from kali_mcp.logging_utils import log_execution
23
+ from kali_mcp.models import ExecutionResult, utc_now_iso
24
+ from kali_mcp.security import sanitize_command_parts
25
+ from kali_mcp.utils.process import kill_process_tree
26
+
27
+ logger = logging.getLogger("kali_mcp.execution")
28
+
29
+ MAX_OUTPUT = 100_000 # 100KB per stream
30
+
31
+ # Per-request progress callback, set by the MCP handler so tool executions
32
+ # report progress without each tool needing to thread the callback through.
33
+ _progress_ctx: contextvars.ContextVar = contextvars.ContextVar("kali_mcp_progress", default=None)
34
+
35
+
36
+ def set_progress_cb(cb) -> contextvars.Token | None:
37
+ """Set the current task's progress callback; returns a token to reset."""
38
+ if cb is None:
39
+ return None
40
+ return _progress_ctx.set(cb)
41
+
42
+
43
+ def reset_progress_cb(token) -> None:
44
+ if token is not None:
45
+ _progress_ctx.reset(token)
46
+
47
+
48
+ async def _call_progress(progress_cb, completed: int, total: int) -> None:
49
+ """Invoke a progress callback, supporting sync and async callables."""
50
+ result = progress_cb(completed, total)
51
+ if inspect.isawaitable(result):
52
+ await result
53
+
54
+
55
+ class ExecutionEngine:
56
+ """Runs subprocess commands with timeout, captures output, returns structured results.
57
+
58
+ Hardened against:
59
+ - Tool subprocess hangs (asyncio.wait_for timeout)
60
+ - Tool subprocess zombies (kill_process_tree)
61
+ - Event loop starvation (semaphore with max_concurrent)
62
+ - Stuck executions (watchdog thread)
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ self._semaphore = asyncio.Semaphore(config.max_concurrent)
67
+ self._active: dict[str, str] = {} # request_id → tool
68
+ self._request_times: dict[str, float] = {} # request_id → start monotonic
69
+ self._procs: dict[str, asyncio.subprocess.Process] = {} # request_id → process
70
+ self._watchdog_running = False
71
+ self._request_count = 0
72
+ self._error_count = 0
73
+ self._lock = threading.Lock()
74
+
75
+ def _start_watchdog(self) -> None:
76
+ """Start background watchdog that kills stuck executions."""
77
+ if self._watchdog_running:
78
+ return
79
+ self._watchdog_running = True
80
+
81
+ def _watchdog():
82
+ while self._watchdog_running:
83
+ time.sleep(5)
84
+ now = time.monotonic()
85
+ stuck = [
86
+ (rid, self._active.get(rid, "unknown"), t)
87
+ for rid, t in list(self._request_times.items())
88
+ if now - t > config.max_timeout + 30
89
+ ]
90
+ for rid, tool, t in stuck:
91
+ logger.warning(
92
+ "watchdog: request=%s tool=%s stuck for %.0fs — force cleaning",
93
+ rid,
94
+ tool,
95
+ now - t,
96
+ )
97
+ self._active.pop(rid, None)
98
+ self._request_times.pop(rid, None)
99
+
100
+ t = threading.Thread(target=_watchdog, daemon=True, name="exec-watchdog")
101
+ t.start()
102
+
103
+ def _cleanup_request(self, req_id: str) -> None:
104
+ self._active.pop(req_id, None)
105
+ self._request_times.pop(req_id, None)
106
+ self._procs.pop(req_id, None)
107
+
108
+ async def execute(
109
+ self,
110
+ command: list[str] | str,
111
+ tool: str = "generic_command",
112
+ timeout: int | None = None,
113
+ cwd: str | None = None,
114
+ env: dict[str, str] | None = None,
115
+ request_id: str | None = None,
116
+ progress_cb: object | None = None,
117
+ ) -> ExecutionResult:
118
+ """Execute a command and return structured result. Never raises.
119
+ If command is a str, runs via create_subprocess_shell (for shell commands).
120
+ If command is a list, runs via create_subprocess_exec (for direct binary exec).
121
+ progress_cb, if given, is called as progress_cb(completed, total) over time.
122
+ """
123
+ self._start_watchdog()
124
+ req_id = request_id or uuid.uuid4().hex[:12]
125
+ try:
126
+ effective_timeout = min(int(timeout or config.default_timeout), config.max_timeout)
127
+ except (ValueError, TypeError):
128
+ effective_timeout = config.default_timeout
129
+
130
+ use_shell = isinstance(command, str)
131
+ command_str = command if use_shell else " ".join(command)
132
+ if not use_shell:
133
+ command = sanitize_command_parts(command)
134
+
135
+ if cwd is not None and not cwd.strip():
136
+ cwd = None
137
+
138
+ logger.info(
139
+ "request=%s executing tool=%s command=%s timeout=%d shell=%s",
140
+ req_id,
141
+ tool,
142
+ command_str,
143
+ effective_timeout,
144
+ use_shell,
145
+ )
146
+
147
+ start_time = utc_now_iso()
148
+ start_monotonic = time.monotonic()
149
+ if progress_cb is None:
150
+ progress_cb = _progress_ctx.get()
151
+ if progress_cb is not None:
152
+ try:
153
+ await _call_progress(progress_cb, 0, 100)
154
+ except Exception: # noqa: BLE001
155
+ progress_cb = None
156
+
157
+ try:
158
+ async with self._semaphore:
159
+ self._active[req_id] = tool
160
+ self._request_times[req_id] = start_monotonic
161
+
162
+ try:
163
+ exec_env = env if env is not None else os.environ.copy()
164
+ system_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/usr/games"
165
+ existing = exec_env.get("PATH", "")
166
+ exec_env["PATH"] = f"{existing}:{system_path}" if existing else system_path
167
+
168
+ try:
169
+ if use_shell:
170
+ proc = await asyncio.create_subprocess_exec(
171
+ "/bin/bash",
172
+ "-c",
173
+ command,
174
+ stdout=asyncio.subprocess.PIPE,
175
+ stderr=asyncio.subprocess.PIPE,
176
+ cwd=cwd,
177
+ env=exec_env,
178
+ start_new_session=True,
179
+ )
180
+ else:
181
+ proc = await asyncio.create_subprocess_exec(
182
+ *command,
183
+ stdout=asyncio.subprocess.PIPE,
184
+ stderr=asyncio.subprocess.PIPE,
185
+ cwd=cwd,
186
+ env=exec_env,
187
+ start_new_session=True,
188
+ )
189
+ self._procs[req_id] = proc
190
+ except FileNotFoundError:
191
+ elapsed = time.monotonic() - start_monotonic
192
+ if use_shell:
193
+ binary = "/bin/bash"
194
+ hint = "install it with 'apt install bash'"
195
+ else:
196
+ binary = command[0] if command else "unknown"
197
+ hint = f"install it with 'apt install {binary}' or check PATH"
198
+ logger.warning(
199
+ "request=%s tool=%s binary not found: %s",
200
+ req_id,
201
+ tool,
202
+ binary,
203
+ )
204
+ result = ExecutionResult(
205
+ tool=tool,
206
+ command=command_str,
207
+ stdout="",
208
+ stderr=f"Binary not found: {binary} — {hint}",
209
+ exit_code=-1,
210
+ success=False,
211
+ timed_out=False,
212
+ duration=round(elapsed, 3),
213
+ start_time=start_time,
214
+ end_time=utc_now_iso(),
215
+ )
216
+ log_execution(result)
217
+ return result
218
+
219
+ heartbeat_task = None
220
+ if progress_cb is not None:
221
+
222
+ async def _heartbeat():
223
+ try:
224
+ interval = max(min(effective_timeout / 20.0, 5.0), 0.25)
225
+ for step in range(1, 20):
226
+ await asyncio.sleep(interval)
227
+ await _call_progress(progress_cb, min(step * 5, 95), 100)
228
+ except Exception: # noqa: BLE001, S110
229
+ pass
230
+
231
+ heartbeat_task = asyncio.create_task(_heartbeat())
232
+
233
+ try:
234
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
235
+ proc.communicate(),
236
+ timeout=effective_timeout,
237
+ )
238
+ except (TimeoutError, asyncio.TimeoutError):
239
+ logger.warning(
240
+ "request=%s tool=%s timed out after %ds, killing process",
241
+ req_id,
242
+ tool,
243
+ effective_timeout,
244
+ )
245
+ # Kill the entire process tree — the subprocess runs in
246
+ # its own session, so this cannot hit the server itself
247
+ from kali_mcp.utils.process import kill_process_tree
248
+
249
+ await kill_process_tree(proc)
250
+ stdout_bytes, stderr_bytes = b"", b""
251
+
252
+ elapsed = time.monotonic() - start_monotonic
253
+ result = ExecutionResult(
254
+ tool=tool,
255
+ command=command_str,
256
+ stdout="",
257
+ stderr=f"Timed out after {effective_timeout}s",
258
+ exit_code=-1,
259
+ success=False,
260
+ timed_out=True,
261
+ duration=round(elapsed, 3),
262
+ start_time=start_time,
263
+ end_time=utc_now_iso(),
264
+ )
265
+ log_execution(result)
266
+ logger.info(
267
+ "request=%s tool=%s TIMED OUT duration=%.3fs",
268
+ req_id,
269
+ tool,
270
+ result.duration,
271
+ )
272
+ return result
273
+ finally:
274
+ if heartbeat_task is not None:
275
+ heartbeat_task.cancel()
276
+ try:
277
+ await heartbeat_task
278
+ except (asyncio.CancelledError, Exception): # noqa: S110, BLE001
279
+ pass
280
+ if progress_cb is not None:
281
+ try:
282
+ await _call_progress(progress_cb, 100, 100)
283
+ except Exception: # noqa: BLE001, S110
284
+ pass
285
+
286
+ elapsed = time.monotonic() - start_monotonic
287
+ stdout_str = stdout_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT]
288
+ stderr_str = stderr_bytes.decode("utf-8", errors="replace")[:MAX_OUTPUT]
289
+
290
+ result = ExecutionResult(
291
+ tool=tool,
292
+ command=command_str,
293
+ stdout=stdout_str,
294
+ stderr=stderr_str,
295
+ exit_code=proc.returncode or 0,
296
+ success=(proc.returncode == 0),
297
+ timed_out=False,
298
+ duration=round(elapsed, 3),
299
+ start_time=start_time,
300
+ end_time=utc_now_iso(),
301
+ )
302
+
303
+ log_execution(result)
304
+ logger.info(
305
+ "request=%s tool=%s exit_code=%d success=%s duration=%.3fs",
306
+ req_id,
307
+ tool,
308
+ result.exit_code,
309
+ result.success,
310
+ result.duration,
311
+ )
312
+ if heartbeat_task is not None:
313
+ heartbeat_task.cancel()
314
+ try:
315
+ await heartbeat_task
316
+ except (asyncio.CancelledError, Exception): # noqa: S110, BLE001
317
+ pass
318
+ return result
319
+
320
+ finally:
321
+ self._cleanup_request(req_id)
322
+
323
+ except asyncio.CancelledError:
324
+ logger.warning("request=%s execution cancelled", req_id)
325
+ elapsed = time.monotonic() - start_monotonic
326
+ with self._lock:
327
+ self._error_count += 1
328
+ return ExecutionResult(
329
+ tool=tool,
330
+ command=command_str,
331
+ stdout="",
332
+ stderr="Execution cancelled",
333
+ exit_code=-1,
334
+ success=False,
335
+ timed_out=False,
336
+ duration=round(elapsed, 3),
337
+ start_time=start_time,
338
+ end_time=utc_now_iso(),
339
+ )
340
+ except Exception as e:
341
+ elapsed = time.monotonic() - start_monotonic
342
+ logger.exception("request=%s unexpected execution error", req_id)
343
+ with self._lock:
344
+ self._error_count += 1
345
+ return ExecutionResult(
346
+ tool=tool,
347
+ command=command_str,
348
+ stdout="",
349
+ stderr=f"Internal error: {type(e).__name__}",
350
+ exit_code=-1,
351
+ success=False,
352
+ timed_out=False,
353
+ duration=round(elapsed, 3),
354
+ start_time=start_time,
355
+ end_time=utc_now_iso(),
356
+ )
357
+ finally:
358
+ self._cleanup_request(req_id)
359
+ with self._lock:
360
+ self._request_count += 1
361
+
362
+ @property
363
+ def active_count(self) -> int:
364
+ return len(self._active)
365
+
366
+ def get_stuck_executions(self) -> list[dict]:
367
+ """Return list of potentially stuck executions."""
368
+ now = time.monotonic()
369
+ stuck = []
370
+ for req_id, tool in list(self._active.items()):
371
+ start_t = self._request_times.get(req_id, now)
372
+ elapsed = now - start_t
373
+ if elapsed > config.default_timeout:
374
+ stuck.append(
375
+ {
376
+ "request_id": req_id,
377
+ "tool": tool,
378
+ "elapsed_seconds": round(elapsed, 1),
379
+ }
380
+ )
381
+ return stuck
382
+
383
+ def get_active_jobs(self) -> list[dict]:
384
+ """Return a list of currently active tool executions.
385
+
386
+ Each entry: {"request_id": str, "tool": str, "elapsed_seconds": float}.
387
+ """
388
+ now = time.monotonic()
389
+ with self._lock:
390
+ return [
391
+ {
392
+ "request_id": req_id,
393
+ "tool": tool,
394
+ "elapsed_seconds": round(now - self._request_times.get(req_id, now), 3),
395
+ }
396
+ for req_id, tool in self._active.items()
397
+ ]
398
+
399
+ async def kill_job(self, request_id: str) -> bool:
400
+ """Kill a specific active execution by request ID.
401
+
402
+ Terminates the subprocess (process tree) and removes it from tracking.
403
+ Returns True if the job was found and killed, False if unknown.
404
+ """
405
+ proc = None
406
+ with self._lock:
407
+ if request_id not in self._active:
408
+ return False
409
+ proc = self._procs.pop(request_id, None)
410
+ self._active.pop(request_id, None)
411
+ self._request_times.pop(request_id, None)
412
+
413
+ if proc is not None:
414
+ try:
415
+ await kill_process_tree(proc)
416
+ except Exception:
417
+ logger.warning("monitor: failed to kill job %s", request_id, exc_info=True)
418
+ return True
419
+
420
+ def force_cleanup(self) -> int:
421
+ """Force cleanup of all tracked requests. Returns count cleaned."""
422
+ count = len(self._active)
423
+ self._active.clear()
424
+ self._request_times.clear()
425
+ gc.collect()
426
+ return count
427
+
428
+ def get_stats(self) -> dict:
429
+ """Get engine statistics."""
430
+ with self._lock:
431
+ return {
432
+ "active_executions": self.active_count,
433
+ "total_requests": self._request_count,
434
+ "total_errors": self._error_count,
435
+ "max_concurrent": config.max_concurrent,
436
+ }
437
+
438
+
439
+ engine = ExecutionEngine()
@@ -0,0 +1,74 @@
1
+ """Structured logging configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from datetime import datetime, timezone
8
+ from logging.handlers import RotatingFileHandler
9
+
10
+ from kali_mcp.config import config
11
+
12
+
13
+ class JSONFormatter(logging.Formatter):
14
+ def format(self, record: logging.LogRecord) -> str:
15
+ log_entry = {
16
+ "timestamp": datetime.now(timezone.utc).isoformat(),
17
+ "level": record.levelname,
18
+ "logger": record.name,
19
+ "message": record.getMessage(),
20
+ }
21
+ if hasattr(record, "extra_data"):
22
+ log_entry["data"] = record.extra_data
23
+ return json.dumps(log_entry)
24
+
25
+
26
+ def setup_logging() -> None:
27
+ """Configure structured logging to file and console with rotation."""
28
+ root = logging.getLogger("kali_mcp")
29
+
30
+ # Prevent duplicate handlers on re-init
31
+ if root.handlers:
32
+ return
33
+
34
+ root.setLevel(logging.DEBUG if config.debug else logging.INFO)
35
+
36
+ console = logging.StreamHandler()
37
+ console.setLevel(logging.DEBUG if config.debug else logging.INFO)
38
+ console.setFormatter(JSONFormatter())
39
+ root.addHandler(console)
40
+
41
+ log_file = config.log_dir / "server.log"
42
+ file_handler = RotatingFileHandler(
43
+ log_file,
44
+ maxBytes=10_000_000,
45
+ backupCount=5, # 10MB, 5 backups
46
+ )
47
+ file_handler.setLevel(logging.DEBUG)
48
+ file_handler.setFormatter(JSONFormatter())
49
+ root.addHandler(file_handler)
50
+
51
+ exec_file = config.log_dir / "executions.jsonl"
52
+ exec_handler = RotatingFileHandler(exec_file, maxBytes=10_000_000, backupCount=5)
53
+ exec_handler.setLevel(logging.INFO)
54
+ exec_handler.setFormatter(JSONFormatter())
55
+ exec_logger = logging.getLogger("kali_mcp.executions")
56
+ exec_logger.addHandler(exec_handler)
57
+ exec_logger.setLevel(logging.INFO)
58
+
59
+
60
+ def log_execution(result) -> None:
61
+ """Log a completed execution to the executions log."""
62
+ try:
63
+ logger = logging.getLogger("kali_mcp.executions")
64
+ logger.info(
65
+ "tool=%s command=%s exit_code=%d success=%s duration=%.3fs timed_out=%s",
66
+ result.tool,
67
+ result.command,
68
+ result.exit_code,
69
+ result.success,
70
+ result.duration,
71
+ result.timed_out,
72
+ )
73
+ except Exception: # noqa: BLE001, S110
74
+ pass # Never let logging failure crash the handler
kali_mcp/models.py ADDED
@@ -0,0 +1,51 @@
1
+ """Core data models for execution results and errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class ExecutionResult:
12
+ tool: str
13
+ command: str
14
+ stdout: str
15
+ stderr: str
16
+ exit_code: int
17
+ success: bool
18
+ timed_out: bool
19
+ duration: float
20
+ start_time: str
21
+ end_time: str
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ return {
25
+ "tool": self.tool,
26
+ "command": self.command,
27
+ "stdout": self.stdout,
28
+ "stderr": self.stderr,
29
+ "exit_code": self.exit_code,
30
+ "success": self.success,
31
+ "timed_out": self.timed_out,
32
+ "duration": self.duration,
33
+ "start_time": self.start_time,
34
+ "end_time": self.end_time,
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class ToolError:
40
+ error: str
41
+ details: str = ""
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ d: dict[str, Any] = {"success": False, "error": self.error}
45
+ if self.details:
46
+ d["details"] = self.details
47
+ return d
48
+
49
+
50
+ def utc_now_iso() -> str:
51
+ return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,105 @@
1
+ """Interactive monitor module for the Kali MCP Server.
2
+
3
+ Aggregates live state from the session registry, execution engine, and health
4
+ monitor into a single snapshot that can be pushed to a CLI/TUI client over
5
+ SSE or queried directly via HTTP actions.
6
+
7
+ The monitor maintains a set of SSE client queues and broadcasts snapshots to
8
+ all connected clients whenever state changes (session connect/disconnect, job
9
+ start/end, or on-demand via the action endpoint).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ from typing import Any
17
+
18
+ from kali_mcp.models import utc_now_iso
19
+
20
+ logger = logging.getLogger("kali_mcp.monitor")
21
+
22
+
23
+ class Monitor:
24
+ """Live monitor aggregating sessions, active jobs, and server health."""
25
+
26
+ def __init__(
27
+ self,
28
+ session_registry: Any,
29
+ engine: Any,
30
+ health: Any,
31
+ ) -> None:
32
+ self.session_registry = session_registry
33
+ self.engine = engine
34
+ self.health = health
35
+ self._clients: set[asyncio.Queue[dict[str, Any]]] = set()
36
+ self._lock = asyncio.Lock()
37
+
38
+ # ── SSE client management ──────────────────────────────────────
39
+
40
+ def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
41
+ """Register a new SSE client queue. Returns the queue."""
42
+ q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
43
+ self._clients.add(q)
44
+ return q
45
+
46
+ def unsubscribe(self, q: asyncio.Queue[dict[str, Any]]) -> None:
47
+ """Remove an SSE client queue (client disconnected)."""
48
+ self._clients.discard(q)
49
+
50
+ async def broadcast(self, snapshot: dict[str, Any]) -> None:
51
+ """Push a snapshot to all connected SSE clients.
52
+
53
+ Queues that are full (slow clients) are dropped to avoid blocking
54
+ the broadcast path.
55
+ """
56
+ dead: list[asyncio.Queue[dict[str, Any]]] = []
57
+ for q in list(self._clients):
58
+ try:
59
+ q.put_nowait(snapshot)
60
+ except asyncio.QueueFull:
61
+ dead.append(q)
62
+ for q in dead:
63
+ self._clients.discard(q)
64
+
65
+ # ── Snapshot ───────────────────────────────────────────────────
66
+
67
+ def build_snapshot(self) -> dict[str, Any]:
68
+ """Build a complete live snapshot of server state."""
69
+ return {
70
+ "timestamp": utc_now_iso(),
71
+ "sessions": self.session_registry.list_sessions(),
72
+ "jobs": self.engine.get_active_jobs(),
73
+ "health": self.health.get_status(),
74
+ }
75
+
76
+ # ── Actions ────────────────────────────────────────────────────
77
+
78
+ async def kill_job(self, request_id: str) -> dict[str, Any]:
79
+ """Kill a specific active execution by request ID.
80
+
81
+ Terminates the subprocess (process tree) and broadcasts the new
82
+ snapshot to all connected clients.
83
+ """
84
+ killed = await self.engine.kill_job(request_id)
85
+ snapshot = self.build_snapshot()
86
+ await self.broadcast(snapshot)
87
+ return {
88
+ "action": "kill_job",
89
+ "request_id": request_id,
90
+ "killed": killed,
91
+ }
92
+
93
+ async def force_cleanup(self) -> dict[str, Any]:
94
+ """Force cleanup all tracked executions and broadcast the snapshot."""
95
+ cleaned = self.engine.force_cleanup()
96
+ snapshot = self.build_snapshot()
97
+ await self.broadcast(snapshot)
98
+ return {
99
+ "action": "force_cleanup",
100
+ "cleaned": cleaned,
101
+ }
102
+
103
+ def get_snapshot(self) -> dict[str, Any]:
104
+ """Synchronous snapshot getter (for non-async callers)."""
105
+ return self.build_snapshot()