apache-aiops 0.2.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.
- apache_aiops/__init__.py +8 -0
- apache_aiops/cli/__init__.py +9 -0
- apache_aiops/cli/_common.py +118 -0
- apache_aiops/cli/_root.py +206 -0
- apache_aiops/cli/undo.py +66 -0
- apache_aiops/config.py +74 -0
- apache_aiops/connection.py +222 -0
- apache_aiops/governance/__init__.py +50 -0
- apache_aiops/governance/audit.py +526 -0
- apache_aiops/governance/budget.py +222 -0
- apache_aiops/governance/decorators.py +599 -0
- apache_aiops/governance/outcome.py +102 -0
- apache_aiops/governance/paths.py +23 -0
- apache_aiops/governance/patterns.py +396 -0
- apache_aiops/governance/policy.py +67 -0
- apache_aiops/governance/sanitize.py +79 -0
- apache_aiops/governance/undo.py +259 -0
- apache_aiops/mcp_server/__init__.py +1 -0
- apache_aiops/mcp_server/_annotations.py +66 -0
- apache_aiops/mcp_server/_shared.py +67 -0
- apache_aiops/mcp_server/server.py +14 -0
- apache_aiops/mcp_server/tools/__init__.py +1 -0
- apache_aiops/mcp_server/tools/httpd.py +143 -0
- apache_aiops/mcp_server/tools/undo.py +204 -0
- apache_aiops/ops/__init__.py +1 -0
- apache_aiops/ops/httpd.py +81 -0
- apache_aiops-0.2.0.dist-info/METADATA +50 -0
- apache_aiops-0.2.0.dist-info/RECORD +31 -0
- apache_aiops-0.2.0.dist-info/WHEEL +4 -0
- apache_aiops-0.2.0.dist-info/entry_points.txt +3 -0
- apache_aiops-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Undo-token primitive — record the inverse of a write operation.
|
|
2
|
+
|
|
3
|
+
Reversibility is a 2026 agentic-ops requirement: an operator (or an external orchestrator)
|
|
4
|
+
needs to roll a change back to a known-good state. The inverse of an operation
|
|
5
|
+
is domain-specific (power-off ↔ power-on, create-snapshot ↔ delete-snapshot,
|
|
6
|
+
and some ops — like delete-vm — have NO safe inverse), so a tool *declares* its
|
|
7
|
+
undo via ``@governed_tool(undo=...)``.
|
|
8
|
+
|
|
9
|
+
This module only RECORDS the inverse descriptor (a replayable
|
|
10
|
+
``{skill, tool, params}``) into ``~/.apache-aiops/undo.db`` and hands back an
|
|
11
|
+
``undo_id``. It deliberately does NOT execute the inverse — coordinated,
|
|
12
|
+
multi-step rollback is an external orchestrator's job. Keeping recording and execution
|
|
13
|
+
separate matches the "separate the deterministic parts from the parts where the
|
|
14
|
+
LLM reasons" guardrail.
|
|
15
|
+
|
|
16
|
+
A tool's ``undo`` is a callable ``(params, result) -> dict | None``:
|
|
17
|
+
|
|
18
|
+
@governed_tool(risk_level="medium", undo=lambda p, r: {
|
|
19
|
+
"tool": "vm_power_on", "params": {"vm_name": p["vm_name"]}})
|
|
20
|
+
def vm_power_off(vm_name: str, target: str = "") -> dict: ...
|
|
21
|
+
|
|
22
|
+
Returning ``None`` means "no inverse for this call" (nothing is recorded).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import sqlite3
|
|
31
|
+
import threading
|
|
32
|
+
import uuid
|
|
33
|
+
from datetime import UTC, datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from apache_aiops.governance.paths import ops_path
|
|
38
|
+
|
|
39
|
+
_log = logging.getLogger("apache-aiops.undo")
|
|
40
|
+
|
|
41
|
+
_BUSY_TIMEOUT_MS = 5000
|
|
42
|
+
|
|
43
|
+
_CREATE_TABLE = """\
|
|
44
|
+
CREATE TABLE IF NOT EXISTS undo_log (
|
|
45
|
+
undo_id TEXT PRIMARY KEY,
|
|
46
|
+
ts TEXT NOT NULL,
|
|
47
|
+
skill TEXT NOT NULL DEFAULT '',
|
|
48
|
+
tool TEXT NOT NULL DEFAULT '',
|
|
49
|
+
undo_skill TEXT NOT NULL DEFAULT '',
|
|
50
|
+
undo_tool TEXT NOT NULL DEFAULT '',
|
|
51
|
+
undo_params TEXT NOT NULL DEFAULT '{}',
|
|
52
|
+
orig_params TEXT NOT NULL DEFAULT '{}',
|
|
53
|
+
status TEXT NOT NULL DEFAULT 'recorded',
|
|
54
|
+
workflow_id TEXT NOT NULL DEFAULT '',
|
|
55
|
+
note TEXT NOT NULL DEFAULT '',
|
|
56
|
+
effect_verified INTEGER NOT NULL DEFAULT 1
|
|
57
|
+
)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
_CREATE_INDEX = "CREATE INDEX IF NOT EXISTS idx_undo_status ON undo_log(status)"
|
|
61
|
+
|
|
62
|
+
# Columns added after the first release. CREATE TABLE IF NOT EXISTS leaves an
|
|
63
|
+
# existing DB untouched, so each one needs an explicit ALTER. The default must
|
|
64
|
+
# describe the rows already there: every pre-existing token was recorded on the
|
|
65
|
+
# confirmed path, so effect_verified=1 is accurate for them, not just convenient.
|
|
66
|
+
_MIGRATIONS = (
|
|
67
|
+
(
|
|
68
|
+
"effect_verified",
|
|
69
|
+
"ALTER TABLE undo_log ADD COLUMN effect_verified INTEGER NOT NULL DEFAULT 1",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class UndoStore:
|
|
75
|
+
"""Append-and-update store for recorded undo descriptors (SQLite/WAL)."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
78
|
+
self._path = Path(db_path).expanduser() if db_path else ops_path("undo.db")
|
|
79
|
+
self._ok = False
|
|
80
|
+
try:
|
|
81
|
+
self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
82
|
+
conn = self._connect()
|
|
83
|
+
conn.execute(_CREATE_TABLE)
|
|
84
|
+
conn.execute(_CREATE_INDEX)
|
|
85
|
+
_migrate(conn)
|
|
86
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
87
|
+
conn.commit()
|
|
88
|
+
conn.close()
|
|
89
|
+
self._harden()
|
|
90
|
+
self._ok = True
|
|
91
|
+
except Exception:
|
|
92
|
+
_log.warning("Cannot initialize undo DB at %s", self._path, exc_info=True)
|
|
93
|
+
|
|
94
|
+
def _connect(self) -> sqlite3.Connection:
|
|
95
|
+
conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
|
|
96
|
+
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
97
|
+
return conn
|
|
98
|
+
|
|
99
|
+
def _harden(self) -> None:
|
|
100
|
+
try:
|
|
101
|
+
os.chmod(self._path.parent, 0o700)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
for suffix in ("", "-wal", "-shm"):
|
|
105
|
+
candidate = self._path.with_name(self._path.name + suffix)
|
|
106
|
+
try:
|
|
107
|
+
if candidate.exists():
|
|
108
|
+
os.chmod(candidate, 0o600)
|
|
109
|
+
except OSError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def record(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
skill: str,
|
|
116
|
+
tool: str,
|
|
117
|
+
undo_descriptor: dict[str, Any],
|
|
118
|
+
orig_params: dict[str, Any] | None = None,
|
|
119
|
+
workflow_id: str = "",
|
|
120
|
+
effect_verified: bool = True,
|
|
121
|
+
) -> str | None:
|
|
122
|
+
"""Persist an inverse descriptor; return its undo_id (or None on failure).
|
|
123
|
+
|
|
124
|
+
``undo_descriptor`` must carry at least ``tool`` (the inverse tool) and
|
|
125
|
+
``params``; ``skill`` defaults to the original skill. Never raises —
|
|
126
|
+
recording must not break the underlying tool call.
|
|
127
|
+
|
|
128
|
+
``effect_verified=False`` marks a token whose *original* write lost
|
|
129
|
+
its response: the change is probably live but unconfirmed. The token
|
|
130
|
+
stays listable and appliable — an unconfirmed change is exactly when
|
|
131
|
+
a rollback is most wanted — but the flag travels with it so an
|
|
132
|
+
operator is never told a state was restored that was never reached.
|
|
133
|
+
"""
|
|
134
|
+
if not self._ok:
|
|
135
|
+
return None
|
|
136
|
+
if not isinstance(undo_descriptor, dict) or not undo_descriptor.get("tool"):
|
|
137
|
+
_log.debug("undo descriptor for %s.%s missing 'tool' — not recorded", skill, tool)
|
|
138
|
+
return None
|
|
139
|
+
undo_id = uuid.uuid4().hex[:16]
|
|
140
|
+
try:
|
|
141
|
+
conn = self._connect()
|
|
142
|
+
conn.execute(
|
|
143
|
+
"INSERT INTO undo_log (undo_id, ts, skill, tool, undo_skill, undo_tool, "
|
|
144
|
+
"undo_params, orig_params, status, workflow_id, note, "
|
|
145
|
+
"effect_verified) "
|
|
146
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'recorded', ?, ?, ?)",
|
|
147
|
+
(
|
|
148
|
+
undo_id,
|
|
149
|
+
datetime.now(tz=UTC).isoformat(),
|
|
150
|
+
skill,
|
|
151
|
+
tool,
|
|
152
|
+
str(undo_descriptor.get("skill", skill)),
|
|
153
|
+
str(undo_descriptor["tool"]),
|
|
154
|
+
_safe_json(undo_descriptor.get("params", {})),
|
|
155
|
+
_safe_json(orig_params or {}),
|
|
156
|
+
workflow_id,
|
|
157
|
+
str(undo_descriptor.get("note", "")),
|
|
158
|
+
1 if effect_verified else 0,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
conn.commit()
|
|
162
|
+
conn.close()
|
|
163
|
+
return undo_id
|
|
164
|
+
except Exception:
|
|
165
|
+
_log.warning("Failed to record undo for %s.%s", skill, tool, exc_info=True)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def get(self, undo_id: str) -> dict[str, Any] | None:
|
|
169
|
+
if not self._ok:
|
|
170
|
+
return None
|
|
171
|
+
conn = self._connect()
|
|
172
|
+
try:
|
|
173
|
+
conn.row_factory = sqlite3.Row
|
|
174
|
+
row = conn.execute("SELECT * FROM undo_log WHERE undo_id = ?", (undo_id,)).fetchone()
|
|
175
|
+
return dict(row) if row else None
|
|
176
|
+
finally:
|
|
177
|
+
conn.close()
|
|
178
|
+
|
|
179
|
+
def list(self, *, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
|
|
180
|
+
if not self._ok:
|
|
181
|
+
return []
|
|
182
|
+
conn = self._connect()
|
|
183
|
+
try:
|
|
184
|
+
conn.row_factory = sqlite3.Row
|
|
185
|
+
if status:
|
|
186
|
+
rows = conn.execute(
|
|
187
|
+
"SELECT * FROM undo_log WHERE status = ? ORDER BY ts DESC LIMIT ?",
|
|
188
|
+
(status, limit),
|
|
189
|
+
).fetchall()
|
|
190
|
+
else:
|
|
191
|
+
rows = conn.execute(
|
|
192
|
+
"SELECT * FROM undo_log ORDER BY ts DESC LIMIT ?", (limit,)
|
|
193
|
+
).fetchall()
|
|
194
|
+
return [dict(r) for r in rows]
|
|
195
|
+
finally:
|
|
196
|
+
conn.close()
|
|
197
|
+
|
|
198
|
+
def mark(self, undo_id: str, status: str) -> bool:
|
|
199
|
+
"""Update an undo record's status (e.g. 'applied' / 'expired')."""
|
|
200
|
+
if not self._ok:
|
|
201
|
+
return False
|
|
202
|
+
conn = self._connect()
|
|
203
|
+
try:
|
|
204
|
+
cur = conn.execute(
|
|
205
|
+
"UPDATE undo_log SET status = ? WHERE undo_id = ?", (status, undo_id)
|
|
206
|
+
)
|
|
207
|
+
conn.commit()
|
|
208
|
+
return cur.rowcount > 0
|
|
209
|
+
finally:
|
|
210
|
+
conn.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
214
|
+
"""Apply additive schema migrations to an already-created undo_log.
|
|
215
|
+
|
|
216
|
+
Idempotent and best-effort: a store that cannot be migrated still records
|
|
217
|
+
and lists tokens, which matters more than any single column.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
existing = {row[1] for row in conn.execute("PRAGMA table_info(undo_log)")}
|
|
221
|
+
except sqlite3.Error:
|
|
222
|
+
return
|
|
223
|
+
for column, statement in _MIGRATIONS:
|
|
224
|
+
if column in existing:
|
|
225
|
+
continue
|
|
226
|
+
try:
|
|
227
|
+
conn.execute(statement)
|
|
228
|
+
except sqlite3.Error:
|
|
229
|
+
_log.warning("undo schema migration for '%s' failed", column, exc_info=True)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _safe_json(obj: Any) -> str:
|
|
233
|
+
try:
|
|
234
|
+
return json.dumps(obj, ensure_ascii=False, default=str)
|
|
235
|
+
except Exception:
|
|
236
|
+
return json.dumps({"_raw": str(obj)})
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── Singleton ──────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
_store: UndoStore | None = None
|
|
242
|
+
_store_lock = threading.Lock()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_undo_store(db_path: Path | str | None = None) -> UndoStore:
|
|
246
|
+
"""Return the global UndoStore singleton (lazy, lock-guarded)."""
|
|
247
|
+
global _store
|
|
248
|
+
if _store is None:
|
|
249
|
+
with _store_lock:
|
|
250
|
+
if _store is None:
|
|
251
|
+
_store = UndoStore(db_path)
|
|
252
|
+
return _store
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def reset_undo_store() -> None:
|
|
256
|
+
"""Reset the singleton. Tests use this between cases."""
|
|
257
|
+
global _store
|
|
258
|
+
with _store_lock:
|
|
259
|
+
_store = None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server package for apache-aiops."""
|
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Shared Apache MCP server and sanitized errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from mcp.server import MCPServer
|
|
12
|
+
|
|
13
|
+
from apache_aiops import __version__
|
|
14
|
+
from apache_aiops.connection import ApacheCommandError, ApacheWriteUncertainError, ConnectionManager
|
|
15
|
+
from apache_aiops.governance import mark_unknown, sanitize
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
_ERROR_MAX = 800
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def tool_errors(shape: str = "dict") -> Callable:
|
|
22
|
+
def decorator(func: Callable) -> Callable:
|
|
23
|
+
@functools.wraps(func)
|
|
24
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
25
|
+
try:
|
|
26
|
+
return func(*args, **kwargs)
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
logger.error("Tool %s failed", func.__name__, exc_info=True)
|
|
29
|
+
message = (
|
|
30
|
+
sanitize(str(exc), _ERROR_MAX)
|
|
31
|
+
if isinstance(
|
|
32
|
+
exc, (ApacheCommandError, ValueError, KeyError, FileNotFoundError, OSError)
|
|
33
|
+
)
|
|
34
|
+
else f"{type(exc).__name__}: operation failed."
|
|
35
|
+
)
|
|
36
|
+
payload = {
|
|
37
|
+
"error": message,
|
|
38
|
+
"hint": "Run 'apache-aiops doctor' and check local command permissions.",
|
|
39
|
+
}
|
|
40
|
+
if isinstance(exc, ApacheWriteUncertainError):
|
|
41
|
+
return mark_unknown(payload)
|
|
42
|
+
return payload
|
|
43
|
+
|
|
44
|
+
return wrapper
|
|
45
|
+
|
|
46
|
+
return decorator
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
mcp = MCPServer(
|
|
50
|
+
"apache-aiops",
|
|
51
|
+
version=__version__,
|
|
52
|
+
instructions=(
|
|
53
|
+
"Governed Apache HTTP Server config, vhost, module and HTTP diagnosis. "
|
|
54
|
+
"Every tool is audited and budgeted."
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
_manager: Optional[ConnectionManager] = None
|
|
58
|
+
_lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_connection(target: Optional[str] = None) -> Any:
|
|
62
|
+
global _manager
|
|
63
|
+
with _lock:
|
|
64
|
+
if _manager is None:
|
|
65
|
+
_manager = ConnectionManager()
|
|
66
|
+
manager = _manager
|
|
67
|
+
return manager.connect(target)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Apache AIops MCP entry point (stdio)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from apache_aiops.mcp_server._annotations import apply_risk_annotations
|
|
6
|
+
from apache_aiops.mcp_server._shared import mcp
|
|
7
|
+
from apache_aiops.mcp_server.tools import httpd, undo # noqa: F401 — registration
|
|
8
|
+
|
|
9
|
+
apply_risk_annotations(mcp)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
logging.basicConfig(level=logging.INFO)
|
|
14
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP tool modules. Importing each registers its @mcp.tool() functions."""
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Governed Apache HTTP Server read tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from apache_aiops.governance import governed_tool, mark_unknown
|
|
6
|
+
from apache_aiops.mcp_server._shared import _get_connection, mcp, tool_errors
|
|
7
|
+
from apache_aiops.ops import httpd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
@governed_tool(risk_level="low")
|
|
12
|
+
@tool_errors()
|
|
13
|
+
def apache_config_includes(target: Optional[str] = None) -> dict:
|
|
14
|
+
"""[READ] Show Apache's parsed config include tree with bounded output."""
|
|
15
|
+
return httpd.config_includes(_get_connection(target))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@mcp.tool()
|
|
19
|
+
@governed_tool(risk_level="low")
|
|
20
|
+
@tool_errors()
|
|
21
|
+
def apache_compiled_modules(target: Optional[str] = None) -> dict:
|
|
22
|
+
"""[READ] List modules compiled into Apache, excluding dynamic modules."""
|
|
23
|
+
return httpd.compiled_modules(_get_connection(target))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@mcp.tool()
|
|
27
|
+
@governed_tool(risk_level="low")
|
|
28
|
+
@tool_errors()
|
|
29
|
+
def apache_static_directives(target: Optional[str] = None) -> dict:
|
|
30
|
+
"""[READ] List directives from Apache's static modules with bounded output."""
|
|
31
|
+
return httpd.static_directives(_get_connection(target))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
@governed_tool(risk_level="low")
|
|
36
|
+
@tool_errors()
|
|
37
|
+
def apache_build(target: Optional[str] = None) -> dict:
|
|
38
|
+
"""[READ] Show Apache build options and compiled paths."""
|
|
39
|
+
return httpd.build(_get_connection(target))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
@governed_tool(risk_level="low")
|
|
44
|
+
@tool_errors()
|
|
45
|
+
def apache_runtime_config(target: Optional[str] = None) -> dict:
|
|
46
|
+
"""[READ] Dump Apache's parsed runtime configuration."""
|
|
47
|
+
return httpd.runtime_config(_get_connection(target))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
@governed_tool(risk_level="low")
|
|
52
|
+
@tool_errors()
|
|
53
|
+
def apache_server_status(target: Optional[str] = None) -> dict:
|
|
54
|
+
"""[READ] Get bounded machine-readable mod_status metrics from configured URL."""
|
|
55
|
+
return httpd.server_status(_get_connection(target))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
@governed_tool(risk_level="low")
|
|
60
|
+
@tool_errors()
|
|
61
|
+
def apache_error_log(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
62
|
+
"""[READ] Tail an operator-configured Apache error log, with explicit truncation."""
|
|
63
|
+
return httpd.error_log(_get_connection(target), limit)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
@governed_tool(risk_level="low")
|
|
68
|
+
@tool_errors()
|
|
69
|
+
def apache_access_status(limit: int = 200, target: Optional[str] = None) -> dict:
|
|
70
|
+
"""[READ] Count HTTP statuses in recent common/combined access logs, without raw requests."""
|
|
71
|
+
return httpd.access_status(_get_connection(target), limit)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
@governed_tool(risk_level="medium")
|
|
76
|
+
@tool_errors()
|
|
77
|
+
def apache_graceful_reload(dry_run: bool = False, target: Optional[str] = None) -> dict:
|
|
78
|
+
"""[WRITE] Config-check then request a graceful reload; outcome needs operator check."""
|
|
79
|
+
conn = _get_connection(target)
|
|
80
|
+
check = httpd.config_check(conn)
|
|
81
|
+
before = httpd.probe(conn) if conn.target.probe_url else None
|
|
82
|
+
if dry_run:
|
|
83
|
+
return {"dryRun": True, "configCheck": check, "probeBefore": before,
|
|
84
|
+
"wouldRun": "apachectl graceful"}
|
|
85
|
+
response = conn.graceful()
|
|
86
|
+
try:
|
|
87
|
+
after = httpd.probe(conn) if conn.target.probe_url else None
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
return mark_unknown({"accepted": True, "response": response, "probeAfter": None,
|
|
90
|
+
"probeError": f"Probe failed after reload: {type(exc).__name__}"})
|
|
91
|
+
return mark_unknown({
|
|
92
|
+
"accepted": True, "response": response, "probeBefore": before,
|
|
93
|
+
"probeAfter": after,
|
|
94
|
+
"note": "A successful command/probe does not prove all workers loaded new config.",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool()
|
|
99
|
+
@governed_tool(risk_level="low")
|
|
100
|
+
@tool_errors()
|
|
101
|
+
def apache_version(target: Optional[str] = None) -> dict:
|
|
102
|
+
"""[READ] Get Apache HTTP Server version from apachectl -v."""
|
|
103
|
+
return httpd.version(_get_connection(target))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool()
|
|
107
|
+
@governed_tool(risk_level="low")
|
|
108
|
+
@tool_errors()
|
|
109
|
+
def apache_config_check(target: Optional[str] = None) -> dict:
|
|
110
|
+
"""[READ] Run Apache's native config syntax test."""
|
|
111
|
+
return httpd.config_check(_get_connection(target))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
@governed_tool(risk_level="low")
|
|
116
|
+
@tool_errors()
|
|
117
|
+
def apache_vhosts(target: Optional[str] = None) -> dict:
|
|
118
|
+
"""[READ] Show Apache's parsed virtual-host mapping."""
|
|
119
|
+
return httpd.vhosts(_get_connection(target))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@mcp.tool()
|
|
123
|
+
@governed_tool(risk_level="low")
|
|
124
|
+
@tool_errors()
|
|
125
|
+
def apache_modules(target: Optional[str] = None) -> dict:
|
|
126
|
+
"""[READ] List Apache's loaded modules."""
|
|
127
|
+
return httpd.modules(_get_connection(target))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp.tool()
|
|
131
|
+
@governed_tool(risk_level="low")
|
|
132
|
+
@tool_errors()
|
|
133
|
+
def apache_probe(target: Optional[str] = None) -> dict:
|
|
134
|
+
"""[READ] GET the configured health URL without downloading its body."""
|
|
135
|
+
return httpd.probe(_get_connection(target))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@mcp.tool()
|
|
139
|
+
@governed_tool(risk_level="low")
|
|
140
|
+
@tool_errors()
|
|
141
|
+
def apache_diagnose(target: Optional[str] = None) -> dict:
|
|
142
|
+
"""[READ] Correlate version, config, vhosts, modules and optional HTTP probe."""
|
|
143
|
+
return httpd.diagnose(_get_connection(target))
|