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.
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.claude-plugin/plugin.json +1 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.mcp.json +1 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/CHANGELOG.md +34 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/PKG-INFO +1 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/RELEASE_NOTES.md +3 -0
- queue_aiops-0.10.0/mcp_server/_annotations.py +66 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/server.py +5 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/openclaw.plugin.json +1 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/package.json +1 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/pyproject.toml +1 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/analysis.py +5 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/server.json +2 -2
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/SKILL.md +2 -2
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/agent-guardrails.md +10 -2
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_analysis.py +68 -0
- queue_aiops-0.10.0/tests/test_tool_annotations.py +128 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/uv.lock +1 -1
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.github/workflows/mcp-publish.yml +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.github/workflows/publish.yml +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/.gitignore +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/Dockerfile +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/LICENSE +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/README.md +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/SECURITY.md +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/docs/VERIFICATION.md +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/glama.json +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/__init__.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/_shared.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/__init__.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/analysis.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/overview.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/rabbit_reads.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/redis_reads.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/undo.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/mcp_server/tools/writes.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/__init__.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/__init__.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/_common.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/_root.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/analyze.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/doctor.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/init.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/overview.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/rabbit_cmds.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/redis_cmds.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/secret.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/cli/undo.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/config.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/connection.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/doctor.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/__init__.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/audit.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/budget.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/decorators.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/outcome.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/paths.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/patterns.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/policy.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/sanitize.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/governance/undo.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/__init__.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/_util.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/overview.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/rabbit_reads.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/redis_reads.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/ops/writes.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/platform.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/queue_aiops/secretstore.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/capabilities.md +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/cli-reference.md +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/skills/queue-aiops/references/setup-guide.md +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/smithery.yaml +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/conftest.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/fakes.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_audited.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_governed_exit.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_rabbit_writes.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_reads.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_secret.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_cli_writes.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_connection.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_doctor.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_audit.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_decorators.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_patterns.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_gov_policy.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_governance_persistence.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_init.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_int_fidelity.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_mcp_reads.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_optional_fields.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_platform.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_reads.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_secretstore.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_self_kill.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_self_lockout.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_smoke.py +0 -0
- {queue_aiops-0.9.3 → queue_aiops-0.10.0}/tests/test_undo_executor.py +0 -0
- {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.
|
|
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",
|
|
@@ -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.
|
|
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__)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "queue-aiops"
|
|
7
|
-
version = "0.
|
|
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":
|
|
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.
|
|
10
|
+
"version": "0.10.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "queue-aiops",
|
|
15
|
-
"version": "0.
|
|
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` →
|
|
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
|
-
| "
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|