queue-aiops 0.9.3__tar.gz → 0.10.0__tar.gz

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 (99) hide show
  1. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.claude-plugin/plugin.json +1 -1
  2. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.mcp.json +1 -1
  3. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/CHANGELOG.md +34 -0
  4. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/PKG-INFO +1 -1
  5. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/RELEASE_NOTES.md +3 -0
  6. queue_aiops-0.10.0/mcp_server/_annotations.py +66 -0
  7. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/server.py +5 -0
  8. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/openclaw.plugin.json +1 -1
  9. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/package.json +1 -1
  10. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/pyproject.toml +1 -1
  11. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/analysis.py +5 -1
  12. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/server.json +2 -2
  13. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/SKILL.md +2 -2
  14. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/agent-guardrails.md +10 -2
  15. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_analysis.py +68 -0
  16. queue_aiops-0.10.0/tests/test_tool_annotations.py +128 -0
  17. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/uv.lock +1 -1
  18. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.github/workflows/mcp-publish.yml +0 -0
  19. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.github/workflows/publish.yml +0 -0
  20. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.gitignore +0 -0
  21. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/Dockerfile +0 -0
  22. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/LICENSE +0 -0
  23. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/README.md +0 -0
  24. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/SECURITY.md +0 -0
  25. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/docs/VERIFICATION.md +0 -0
  26. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/glama.json +0 -0
  27. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/__init__.py +0 -0
  28. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/_shared.py +0 -0
  29. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/__init__.py +0 -0
  30. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/analysis.py +0 -0
  31. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/overview.py +0 -0
  32. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/rabbit_reads.py +0 -0
  33. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/redis_reads.py +0 -0
  34. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/undo.py +0 -0
  35. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/writes.py +0 -0
  36. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/__init__.py +0 -0
  37. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/__init__.py +0 -0
  38. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/_common.py +0 -0
  39. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/_root.py +0 -0
  40. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/analyze.py +0 -0
  41. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/doctor.py +0 -0
  42. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/init.py +0 -0
  43. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/overview.py +0 -0
  44. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/rabbit_cmds.py +0 -0
  45. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/redis_cmds.py +0 -0
  46. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/secret.py +0 -0
  47. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/undo.py +0 -0
  48. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/config.py +0 -0
  49. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/connection.py +0 -0
  50. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/doctor.py +0 -0
  51. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/__init__.py +0 -0
  52. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/audit.py +0 -0
  53. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/budget.py +0 -0
  54. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/decorators.py +0 -0
  55. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/outcome.py +0 -0
  56. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/paths.py +0 -0
  57. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/patterns.py +0 -0
  58. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/policy.py +0 -0
  59. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/sanitize.py +0 -0
  60. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/undo.py +0 -0
  61. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/__init__.py +0 -0
  62. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/_util.py +0 -0
  63. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/overview.py +0 -0
  64. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/rabbit_reads.py +0 -0
  65. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/redis_reads.py +0 -0
  66. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/writes.py +0 -0
  67. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/platform.py +0 -0
  68. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/secretstore.py +0 -0
  69. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/capabilities.md +0 -0
  70. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/cli-reference.md +0 -0
  71. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/setup-guide.md +0 -0
  72. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/smithery.yaml +0 -0
  73. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/conftest.py +0 -0
  74. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/fakes.py +0 -0
  75. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_audited.py +0 -0
  76. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_governed_exit.py +0 -0
  77. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_rabbit_writes.py +0 -0
  78. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_reads.py +0 -0
  79. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_secret.py +0 -0
  80. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_writes.py +0 -0
  81. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_connection.py +0 -0
  82. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_doctor.py +0 -0
  83. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_audit.py +0 -0
  84. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_decorators.py +0 -0
  85. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_patterns.py +0 -0
  86. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_policy.py +0 -0
  87. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_governance_persistence.py +0 -0
  88. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_init.py +0 -0
  89. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_int_fidelity.py +0 -0
  90. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_mcp_reads.py +0 -0
  91. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_optional_fields.py +0 -0
  92. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_platform.py +0 -0
  93. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_reads.py +0 -0
  94. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_secretstore.py +0 -0
  95. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_self_kill.py +0 -0
  96. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_self_lockout.py +0 -0
  97. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_smoke.py +0 -0
  98. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_undo_executor.py +0 -0
  99. {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_writes.py +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "queue-aiops",
4
4
  "displayName": "Queue AIops",
5
- "version": "0.9.3",
5
+ "version": "0.10.0",
6
6
  "description": "Governed redis + rabbitmq ops: memory/latency/backlog/churn RCA, policies. 28 tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "queue-aiops==0.9.3",
8
+ "queue-aiops==0.10.0",
9
9
  "queue-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.10.0 — 2026-10-15
4
+
5
+ ### Added
6
+ - MCP tool annotations derived from each tool's governance `risk_level`, so
7
+ clients can gate approval from `tools/list` alone. `low` tools advertise
8
+ `readOnlyHint: true`; every other tool advertises `readOnlyHint: false` and
9
+ `destructiveHint: true`. The `medium` tier includes deletes and hard
10
+ power-offs, so a tool is never advertised as non-destructive on the strength
11
+ of its tier. `idempotentHint` only repeats an explicit declaration. The exact
12
+ level is published in `_meta` under `io.github.AIops-tools/riskLevel`. These
13
+ are hints for the client; audit and undo are unchanged.
14
+
15
+ ## v0.9.4 — 2026-09-16
16
+
17
+ ### Fixed
18
+ - `redis_memory_pressure_rca`'s top-level `pressure` flag no longer decides by
19
+ comparing another finding's `cause` against the literal sentence "Healthy —
20
+ within thresholds". The flag is behaviourally unchanged, but rewording that
21
+ sentence — or normalising its em dash in a style sweep — would have reported
22
+ pressure on a healthy broker. It now reads the measured condition captured
23
+ before the healthy placeholder is appended, and an AST-based structural test
24
+ pins the whole package against a decision being taken on a finding's prose —
25
+ either operand order, the literal hoisted into a constant, `.get()`, `in`,
26
+ `startswith`, slicing and `.lower()` are all rejected.
27
+ - `agent-guardrails.md` and `SKILL.md` claimed the four RCAs "rank findings
28
+ worst-first" and that "priority is in the payload, not implied by list
29
+ position". Neither was ever true here: there is no `rank` key, and findings are
30
+ appended in the order the checks run. The claim sat in the table headed "what
31
+ the tool enforces — do not waste prompt budget on these", so it actively told
32
+ operators to drop the prompt that would have compensated. A broker at 12 % of
33
+ `maxmemory` with 35 GiB of oversized keys reports the fragmentation finding
34
+ first, purely because fragmentation is checked earlier. The docs now say order
35
+ is not severity, and point at the `evidence` number each finding does carry.
36
+
3
37
  ## v0.9.3 — 2026-09-15
4
38
 
5
39
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: queue-aiops
3
- Version: 0.9.3
3
+ Version: 0.10.0
4
4
  Summary: Governed AI-ops for redis + rabbitmq: memory-pressure/latency/queue-backlog/connection-churn RCAs, slowlog and SCAN-budgeted big-key sampling, queues/policies/nodes, and governed writes (config set, client kill, purge/delete queue, policies) with a built-in governance harness (audit, budget, undo, risk tiers)
5
5
  Author-email: Wei Zhou <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
@@ -1,5 +1,8 @@
1
1
  # Release notes — queue-aiops 0.4.0
2
2
 
3
+ > Historical — this file covers the 0.4.0 release only and is not updated.
4
+ > For every release since, see [CHANGELOG.md](CHANGELOG.md) and the GitHub releases.
5
+
3
6
  Previous release: 0.3.0.
4
7
 
5
8
  ## BREAKING — the authorization layer is removed
@@ -0,0 +1,66 @@
1
+ """Derive MCP tool annotations from the governance risk level.
2
+
3
+ Every tool already declares ``risk_level`` on ``@governed_tool``; this module
4
+ publishes it to MCP clients as the standard ``ToolAnnotations`` hints plus the
5
+ exact level under ``_meta``, so a client can gate approval without reading our
6
+ docstrings.
7
+
8
+ Only the safe direction is ever asserted:
9
+
10
+ - ``low`` → ``readOnlyHint: true``. Backed by the smoke-test invariant that
11
+ ``risk_level == "low"`` exactly when the docstring says ``[READ]``.
12
+ - anything else → ``readOnlyHint: false, destructiveHint: true``. The
13
+ ``medium`` tier is NOT non-destructive — it holds deletes, hard power-offs,
14
+ state kills and stat resets — so claiming ``destructiveHint: false`` from the
15
+ tier would tell a client it may skip approval for a delete. Over-warning
16
+ costs a confirmation prompt; under-warning costs data.
17
+ - ``idempotentHint`` only echoes an explicit ``@governed_tool(idempotent=True)``
18
+ declaration; it is never inferred.
19
+ - ``openWorldHint`` is left unset (spec default ``true``): every tool talks to
20
+ an external system.
21
+
22
+ This file is identical across the AIops line; keep it that way.
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ from mcp.types import ToolAnnotations
28
+
29
+ RISK_META_KEY = "io.github.AIops-tools/riskLevel"
30
+ RISK_LEVELS = frozenset({"low", "medium", "high", "critical"})
31
+
32
+
33
+ def annotations_for(risk_level: str, idempotent: bool) -> ToolAnnotations:
34
+ """Return the MCP hints that a tool of ``risk_level`` may honestly claim."""
35
+ if risk_level not in RISK_LEVELS:
36
+ raise ValueError(
37
+ f"unknown risk_level {risk_level!r}; expected one of {sorted(RISK_LEVELS)}"
38
+ )
39
+ if risk_level == "low":
40
+ return ToolAnnotations(read_only_hint=True)
41
+ return ToolAnnotations(
42
+ read_only_hint=False,
43
+ destructive_hint=True,
44
+ idempotent_hint=idempotent,
45
+ )
46
+
47
+
48
+ def apply_risk_annotations(server: Any) -> None:
49
+ """Replace every registered tool with a copy carrying its risk annotations.
50
+
51
+ Fails fast on a tool without ``@governed_tool`` metadata: an unannotated
52
+ tool would be advertised with the spec defaults, i.e. as a destructive
53
+ write with no stated risk level — wrong for a read, silent for a write.
54
+ """
55
+ tools = server._tool_manager._tools # same private registry undo.py relies on
56
+ for name, tool in list(tools.items()):
57
+ risk_level = getattr(tool.fn, "_risk_level", None)
58
+ if risk_level is None:
59
+ raise RuntimeError(f"tool {name!r} has no @governed_tool risk_level")
60
+ idempotent = bool(getattr(tool.fn, "_idempotent", False))
61
+ tools[name] = tool.model_copy(
62
+ update={
63
+ "annotations": annotations_for(risk_level, idempotent),
64
+ "meta": {**(tool.meta or {}), RISK_META_KEY: risk_level},
65
+ }
66
+ )
@@ -17,6 +17,7 @@ License: MIT
17
17
  import logging
18
18
  import os
19
19
 
20
+ from mcp_server._annotations import apply_risk_annotations
20
21
  from mcp_server._shared import _safe_error, mcp, tool_errors
21
22
 
22
23
  # Importing the tool modules registers every @mcp.tool() onto the shared
@@ -30,6 +31,10 @@ from mcp_server.tools import ( # noqa: F401 — side effects
30
31
  writes,
31
32
  )
32
33
 
34
+ # Publish each tool's governance risk level as MCP annotations. Runs after
35
+ # every tool module has registered, so no tool is advertised unannotated.
36
+ apply_risk_annotations(mcp)
37
+
33
38
  __all__ = ["mcp", "main", "_safe_error", "tool_errors"]
34
39
 
35
40
  logger = logging.getLogger(__name__)
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "queue-aiops==0.9.3",
14
+ "queue-aiops==0.10.0",
15
15
  "queue-aiops-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/queue-aiops",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "Governed redis + rabbitmq ops: memory/latency/backlog/churn RCA, policies. 28 tools.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "queue-aiops"
7
- version = "0.9.3"
7
+ version = "0.10.0"
8
8
  description = "Governed AI-ops for redis + rabbitmq: memory-pressure/latency/queue-backlog/connection-churn RCAs, slowlog and SCAN-budgeted big-key sampling, queues/policies/nodes, and governed writes (config set, client kill, purge/delete queue, policies) with a built-in governance harness (audit, budget, undo, risk tiers)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -126,6 +126,10 @@ def redis_memory_pressure_rca(
126
126
  "evidence": {"bigKeys": big[:10]},
127
127
  })
128
128
 
129
+ # Captured before the healthy placeholder is appended. The flag reports the measured
130
+ # condition — did any check fire — and is never derived from another finding's prose:
131
+ # a reworded sentence would otherwise report pressure on a healthy broker.
132
+ pressure = bool(findings)
129
133
  if not findings:
130
134
  findings.append({
131
135
  "cause": "Healthy — within thresholds",
@@ -133,7 +137,7 @@ def redis_memory_pressure_rca(
133
137
  "evidence": {"usedPctOfMax": used_of_max, "fragmentationRatio": frag},
134
138
  })
135
139
  return {
136
- "pressure": any(f["cause"] != "Healthy — within thresholds" for f in findings),
140
+ "pressure": pressure,
137
141
  "usedPctOfMax": used_of_max,
138
142
  "maxmemoryPolicy": policy,
139
143
  "fragmentationRatio": frag,
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Queue-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.9.3",
10
+ "version": "0.10.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "queue-aiops",
15
- "version": "0.9.3",
15
+ "version": "0.10.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -121,8 +121,8 @@ skill.
121
121
  works before you read numbers off it.
122
122
  2. `queue-aiops redis memory` → used vs `maxmemory`, the eviction policy in force, and
123
123
  the fragmentation ratio, straight from `INFO memory`.
124
- 3. `queue-aiops analyze memory --used-pct 85` → ranked findings, each citing its measured
125
- number: **noeviction near the limit** (the dangerous one — writes will start failing
124
+ 3. `queue-aiops analyze memory --used-pct 85` → findings in check order, each citing its measured
125
+ number (order is not severity — weigh them all): **noeviction near the limit** (the dangerous one — writes will start failing
126
126
  with OOM rather than evicting), active eviction in progress, fragmentation versus real
127
127
  swapping, and oversized keys.
128
128
  4. `queue-aiops redis bigkeys --count 500` → a SCAN-budgeted sample of the largest keys,
@@ -31,7 +31,7 @@ What the tool *does* guarantee is that you can always see what happened:
31
31
  |---|---|
32
32
  | "Don't invent a value when a field is missing" | RabbitMQ omits `idle_since` for an active queue; a Redis primary reports no `master_link_status`; an unnamed client has no name. Those come back as `null`, never as `""`. |
33
33
  | "Tell me if the output was cut off" | `redis_slowlog`, `redis_clients`, `redis_big_keys`, `list_queues`, `list_connections`, `list_channels` and `list_policies` all return `{"<items>": [...], "returned": N, "limit": L, "truncated": true/false}`. Truncation is measured — one extra entry is requested from Redis — not guessed from a length coincidence. |
34
- | "Preserve the ordering / tell me what's most urgent" | `rabbitmq_queue_backlog_rca`, `redis_memory_pressure_rca`, `redis_latency_rca` and `connection_churn_analysis` rank findings worst-first, each carrying the measured number it was based on. Priority is in the payload, not implied by list position. |
34
+ | "Make it show the number it judged on" | Every finding from `rabbitmq_queue_backlog_rca`, `redis_memory_pressure_rca`, `redis_latency_rca` and `connection_churn_analysis` carries the measured value it was based on under `evidence`, so a claim can always be checked against a number rather than taken on the model's word. |
35
35
  | "Don't run KEYS * on production" | `redis_big_keys` uses SCAN under a hard key budget and sizes only an evenly-spaced subset with MEMORY USAGE. There is no code path that can issue `KEYS *`. `coveragePct` reports how partial the walk was. |
36
36
  | "Confirm before anything destructive" | `delete_queue` and `purge_queue` require a `--dry-run`-able preview plus double confirmation at the CLI. |
37
37
  | "Log what you did" | Every governed call is audited to `~/.queue-aiops/audit.db` regardless of what the model says it did — and the CLI writes the same row the MCP path does, so there is no unaudited entry point. |
@@ -40,7 +40,15 @@ What the tool *does* guarantee is that you can always see what happened:
40
40
  ## What still needs a prompt
41
41
 
42
42
  These are model-behaviour problems the harness cannot fix from the outside.
43
- Copy this into your agent's system prompt:
43
+
44
+ ⚠️ **Finding order is not priority.** These RCAs append findings in the order the checks
45
+ run, not worst-first, and there is no `rank` key in the payload. A broker at 12 % of
46
+ `maxmemory` with 35 GiB of oversized keys reports the fragmentation finding first purely
47
+ because fragmentation is checked before big keys. Do not let the model treat `findings[0]`
48
+ as the headline — make it weigh every finding's `evidence` and say which one it acted on.
49
+ (Earlier versions of this file claimed these lists were ranked worst-first. They never were.)
50
+
51
+ Recommended setup for a local model (adapt it to your agent client):
44
52
 
45
53
  ```text
46
54
  You operate a RabbitMQ broker or a Redis instance through the queue-aiops MCP
@@ -2,6 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ast
6
+ from pathlib import Path
7
+
5
8
  import pytest
6
9
 
7
10
  from queue_aiops.ops import analysis as ops
@@ -200,3 +203,68 @@ def test_churn_healthy_reports_metrics():
200
203
  })
201
204
  assert out["findings"][0]["action"] == "No action needed."
202
205
  assert out["metrics"]["channels"] == 8
206
+
207
+
208
+ @pytest.mark.unit
209
+ def test_no_decision_is_derived_from_a_findings_display_text():
210
+ """A rule's input is never another rule's display text.
211
+
212
+ `pressure` used to be `any(f["cause"] != "Healthy — within thresholds" ...)`, so
213
+ rewording that sentence — or normalising its em dash in a style sweep — would have
214
+ reported pressure on a healthy broker. The check is AST-based on purpose: a regex on
215
+ the source missed the shape that produced the original incident in another repo
216
+ (`"critical blocker" in f["signal"]`, literal on the left), and also fired on prose
217
+ inside comments and docstrings. Walking the tree catches either operand order, the
218
+ literal hoisted into a constant, `.get()`, `.lower()`, slicing and `startswith`, and
219
+ ignores comments entirely.
220
+ """
221
+ offenders = []
222
+ for path in _scanned_sources():
223
+ tree = ast.parse(path.read_text(encoding="utf-8"))
224
+ for node in ast.walk(tree):
225
+ if isinstance(node, ast.Compare):
226
+ operands = [node.left, *node.comparators]
227
+ if any(_reads_display_text(o) for o in operands) and not _is_none_check(node):
228
+ offenders.append(f"{path.name}:{node.lineno}: {ast.unparse(node)}")
229
+ elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \
230
+ and node.func.attr in _TEXT_PREDICATES and _reads_display_text(node.func.value):
231
+ offenders.append(f"{path.name}:{node.lineno}: {ast.unparse(node)}")
232
+ assert offenders == [], ("a decision is being derived from a finding's prose:\n"
233
+ + "\n".join(offenders))
234
+
235
+
236
+ _DISPLAY_KEYS = {"cause", "action", "signal", "note", "summary"}
237
+ _TEXT_PREDICATES = {"startswith", "endswith", "count", "find", "index"}
238
+
239
+
240
+ def _scanned_sources():
241
+ """Every module that could post-process a finding — not just the one that builds them.
242
+
243
+ `governance/` is excluded: it is vendored verbatim across the line and held byte-identical
244
+ by the drift gate, so it is not this repo's to edit.
245
+ """
246
+ root = Path(ops.__file__).parents[2]
247
+ found = sorted(root.glob("queue_aiops/**/*.py")) + sorted(root.glob("mcp_server/**/*.py"))
248
+ files = [p for p in found if "governance" not in p.parts]
249
+ assert len(files) > 10, f"source sweep found only {len(files)} modules — the glob is wrong"
250
+ return files
251
+
252
+
253
+ def _reads_display_text(node):
254
+ """True if evaluating this expression reads a finding's display field, however wrapped."""
255
+ for sub in ast.walk(node):
256
+ if isinstance(sub, ast.Subscript) and isinstance(sub.slice, ast.Constant) \
257
+ and sub.slice.value in _DISPLAY_KEYS:
258
+ return True
259
+ if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) \
260
+ and sub.func.attr == "get" and sub.args \
261
+ and isinstance(sub.args[0], ast.Constant) \
262
+ and sub.args[0].value in _DISPLAY_KEYS:
263
+ return True
264
+ return False
265
+
266
+
267
+ def _is_none_check(node):
268
+ """`f["cause"] is None` is a presence test, not a decision taken on the prose."""
269
+ return all(isinstance(op, (ast.Is, ast.IsNot)) for op in node.ops) and all(
270
+ isinstance(c, ast.Constant) and c.value is None for c in node.comparators)
@@ -0,0 +1,128 @@
1
+ """Invariants for the MCP annotations derived from ``risk_level``.
2
+
3
+ Asserted on ``list_tools()`` — the payload a client actually receives — not on
4
+ the internal registry, so a regression in how annotations reach the wire fails
5
+ here too. This file is identical across the AIops line.
6
+ """
7
+
8
+ import asyncio
9
+ import re
10
+ from types import SimpleNamespace
11
+
12
+ import pytest
13
+ from mcp.server import MCPServer
14
+
15
+ import mcp_server.server as server
16
+ from mcp_server._annotations import (
17
+ RISK_META_KEY,
18
+ annotations_for,
19
+ apply_risk_annotations,
20
+ )
21
+
22
+
23
+ @pytest.fixture(scope="module")
24
+ def listed():
25
+ return asyncio.run(server.mcp.list_tools())
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def registry():
30
+ return server.mcp._tool_manager._tools
31
+
32
+
33
+ def test_every_tool_is_annotated(listed):
34
+ assert listed, "no tools listed"
35
+ missing = [t.name for t in listed if t.annotations is None or not t.meta]
36
+ assert not missing, f"tools advertised without annotations: {missing}"
37
+
38
+
39
+ def test_meta_carries_the_declared_risk_level(listed, registry):
40
+ wrong = [t.name for t in listed if t.meta.get(RISK_META_KEY) != registry[t.name].fn._risk_level]
41
+ assert not wrong, f"_meta risk level differs from @governed_tool: {wrong}"
42
+
43
+
44
+ def test_read_only_hint_true_exactly_for_low_risk(listed):
45
+ wrong = [
46
+ t.name for t in listed if t.annotations.read_only_hint != (t.meta[RISK_META_KEY] == "low")
47
+ ]
48
+ assert not wrong, f"readOnlyHint disagrees with risk_level: {wrong}"
49
+
50
+
51
+ def test_no_write_claims_to_be_non_destructive(listed):
52
+ # medium holds deletes and hard power-offs; the tier cannot prove safety.
53
+ wrong = [
54
+ t.name
55
+ for t in listed
56
+ if not t.annotations.read_only_hint and t.annotations.destructive_hint is not True
57
+ ]
58
+ assert not wrong, f"writes advertised as non-destructive: {wrong}"
59
+
60
+
61
+ def test_idempotent_hint_only_echoes_an_explicit_declaration(listed, registry):
62
+ wrong = [
63
+ t.name
64
+ for t in listed
65
+ if t.annotations.idempotent_hint and not registry[t.name].fn._idempotent
66
+ ]
67
+ assert not wrong, f"idempotentHint inferred without a declaration: {wrong}"
68
+
69
+
70
+ def test_open_world_hint_is_never_narrowed(listed):
71
+ assert all(t.annotations.open_world_hint in (None, True) for t in listed)
72
+
73
+
74
+ @pytest.mark.parametrize("level", ["low", "medium", "high", "critical"])
75
+ def test_mapping_per_level(level):
76
+ hints = annotations_for(level, idempotent=False)
77
+ if level == "low":
78
+ assert hints.read_only_hint is True
79
+ assert hints.destructive_hint is None
80
+ else:
81
+ assert hints.read_only_hint is False
82
+ assert hints.destructive_hint is True
83
+ assert hints.idempotent_hint is False
84
+
85
+
86
+ def test_declared_idempotent_write_is_echoed():
87
+ assert annotations_for("medium", idempotent=True).idempotent_hint is True
88
+
89
+
90
+ def test_unknown_risk_level_is_rejected():
91
+ with pytest.raises(ValueError, match="unknown risk_level"):
92
+ annotations_for("Low", idempotent=False)
93
+
94
+
95
+ def test_ungoverned_tool_fails_fast():
96
+ fake_tool = SimpleNamespace(fn=lambda: None, meta=None)
97
+ fake = SimpleNamespace(_tool_manager=SimpleNamespace(_tools={"rogue": fake_tool}))
98
+ with pytest.raises(RuntimeError, match="rogue"):
99
+ apply_risk_annotations(fake)
100
+
101
+
102
+ def test_meta_key_is_the_published_contract():
103
+ # Clients match on this literal; it must be a valid, non-reserved _meta key.
104
+ assert RISK_META_KEY == "io.github.AIops-tools/riskLevel"
105
+ prefix, name = RISK_META_KEY.split("/")
106
+ label = re.compile(r"^[A-Za-z]([A-Za-z0-9-]*[A-Za-z0-9])?$")
107
+ labels = prefix.split(".")
108
+ assert all(label.match(part) for part in labels)
109
+ assert not {"mcp", "modelcontextprotocol"} & {part.lower() for part in labels}
110
+ assert re.match(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$", name)
111
+
112
+
113
+ def test_existing_meta_survives_on_a_real_tool():
114
+ fresh = MCPServer("annotations-test")
115
+
116
+ def probe() -> str:
117
+ return "ok"
118
+
119
+ probe._risk_level = "medium"
120
+ probe._idempotent = True
121
+ fresh.tool(meta={"x": 1})(probe)
122
+ apply_risk_annotations(fresh)
123
+
124
+ (listed_tool,) = asyncio.run(fresh.list_tools())
125
+ assert listed_tool.meta == {"x": 1, RISK_META_KEY: "medium"}
126
+ assert listed_tool.annotations.destructive_hint is True
127
+ assert listed_tool.annotations.idempotent_hint is True
128
+ assert fresh._tool_manager._tools["probe"].fn is probe
@@ -812,7 +812,7 @@ wheels = [
812
812
 
813
813
  [[package]]
814
814
  name = "queue-aiops"
815
- version = "0.9.3"
815
+ version = "0.10.0"
816
816
  source = { editable = "." }
817
817
  dependencies = [
818
818
  { name = "cryptography" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes