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.
Files changed (48) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +107 -0
  3. mcp_server/server.py +38 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/analysis.py +148 -0
  6. mcp_server/tools/overview.py +23 -0
  7. mcp_server/tools/rabbit_reads.py +95 -0
  8. mcp_server/tools/redis_reads.py +98 -0
  9. mcp_server/tools/undo.py +121 -0
  10. mcp_server/tools/writes.py +355 -0
  11. queue_aiops/__init__.py +14 -0
  12. queue_aiops/cli/__init__.py +9 -0
  13. queue_aiops/cli/_common.py +78 -0
  14. queue_aiops/cli/_root.py +62 -0
  15. queue_aiops/cli/analyze.py +85 -0
  16. queue_aiops/cli/doctor.py +21 -0
  17. queue_aiops/cli/init.py +176 -0
  18. queue_aiops/cli/overview.py +16 -0
  19. queue_aiops/cli/rabbit_cmds.py +234 -0
  20. queue_aiops/cli/redis_cmds.py +147 -0
  21. queue_aiops/cli/secret.py +105 -0
  22. queue_aiops/cli/undo.py +62 -0
  23. queue_aiops/config.py +175 -0
  24. queue_aiops/connection.py +277 -0
  25. queue_aiops/doctor.py +98 -0
  26. queue_aiops/governance/__init__.py +40 -0
  27. queue_aiops/governance/audit.py +377 -0
  28. queue_aiops/governance/budget.py +225 -0
  29. queue_aiops/governance/decorators.py +482 -0
  30. queue_aiops/governance/paths.py +23 -0
  31. queue_aiops/governance/patterns.py +378 -0
  32. queue_aiops/governance/policy.py +430 -0
  33. queue_aiops/governance/sanitize.py +45 -0
  34. queue_aiops/governance/undo.py +218 -0
  35. queue_aiops/ops/__init__.py +1 -0
  36. queue_aiops/ops/_util.py +55 -0
  37. queue_aiops/ops/analysis.py +591 -0
  38. queue_aiops/ops/overview.py +90 -0
  39. queue_aiops/ops/rabbit_reads.py +218 -0
  40. queue_aiops/ops/redis_reads.py +237 -0
  41. queue_aiops/ops/writes.py +284 -0
  42. queue_aiops/platform.py +217 -0
  43. queue_aiops/secretstore.py +304 -0
  44. queue_aiops-0.1.0.dist-info/METADATA +168 -0
  45. queue_aiops-0.1.0.dist-info/RECORD +48 -0
  46. queue_aiops-0.1.0.dist-info/WHEEL +4 -0
  47. queue_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  48. 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)
@@ -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
+ }