diff --git a/config/operator-system-prompt.txt b/config/operator-system-prompt.txt index 772d9d8..9827e1b 100644 --- a/config/operator-system-prompt.txt +++ b/config/operator-system-prompt.txt @@ -65,6 +65,15 @@ failure. For public information make at most one focused fallback attempt with the general web tool, then synthesize the available evidence or stop clearly. Never enter a fallback or synonym-search loop. +Before designing, installing or migrating a backend, query the versioned +external-service catalog and then the listed specialist tool. Existing services +on Unraid or elsewhere in the home network are dependencies to integrate, not +components to duplicate. If inventory or specialist verification is missing, +disabled, unreachable or inconclusive, stop and ask the user. Never fill that +knowledge gap by proposing or deploying a replacement service. A duplicate is +allowed only when the user explicitly requests migration, replacement, +redundancy or an isolated experiment after the existing service was identified. + For models and GPU services, introduce changes only through the experimental profile or an isolated container. Change one variable at a time, record source, license, revision, size and SHA256, account for weights, KV cache, projector, diff --git a/config/service-catalog.json b/config/service-catalog.json new file mode 100644 index 0000000..8e4e11f --- /dev/null +++ b/config/service-catalog.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "updated": "2026-08-23", + "scope": "Verified external services used by Athena; no credentials or secret values.", + "services": [ + { + "id": "unraid", + "name": "HomeServer Unraid", + "location": "home-network", + "host": "homeserver.fritz.box", + "address": "192.168.1.2", + "port": 5001, + "protocol": "https", + "probe": "tcp", + "specialist_tool": "unraid-readonly-local", + "purpose": "Existing NAS, Docker host and storage platform. Inspect it before planning any replacement service on Athena." + }, + { + "id": "mua", + "name": "MUA Unraid management", + "location": "home-network", + "host": "192.168.1.2", + "address": "192.168.1.2", + "port": 3002, + "protocol": "http", + "probe": "tcp", + "specialist_tool": "mua", + "purpose": "Explicitly enabled Unraid management actions; not the default diagnostic path." + }, + { + "id": "deemix", + "name": "Existing Deemix on Unraid", + "location": "unraid-container", + "host": "192.168.1.2", + "address": "192.168.1.2", + "port": 6595, + "protocol": "http", + "probe": "http-head", + "probe_path": "/", + "specialist_tool": "unraid-readonly-local", + "purpose": "Existing Deemix backend and download location. An Athena MCP must integrate this instance over WireGuard; it must not create a second Deemix unless the user explicitly requests migration or replacement." + } + ] +} diff --git a/dev/test_openwebui_filters.py b/dev/test_openwebui_filters.py index 3842a58..ff23d2c 100644 --- a/dev/test_openwebui_filters.py +++ b/dev/test_openwebui_filters.py @@ -187,6 +187,16 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase): ["server:mcp:github-local", "server:mcp:athena-operator-local"], ) + async def test_existing_unraid_backend_beats_duplicate_operator_plan(self): + result = await self._select( + "Baue aus https://github.com/foo/deemix einen MCP. Deemix läuft bereits als Container auf Unraid; prüfe ihn zuerst." + ) + self.assertEqual( + result["tool_ids"], + ["server:mcp:github-local", "server:mcp:unraid-readonly-local"], + ) + self.assertIn("Never compensate", result["messages"][0]["content"]) + async def test_github_and_explicit_web_are_bounded_to_two(self): result = await self._select( "Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen." diff --git a/dev/test_platform_context_mcp.py b/dev/test_platform_context_mcp.py index f5f4640..d854f38 100644 --- a/dev/test_platform_context_mcp.py +++ b/dev/test_platform_context_mcp.py @@ -30,16 +30,22 @@ def main(): state = base / "state" runtime = base / "runtime.json" (repo / "docs").mkdir(parents=True) + (repo / "config").mkdir(parents=True) docs.mkdir() (repo / "docs/PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n") (repo / "docs/OPERATIONS.md").write_text("# Operations\nUse bounded tools.\n") (docs / "PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n") + (repo / "config/service-catalog.json").write_text(json.dumps({ + "version": 1, + "services": [{"id": "test", "name": "Test", "address": "127.0.0.1", "port": 9, "protocol": "tcp", "probe": "tcp"}], + })) runtime.write_text(json.dumps({"generated_unix": 4102444800, "generated_at": "2100-01-01T00:00:00Z", "source_commit": "abc", "containers": []})) m = load_module(repo, docs, runtime, state) - assert len(m.TOOLS) == 9 + assert len(m.TOOLS) == 10 assert m.overview()["source"] == "docs/PLATFORM_OVERVIEW.md" assert m.current_state()["available"] is True + assert m.external_services()["services"][0]["id"] == "test" assert m.search_knowledge({"query": "recovery", "max_results": 3})["count"] >= 1 assert "Operations" in m.read_source({"path": "docs/OPERATIONS.md"})["content"] try: diff --git a/docs/PLATFORM_CONTEXT_MCP.md b/docs/PLATFORM_CONTEXT_MCP.md index 3bc080b..73601b1 100644 --- a/docs/PLATFORM_CONTEXT_MCP.md +++ b/docs/PLATFORM_CONTEXT_MCP.md @@ -11,7 +11,9 @@ Operator-Kontext in jeden Prompt zu kopieren. Der MCP ist zugleich das kontrollierte Pflegefenster für seine eigene Dokumentation. Er ist **kein** allgemeiner Athena-Administrator und erhält -weder Docker-Socket noch Shell, Netzwerkzugang, Git-Schlüssel oder Secrets. +weder Docker-Socket noch Shell, Git-Schlüssel oder Secrets. Sein Netzzugang ist +auf feste, versionierte Erreichbarkeitsprüfungen aus dem Diensteverzeichnis +beschränkt; Modellparameter können keine freie Adresse vorgeben. ## Werkzeuge @@ -19,6 +21,7 @@ weder Docker-Socket noch Shell, Netzwerkzugang, Git-Schlüssel oder Secrets. |---|---| | `athena_get_overview` | kurze Architektur und Quellenhierarchie | | `athena_get_current_state` | begrenzter aktueller Snapshot ohne Nutzdaten | +| `athena_get_external_services` | vorhandene externe Dienste plus feste, bounded Erreichbarkeitsprüfung | | `athena_search_knowledge` | Suche in Dokumentation und versionierten Quellen | | `athena_read_source` | begrenzter Ausschnitt einer ausgewählten Textdatei | | `athena_get_change_workflow` | verbindlicher Ablauf je Änderungstyp | @@ -45,6 +48,13 @@ Dateiinhalte außerhalb der versionierten Dokumentation oder Secretwerte. Der Container liest nur die erzeugte JSON-Datei. Ein Snapshot älter als drei Minuten gilt als veraltet. +Das zusätzliche Diensteverzeichnis unter `config/service-catalog.json` enthält +nur bekannte interne Namen, Adressen, Ports, Zuständigkeiten und Zwecke, keine +Zugangsdaten. `athena_get_external_services` prüft ausschließlich diese festen +Einträge. Es ist kein Portscanner, liest keine Antwortinhalte und akzeptiert +keine URL oder Adresse aus dem Modell. Für Details bleibt anschließend das im +Katalog genannte Fachwerkzeug zuständig. + ## Dokumentationspflege Die Pflege ist absichtlich zweistufig: diff --git a/docs/PLATFORM_OVERVIEW.md b/docs/PLATFORM_OVERVIEW.md index 42a052c..65f20f4 100644 --- a/docs/PLATFORM_OVERVIEW.md +++ b/docs/PLATFORM_OVERVIEW.md @@ -19,6 +19,12 @@ Host-Snapshot ersetzt einen Docker-Socket. Dokumentationsänderungen laufen nur bleiben getrennte, nachzuweisende Abschlussarbeiten. Details stehen in `PLATFORM_CONTEXT_MCP.md`. +Das versionierte, secret-freie Diensteverzeichnis +`config/service-catalog.json` dokumentiert bereits vorhandene externe +Abhängigkeiten. Vor der Planung eines neuen Backends muss es gelesen und der +Bestand mit dem dort genannten Fachwerkzeug geprüft werden. Ein nicht +erreichbares Werkzeug bedeutet „nicht verifiziert“, niemals „nicht vorhanden“. + ## Hardware - Debian 13 `trixie`, Kernel 6.12 @@ -58,6 +64,10 @@ Open WebUI ---> Profile Router ---> Profile Controller ---> genau ein llama.cpp- +-- Unraid ``` +Deemix läuft bereits als Container auf dem Unraid-HomeServer. Eine künftige +Deemix-MCP-Integration auf Athena verwendet dieses Backend über WireGuard und +erzeugt nicht ungefragt eine zweite Deemix-Instanz. + Open WebUI und Router veröffentlichen keinen normalen Host-Port. Der WireGuard-Gateway-Container stellt nur die vorgesehenen VPN-Endpunkte bereit. Anwendungscontainer erreichen Heimnetz und Internet fail-closed über WireGuard; diff --git a/docs/QWEN_OPERATOR_CONTEXT.md b/docs/QWEN_OPERATOR_CONTEXT.md index b7f59f5..4f3da76 100644 --- a/docs/QWEN_OPERATOR_CONTEXT.md +++ b/docs/QWEN_OPERATOR_CONTEXT.md @@ -37,6 +37,13 @@ Dokumentations-Schreibweg darf erst nach Vorschau und ausdrücklicher Freigabe verwendet werden. Eine lokale Dokumentationsänderung ist ohne separaten Git-Commit/Push und erneuerten Recovery-Koffer nicht abgeschlossen. +Vor einem neuen Backend, Relay oder MCP liest du zusätzlich mit +`athena_get_external_services` das versionierte Diensteverzeichnis. Prüfe den +gefundenen Bestand danach mit dem genannten Fachwerkzeug. Scheitert diese +Prüfung, stoppst du und meldest die Lücke. Du darfst aus einem Toolfehler oder +fehlenden Zugriff niemals ableiten, dass der Dienst nicht existiert, und als +Ersatz ungefragt eine zweite Instanz planen. + ## 2. Auftrag und Einsatzumgebung MikeAI stellt lokal Inferenz, multimodale Bildanalyse, Bildgenerierung, @@ -235,6 +242,11 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht. | Unraid | Host-, Docker-, Array-, Netzwerk- und Logdiagnose | read-only Standard | | MUA/Admin | eng definierte Unraid-Verwaltung | bewusst aktivieren | +Der vorhandene Deemix-Dienst läuft auf Unraid und ist als externe Abhängigkeit +im Diensteverzeichnis eingetragen. Für ein Deemix-MCP wird standardmäßig nur +ein Relay auf Athena gebaut; ein zweites Deemix-Backend erfordert einen +ausdrücklichen Migrations-, Ersatz- oder Testauftrag. + `Athena Operator` ist die zentrale Arbeitsumgebung für Änderungen an Athena. Nutze ihn zum Lesen der tatsächlichen Quellen, Erstellen und Anwenden von Dateiänderungen, Testen, Deployen von Compose-Diensten und MCPs, Verwalten der diff --git a/platform/mcp/compose.yaml b/platform/mcp/compose.yaml index 1de0365..190419a 100644 --- a/platform/mcp/compose.yaml +++ b/platform/mcp/compose.yaml @@ -142,7 +142,7 @@ services: build: context: . dockerfile: Dockerfile.platform-context - image: mike-ai/mcp-platform-context:1.0.0 + image: mike-ai/mcp-platform-context:1.1.0 container_name: mike-ai-mcp-platform-context environment: ATHENA_REPO_ROOT: /knowledge/repo @@ -158,7 +158,9 @@ services: - ${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] + # The context service has no arbitrary URL input. Its only egress use is a + # bounded reachability check of endpoints from config/service-catalog.json. + networks: [tools, egress] healthcheck: test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"] interval: 30s @@ -224,6 +226,10 @@ services: UNRAID_RMCP_DISABLE_HTTP_AUTH: "true" UNRAID_NOAUTH: "true" UNRAID_RMCP_ALLOWED_HOSTS: "mike-ai-mcp-unraid-official:8000,mike-ai-mcp-unraid-official,localhost:8000,127.0.0.1:8000" + # Public DNS cannot resolve the Fritzbox-only name. Preserve the hostname + # used by the TLS endpoint while binding it to the verified home-LAN IP. + extra_hosts: + - "homeserver.fritz.box:192.168.1.2" volumes: - ${RUNRAID_BINARY:-/usr/local/bin/runraid}:/usr/local/bin/unraid:ro entrypoint: ["/usr/local/bin/unraid"] diff --git a/platform/mcp/platform_context_mcp.py b/platform/mcp/platform_context_mcp.py index 010e75a..16d49ca 100644 --- a/platform/mcp/platform_context_mcp.py +++ b/platform/mcp/platform_context_mcp.py @@ -15,15 +15,18 @@ import calendar import json import os import re +import socket import sys import tempfile import time import uuid +import urllib.error +import urllib.request from pathlib import Path from typing import Any -SERVER_VERSION = "1.0.0" +SERVER_VERSION = "1.1.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")) @@ -64,6 +67,18 @@ TOOLS = [ ), "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, }, + { + "name": "athena_get_external_services", + "description": ( + "USE before designing or installing an integration that may already run on Unraid " + "or elsewhere in the home network. Returns the versioned, secret-free service " + "catalog and performs only fixed bounded reachability checks for those catalogued " + "endpoints. It accepts no host, URL or port from the model and is not a scanner. " + "A failed check means unavailable or unverified; it never authorizes creating a " + "duplicate service. Use the listed specialist MCP for detailed current state." + ), + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, { "name": "athena_search_knowledge", "description": ( @@ -293,6 +308,63 @@ def current_state() -> dict[str, Any]: return data +def external_services() -> dict[str, Any]: + catalog_path = REPO_ROOT / "config/service-catalog.json" + try: + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return { + "available": False, + "error": str(exc), + "instruction": "The service inventory is unavailable. Do not infer that a replacement service is needed.", + } + + results = [] + for service in catalog.get("services", [])[:24]: + item = {key: service.get(key) for key in ( + "id", "name", "location", "host", "address", "port", "protocol", + "specialist_tool", "purpose", + )} + address = str(service.get("address", "")) + port = int(service.get("port", 0)) + started = time.monotonic() + reachable = False + http_status = None + error = None + try: + with socket.create_connection((address, port), timeout=2): + reachable = True + if service.get("probe") == "http-head": + path = str(service.get("probe_path", "/")) + url = f"{service.get('protocol', 'http')}://{address}:{port}{path}" + request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "MikeAI-Service-Catalog/1"}) + try: + with urllib.request.urlopen(request, timeout=3) as response: + http_status = response.status + except urllib.error.HTTPError as exc: + http_status = exc.code + except (OSError, ValueError, urllib.error.URLError) as exc: + error = type(exc).__name__ + item["check"] = { + "reachable": reachable, + "http_status": http_status, + "elapsed_ms": round((time.monotonic() - started) * 1000), + "error_class": error, + } + results.append(item) + return { + "available": True, + "source": "config/service-catalog.json", + "catalog_version": catalog.get("version"), + "updated": catalog.get("updated"), + "services": results, + "instruction": ( + "Existing catalog entries are architecture constraints, not disposable suggestions. " + "If a check or specialist tool fails, report the gap and ask for direction; do not plan a duplicate backend." + ), + } + + def candidate_files() -> list[Path]: files: list[Path] = [] for path in REPO_ROOT.rglob("*"): @@ -550,6 +622,8 @@ def call_tool(name: str, arguments: dict[str, Any]) -> str: result = overview() elif name == "athena_get_current_state": result = current_state() + elif name == "athena_get_external_services": + result = external_services() elif name == "athena_search_knowledge": result = search_knowledge(arguments) elif name == "athena_read_source": diff --git a/platform/openwebui/filters/auto_tool_selector.py b/platform/openwebui/filters/auto_tool_selector.py index 7591269..a595d36 100644 --- a/platform/openwebui/filters/auto_tool_selector.py +++ b/platform/openwebui/filters/auto_tool_selector.py @@ -1,7 +1,7 @@ """ title: MikeAI Auto Tool Selector author: MikeAI -version: 2.0.0 +version: 2.1.0 description: Selects a small, relevant set of MCP servers for each user request. """ @@ -80,7 +80,10 @@ class Filter: "unless the user's current message explicitly requests that exact change " "and every required preview, confirmation, backup, and validation rule " "of the tool is satisfied. Do not call unrelated tools merely because " - "they are available. If the selected tool cannot verify the claim, say so." + "they are available. If the selected tool cannot verify the claim, say so. " + "A missing, disabled or failed inventory tool is not evidence that a service " + "does not exist. Never compensate by planning or installing a duplicate backend; " + "stop and request clarification." ) messages = body.setdefault("messages", []) for message in messages: @@ -165,9 +168,13 @@ class Filter: ), ) - # A specialist source is more precise than public web search. Platform - # wins over a generic Docker mention when Athena is explicitly named. - if operator: + # Existing infrastructure must be inspected before a new integration + # is designed. For a GitHub-backed MCP that targets a service already + # running on Unraid, source plus Unraid inventory are the two most + # useful bounded capabilities; the Operator follows at implementation. + if github and unraid: + selected.extend(("github", "unraid")) + elif operator: selected.append("operator") elif platform: selected.append("platform")