Improve external service discovery and tool routing

This commit is contained in:
Mikei386
2026-08-23 21:44:33 +02:00
parent b5118099ed
commit feb99293b5
10 changed files with 198 additions and 10 deletions
+9
View File
@@ -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. the general web tool, then synthesize the available evidence or stop clearly.
Never enter a fallback or synonym-search loop. 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 For models and GPU services, introduce changes only through the experimental
profile or an isolated container. Change one variable at a time, record source, profile or an isolated container. Change one variable at a time, record source,
license, revision, size and SHA256, account for weights, KV cache, projector, license, revision, size and SHA256, account for weights, KV cache, projector,
+44
View File
@@ -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."
}
]
}
+10
View File
@@ -187,6 +187,16 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
["server:mcp:github-local", "server:mcp:athena-operator-local"], ["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): async def test_github_and_explicit_web_are_bounded_to_two(self):
result = await self._select( result = await self._select(
"Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen." "Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen."
+7 -1
View File
@@ -30,16 +30,22 @@ def main():
state = base / "state" state = base / "state"
runtime = base / "runtime.json" runtime = base / "runtime.json"
(repo / "docs").mkdir(parents=True) (repo / "docs").mkdir(parents=True)
(repo / "config").mkdir(parents=True)
docs.mkdir() docs.mkdir()
(repo / "docs/PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n") (repo / "docs/PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\n")
(repo / "docs/OPERATIONS.md").write_text("# Operations\nUse bounded tools.\n") (repo / "docs/OPERATIONS.md").write_text("# Operations\nUse bounded tools.\n")
(docs / "PLATFORM_OVERVIEW.md").write_text("# Athena\nRouter and recovery.\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": []})) 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) 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.overview()["source"] == "docs/PLATFORM_OVERVIEW.md"
assert m.current_state()["available"] is True 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 m.search_knowledge({"query": "recovery", "max_results": 3})["count"] >= 1
assert "Operations" in m.read_source({"path": "docs/OPERATIONS.md"})["content"] assert "Operations" in m.read_source({"path": "docs/OPERATIONS.md"})["content"]
try: try:
+11 -1
View File
@@ -11,7 +11,9 @@ Operator-Kontext in jeden Prompt zu kopieren.
Der MCP ist zugleich das kontrollierte Pflegefenster für seine eigene Der MCP ist zugleich das kontrollierte Pflegefenster für seine eigene
Dokumentation. Er ist **kein** allgemeiner Athena-Administrator und erhält 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 ## 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_overview` | kurze Architektur und Quellenhierarchie |
| `athena_get_current_state` | begrenzter aktueller Snapshot ohne Nutzdaten | | `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_search_knowledge` | Suche in Dokumentation und versionierten Quellen |
| `athena_read_source` | begrenzter Ausschnitt einer ausgewählten Textdatei | | `athena_read_source` | begrenzter Ausschnitt einer ausgewählten Textdatei |
| `athena_get_change_workflow` | verbindlicher Ablauf je Änderungstyp | | `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 Container liest nur die erzeugte JSON-Datei. Ein Snapshot älter als drei
Minuten gilt als veraltet. 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 ## Dokumentationspflege
Die Pflege ist absichtlich zweistufig: Die Pflege ist absichtlich zweistufig:
+10
View File
@@ -19,6 +19,12 @@ Host-Snapshot ersetzt einen Docker-Socket. Dokumentationsänderungen laufen nur
bleiben getrennte, nachzuweisende Abschlussarbeiten. Details stehen in bleiben getrennte, nachzuweisende Abschlussarbeiten. Details stehen in
`PLATFORM_CONTEXT_MCP.md`. `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 ## Hardware
- Debian 13 `trixie`, Kernel 6.12 - Debian 13 `trixie`, Kernel 6.12
@@ -58,6 +64,10 @@ Open WebUI ---> Profile Router ---> Profile Controller ---> genau ein llama.cpp-
+-- Unraid +-- 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 Open WebUI und Router veröffentlichen keinen normalen Host-Port. Der
WireGuard-Gateway-Container stellt nur die vorgesehenen VPN-Endpunkte bereit. WireGuard-Gateway-Container stellt nur die vorgesehenen VPN-Endpunkte bereit.
Anwendungscontainer erreichen Heimnetz und Internet fail-closed über WireGuard; Anwendungscontainer erreichen Heimnetz und Internet fail-closed über WireGuard;
+12
View File
@@ -37,6 +37,13 @@ Dokumentations-Schreibweg darf erst nach Vorschau und ausdrücklicher Freigabe
verwendet werden. Eine lokale Dokumentationsänderung ist ohne separaten verwendet werden. Eine lokale Dokumentationsänderung ist ohne separaten
Git-Commit/Push und erneuerten Recovery-Koffer nicht abgeschlossen. 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 ## 2. Auftrag und Einsatzumgebung
MikeAI stellt lokal Inferenz, multimodale Bildanalyse, Bildgenerierung, 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 | | Unraid | Host-, Docker-, Array-, Netzwerk- und Logdiagnose | read-only Standard |
| MUA/Admin | eng definierte Unraid-Verwaltung | bewusst aktivieren | | 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. `Athena Operator` ist die zentrale Arbeitsumgebung für Änderungen an Athena.
Nutze ihn zum Lesen der tatsächlichen Quellen, Erstellen und Anwenden von Nutze ihn zum Lesen der tatsächlichen Quellen, Erstellen und Anwenden von
Dateiänderungen, Testen, Deployen von Compose-Diensten und MCPs, Verwalten der Dateiänderungen, Testen, Deployen von Compose-Diensten und MCPs, Verwalten der
+8 -2
View File
@@ -142,7 +142,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.platform-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 container_name: mike-ai-mcp-platform-context
environment: environment:
ATHENA_REPO_ROOT: /knowledge/repo ATHENA_REPO_ROOT: /knowledge/repo
@@ -158,7 +158,9 @@ services:
- ${PLATFORM_DOCS_DIR:-/opt/mike-ai/stack/docs}:/workspace/docs:rw - ${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_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
- ${PLATFORM_CONTEXT_STATE_DIR:-/data/mike-ai-platform-context}:/state:rw - ${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: healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"] test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
interval: 30s interval: 30s
@@ -224,6 +226,10 @@ services:
UNRAID_RMCP_DISABLE_HTTP_AUTH: "true" UNRAID_RMCP_DISABLE_HTTP_AUTH: "true"
UNRAID_NOAUTH: "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" 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: volumes:
- ${RUNRAID_BINARY:-/usr/local/bin/runraid}:/usr/local/bin/unraid:ro - ${RUNRAID_BINARY:-/usr/local/bin/runraid}:/usr/local/bin/unraid:ro
entrypoint: ["/usr/local/bin/unraid"] entrypoint: ["/usr/local/bin/unraid"]
+75 -1
View File
@@ -15,15 +15,18 @@ import calendar
import json import json
import os import os
import re import re
import socket
import sys import sys
import tempfile import tempfile
import time import time
import uuid import uuid
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any 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")) REPO_ROOT = Path(os.environ.get("ATHENA_REPO_ROOT", "/knowledge/repo"))
DOCS_ROOT = Path(os.environ.get("ATHENA_DOCS_ROOT", "/workspace/docs")) DOCS_ROOT = Path(os.environ.get("ATHENA_DOCS_ROOT", "/workspace/docs"))
RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json")) RUNTIME_FILE = Path(os.environ.get("ATHENA_RUNTIME_FILE", "/runtime/runtime.json"))
@@ -64,6 +67,18 @@ TOOLS = [
), ),
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, "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", "name": "athena_search_knowledge",
"description": ( "description": (
@@ -293,6 +308,63 @@ def current_state() -> dict[str, Any]:
return data 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]: def candidate_files() -> list[Path]:
files: list[Path] = [] files: list[Path] = []
for path in REPO_ROOT.rglob("*"): for path in REPO_ROOT.rglob("*"):
@@ -550,6 +622,8 @@ def call_tool(name: str, arguments: dict[str, Any]) -> str:
result = overview() result = overview()
elif name == "athena_get_current_state": elif name == "athena_get_current_state":
result = current_state() result = current_state()
elif name == "athena_get_external_services":
result = external_services()
elif name == "athena_search_knowledge": elif name == "athena_search_knowledge":
result = search_knowledge(arguments) result = search_knowledge(arguments)
elif name == "athena_read_source": elif name == "athena_read_source":
@@ -1,7 +1,7 @@
""" """
title: MikeAI Auto Tool Selector title: MikeAI Auto Tool Selector
author: MikeAI author: MikeAI
version: 2.0.0 version: 2.1.0
description: Selects a small, relevant set of MCP servers for each user request. description: Selects a small, relevant set of MCP servers for each user request.
""" """
@@ -81,6 +81,9 @@ class Filter:
"and every required preview, confirmation, backup, and validation rule " "and every required preview, confirmation, backup, and validation rule "
"of the tool is satisfied. Do not call unrelated tools merely because " "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", []) messages = body.setdefault("messages", [])
for message in messages: for message in messages:
@@ -165,9 +168,13 @@ class Filter:
), ),
) )
# A specialist source is more precise than public web search. Platform # Existing infrastructure must be inspected before a new integration
# wins over a generic Docker mention when Athena is explicitly named. # is designed. For a GitHub-backed MCP that targets a service already
if operator: # 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") selected.append("operator")
elif platform: elif platform:
selected.append("platform") selected.append("platform")