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
@@ -0,0 +1,70 @@
1
+ """MCP client session registry for the interactive monitor.
2
+
3
+ Tracks active MCP client sessions: when they connect, their last activity,
4
+ and client information from the MCP ``initialize`` handshake. State lives
5
+ in-memory and resets on server restart (by design).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from typing import Any
12
+
13
+ from kali_mcp.models import utc_now_iso
14
+
15
+
16
+ class SessionRegistry:
17
+ """Thread-safe registry of active MCP client sessions."""
18
+
19
+ def __init__(self) -> None:
20
+ self._sessions: dict[str, dict[str, Any]] = {}
21
+ self._lock = threading.RLock()
22
+
23
+ def on_connect(self, session_id: str, client_info: dict[str, Any] | None = None) -> None:
24
+ """Record that a new MCP client session has connected."""
25
+ now = utc_now_iso()
26
+ with self._lock:
27
+ self._sessions[session_id] = {
28
+ "session_id": session_id,
29
+ "connected_at": now,
30
+ "last_activity": now,
31
+ "client_info": client_info or {},
32
+ }
33
+
34
+ def on_activity(self, session_id: str) -> None:
35
+ """Update the last-activity timestamp for an existing session."""
36
+ now = utc_now_iso()
37
+ with self._lock:
38
+ if session_id in self._sessions:
39
+ self._sessions[session_id]["last_activity"] = now
40
+
41
+ def on_disconnect(self, session_id: str) -> None:
42
+ """Remove a session from the registry (client disconnected)."""
43
+ with self._lock:
44
+ self._sessions.pop(session_id, None)
45
+
46
+ def list_sessions(self) -> list[dict[str, Any]]:
47
+ """Return a copy of all known sessions, newest-created first."""
48
+ with self._lock:
49
+ sessions = list(self._sessions.values())
50
+ sessions.sort(key=lambda s: s["connected_at"], reverse=True)
51
+ return sessions
52
+
53
+ def get_session(self, session_id: str) -> dict[str, Any] | None:
54
+ """Return a single session by ID, or None if unknown."""
55
+ with self._lock:
56
+ return self._sessions.get(session_id)
57
+
58
+ def count(self) -> int:
59
+ """Return the number of currently tracked sessions."""
60
+ with self._lock:
61
+ return len(self._sessions)
62
+
63
+ def clear(self) -> None:
64
+ """Remove all sessions (used in tests / forced reset)."""
65
+ with self._lock:
66
+ self._sessions.clear()
67
+
68
+
69
+ # Global session registry shared by the server and the monitor module.
70
+ session_registry = SessionRegistry()
@@ -0,0 +1,87 @@
1
+ """SSE client for the monitor TUI.
2
+
3
+ Connects to ``GET /monitor/stream`` on the Kali MCP Server and yields live
4
+ snapshots as they arrive. Slow consumers have their snapshots dropped
5
+ (queue full) so the reader never blocks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+
17
+ class SseClient:
18
+ """Reads SSE snapshots from the monitor stream endpoint.
19
+
20
+ Usage::
21
+
22
+ client = SseClient("http://localhost:8399/monitor/stream")
23
+ await client.connect()
24
+ snapshot = await client.next_snapshot() # blocks until next event
25
+ """
26
+
27
+ def __init__(self, url: str, max_queue: int = 10) -> None:
28
+ self.url = url
29
+ self._max_queue = max_queue
30
+ self._snapshot: dict[str, Any] | None = None
31
+ self._queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue(maxsize=max_queue)
32
+ self._task: asyncio.Task | None = None
33
+ self._closed = False
34
+
35
+ async def connect(self) -> None:
36
+ """Connect to the SSE stream and start reading in the background."""
37
+ self._task = asyncio.create_task(self._read_loop())
38
+
39
+ async def _read_loop(self) -> None:
40
+ while not self._closed:
41
+ try:
42
+ async with httpx.AsyncClient(timeout=None) as http, http.stream(
43
+ "GET", self.url
44
+ ) as response:
45
+ response.raise_for_status()
46
+ async for line in response.aiter_lines():
47
+ if self._closed:
48
+ return
49
+ if line.startswith("data: "):
50
+ try:
51
+ payload = json.loads(line[len("data: ") :])
52
+ except json.JSONDecodeError:
53
+ continue
54
+ self._snapshot = payload
55
+ try:
56
+ self._queue.put_nowait(payload)
57
+ except asyncio.QueueFull:
58
+ # Slow consumer — drop this snapshot
59
+ try:
60
+ self._queue.get_nowait()
61
+ except asyncio.QueueEmpty:
62
+ pass
63
+ self._queue.put_nowait(payload)
64
+ except (httpx.ConnectError, httpx.ReadError, OSError):
65
+ # Server went down — retry after a short pause
66
+ await asyncio.sleep(2.0)
67
+ except asyncio.CancelledError:
68
+ return
69
+
70
+ async def next_snapshot(self) -> dict[str, Any] | None:
71
+ """Wait for and return the next snapshot from the stream."""
72
+ item = await self._queue.get()
73
+ return item
74
+
75
+ def get_latest_snapshot(self) -> dict[str, Any] | None:
76
+ """Return the most recent snapshot without waiting."""
77
+ return self._snapshot
78
+
79
+ async def close(self) -> None:
80
+ """Close the SSE connection."""
81
+ self._closed = True
82
+ if self._task is not None:
83
+ self._task.cancel()
84
+ try:
85
+ await self._task
86
+ except asyncio.CancelledError:
87
+ pass
@@ -0,0 +1,364 @@
1
+ """Interactive CLI/TUI for the Kali MCP monitor.
2
+
3
+ Female cyberspace aesthetic — gummy amber (#FFBF00), magenta-pink (#FF69B4),
4
+ and cyberspace cyan (#18FFFF) on a deep dark background. Built with Rich for
5
+ rendering and a background thread for non-blocking stdin.
6
+
7
+ Connects to the server's ``GET /monitor/stream`` SSE endpoint and renders a
8
+ live dashboard with three panels:
9
+
10
+ * Connected Users — active MCP client sessions
11
+ * Running Jobs — active tool executions (nmap, sqlmap, etc.)
12
+ * Server Health — uptime, memory, request counts, tool inventory
13
+
14
+ Keybindings:
15
+
16
+ * ``q`` — quit
17
+ * ``r`` — force a refresh of the display
18
+ * ``f`` — force cleanup all tracked executions (with confirmation)
19
+ * ``k`` — kill a specific job (prompts for request id)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import os
26
+ import sys
27
+ import termios
28
+ import threading
29
+ import tty
30
+ from typing import Any
31
+
32
+ import httpx
33
+ from rich.layout import Layout
34
+ from rich.live import Live
35
+ from rich.panel import Panel
36
+ from rich.table import Table
37
+ from rich.text import Text
38
+
39
+ from kali_mcp.monitor.sse_client import SseClient
40
+
41
+ # ════════════════════════════════════════════════════════════════════════════
42
+ # Cyberspace color palette (dark-background friendly)
43
+ # ════════════════════════════════════════════════════════════════════════════
44
+
45
+ CYBERSPACE_CYAN = "#18FFFF" # bright electric cyan — headers / primary text
46
+ GUMMY_AMBER = "#FFBF00" # gummy amber — highlights / values / jobs
47
+ GUMMY_PINK = "#FF69B4" # hot pink / magenta — alerts / actions / borders
48
+ GUMMY_LIGHT_PINK = "#FFB6C1" # soft pink — secondary text
49
+ DARK_PANEL_BG = "#0a0a0f" # near-black panel background — deep cyberspace
50
+
51
+
52
+ def _styled_text(content: str, style: str) -> Text:
53
+ return Text(content, style=style)
54
+
55
+
56
+ # ════════════════════════════════════════════════════════════════════════════
57
+ # Panel builders
58
+ # ════════════════════════════════════════════════════════════════════════════
59
+
60
+
61
+ def _sessions_panel(sessions: list[dict[str, Any]]) -> Panel:
62
+ table = Table(show_header=True, header_style=f"bold {GUMMY_AMBER}", expand=True)
63
+ table.add_column("Session ID", style=CYBERSPACE_CYAN, width=14)
64
+ table.add_column("Connected At", style=GUMMY_LIGHT_PINK, width=20)
65
+ table.add_column("Last Activity", style=GUMMY_LIGHT_PINK, width=20)
66
+ table.add_column("Client", style=CYBERSPACE_CYAN, width=26)
67
+
68
+ for s in sessions:
69
+ ci = s.get("client_info", {}) or {}
70
+ client_str = f"{ci.get('name', '-')}/{ci.get('version', '-')}"
71
+ table.add_row(
72
+ str(s.get("session_id", "-")),
73
+ str(s.get("connected_at", "-")),
74
+ str(s.get("last_activity", "-")),
75
+ client_str,
76
+ )
77
+
78
+ return Panel(
79
+ table,
80
+ title=_styled_text("Connected Users", f"bold {GUMMY_PINK}"),
81
+ border_style=GUMMY_PINK,
82
+ title_align="left",
83
+ )
84
+
85
+
86
+ def _jobs_panel(jobs: list[dict[str, Any]]) -> Panel:
87
+ table = Table(show_header=True, header_style=f"bold {GUMMY_AMBER}", expand=True)
88
+ table.add_column("Request ID", style=CYBERSPACE_CYAN, width=14)
89
+ table.add_column("Tool", style=CYBERSPACE_CYAN, width=16)
90
+ table.add_column("Elapsed (s)", style=GUMMY_AMBER, width=12, justify="right")
91
+
92
+ for j in jobs:
93
+ table.add_row(
94
+ str(j.get("request_id", "-")),
95
+ str(j.get("tool", "-")),
96
+ f"{j.get('elapsed_seconds', 0):.1f}",
97
+ )
98
+
99
+ return Panel(
100
+ table,
101
+ title=_styled_text("Running Jobs", f"bold {GUMMY_PINK}"),
102
+ border_style=GUMMY_PINK,
103
+ title_align="left",
104
+ )
105
+
106
+
107
+ def _health_panel(health: dict[str, Any]) -> Panel:
108
+ grid = Table.grid(padding=(0, 1), expand=True)
109
+ grid.add_column(style=CYBERSPACE_CYAN, width=24)
110
+ grid.add_column(style=GUMMY_LIGHT_PINK)
111
+
112
+ grid.add_row("Status:", str(health.get("status", "-")))
113
+ grid.add_row("Uptime:", f"{health.get('uptime_seconds', 0):.1f}s")
114
+ grid.add_row("Active Executions:", str(health.get("active_executions", 0)))
115
+ grid.add_row("Total Requests:", str(health.get("total_requests", 0)))
116
+ grid.add_row("Total Errors:", str(health.get("total_errors", 0)))
117
+ grid.add_row("Tools Registered:", str(health.get("tools_registered", 0)))
118
+ grid.add_row("Memory:", f"{health.get('memory_mb', 0):.1f} MB")
119
+
120
+ return Panel(
121
+ grid,
122
+ title=_styled_text("Server Health", f"bold {GUMMY_PINK}"),
123
+ border_style=GUMMY_PINK,
124
+ title_align="left",
125
+ )
126
+
127
+
128
+ def build_layout(
129
+ snapshot: dict[str, Any] | None,
130
+ status_msg: str = "",
131
+ ) -> Layout:
132
+ """Build the full dashboard layout from a monitor snapshot."""
133
+ layout = Layout()
134
+
135
+ ts = snapshot.get("timestamp", "-") if snapshot else "-"
136
+ header_text = Text(
137
+ f"Kali MCP Monitor — {ts}",
138
+ style=f"bold {CYBERSPACE_CYAN} on {DARK_PANEL_BG}",
139
+ )
140
+ if status_msg:
141
+ header_text.append(" | ", style=GUMMY_LIGHT_PINK)
142
+ header_text.append(status_msg, style=GUMMY_AMBER)
143
+ header = Panel(header_text, border_style=GUMMY_PINK, style=DARK_PANEL_BG)
144
+
145
+ layout.split(
146
+ Layout(header, name="top", size=3),
147
+ Layout(name="middle", ratio=1),
148
+ Layout(name="bottom", ratio=1),
149
+ )
150
+
151
+ layout["middle"].split_row(
152
+ Layout(
153
+ _sessions_panel(snapshot.get("sessions", []) if snapshot else []),
154
+ name="sessions",
155
+ ),
156
+ Layout(_jobs_panel(snapshot.get("jobs", []) if snapshot else []), name="jobs"),
157
+ )
158
+ layout["bottom"].update(_health_panel(snapshot.get("health", {}) if snapshot else {}))
159
+
160
+ return layout
161
+
162
+
163
+ # ════════════════════════════════════════════════════════════════════════════
164
+ # HTTP action helpers
165
+ # ════════════════════════════════════════════════════════════════════════════
166
+
167
+
168
+ async def _http_post(base_url: str, payload: dict[str, Any]) -> dict[str, Any]:
169
+ async with httpx.AsyncClient(timeout=10.0) as http:
170
+ resp = await http.post(f"{base_url}/monitor/action", json=payload)
171
+ resp.raise_for_status()
172
+ return resp.json()
173
+
174
+
175
+ # ════════════════════════════════════════════════════════════════════════════
176
+ # TUI
177
+ # ════════════════════════════════════════════════════════════════════════════
178
+
179
+
180
+ async def run(url: str) -> None:
181
+ """Run the interactive monitor TUI.
182
+
183
+ Parameters
184
+ ----------
185
+ url:
186
+ Base URL of the Kali MCP Server, e.g. ``http://127.0.0.1:8399``.
187
+ """
188
+ client = SseClient(f"{url}/monitor/stream")
189
+ await client.connect()
190
+
191
+ loop = asyncio.get_event_loop()
192
+ input_queue: asyncio.Queue[str] = asyncio.Queue()
193
+ fd = sys.stdin.fileno()
194
+
195
+ # Put stdin in cbreak mode: single keypresses available immediately,
196
+ # no Enter required, but signals (Ctrl+C) still work.
197
+ old_settings = None
198
+ try:
199
+ old_settings = termios.tcgetattr(fd)
200
+ except Exception: # noqa: BLE001
201
+ old_settings = None
202
+ if old_settings is not None:
203
+ try:
204
+ tty.setcbreak(fd)
205
+ except Exception: # noqa: BLE001
206
+ old_settings = None
207
+
208
+ def _read_stdin() -> None:
209
+ """Blocking stdin reader in a background thread."""
210
+ try:
211
+ while True:
212
+ ch = os.read(fd, 1)
213
+ if not ch:
214
+ break
215
+ try:
216
+ loop.call_soon_threadsafe(input_queue.put_nowait, ch.decode(errors="replace"))
217
+ except Exception: # noqa: BLE001
218
+ break
219
+ except OSError:
220
+ pass
221
+
222
+ stdin_thread = threading.Thread(target=_read_stdin, daemon=True)
223
+ stdin_thread.start()
224
+
225
+ snapshot = client.get_latest_snapshot() or {}
226
+ layout = build_layout(
227
+ snapshot,
228
+ "Press q to quit · f: force cleanup · k: kill job",
229
+ )
230
+
231
+ waiting_for_kill_id = False
232
+ kill_id_buffer = ""
233
+
234
+ try:
235
+ with Live(layout, refresh_per_second=4, screen=False) as live:
236
+ while True:
237
+ snapshot_task = asyncio.create_task(client.next_snapshot())
238
+ input_task = asyncio.create_task(input_queue.get())
239
+
240
+ done, pending = await asyncio.wait(
241
+ [snapshot_task, input_task],
242
+ return_when=asyncio.FIRST_COMPLETED,
243
+ )
244
+ for task in pending:
245
+ task.cancel()
246
+
247
+ result = done.pop().result()
248
+
249
+ # ── New snapshot from SSE ──────────────────────────────
250
+ if isinstance(result, dict):
251
+ snapshot = result
252
+ layout = build_layout(
253
+ snapshot,
254
+ "Press q to quit · f: force cleanup · k: kill job",
255
+ )
256
+ live.update(layout, refresh=True)
257
+ continue
258
+
259
+ # ── User input (single keypress in cbreak mode) ────────
260
+ key = result
261
+
262
+ # Ctrl+C → quit
263
+ if key == "\x03":
264
+ break
265
+
266
+ if waiting_for_kill_id:
267
+ # Enter → submit the request id
268
+ if key in ("\r", "\n"):
269
+ req_id = kill_id_buffer.strip()
270
+ if req_id:
271
+ try:
272
+ res = await _http_post(
273
+ f"{url}/monitor/action",
274
+ {"action": "kill_job", "request_id": req_id},
275
+ )
276
+ status_msg = (
277
+ f"kill_job {req_id}: "
278
+ f"{'killed' if res.get('killed') else 'not found'}"
279
+ )
280
+ except Exception as exc: # noqa: BLE001
281
+ status_msg = f"kill_job {req_id}: error — {exc}"
282
+ else:
283
+ status_msg = "kill_job cancelled (no id)"
284
+ waiting_for_kill_id = False
285
+ kill_id_buffer = ""
286
+ layout = build_layout(snapshot, status_msg)
287
+ live.update(layout, refresh=True)
288
+ # Backspace / delete → remove last char
289
+ elif key in ("\x7f", "\x08"):
290
+ kill_id_buffer = kill_id_buffer[:-1]
291
+ layout = build_layout(
292
+ snapshot,
293
+ f"Enter request id to kill: {kill_id_buffer}_",
294
+ )
295
+ live.update(layout, refresh=True)
296
+ # Printable char → accumulate (digits only, it's a PID)
297
+ elif len(key) == 1 and key.isdigit():
298
+ kill_id_buffer += key
299
+ layout = build_layout(
300
+ snapshot,
301
+ f"Enter request id to kill: {kill_id_buffer}_",
302
+ )
303
+ live.update(layout, refresh=True)
304
+ # Escape → cancel back to normal mode
305
+ elif key == "\x1b":
306
+ waiting_for_kill_id = False
307
+ kill_id_buffer = ""
308
+ layout = build_layout(
309
+ snapshot,
310
+ "Press q to quit · f: force cleanup · k: kill job",
311
+ )
312
+ live.update(layout, refresh=True)
313
+ # 'q' while waiting → quit anyway
314
+ elif key == "q":
315
+ break
316
+ continue
317
+
318
+ # Normal mode keybindings
319
+ if key == "q":
320
+ break
321
+
322
+ elif key == "r":
323
+ latest = client.get_latest_snapshot() or {}
324
+ layout = build_layout(
325
+ latest, "Press q to quit · f: force cleanup · k: kill job"
326
+ )
327
+ live.update(layout, refresh=True)
328
+
329
+ elif key == "f":
330
+ try:
331
+ res = await _http_post(f"{url}/monitor/action", {"action": "force_cleanup"})
332
+ status_msg = f"force_cleanup: {res.get('cleaned', 0)} cleaned"
333
+ except Exception as exc: # noqa: BLE001
334
+ status_msg = f"force_cleanup: error — {exc}"
335
+ layout = build_layout(snapshot, status_msg)
336
+ live.update(layout, refresh=True)
337
+
338
+ elif key == "k":
339
+ waiting_for_kill_id = True
340
+ kill_id_buffer = ""
341
+ layout = build_layout(
342
+ snapshot,
343
+ "Enter request id to kill: ",
344
+ )
345
+ live.update(layout, refresh=True)
346
+
347
+ except (KeyboardInterrupt, EOFError):
348
+ # Ctrl+C or Ctrl+D — exit gracefully without traceback noise
349
+ pass
350
+ finally:
351
+ if old_settings is not None:
352
+ try:
353
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
354
+ except Exception: # noqa: BLE001, S110
355
+ pass
356
+ # Clear the screen on exit so the TUI doesn't leave artifacts
357
+ sys.stdout.write("\x1b[2J\x1b[H")
358
+ sys.stdout.flush()
359
+ await client.close()
360
+
361
+
362
+ def main(url: str = "http://127.0.0.1:8399") -> None:
363
+ """Entry point for ``python -m kali_mcp monitor``."""
364
+ asyncio.run(run(url))
@@ -0,0 +1,130 @@
1
+ """MCP server→client notification helpers.
2
+ All helpers are best-effort: if the Context (or its active session) is not
3
+ available the call is a safe no-op. For a local single-user server this is
4
+ enough — notifications are emitted on the request's active session.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from mcp.server.fastmcp.server import Context
15
+
16
+ logger = logging.getLogger("kali_mcp.notifications")
17
+
18
+
19
+ def _session(ctx: Any) -> Any:
20
+ """Return the active MCP ServerSession from a Context, or None."""
21
+ try:
22
+ if ctx is None:
23
+ return None
24
+ session = getattr(ctx, "session", None)
25
+ if session is None:
26
+ request_context = getattr(ctx, "request_context", None)
27
+ session = getattr(request_context, "session", None) if request_context else None
28
+ if session is None:
29
+ return None
30
+ if not hasattr(session, "send_notification"):
31
+ return None
32
+ return session
33
+ except Exception: # noqa: BLE001
34
+ return None
35
+
36
+
37
+ def _emit(session: Any, coro: Any) -> None:
38
+ """Schedule a notification coroutine fire-and-forget on the loop."""
39
+ try:
40
+ task = asyncio.create_task(coro)
41
+ task.add_done_callback(_swallow_task_error)
42
+ except RuntimeError:
43
+ # No running loop (e.g. context teardown) — run it synchronously.
44
+ try:
45
+ asyncio.run(coro)
46
+ except Exception:
47
+ logger.debug("notification failed (session closing?)", exc_info=True)
48
+
49
+
50
+ def _swallow_task_error(task: asyncio.Task) -> None:
51
+ if task.cancelled():
52
+ return
53
+ exc = task.exception()
54
+ if exc is not None:
55
+ logger.debug("notification failed (session closing?)", exc_info=exc)
56
+
57
+
58
+ def notify_resource_updated(ctx: Context | None, uri: str) -> None:
59
+ """Notify the client that a resource changed."""
60
+ session = _session(ctx)
61
+ if session is None:
62
+ return
63
+ try:
64
+ send = getattr(session, "send_resource_updated", None)
65
+ if send is not None:
66
+ from pydantic.networks import AnyUrl
67
+
68
+ _emit(session, send(AnyUrl(uri)))
69
+ return
70
+ from mcp.types import (
71
+ ResourceUpdatedNotification,
72
+ ResourceUpdatedNotificationParams,
73
+ )
74
+ from pydantic.networks import AnyUrl
75
+
76
+ notification = ResourceUpdatedNotification(
77
+ params=ResourceUpdatedNotificationParams(uri=AnyUrl(uri))
78
+ )
79
+ _emit(session, session.send_notification(notification))
80
+ except Exception:
81
+ logger.debug("resource-updated notification failed", exc_info=True)
82
+
83
+
84
+ def notify_resources_changed(ctx: Context | None) -> None:
85
+ """Notify the client that the resource list changed."""
86
+ session = _session(ctx)
87
+ if session is None:
88
+ return
89
+ try:
90
+ from mcp.types import ResourceListChangedNotification, ServerNotification
91
+
92
+ _emit(
93
+ session,
94
+ session.send_notification(ServerNotification(ResourceListChangedNotification())),
95
+ )
96
+ except Exception:
97
+ logger.debug("resources-changed notification failed", exc_info=True)
98
+
99
+
100
+ def notify_tools_changed(ctx: Context | None) -> None:
101
+ """Notify the client that the tool list changed."""
102
+ session = _session(ctx)
103
+ if session is None:
104
+ return
105
+ try:
106
+ _emit(session, session.send_tool_list_changed())
107
+ except Exception:
108
+ logger.debug("tool-list-changed notification failed", exc_info=True)
109
+
110
+
111
+ def notify_prompts_changed(ctx: Context | None) -> None:
112
+ """Notify the client that the prompt list changed."""
113
+ session = _session(ctx)
114
+ if session is None:
115
+ return
116
+ try:
117
+ _emit(session, session.send_prompt_list_changed())
118
+ except Exception:
119
+ logger.debug("prompt-list-changed notification failed", exc_info=True)
120
+
121
+
122
+ def emit_log(ctx: Context | None, level: str, message: str) -> None:
123
+ """Emit an MCP protocol-level log message on the session."""
124
+ session = _session(ctx)
125
+ if session is None:
126
+ return
127
+ try:
128
+ _emit(session, session.send_log_message(level, message, logger="kali_mcp"))
129
+ except Exception:
130
+ logger.debug("log-message notification failed", exc_info=True)