Add self-maintaining Athena platform context MCP

This commit is contained in:
Mikei386
2026-08-23 17:31:05 +02:00
parent 64d36ad838
commit 3dbb66037d
22 changed files with 1071 additions and 9 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a
ARG MCP_PROXY_VERSION=0.12.0
RUN pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" "mcp==1.29.0"
RUN useradd --system --uid 10001 --create-home --home-dir /app mcp
COPY platform_context_mcp.py /app/platform_context_mcp.py
RUN chown -R 10001:10001 /app
USER 10001:10001
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"]
CMD ["python", "/app/platform_context_mcp.py"]
+7
View File
@@ -10,6 +10,7 @@ Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|---|---|---|---|
| `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | Athena-Wissen, begrenzter Snapshot und kontrollierte Docs-Pflege | an |
| `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | kompakte Websuche und Quellenvergleich | an |
| `mcp-homeassistant` | `http://mike-ai-mcp-homeassistant:8000/mcp` | Relay zum nativen HA-MCP; Token bleibt serverseitig | Profil `homeassistant` |
| `mcp-arr` | `http://mike-ai-mcp-arr:8000/mcp` | Sonarr/Radarr/Prowlarr mit serverseitiger Policy | Profil `arr` |
@@ -25,6 +26,12 @@ SSRF-Prüfung selbst abrufen muss. Ohne diese beiden Einstellungen kann die
Werkzeugauswahl korrekt wirken, während alle Suchmaschinen und Seitenabrufe
gleichzeitig fehlschlagen.
Der Platform Context MCP hat keinen Docker-Socket, keine Shell, keinen Egress
und keine Secrets. Sein aktueller Zustand stammt aus einem fest programmierten
Host-Snapshot. Dokumentationspflege ist auf `docs/*.md` und einen zweistufigen
Preview/Approval-Ablauf begrenzt. Vollständige Beschreibung:
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
`/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen
temporären Browser- und Sitzungszustand erzeugt. Es wird bei jedem
+29
View File
@@ -137,6 +137,35 @@ services:
- /config:rw,noexec,nosuid,nodev,size=4m,mode=0700
networks: [tools, egress]
mcp-platform-context:
<<: *tool-common
build:
context: .
dockerfile: Dockerfile.platform-context
image: mike-ai/mcp-platform-context:1.0.0
container_name: mike-ai-mcp-platform-context
environment:
ATHENA_REPO_ROOT: /knowledge/repo
ATHENA_DOCS_ROOT: /workspace/docs
ATHENA_RUNTIME_FILE: /runtime/runtime.json
ATHENA_CONTEXT_STATE: /state
# Writes remain confined to docs/*.md and require a prepared proposal
# plus its exact confirmation string. The MCP cannot change code,
# containers, networking, secrets, Git or recovery bundles.
ATHENA_DOC_WRITE_MODE: enabled
volumes:
- ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/knowledge/repo:ro
- ${PLATFORM_DOCS_DIR:-/opt/mike-ai/stack/docs}:/workspace/docs:rw
- ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
- ${PLATFORM_CONTEXT_STATE_DIR:-/data/mike-ai-platform-context}:/state:rw
networks: [tools]
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
mcp-github:
<<: *tool-common
build:
+17
View File
@@ -17,6 +17,23 @@ export SEARXNG_SETTINGS_FILE="${SEARXNG_SETTINGS_FILE:-$MCP_DIR/../web-search/se
exit 1
}
# The platform context MCP never receives the Docker socket. A root-owned
# timer writes a bounded metadata snapshot instead. Only documentation files
# and the dedicated state directory are writable by the unprivileged MCP uid.
install -d -m 0755 /usr/local/libexec /var/lib/mike-ai-platform-context
install -d -o 10001 -g 10001 -m 0750 /data/mike-ai-platform-context
install -m 0755 "$MCP_DIR/platform-context-snapshot.py" \
/usr/local/libexec/mike-ai-platform-context-snapshot
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.service" \
/etc/systemd/system/mike-ai-platform-context-snapshot.service
install -m 0644 "$MCP_DIR/../systemd/mike-ai-platform-context-snapshot.timer" \
/etc/systemd/system/mike-ai-platform-context-snapshot.timer
find /opt/mike-ai/stack/docs -type d -exec chown root:10001 {} + -exec chmod 0775 {} +
find /opt/mike-ai/stack/docs -type f -name '*.md' -exec chown root:10001 {} + -exec chmod 0664 {} +
systemctl daemon-reload
systemctl enable --now mike-ai-platform-context-snapshot.timer
systemctl start mike-ai-platform-context-snapshot.service
profiles=()
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
profiles+=(--profile homeassistant)
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Create a bounded, payload-free Athena runtime snapshot for the context MCP."""
from __future__ import annotations
import hashlib
import json
import os
import pathlib
import subprocess
import tempfile
import time
OUTPUT = pathlib.Path("/var/lib/mike-ai-platform-context/runtime.json")
STACK = pathlib.Path("/opt/mike-ai/stack")
def command(*args: str) -> str:
try:
return subprocess.run(args, check=True, text=True, capture_output=True, timeout=15).stdout.strip()
except (OSError, subprocess.SubprocessError):
return ""
def docs_hash() -> str | None:
digest = hashlib.sha256()
files = sorted((STACK / "docs").glob("*.md"))
if not files:
return None
for path in files:
digest.update(path.name.encode())
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def containers() -> list[dict[str, str]]:
raw = command("docker", "ps", "--filter", "name=mike-ai-", "--format", "{{.Names}}|{{.Image}}|{{.Status}}")
result = []
for line in raw.splitlines():
fields = line.split("|", 2)
if len(fields) == 3:
result.append({"name": fields[0], "image": fields[1], "status": fields[2]})
return sorted(result, key=lambda item: item["name"])
def gpus() -> list[dict[str, object]]:
raw = command("nvidia-smi", "--query-gpu=uuid,name,memory.total,memory.used,driver_version", "--format=csv,noheader,nounits")
result = []
for line in raw.splitlines():
fields = [field.strip() for field in line.split(",")]
if len(fields) == 5:
result.append({"uuid": fields[0], "name": fields[1], "memory_total_mib": int(fields[2]), "memory_used_mib": int(fields[3]), "driver": fields[4]})
return result
def filesystems() -> list[dict[str, object]]:
raw = command("df", "-B1", "--output=target,fstype,size,used,avail,pcent", "/", "/data")
result = []
for line in raw.splitlines()[1:]:
fields = line.split()
if len(fields) == 6:
result.append({"mount": fields[0], "fstype": fields[1], "size_bytes": int(fields[2]), "used_bytes": int(fields[3]), "available_bytes": int(fields[4]), "used_percent": fields[5]})
return result
def recovery_status() -> dict[str, object]:
link = pathlib.Path("/data/mike-ai-recovery-kit")
if not link.exists():
return {"present": False}
target = link.resolve()
checksums = target / "SHA256SUMS"
return {"present": True, "target": str(target), "modified_unix": int(target.stat().st_mtime), "checksums_present": checksums.is_file()}
def main() -> None:
generated = int(time.time())
active = [item["name"].removeprefix("mike-ai-llama-") for item in containers() if item["name"].startswith("mike-ai-llama-")]
commit_file = STACK / ".mike-ai-source-commit"
data = {
"generated_unix": generated,
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(generated)),
"hostname": command("hostname"),
"os_release": command("sh", "-c", ". /etc/os-release && printf '%s %s' \"$ID\" \"$VERSION_ID\""),
"kernel": command("uname", "-r"),
"uptime_seconds": float(pathlib.Path("/proc/uptime").read_text().split()[0]),
"memory": {"summary": command("free", "-b", "--si").splitlines()[1] if command("free", "-b", "--si") else ""},
"filesystems": filesystems(),
"gpus": gpus(),
"containers": containers(),
"active_inference_profiles": active,
"source_commit": commit_file.read_text().strip() if commit_file.is_file() else None,
"documentation_tree_sha256": docs_hash(),
"recovery_kit": recovery_status(),
"privacy_scope": "No logs, prompts, chats, container environment values, file contents outside versioned docs, or secrets are collected.",
}
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".runtime.", dir=OUTPUT.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.write("\n")
os.chmod(temporary, 0o644)
os.replace(temporary, OUTPUT)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
if __name__ == "__main__":
main()
+609
View File
@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""Bounded platform knowledge and documentation-maintenance MCP for Athena.
This service deliberately has no Docker socket, shell tool, network egress or
secret mounts. Live data is supplied by a root-owned, fixed-command snapshot
timer. Canonical documentation may only be changed through a preview/apply
workflow and only below docs/.
"""
from __future__ import annotations
import hashlib
import difflib
import calendar
import json
import os
import re
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any
SERVER_VERSION = "1.0.0"
REPO_ROOT = Path(os.environ.get("ATHENA_REPO_ROOT", "/knowledge/repo"))
DOCS_ROOT = Path(os.environ.get("ATHENA_DOCS_ROOT", "/workspace/docs"))
RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json"))
STATE_ROOT = Path(os.environ.get("ATHENA_CONTEXT_STATE", "/state"))
WRITE_MODE = os.environ.get("ATHENA_DOC_WRITE_MODE", "proposal-only")
MAX_DOCUMENT_CHARS = 24000
MAX_SEARCH_RESULTS = 8
MAX_UPDATE_CHARS = 120000
ALLOWED_TEXT_SUFFIXES = {".md", ".txt", ".yaml", ".yml", ".json", ".py", ".sh", ".service", ".timer", ".conf", ".example"}
EXCLUDED_PARTS = {".git", "__pycache__", "xtts-test-audio", ".venv", "node_modules"}
if hasattr(sys.stdin, "reconfigure"):
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
TOOLS = [
{
"name": "athena_get_overview",
"description": (
"USE FIRST when a request concerns Athena, MikeAI, its models, profiles, GPUs, "
"OpenWebUI, router, MCPs, TTS/STT, Vision, networking or recovery. Returns the "
"short authoritative architecture overview plus snapshot freshness. This is "
"read-only and contains no secrets. Runtime claims still require "
"athena_get_current_state or the relevant specialist MCP."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_get_current_state",
"description": (
"USE for the current bounded Athena runtime inventory: host, filesystems, GPUs, "
"active MikeAI containers, active inference profile, source commit and recovery "
"freshness. The snapshot contains no logs, prompts, chats, environment values or "
"secrets. If stale, state that explicitly. For detailed service diagnosis use the "
"specialist management tool instead of guessing."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_search_knowledge",
"description": (
"USE to find the relevant MikeAI documentation, Compose definition, installer, "
"profile, runbook or source file before planning a platform change. Returns bounded "
"matching excerpts and paths. Do not repeatedly rephrase the same search; follow up "
"with athena_read_source for the selected file."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "minLength": 2, "maxLength": 300},
"max_results": {"type": "integer", "minimum": 1, "maximum": 8, "default": 5},
},
"required": ["query"],
"additionalProperties": False,
},
},
{
"name": "athena_read_source",
"description": (
"USE after athena_search_knowledge to read a bounded section of one versioned "
"MikeAI source or documentation file. Secret files, .git and binary artifacts are "
"not accessible. Paths are relative to the repository, for example "
"docs/OPERATIONS.md or platform/mcp/compose.yaml."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "minLength": 3, "maxLength": 240},
"start_line": {"type": "integer", "minimum": 1, "default": 1},
"max_lines": {"type": "integer", "minimum": 1, "maximum": 300, "default": 160},
},
"required": ["path"],
"additionalProperties": False,
},
},
{
"name": "athena_get_change_workflow",
"description": (
"USE before adding or replacing a model, MCP, TTS/STT, Vision/image service, "
"OpenWebUI integration, network component or recovery behavior. Returns the source "
"files, safety gates, validation steps, documentation duties, Git duties and "
"recovery duties for that change type. It performs no change."
),
"inputSchema": {
"type": "object",
"properties": {
"change_type": {
"type": "string",
"enum": ["mcp", "model", "profile", "tts", "stt", "vision", "image", "openwebui", "network", "recovery", "other"],
}
},
"required": ["change_type"],
"additionalProperties": False,
},
},
{
"name": "athena_prepare_documentation_update",
"description": (
"USE only after a real platform change or verified documentation drift. Creates a "
"reviewable proposal; it does not alter canonical documentation. Each update must "
"target an existing or new Markdown file below docs/. Include only verified facts, "
"never secrets, prompts, chats or private content. After preview, wait for explicit "
"user approval before calling athena_apply_documentation_update."
),
"inputSchema": {
"type": "object",
"properties": {
"summary": {"type": "string", "minLength": 5, "maxLength": 500},
"evidence": {"type": "string", "minLength": 5, "maxLength": 2000},
"updates": {
"type": "array",
"minItems": 1,
"maxItems": 6,
"items": {
"type": "object",
"properties": {
"path": {"type": "string", "pattern": "^docs/[A-Za-z0-9_.-]+\\.md$"},
"content": {"type": "string", "minLength": 1, "maxLength": 120000},
},
"required": ["path", "content"],
"additionalProperties": False,
},
},
},
"required": ["summary", "evidence", "updates"],
"additionalProperties": False,
},
},
{
"name": "athena_apply_documentation_update",
"description": (
"WRITE TOOL. Use only after the user explicitly approved the exact proposal in the "
"current conversation. Applies an already prepared proposal atomically below docs/, "
"backs up prior files and appends an audit record. It cannot change code, Compose, "
"services, secrets, Git or recovery bundles. The result always lists required Git "
"commit/push and recovery refresh work; never claim those are complete unless their "
"separate tools verify them."
),
"inputSchema": {
"type": "object",
"properties": {
"proposal_id": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
"confirmation": {"type": "string", "minLength": 38, "maxLength": 64},
},
"required": ["proposal_id", "confirmation"],
"additionalProperties": False,
},
},
{
"name": "athena_get_maintenance_status",
"description": (
"USE after documentation or platform work. Reports pending documentation proposals, "
"applied documentation changes awaiting Git/recovery handling, source commit and "
"recovery-kit freshness. It never commits, pushes or rebuilds recovery automatically."
),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
},
{
"name": "athena_close_maintenance_record",
"description": (
"WRITE TOOL for maintenance metadata only. Use after separate tools have verified "
"that the documentation change was committed/pushed, deployed to Athena and followed "
"by a newer recovery kit. The server checks source commit and recovery timestamp before "
"moving the record to resolved. It changes no documentation, Git or recovery data."
),
"inputSchema": {
"type": "object",
"properties": {
"proposal_id": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
"git_commit": {"type": "string", "pattern": "^[a-f0-9]{40}$"},
"verification": {"type": "string", "minLength": 10, "maxLength": 1000},
"confirmation": {"type": "string", "minLength": 38, "maxLength": 64},
},
"required": ["proposal_id", "git_commit", "verification", "confirmation"],
"additionalProperties": False,
},
},
]
def now_iso() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def json_text(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def allowed_text_file(path: Path) -> bool:
return (
path.suffix.lower() in ALLOWED_TEXT_SUFFIXES
or path.name.startswith("Dockerfile")
or path.name in {"LLAMA_CPP_COMMIT"}
)
def safe_repo_path(relative: str) -> Path:
if not relative or relative.startswith("/") or "\\" in relative:
raise ValueError("path must be repository-relative")
parts = Path(relative).parts
if ".." in parts or any(part in EXCLUDED_PARTS for part in parts):
raise ValueError("path is outside the allowed source tree")
lowered = relative.lower()
if any(token in lowered for token in ("secret", "authorized_keys", ".env", "agekey")):
raise ValueError("secret-bearing paths are not exposed")
path = (REPO_ROOT / relative).resolve()
root = REPO_ROOT.resolve()
if root not in path.parents and path != root:
raise ValueError("path escapes repository")
if not path.is_file() or not allowed_text_file(path):
raise ValueError("path is not an allowed text source")
return path
def safe_doc_path(relative: str) -> Path:
match = re.fullmatch(r"docs/([A-Za-z0-9_.-]+\.md)", relative)
if not match:
raise ValueError("documentation updates are limited to docs/*.md")
path = (DOCS_ROOT / match.group(1)).resolve()
root = DOCS_ROOT.resolve()
if root not in path.parents:
raise ValueError("documentation path escapes docs root")
if path.exists() and path.is_symlink():
raise ValueError("symbolic links are not writable")
return path
def read_runtime() -> dict[str, Any]:
try:
data = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"available": False, "error": str(exc), "instruction": "Do not infer current runtime state."}
generated = int(data.get("generated_unix", 0))
age = max(0, int(time.time()) - generated) if generated else None
data["available"] = True
data["age_seconds"] = age
data["stale"] = age is None or age > 180
return data
def overview() -> dict[str, Any]:
path = REPO_ROOT / "docs/PLATFORM_OVERVIEW.md"
text = path.read_text(encoding="utf-8")[:MAX_DOCUMENT_CHARS]
runtime = read_runtime()
return {
"source": "docs/PLATFORM_OVERVIEW.md",
"source_hierarchy": [
"current specialist-tool evidence",
"bounded Athena runtime snapshot",
"CURRENT_REFERENCE.md and STANDARD_PROFILE_MATRIX.md",
"versioned source and runbooks",
"chat memory only as an unverified hint",
],
"runtime_snapshot": {k: runtime.get(k) for k in ("available", "generated_at", "age_seconds", "stale", "source_commit")},
"content": text,
"instruction": "Search or read the relevant source before proposing a change; verify mutable claims with a current tool.",
}
def current_state() -> dict[str, Any]:
data = read_runtime()
data["scope"] = "bounded metadata only; no logs, prompts, chats, environment values or secrets"
if data.get("stale"):
data["instruction"] = "Snapshot is stale. Do not claim current service state until a specialist tool verifies it."
return data
def candidate_files() -> list[Path]:
files: list[Path] = []
for path in REPO_ROOT.rglob("*"):
try:
rel = path.relative_to(REPO_ROOT)
except ValueError:
continue
if not path.is_file() or any(part in EXCLUDED_PARTS for part in rel.parts):
continue
if not allowed_text_file(path):
continue
lowered = str(rel).lower()
if any(token in lowered for token in ("secret", "authorized_keys", ".env", "agekey")):
continue
files.append(path)
return files
def search_knowledge(arguments: dict[str, Any]) -> dict[str, Any]:
query = str(arguments.get("query", "")).strip()
if len(query) < 2:
raise ValueError("query is too short")
limit = max(1, min(MAX_SEARCH_RESULTS, int(arguments.get("max_results", 5))))
terms = [term for term in re.findall(r"[a-zA-Z0-9_.-]{2,}", query.lower()) if term]
scored: list[tuple[int, str, int, str]] = []
for path in candidate_files():
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
rel = str(path.relative_to(REPO_ROOT))
for index, line in enumerate(lines):
lower = line.lower()
score = sum(3 if term in rel.lower() else 1 for term in terms if term in lower or term in rel.lower())
if score:
excerpt = "\n".join(lines[max(0, index - 2): min(len(lines), index + 4)])[:1800]
scored.append((score, rel, index + 1, excerpt))
scored.sort(key=lambda item: (-item[0], item[1], item[2]))
seen: set[tuple[str, int]] = set()
results = []
for score, rel, line, excerpt in scored:
key = (rel, line // 20)
if key in seen:
continue
seen.add(key)
results.append({"path": rel, "line": line, "score": score, "excerpt": excerpt})
if len(results) >= limit:
break
return {"query": query, "count": len(results), "results": results, "instruction": "Read selected sources; do not treat search excerpts as current runtime proof."}
def read_source(arguments: dict[str, Any]) -> dict[str, Any]:
relative = str(arguments.get("path", ""))
path = safe_repo_path(relative)
start = max(1, int(arguments.get("start_line", 1)))
max_lines = max(1, min(300, int(arguments.get("max_lines", 160))))
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
selected = lines[start - 1:start - 1 + max_lines]
content = "\n".join(f"{start + i}: {line}" for i, line in enumerate(selected))
return {"path": relative, "start_line": start, "end_line": start + len(selected) - 1, "total_lines": len(lines), "truncated": start - 1 + len(selected) < len(lines), "content": content[:MAX_DOCUMENT_CHARS]}
WORKFLOWS = {
"mcp": ["platform/mcp/compose.yaml", "platform/mcp/README.md", "compose.yaml", "docs/COMPONENTS.md", "docs/SECURITY.md", "docs/QWEN_OPERATOR_CONTEXT.md"],
"model": ["config/install.env.example", "platform/models/manifest.example.yaml", "platform/profiles/", "docs/STANDARD_PROFILE_MATRIX.md", "docs/QWEN_OPERATOR_CONTEXT.md"],
"profile": ["platform/profiles/", "router/router_profiles.json", "platform/openwebui/install-models.sh", "docs/STANDARD_PROFILE_MATRIX.md"],
"tts": ["compose.yaml", "router/xtts_worker.py", "platform/scripts/rollback-tts-production.sh", "docs/XTTS_EVALUATION_2026-08-23.md"],
"stt": ["compose.yaml", "router/stt_worker.py", "docs/COMPONENTS.md"],
"vision": ["compose.yaml", "router/ai_profile_router.py", "docs/STANDARD_PROFILE_MATRIX.md"],
"image": ["compose.yaml", "router/image_worker.py", "docs/OPERATIONS.md"],
"openwebui": ["compose.yaml", "platform/openwebui/", "docs/OPERATIONS.md", "docs/DISASTER_RECOVERY.md"],
"network": ["compose.yaml", "platform/host/", "docs/SECURITY.md", "docs/WIREGUARD_HOME_PEER.md", "docs/EMERGENCY_UNI_ACCESS.md"],
"recovery": ["platform/recovery/", "docs/BARE_METAL_RECOVERY.md", "docs/DISASTER_RECOVERY.md", "docs/RECOVERY_REQUIREMENTS.md"],
"other": ["docs/PLATFORM_OVERVIEW.md", "docs/QWEN_OPERATOR_CONTEXT.md", "docs/OPERATIONS.md"],
}
def change_workflow(arguments: dict[str, Any]) -> dict[str, Any]:
kind = str(arguments.get("change_type", "other"))
if kind not in WORKFLOWS:
raise ValueError("unsupported change_type")
return {
"change_type": kind,
"read_first": WORKFLOWS[kind],
"mandatory_sequence": [
"Capture current state with the narrowest specialist tool.",
"Read relevant versioned sources and identify documentation drift.",
"Define rollback and protect SSH, LAN, WireGuard and the active inference path.",
"Change source-of-truth files, not only a running container.",
"Validate syntax/configuration and run a bounded synthetic test.",
"Verify service health and remote reachability without reading chats or private payloads.",
"Update PLATFORM_OVERVIEW/CURRENT_REFERENCE/QWEN_OPERATOR_CONTEXT and the affected runbook.",
"Commit and push the private Git repository using a separate authorized Git tool.",
"Create and verify a new encrypted recovery bundle and self-contained data-disk kit.",
],
"hard_boundaries": [
"This context MCP does not modify services, Docker, networking, models or secrets.",
"No shutdown, reboot, kernel/driver, SSH, firewall or VPN change without exact user approval and rollback.",
"Never claim Git or recovery is current until separately verified.",
],
}
def prepare_update(arguments: dict[str, Any]) -> dict[str, Any]:
summary = str(arguments.get("summary", "")).strip()
evidence = str(arguments.get("evidence", "")).strip()
updates = arguments.get("updates")
if len(summary) < 5 or len(evidence) < 5 or not isinstance(updates, list) or not updates:
raise ValueError("summary, evidence and at least one update are required")
normalized = []
total = 0
for update in updates[:6]:
relative = str(update.get("path", ""))
safe_doc_path(relative)
content = str(update.get("content", ""))
if not content or len(content) > MAX_UPDATE_CHARS:
raise ValueError("invalid documentation content size")
if re.search(r"(?i)(BEGIN [A-Z ]*PRIVATE KEY|github_pat_[A-Za-z0-9_]+|GITHUB_PERSONAL_ACCESS_TOKEN\s*=\s*\S+)", content):
raise ValueError("probable secret material detected")
total += len(content)
if total > MAX_UPDATE_CHARS * 2:
raise ValueError("proposal is too large")
target = safe_doc_path(relative)
previous = target.read_text(encoding="utf-8") if target.exists() else ""
diff = "\n".join(difflib.unified_diff(previous.splitlines(), content.splitlines(), fromfile=f"a/{relative}", tofile=f"b/{relative}", lineterm=""))
normalized.append({"path": relative, "content": content, "before_sha256": hashlib.sha256(previous.encode()).hexdigest(), "after_sha256": hashlib.sha256(content.encode()).hexdigest(), "before_chars": len(previous), "after_chars": len(content), "diff_preview": diff[:12000]})
proposal_id = uuid.uuid4().hex
proposal = {"proposal_id": proposal_id, "created_at": now_iso(), "summary": summary, "evidence": evidence, "updates": normalized, "status": "pending"}
pending = STATE_ROOT / "pending"
pending.mkdir(parents=True, exist_ok=True)
(pending / f"{proposal_id}.json").write_text(json.dumps(proposal, ensure_ascii=False, indent=2), encoding="utf-8")
return {"proposal_id": proposal_id, "summary": summary, "files": [{k: item[k] for k in ("path", "before_sha256", "after_sha256", "before_chars", "after_chars", "diff_preview")} for item in normalized], "canonical_files_changed": False, "required_confirmation": f"APPLY {proposal_id}", "instruction": "Show this proposal to the user and wait for explicit approval. Do not call apply in the same autonomous tool sequence."}
def apply_update(arguments: dict[str, Any]) -> dict[str, Any]:
proposal_id = str(arguments.get("proposal_id", ""))
confirmation = str(arguments.get("confirmation", ""))
if not re.fullmatch(r"[a-f0-9]{32}", proposal_id):
raise ValueError("invalid proposal_id")
if confirmation != f"APPLY {proposal_id}":
raise ValueError("confirmation does not match the exact proposal")
if WRITE_MODE != "enabled":
raise PermissionError("documentation writes are in proposal-only mode")
proposal_path = STATE_ROOT / "pending" / f"{proposal_id}.json"
if not proposal_path.is_file():
raise ValueError("proposal not found or already applied")
proposal = json.loads(proposal_path.read_text(encoding="utf-8"))
backup_root = STATE_ROOT / "backups" / f"{int(time.time())}-{proposal_id}"
backup_root.mkdir(parents=True, exist_ok=False)
changed = []
for item in proposal["updates"]:
target = safe_doc_path(item["path"])
current = target.read_text(encoding="utf-8") if target.exists() else ""
current_hash = hashlib.sha256(current.encode()).hexdigest()
if current_hash != item["before_sha256"]:
raise RuntimeError(f"documentation drift after preview: {item['path']}")
if target.exists():
(backup_root / target.name).write_text(current, encoding="utf-8")
target.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(item["content"])
handle.flush()
os.fsync(handle.fileno())
os.chmod(temporary, 0o664)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
changed.append(item["path"])
applied = STATE_ROOT / "applied"
applied.mkdir(parents=True, exist_ok=True)
proposal["status"] = "applied_docs_only"
proposal["applied_at"] = now_iso()
proposal["backup_dir"] = str(backup_root)
destination = applied / proposal_path.name
destination.write_text(json.dumps(proposal, ensure_ascii=False, indent=2), encoding="utf-8")
proposal_path.unlink()
return {
"documentation_applied": True,
"changed_files": changed,
"backup_dir": str(backup_root),
"git_commit_complete": False,
"git_push_complete": False,
"recovery_refresh_complete": False,
"required_next_steps": [
"Use an authorized Git tool to apply the same documentation change to the private source repository, review diff, commit and push.",
"Deploy the committed source back to Athena so .mike-ai-source-commit matches.",
"Create and verify a new encrypted recovery bundle and self-contained /data recovery kit.",
"Run athena_get_maintenance_status and the platform verification checklist.",
],
"instruction": "Do not say the platform is fully documented or recoverable until all three false fields are separately verified.",
}
def maintenance_status() -> dict[str, Any]:
pending_dir = STATE_ROOT / "pending"
applied_dir = STATE_ROOT / "applied"
pending = sorted(path.stem for path in pending_dir.glob("*.json")) if pending_dir.exists() else []
applied = sorted(applied_dir.glob("*.json"), key=lambda path: path.stat().st_mtime, reverse=True) if applied_dir.exists() else []
runtime = read_runtime()
latest_applied = None
if applied:
data = json.loads(applied[0].read_text(encoding="utf-8"))
latest_applied = {"proposal_id": data.get("proposal_id"), "summary": data.get("summary"), "applied_at": data.get("applied_at"), "status": data.get("status")}
return {
"pending_proposals": pending,
"latest_applied_documentation_change": latest_applied,
"source_commit": runtime.get("source_commit"),
"documentation_tree_sha256": runtime.get("documentation_tree_sha256"),
"recovery_kit": runtime.get("recovery_kit"),
"attention_required": bool(pending or latest_applied),
"instruction": "Applied records mean Git and recovery may still be stale; verify them with their dedicated workflow before clearing the maintenance debt.",
}
def close_maintenance(arguments: dict[str, Any]) -> dict[str, Any]:
proposal_id = str(arguments.get("proposal_id", ""))
git_commit = str(arguments.get("git_commit", ""))
confirmation = str(arguments.get("confirmation", ""))
verification = str(arguments.get("verification", "")).strip()
if not re.fullmatch(r"[a-f0-9]{32}", proposal_id):
raise ValueError("invalid proposal_id")
if not re.fullmatch(r"[a-f0-9]{40}", git_commit):
raise ValueError("invalid git_commit")
if confirmation != f"CLOSE {proposal_id}":
raise ValueError("confirmation does not match the exact record")
if len(verification) < 10:
raise ValueError("verification summary is required")
record = STATE_ROOT / "applied" / f"{proposal_id}.json"
if not record.is_file():
raise ValueError("applied maintenance record not found")
data = json.loads(record.read_text(encoding="utf-8"))
runtime = read_runtime()
if runtime.get("stale"):
raise RuntimeError("runtime snapshot is stale")
if runtime.get("source_commit") != git_commit:
raise RuntimeError("deployed source commit does not match the verified Git commit")
applied_at = int(calendar.timegm(time.strptime(data["applied_at"], "%Y-%m-%dT%H:%M:%SZ")))
recovery = runtime.get("recovery_kit") or {}
if not recovery.get("present") or int(recovery.get("modified_unix", 0)) <= applied_at:
raise RuntimeError("recovery kit is absent or older than the documentation change")
data.update({"status": "resolved", "resolved_at": now_iso(), "git_commit": git_commit, "verification": verification, "recovery_kit": recovery})
resolved = STATE_ROOT / "resolved"
resolved.mkdir(parents=True, exist_ok=True)
destination = resolved / record.name
destination.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
record.unlink()
return {"resolved": True, "proposal_id": proposal_id, "git_commit": git_commit, "recovery_kit": recovery.get("target"), "instruction": "Maintenance debt is closed because deployed Git and a newer recovery kit were both verified."}
def call_tool(name: str, arguments: dict[str, Any]) -> str:
if name == "athena_get_overview":
result = overview()
elif name == "athena_get_current_state":
result = current_state()
elif name == "athena_search_knowledge":
result = search_knowledge(arguments)
elif name == "athena_read_source":
result = read_source(arguments)
elif name == "athena_get_change_workflow":
result = change_workflow(arguments)
elif name == "athena_prepare_documentation_update":
result = prepare_update(arguments)
elif name == "athena_apply_documentation_update":
result = apply_update(arguments)
elif name == "athena_get_maintenance_status":
result = maintenance_status()
elif name == "athena_close_maintenance_record":
result = close_maintenance(arguments)
else:
raise ValueError(f"unknown tool: {name}")
return json_text(result)
def response(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None:
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id}
payload["error" if error is not None else "result"] = error if error is not None else result
sys.stdout.write(json_text(payload) + "\n")
sys.stdout.flush()
def handle(message: dict[str, Any]) -> None:
method = message.get("method")
request_id = message.get("id")
if method == "initialize":
response(request_id, {"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"), "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "mike-ai-platform-context", "version": SERVER_VERSION}})
elif method == "tools/list":
response(request_id, {"tools": TOOLS})
elif method == "tools/call":
params = message.get("params", {})
try:
text = call_tool(str(params.get("name", "")), params.get("arguments") or {})
response(request_id, {"content": [{"type": "text", "text": text}], "structuredContent": json.loads(text), "isError": False})
except Exception as exc:
response(request_id, {"content": [{"type": "text", "text": f"ERROR: {exc}"}], "isError": True})
elif request_id is not None:
response(request_id, error={"code": -32601, "message": f"Method not found: {method}"})
def main() -> None:
STATE_ROOT.mkdir(parents=True, exist_ok=True)
for line in sys.stdin:
try:
if line.strip():
handle(json.loads(line))
except Exception as exc:
sys.stderr.write(f"MCP input error: {exc}\n")
sys.stderr.flush()
if __name__ == "__main__":
main()