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.
- kali_mcp/__init__.py +3 -0
- kali_mcp/__main__.py +28 -0
- kali_mcp/_version.py +24 -0
- kali_mcp/cli.py +169 -0
- kali_mcp/config.py +95 -0
- kali_mcp/db/__init__.py +1 -0
- kali_mcp/db/manager.py +248 -0
- kali_mcp/execution.py +439 -0
- kali_mcp/logging_utils.py +74 -0
- kali_mcp/models.py +51 -0
- kali_mcp/monitor/__init__.py +105 -0
- kali_mcp/monitor/sessions.py +70 -0
- kali_mcp/monitor/sse_client.py +87 -0
- kali_mcp/monitor/tui.py +364 -0
- kali_mcp/notifications.py +130 -0
- kali_mcp/prompts.py +184 -0
- kali_mcp/registry.py +33 -0
- kali_mcp/resources.py +263 -0
- kali_mcp/responses.py +39 -0
- kali_mcp/security.py +15 -0
- kali_mcp/server.py +535 -0
- kali_mcp/tools/__init__.py +125 -0
- kali_mcp/tools/amass.py +83 -0
- kali_mcp/tools/arjun.py +86 -0
- kali_mcp/tools/base.py +100 -0
- kali_mcp/tools/bettercap.py +99 -0
- kali_mcp/tools/bloodhound.py +68 -0
- kali_mcp/tools/commix.py +73 -0
- kali_mcp/tools/crackmapexec.py +100 -0
- kali_mcp/tools/dirb.py +75 -0
- kali_mcp/tools/dursgo.py +156 -0
- kali_mcp/tools/enum4linux.py +59 -0
- kali_mcp/tools/farsight.py +127 -0
- kali_mcp/tools/ffuf.py +85 -0
- kali_mcp/tools/file_download.py +119 -0
- kali_mcp/tools/file_read.py +144 -0
- kali_mcp/tools/file_write.py +141 -0
- kali_mcp/tools/flowlyt.py +88 -0
- kali_mcp/tools/generic_command.py +95 -0
- kali_mcp/tools/gobuster.py +84 -0
- kali_mcp/tools/hashcat.py +101 -0
- kali_mcp/tools/httpx.py +85 -0
- kali_mcp/tools/hydra.py +99 -0
- kali_mcp/tools/john.py +100 -0
- kali_mcp/tools/katana.py +81 -0
- kali_mcp/tools/masscan.py +85 -0
- kali_mcp/tools/medusa.py +102 -0
- kali_mcp/tools/msfconsole.py +59 -0
- kali_mcp/tools/naabu.py +100 -0
- kali_mcp/tools/netexec.py +86 -0
- kali_mcp/tools/nmap.py +94 -0
- kali_mcp/tools/normalization.py +18 -0
- kali_mcp/tools/nuclei.py +87 -0
- kali_mcp/tools/parallel_execute.py +246 -0
- kali_mcp/tools/persist.py +436 -0
- kali_mcp/tools/playbook_ad.py +99 -0
- kali_mcp/tools/playbook_engine.py +142 -0
- kali_mcp/tools/playbook_engine_tool.py +72 -0
- kali_mcp/tools/playbook_recon.py +115 -0
- kali_mcp/tools/playbook_tools.py +125 -0
- kali_mcp/tools/playbook_webapp.py +105 -0
- kali_mcp/tools/python_command.py +100 -0
- kali_mcp/tools/responder.py +85 -0
- kali_mcp/tools/searchsploit.py +123 -0
- kali_mcp/tools/spiderfoot.py +75 -0
- kali_mcp/tools/sqlmap.py +73 -0
- kali_mcp/tools/subfinder.py +69 -0
- kali_mcp/tools/theharvester.py +80 -0
- kali_mcp/tools/vault_tool.py +489 -0
- kali_mcp/tools/wfuzz.py +87 -0
- kali_mcp/tools/whatweb.py +82 -0
- kali_mcp/tools/wpscan.py +76 -0
- kali_mcp/tools/zap.py +79 -0
- kali_mcp/tools/zighound.py +139 -0
- kali_mcp/tools/zizmor.py +98 -0
- kali_mcp/utils/__init__.py +0 -0
- kali_mcp/utils/process.py +36 -0
- kali_mcp/utils/vault.py +471 -0
- kali_mcp/validation.py +82 -0
- kmcp-0.1.5.dev0.dist-info/METADATA +251 -0
- kmcp-0.1.5.dev0.dist-info/RECORD +84 -0
- kmcp-0.1.5.dev0.dist-info/WHEEL +5 -0
- kmcp-0.1.5.dev0.dist-info/entry_points.txt +4 -0
- 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
|
kali_mcp/monitor/tui.py
ADDED
|
@@ -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)
|