queue-aiops 0.1.0__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.
- mcp_server/__init__.py +1 -0
- mcp_server/_shared.py +107 -0
- mcp_server/server.py +38 -0
- mcp_server/tools/__init__.py +1 -0
- mcp_server/tools/analysis.py +148 -0
- mcp_server/tools/overview.py +23 -0
- mcp_server/tools/rabbit_reads.py +95 -0
- mcp_server/tools/redis_reads.py +98 -0
- mcp_server/tools/undo.py +121 -0
- mcp_server/tools/writes.py +355 -0
- queue_aiops/__init__.py +14 -0
- queue_aiops/cli/__init__.py +9 -0
- queue_aiops/cli/_common.py +78 -0
- queue_aiops/cli/_root.py +62 -0
- queue_aiops/cli/analyze.py +85 -0
- queue_aiops/cli/doctor.py +21 -0
- queue_aiops/cli/init.py +176 -0
- queue_aiops/cli/overview.py +16 -0
- queue_aiops/cli/rabbit_cmds.py +234 -0
- queue_aiops/cli/redis_cmds.py +147 -0
- queue_aiops/cli/secret.py +105 -0
- queue_aiops/cli/undo.py +62 -0
- queue_aiops/config.py +175 -0
- queue_aiops/connection.py +277 -0
- queue_aiops/doctor.py +98 -0
- queue_aiops/governance/__init__.py +40 -0
- queue_aiops/governance/audit.py +377 -0
- queue_aiops/governance/budget.py +225 -0
- queue_aiops/governance/decorators.py +482 -0
- queue_aiops/governance/paths.py +23 -0
- queue_aiops/governance/patterns.py +378 -0
- queue_aiops/governance/policy.py +430 -0
- queue_aiops/governance/sanitize.py +45 -0
- queue_aiops/governance/undo.py +218 -0
- queue_aiops/ops/__init__.py +1 -0
- queue_aiops/ops/_util.py +55 -0
- queue_aiops/ops/analysis.py +591 -0
- queue_aiops/ops/overview.py +90 -0
- queue_aiops/ops/rabbit_reads.py +218 -0
- queue_aiops/ops/redis_reads.py +237 -0
- queue_aiops/ops/writes.py +284 -0
- queue_aiops/platform.py +217 -0
- queue_aiops/secretstore.py +304 -0
- queue_aiops-0.1.0.dist-info/METADATA +168 -0
- queue_aiops-0.1.0.dist-info/RECORD +48 -0
- queue_aiops-0.1.0.dist-info/WHEEL +4 -0
- queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- queue_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp_server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server package for queue-aiops."""
|
mcp_server/_shared.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Shared MCP server primitives: the FastMCP instance, connection helper,
|
|
2
|
+
error sanitisation, and the ``@tool_errors`` decorator.
|
|
3
|
+
|
|
4
|
+
Tool modules under ``mcp_server/tools/`` import ``mcp`` from here and register
|
|
5
|
+
their ``@mcp.tool()`` functions onto it. ``mcp_server/server.py`` then imports
|
|
6
|
+
those modules and runs the server.
|
|
7
|
+
|
|
8
|
+
Keep ``Optional[X]`` (never PEP 604 ``X | None``) in any FastMCP-reflected
|
|
9
|
+
tool signature — on older mcp/pydantic the union eval'd to ``types.UnionType``
|
|
10
|
+
crashes FastMCP's ``issubclass`` check.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from mcp.server.fastmcp import FastMCP
|
|
21
|
+
|
|
22
|
+
from queue_aiops.config import load_config
|
|
23
|
+
from queue_aiops.connection import ConnectionManager, QueueApiError
|
|
24
|
+
from queue_aiops.governance import sanitize
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_DOCTOR_HINT = "Run 'queue-aiops doctor' to verify connectivity and credentials."
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _safe_error(exc: Exception, tool: str) -> str:
|
|
32
|
+
"""Return an agent-safe error string; log full detail server-side only."""
|
|
33
|
+
logger.error("Tool %s failed", tool, exc_info=True)
|
|
34
|
+
_passthrough = (
|
|
35
|
+
ValueError,
|
|
36
|
+
FileNotFoundError,
|
|
37
|
+
KeyError,
|
|
38
|
+
PermissionError,
|
|
39
|
+
TimeoutError,
|
|
40
|
+
ConnectionError,
|
|
41
|
+
QueueApiError,
|
|
42
|
+
)
|
|
43
|
+
if isinstance(exc, _passthrough):
|
|
44
|
+
return sanitize(str(exc), 300)
|
|
45
|
+
return f"{type(exc).__name__}: operation failed."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tool_errors(shape: str = "dict") -> Callable:
|
|
49
|
+
"""Wrap a tool body in the canonical try/except → ``_safe_error`` pattern.
|
|
50
|
+
|
|
51
|
+
Place this *between* ``@governed_tool`` and the function so the audit
|
|
52
|
+
decorator and FastMCP still see the original signature.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def decorator(func: Callable) -> Callable:
|
|
56
|
+
name = func.__name__
|
|
57
|
+
|
|
58
|
+
@functools.wraps(func)
|
|
59
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
60
|
+
try:
|
|
61
|
+
return func(*args, **kwargs)
|
|
62
|
+
except Exception as e: # noqa: BLE001 — sanitised below
|
|
63
|
+
msg = _safe_error(e, name)
|
|
64
|
+
if shape == "list":
|
|
65
|
+
return [{"error": msg, "hint": _DOCTOR_HINT}]
|
|
66
|
+
if shape == "str":
|
|
67
|
+
return f"Error: {msg} {_DOCTOR_HINT}"
|
|
68
|
+
return {"error": msg, "hint": _DOCTOR_HINT}
|
|
69
|
+
|
|
70
|
+
return wrapper
|
|
71
|
+
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
mcp = FastMCP(
|
|
76
|
+
"queue-aiops",
|
|
77
|
+
instructions=(
|
|
78
|
+
"Queue/cache broker operations (preview) over redis and rabbitmq: for a "
|
|
79
|
+
"redis target — server/memory/keyspace reads, clients, slowlog, config, "
|
|
80
|
+
"and a SCAN-budgeted big-key sample (never KEYS *); for a rabbitmq "
|
|
81
|
+
"target — management-API overview, queues, connections, channels, "
|
|
82
|
+
"policies, and node health. Flagship analyses: "
|
|
83
|
+
"redis_memory_pressure_rca, redis_latency_rca, "
|
|
84
|
+
"rabbitmq_queue_backlog_rca, connection_churn_analysis — transparent "
|
|
85
|
+
"heuristics that show their numbers. Governed writes — config_set and "
|
|
86
|
+
"set_policy/delete_policy (reversible, undo-recorded), declare_queue, "
|
|
87
|
+
"kill_client (priorState only), plus purge_queue / delete_queue at "
|
|
88
|
+
"risk=high with a dry_run preview and an approver (delete_queue's undo "
|
|
89
|
+
"re-declares the captured definition; messages are not restored). Every "
|
|
90
|
+
"tool runs through the queue-aiops governance harness (audit / budget / "
|
|
91
|
+
"risk-tier / undo). A per-target 'platform' field selects the protocol "
|
|
92
|
+
"shape; redis passwords are optional (lab instances), rabbitmq uses the "
|
|
93
|
+
"management user's Basic auth."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
_conn_mgr: Optional[ConnectionManager] = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _get_connection(target: Optional[str] = None) -> Any:
|
|
101
|
+
"""Return a broker connection, lazily initialising the manager."""
|
|
102
|
+
global _conn_mgr # noqa: PLW0603
|
|
103
|
+
if _conn_mgr is None:
|
|
104
|
+
config_path_str = os.environ.get("QUEUE_AIOPS_CONFIG")
|
|
105
|
+
config_path = Path(config_path_str) if config_path_str else None
|
|
106
|
+
_conn_mgr = ConnectionManager(load_config(config_path))
|
|
107
|
+
return _conn_mgr.connect(target)
|
mcp_server/server.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""MCP server wrapping queue-aiops operations (stdio transport).
|
|
2
|
+
|
|
3
|
+
Thin adapter layer: each ``@mcp.tool()`` function (in ``mcp_server/tools/``)
|
|
4
|
+
delegates to the ``queue_aiops`` ops package and is wrapped with the
|
|
5
|
+
queue-aiops ``@governed_tool`` harness (audit / budget / undo / risk-tier).
|
|
6
|
+
|
|
7
|
+
Standalone, self-governed broker operations (preview) over redis and rabbitmq:
|
|
8
|
+
redis server/memory/clients/slowlog/config/keyspace/big-key reads, rabbitmq
|
|
9
|
+
overview/queues/connections/channels/policies/node reads, four flagship
|
|
10
|
+
analyses, and governed writes (redis config set + client kill; rabbitmq queue
|
|
11
|
+
purge/delete/declare and policy set/delete).
|
|
12
|
+
|
|
13
|
+
Source: https://github.com/AIops-tools/Queue-AIops
|
|
14
|
+
License: MIT
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
from mcp_server._shared import _safe_error, mcp, tool_errors
|
|
20
|
+
|
|
21
|
+
# Importing the tool modules registers every @mcp.tool() onto the shared
|
|
22
|
+
# `mcp` instance. Order does not matter; each module is self-contained.
|
|
23
|
+
from mcp_server.tools import ( # noqa: F401 — side effects
|
|
24
|
+
analysis,
|
|
25
|
+
overview,
|
|
26
|
+
rabbit_reads,
|
|
27
|
+
redis_reads,
|
|
28
|
+
undo,
|
|
29
|
+
writes,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = ["mcp", "main", "_safe_error", "tool_errors"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> None:
|
|
36
|
+
"""Run the MCP server over stdio."""
|
|
37
|
+
logging.basicConfig(level=logging.INFO)
|
|
38
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP tool modules. Importing each registers its @mcp.tool() functions."""
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Flagship broker-analysis MCP tools (read-only)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from mcp_server._shared import _get_connection, mcp, tool_errors
|
|
6
|
+
from queue_aiops.governance import governed_tool
|
|
7
|
+
from queue_aiops.ops import analysis as ops
|
|
8
|
+
from queue_aiops.ops._util import as_obj, num
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@mcp.tool()
|
|
12
|
+
@governed_tool(risk_level="low")
|
|
13
|
+
@tool_errors("dict")
|
|
14
|
+
def redis_memory_pressure_rca(
|
|
15
|
+
used_pct: float = 85.0,
|
|
16
|
+
telemetry: Optional[dict[str, Any]] = None,
|
|
17
|
+
target: Optional[str] = None,
|
|
18
|
+
) -> dict:
|
|
19
|
+
"""[READ] Diagnose redis memory pressure → cause + action, with numbers.
|
|
20
|
+
|
|
21
|
+
The flagship memory RCA: used vs maxmemory (+ eviction policy — noeviction
|
|
22
|
+
near the limit means writes will OOM), evicted-keys pressure, fragmentation
|
|
23
|
+
ratio (high = defrag, below 1 = likely swapping), and the SCAN-budgeted
|
|
24
|
+
big-key sample. Every finding carries its numbers, not a black-box verdict.
|
|
25
|
+
Pass 'telemetry' for pure analysis, or a target to pull live.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
used_pct: used/maxmemory %% at/above which pressure is flagged (default 85).
|
|
29
|
+
telemetry: Injected {memory:{...memory_stats fields}, evictedKeys,
|
|
30
|
+
bigKeys:{topKeys:[...]}}; skips the live pull.
|
|
31
|
+
target: redis target name from config; omit for the default.
|
|
32
|
+
|
|
33
|
+
Returns dict: {pressure, usedPctOfMax, maxmemoryPolicy, fragmentationRatio,
|
|
34
|
+
evictedKeys, thresholds, findings:[{cause, action, evidence}], note}.
|
|
35
|
+
"""
|
|
36
|
+
if telemetry is None:
|
|
37
|
+
telemetry = ops.pull_memory_telemetry(_get_connection(target))
|
|
38
|
+
telemetry = as_obj(telemetry)
|
|
39
|
+
return ops.redis_memory_pressure_rca(
|
|
40
|
+
as_obj(telemetry.get("memory")),
|
|
41
|
+
evicted_keys=num(telemetry.get("evictedKeys")),
|
|
42
|
+
big_keys=as_obj(telemetry.get("bigKeys")),
|
|
43
|
+
used_pct=used_pct,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
@governed_tool(risk_level="low")
|
|
49
|
+
@tool_errors("dict")
|
|
50
|
+
def redis_latency_rca(
|
|
51
|
+
slow_us: float = 10_000.0,
|
|
52
|
+
telemetry: Optional[dict[str, Any]] = None,
|
|
53
|
+
target: Optional[str] = None,
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""[READ] Diagnose redis latency → slowlog digest + stall causes + actions.
|
|
56
|
+
|
|
57
|
+
The flagship latency RCA: digests the SLOWLOG by command pattern (flagging
|
|
58
|
+
O(N)/blocking commands), and reads stall signals from INFO — blocked
|
|
59
|
+
clients, fork stalls (BGSAVE/AOF rewrite), delayed AOF fsyncs (slow disk),
|
|
60
|
+
and dataset loading. Every finding carries its numbers. Pass 'telemetry'
|
|
61
|
+
for pure analysis, or a target to pull live.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
slow_us: Slowlog duration (microseconds) at/above which a pattern is
|
|
65
|
+
flagged (default 10000 = 10ms).
|
|
66
|
+
telemetry: Injected {slowlog:[{command, durationUs}], blockedClients,
|
|
67
|
+
latestForkUsec, aofDelayedFsync, aofRewriteInProgress,
|
|
68
|
+
rdbBgsaveInProgress, loading}; skips the live pull.
|
|
69
|
+
target: redis target name from config; omit for the default.
|
|
70
|
+
|
|
71
|
+
Returns dict: {slowlogPatterns, patternsOverThreshold, thresholds,
|
|
72
|
+
findings:[{cause, action, evidence}], note}.
|
|
73
|
+
"""
|
|
74
|
+
if telemetry is None:
|
|
75
|
+
telemetry = ops.pull_latency_telemetry(_get_connection(target))
|
|
76
|
+
return ops.redis_latency_rca(telemetry, slow_us=slow_us)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@mcp.tool()
|
|
80
|
+
@governed_tool(risk_level="low")
|
|
81
|
+
@tool_errors("dict")
|
|
82
|
+
def rabbitmq_queue_backlog_rca(
|
|
83
|
+
vhost: Optional[str] = None,
|
|
84
|
+
top: int = 20,
|
|
85
|
+
queues: Optional[list[dict[str, Any]]] = None,
|
|
86
|
+
nodes: Optional[list[dict[str, Any]]] = None,
|
|
87
|
+
target: Optional[str] = None,
|
|
88
|
+
) -> dict:
|
|
89
|
+
"""[READ] Rank backlogged queues and map each to a cause + action.
|
|
90
|
+
|
|
91
|
+
The flagship backlog RCA: flags queues over the backlog threshold and
|
|
92
|
+
classifies each — no consumers attached, unacked pileup (consumers not
|
|
93
|
+
acking), publish rate outpacing delivery, or residual backlog — plus
|
|
94
|
+
global memory/disk watermark alarms that block every publisher via flow
|
|
95
|
+
control. Every finding carries its numbers. Pass 'queues' (and optionally
|
|
96
|
+
'nodes') for pure analysis, or a target to pull live.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
vhost: Restrict the live pull to one vhost; omit for all.
|
|
100
|
+
top: How many queue rows to return, deepest first (default 20).
|
|
101
|
+
queues: Injected rows {name, vhost, messages, messagesReady,
|
|
102
|
+
messagesUnacked, consumers, publishRate, deliverRate, state}.
|
|
103
|
+
nodes: Injected node_health rows {name, memAlarm, diskAlarm, ...}.
|
|
104
|
+
target: rabbitmq target name from config; omit for the default.
|
|
105
|
+
|
|
106
|
+
Returns dict: {queuesEvaluated, backloggedCount, globalFindings,
|
|
107
|
+
queues:[{name, vhost, messages, consumers, cause, action, ...}],
|
|
108
|
+
thresholds, note}.
|
|
109
|
+
"""
|
|
110
|
+
if queues is None:
|
|
111
|
+
pulled = ops.pull_backlog_telemetry(_get_connection(target), vhost)
|
|
112
|
+
queues = pulled.get("queues", [])
|
|
113
|
+
if nodes is None:
|
|
114
|
+
nodes = pulled.get("nodes", [])
|
|
115
|
+
return ops.rabbitmq_queue_backlog_rca(queues, nodes=nodes, top=top)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
@governed_tool(risk_level="low")
|
|
120
|
+
@tool_errors("dict")
|
|
121
|
+
def connection_churn_analysis(
|
|
122
|
+
snapshot: Optional[dict[str, Any]] = None,
|
|
123
|
+
history: Optional[dict[str, Any]] = None,
|
|
124
|
+
target: Optional[str] = None,
|
|
125
|
+
) -> dict:
|
|
126
|
+
"""[READ] Connection/channel churn → cause + action; works on both platforms.
|
|
127
|
+
|
|
128
|
+
The flagship churn analysis: for a redis target — new-connections rate vs
|
|
129
|
+
steady clients (reconnect-per-operation smell) and rejected connections
|
|
130
|
+
(maxclients); for a rabbitmq target — overview churn rates, channels-per-
|
|
131
|
+
connection ratio (leak smell), and growth vs a prior snapshot. Both group
|
|
132
|
+
clients by source so a finding can be pinned to an app. Call it once, keep
|
|
133
|
+
the returned snapshot fields, and pass them back later as 'history' for
|
|
134
|
+
delta-based churn. Pass 'snapshot' for pure analysis, or a target to pull
|
|
135
|
+
live.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
snapshot: Injected snapshot (from a prior call's live pull shape);
|
|
139
|
+
skips the live pull.
|
|
140
|
+
history: A prior snapshot for delta analysis (optional).
|
|
141
|
+
target: Broker target name from config; omit for the default.
|
|
142
|
+
|
|
143
|
+
Returns dict: {platform, metrics, bySource, comparedToHistory, thresholds,
|
|
144
|
+
findings:[{cause, action, evidence}], note}.
|
|
145
|
+
"""
|
|
146
|
+
if snapshot is None:
|
|
147
|
+
snapshot = ops.pull_churn_snapshot(_get_connection(target))
|
|
148
|
+
return ops.connection_churn_analysis(snapshot, history=history)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Overview MCP tool — one-shot broker health (read-only)."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from mcp_server._shared import _get_connection, mcp, tool_errors
|
|
6
|
+
from queue_aiops.governance import governed_tool
|
|
7
|
+
from queue_aiops.ops import overview as ops
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
@governed_tool(risk_level="low")
|
|
12
|
+
@tool_errors("dict")
|
|
13
|
+
def queue_overview(target: Optional[str] = None) -> dict:
|
|
14
|
+
"""[READ] One-shot summary: platform + version + backlog/memory/client health.
|
|
15
|
+
|
|
16
|
+
Platform-dispatched: a redis target reports memory posture, clients,
|
|
17
|
+
ops/sec and hit rate; a rabbitmq target reports queue/message totals,
|
|
18
|
+
connection/channel counts, rates, and node alarms.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
target: Broker target name from config; omit for the default.
|
|
22
|
+
"""
|
|
23
|
+
return ops.queue_overview(_get_connection(target))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""rabbitmq read MCP tools — overview, queues, connections, channels, policies, nodes."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from mcp_server._shared import _get_connection, mcp, tool_errors
|
|
6
|
+
from queue_aiops.governance import governed_tool
|
|
7
|
+
from queue_aiops.ops import rabbit_reads as ops
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
@governed_tool(risk_level="low")
|
|
12
|
+
@tool_errors("dict")
|
|
13
|
+
def rabbitmq_overview(target: Optional[str] = None) -> dict:
|
|
14
|
+
"""[READ] Broker identity + totals: version, queue/message counts, rates, churn.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
target: rabbitmq target name from config; omit for the default.
|
|
18
|
+
"""
|
|
19
|
+
return ops.broker_overview(_get_connection(target))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
@governed_tool(risk_level="low")
|
|
24
|
+
@tool_errors("dict")
|
|
25
|
+
def list_queues(vhost: Optional[str] = None, target: Optional[str] = None) -> dict:
|
|
26
|
+
"""[READ] Queues (optionally one vhost), deepest backlog first.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
vhost: Restrict to one vhost (the default vhost is '/'); omit for all.
|
|
30
|
+
target: rabbitmq target name from config; omit for the default.
|
|
31
|
+
"""
|
|
32
|
+
return ops.list_queues(_get_connection(target), vhost)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
@governed_tool(risk_level="low")
|
|
37
|
+
@tool_errors("dict")
|
|
38
|
+
def queue_detail(vhost: str, name: str, target: Optional[str] = None) -> dict:
|
|
39
|
+
"""[READ] One queue's full detail: counts, rates, consumers, memory, arguments.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
vhost: The queue's vhost (the default vhost is '/').
|
|
43
|
+
name: Queue name (from list_queues).
|
|
44
|
+
target: rabbitmq target name from config; omit for the default.
|
|
45
|
+
"""
|
|
46
|
+
return ops.queue_detail(_get_connection(target), vhost, name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@mcp.tool()
|
|
50
|
+
@governed_tool(risk_level="low")
|
|
51
|
+
@tool_errors("dict")
|
|
52
|
+
def list_connections(target: Optional[str] = None) -> dict:
|
|
53
|
+
"""[READ] Client connections grouped by peer host, busiest first.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
target: rabbitmq target name from config; omit for the default.
|
|
57
|
+
"""
|
|
58
|
+
return ops.list_connections(_get_connection(target))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
@governed_tool(risk_level="low")
|
|
63
|
+
@tool_errors("dict")
|
|
64
|
+
def list_channels(target: Optional[str] = None) -> dict:
|
|
65
|
+
"""[READ] Channels with unacked/prefetch pressure, most unacked first.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
target: rabbitmq target name from config; omit for the default.
|
|
69
|
+
"""
|
|
70
|
+
return ops.list_channels(_get_connection(target))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
@governed_tool(risk_level="low")
|
|
75
|
+
@tool_errors("dict")
|
|
76
|
+
def list_policies(vhost: Optional[str] = None, target: Optional[str] = None) -> dict:
|
|
77
|
+
"""[READ] Policies (optionally one vhost) with pattern, priority, definition.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
vhost: Restrict to one vhost (the default vhost is '/'); omit for all.
|
|
81
|
+
target: rabbitmq target name from config; omit for the default.
|
|
82
|
+
"""
|
|
83
|
+
return ops.list_policies(_get_connection(target), vhost)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@mcp.tool()
|
|
87
|
+
@governed_tool(risk_level="low")
|
|
88
|
+
@tool_errors("dict")
|
|
89
|
+
def node_health(target: Optional[str] = None) -> dict:
|
|
90
|
+
"""[READ] Node memory/disk/fd posture + watermark alarms, most loaded first.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
target: rabbitmq target name from config; omit for the default.
|
|
94
|
+
"""
|
|
95
|
+
return ops.node_health(_get_connection(target))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""redis read MCP tools — server, memory, clients, slowlog, config, keys."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from mcp_server._shared import _get_connection, mcp, tool_errors
|
|
6
|
+
from queue_aiops.governance import governed_tool
|
|
7
|
+
from queue_aiops.ops import redis_reads as ops
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
@governed_tool(risk_level="low")
|
|
12
|
+
@tool_errors("dict")
|
|
13
|
+
def redis_server_info(target: Optional[str] = None) -> dict:
|
|
14
|
+
"""[READ] redis server identity + health basics: version, role, clients, ops/sec, hit rate.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
target: redis target name from config; omit for the default.
|
|
18
|
+
"""
|
|
19
|
+
return ops.server_info(_get_connection(target))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
@governed_tool(risk_level="low")
|
|
24
|
+
@tool_errors("dict")
|
|
25
|
+
def redis_memory_stats(target: Optional[str] = None) -> dict:
|
|
26
|
+
"""[READ] redis memory posture: used vs maxmemory, eviction policy, fragmentation, peaks.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
target: redis target name from config; omit for the default.
|
|
30
|
+
"""
|
|
31
|
+
return ops.memory_stats(_get_connection(target))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
@governed_tool(risk_level="low")
|
|
36
|
+
@tool_errors("dict")
|
|
37
|
+
def redis_clients(target: Optional[str] = None) -> dict:
|
|
38
|
+
"""[READ] Connected redis clients grouped by source address, busiest first.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
target: redis target name from config; omit for the default.
|
|
42
|
+
"""
|
|
43
|
+
return ops.list_clients(_get_connection(target))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
@governed_tool(risk_level="low")
|
|
48
|
+
@tool_errors("dict")
|
|
49
|
+
def redis_slowlog(count: int = 128, target: Optional[str] = None) -> dict:
|
|
50
|
+
"""[READ] Recent SLOWLOG entries, slowest first.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
count: Max entries to pull (capped at 128).
|
|
54
|
+
target: redis target name from config; omit for the default.
|
|
55
|
+
"""
|
|
56
|
+
return ops.slowlog(_get_connection(target), count)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
@governed_tool(risk_level="low")
|
|
61
|
+
@tool_errors("dict")
|
|
62
|
+
def redis_config_get(pattern: str = "*", target: Optional[str] = None) -> dict:
|
|
63
|
+
"""[READ] CONFIG GET for a glob pattern (e.g. 'maxmemory*').
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
pattern: Config-name glob (default '*').
|
|
67
|
+
target: redis target name from config; omit for the default.
|
|
68
|
+
"""
|
|
69
|
+
return ops.config_get(_get_connection(target), pattern)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@mcp.tool()
|
|
73
|
+
@governed_tool(risk_level="low")
|
|
74
|
+
@tool_errors("dict")
|
|
75
|
+
def redis_keyspace(target: Optional[str] = None) -> dict:
|
|
76
|
+
"""[READ] Per-db key counts and expiry coverage from INFO keyspace.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
target: redis target name from config; omit for the default.
|
|
80
|
+
"""
|
|
81
|
+
return ops.keyspace(_get_connection(target))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@mcp.tool()
|
|
85
|
+
@governed_tool(risk_level="low")
|
|
86
|
+
@tool_errors("dict")
|
|
87
|
+
def redis_big_keys(top: int = 20, target: Optional[str] = None) -> dict:
|
|
88
|
+
"""[READ] SCAN-budgeted big-key sample — largest sampled keys first.
|
|
89
|
+
|
|
90
|
+
Walks at most 10,000 keys with SCAN and sizes at most 200 of them with
|
|
91
|
+
MEMORY USAGE (never KEYS *), so it is safe on a production instance;
|
|
92
|
+
coveragePct reports how partial the sample is.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
top: How many key rows to return, largest first (default 20).
|
|
96
|
+
target: redis target name from config; omit for the default.
|
|
97
|
+
"""
|
|
98
|
+
return ops.big_key_sample(_get_connection(target), top)
|
mcp_server/tools/undo.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Undo executor — list recorded inverse descriptors and APPLY them.
|
|
2
|
+
|
|
3
|
+
Every governed write records a replayable inverse descriptor (tool + params) to
|
|
4
|
+
``undo.db``. This module closes the loop: ``undo_apply`` looks up a recorded
|
|
5
|
+
descriptor and dispatches it to the named governed tool, so the inverse runs on
|
|
6
|
+
the SAME governance path as any other call (audited, budget-checked, and — if
|
|
7
|
+
the inverse itself is destructive — re-gated by its own risk tier / approver
|
|
8
|
+
requirement). ``undo_apply`` is itself governed; the real risk is enforced by
|
|
9
|
+
the inner tool it calls.
|
|
10
|
+
|
|
11
|
+
Note: recorded ``undo_params`` are the redacted safe-params captured at record
|
|
12
|
+
time, so an inverse that would need a secret value cannot be replayed here — by
|
|
13
|
+
design, inverses in this line key off ids/names, not credentials.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from mcp_server._shared import mcp, tool_errors
|
|
20
|
+
from queue_aiops.governance import governed_tool
|
|
21
|
+
from queue_aiops.governance.undo import get_undo_store
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_tool(name: str) -> Any:
|
|
25
|
+
"""Return the governed callable registered under ``name`` (or None)."""
|
|
26
|
+
tool = mcp._tool_manager._tools.get(name)
|
|
27
|
+
return getattr(tool, "fn", None) if tool else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
@governed_tool(risk_level="low")
|
|
32
|
+
@tool_errors("dict")
|
|
33
|
+
def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
34
|
+
"""[READ] List recorded, not-yet-applied undo tokens (most recent first).
|
|
35
|
+
|
|
36
|
+
Each entry names the original tool, the inverse tool that ``undo_apply``
|
|
37
|
+
would run, and a human note. Use the ``undoId`` with ``undo_apply``.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
limit: Max rows to return (default 50).
|
|
41
|
+
target: Unused (undo state is host-local); accepted for CLI uniformity.
|
|
42
|
+
"""
|
|
43
|
+
rows = get_undo_store().list(status="recorded", limit=max(1, min(limit, 500)))
|
|
44
|
+
return {
|
|
45
|
+
"count": len(rows),
|
|
46
|
+
"undos": [
|
|
47
|
+
{
|
|
48
|
+
"undoId": r["undo_id"],
|
|
49
|
+
"ts": r["ts"],
|
|
50
|
+
"originalTool": r["tool"],
|
|
51
|
+
"inverseTool": r["undo_tool"],
|
|
52
|
+
"note": r.get("note", ""),
|
|
53
|
+
}
|
|
54
|
+
for r in rows
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
@governed_tool(risk_level="medium")
|
|
61
|
+
@tool_errors("dict")
|
|
62
|
+
def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None) -> dict:
|
|
63
|
+
"""[WRITE][risk=medium] Apply a recorded undo by dispatching its inverse tool.
|
|
64
|
+
|
|
65
|
+
The inverse runs through its own governed tool, so its real risk tier and
|
|
66
|
+
any approver requirement are enforced there. Pass dry_run=True to preview
|
|
67
|
+
the inverse call without executing it. A token can only be applied once.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
undo_id: The undoId from undo_list (or an ``_undo_id`` in a write result).
|
|
71
|
+
dry_run: If True, preview the inverse tool + params without running it.
|
|
72
|
+
target: Passed through to the inverse tool when it accepts a target.
|
|
73
|
+
"""
|
|
74
|
+
store = get_undo_store()
|
|
75
|
+
rec = store.get(undo_id)
|
|
76
|
+
if not rec:
|
|
77
|
+
raise ValueError(f"Unknown undo id '{undo_id}'. Run undo_list to see available tokens.")
|
|
78
|
+
if rec["status"] != "recorded":
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Undo '{undo_id}' is already '{rec['status']}' — a token can only be applied once."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
inverse_tool = rec["undo_tool"]
|
|
84
|
+
try:
|
|
85
|
+
params = json.loads(rec["undo_params"]) if rec["undo_params"] else {}
|
|
86
|
+
except (ValueError, TypeError):
|
|
87
|
+
params = {}
|
|
88
|
+
if not isinstance(params, dict):
|
|
89
|
+
params = {}
|
|
90
|
+
|
|
91
|
+
fn = _resolve_tool(inverse_tool)
|
|
92
|
+
if fn is None:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if dry_run:
|
|
98
|
+
return {
|
|
99
|
+
"dryRun": True,
|
|
100
|
+
"undoId": undo_id,
|
|
101
|
+
"wouldApply": {"tool": inverse_tool, "params": params},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
call_params = dict(params)
|
|
105
|
+
if target is not None and "target" not in call_params:
|
|
106
|
+
call_params["target"] = target
|
|
107
|
+
result = fn(**call_params)
|
|
108
|
+
|
|
109
|
+
# Only mark applied when the inverse did not itself return an error dict.
|
|
110
|
+
if not (isinstance(result, dict) and result.get("error")):
|
|
111
|
+
store.mark(undo_id, "applied")
|
|
112
|
+
applied = True
|
|
113
|
+
else:
|
|
114
|
+
applied = False
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"undoId": undo_id,
|
|
118
|
+
"applied": applied,
|
|
119
|
+
"inverseTool": inverse_tool,
|
|
120
|
+
"result": result,
|
|
121
|
+
}
|