Add automatic per-request MCP selection

This commit is contained in:
Mikei386
2026-08-23 19:04:42 +02:00
parent ddde433224
commit e9b647e579
7 changed files with 344 additions and 20 deletions
+61
View File
@@ -117,6 +117,67 @@ class StabilityGuardTests(unittest.IsolatedAsyncioTestCase):
self.assertLess(len(result["messages"][1]["content"]), 3000)
class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.module = _load("auto_tool_selector")
self.selector = self.module.Filter()
async def _select(self, prompt: str, existing=None):
body = {
"model": "mikeai-medium",
"messages": [{"role": "user", "content": prompt}],
}
if existing is not None:
body["tool_ids"] = existing
return await self.selector.inlet(body)
async def test_homeassistant_is_selected_for_room_temperature(self):
result = await self._select("Wie warm ist es gerade in der Küche?")
self.assertEqual(
result["tool_ids"], ["server:mcp:homeassistant-local"]
)
async def test_weather_uses_web_not_homeassistant(self):
result = await self._select("Soll es heute in Rastatt regnen?")
self.assertEqual(result["tool_ids"], ["server:mcp:web-local"])
async def test_unraid_uses_readonly_not_mua(self):
result = await self._select(
"Welche Docker-Container laufen aktuell auf Unraid?"
)
self.assertEqual(
result["tool_ids"], ["server:mcp:unraid-readonly-local"]
)
self.assertNotIn("server:mcp:mua", result["tool_ids"])
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."
)
self.assertEqual(
result["tool_ids"],
["server:mcp:github-local", "server:mcp:web-local"],
)
async def test_manual_tool_is_preserved(self):
result = await self._select(
"Prüfe Sonarr.", ["server:mcp:manually-selected"]
)
self.assertEqual(
result["tool_ids"],
["server:mcp:manually-selected", "server:mcp:arr-local"],
)
async def test_plain_chat_gets_no_tools(self):
result = await self._select("Erkläre mir den Unterschied zwischen RAM und SSD.")
self.assertNotIn("tool_ids", result)
async def test_selection_adds_write_safety_rule(self):
result = await self._select("Zeige mir die Home Assistant Automatisierungen.")
self.assertEqual(result["messages"][0]["role"], "system")
self.assertIn("Availability is not authorization", result["messages"][0]["content"])
class MetricsTests(unittest.IsolatedAsyncioTestCase):
async def test_metrics_file_contains_no_chat_content_or_ids(self):
module = _load("local_performance_metrics")
+8
View File
@@ -166,6 +166,14 @@ Aktuell existieren funktionale Adapter für:
- Unraid read-only
- eigener Unraid-Administrationsserver
OpenWebUI bindet diese Kataloge nicht pauschal an jedes Modellprofil. Der
lokale `MikeAI Auto Tool Selector` ergänzt anhand der jüngsten Nutzernachricht
höchstens zwei passende MCP-Verbindungen pro Anfrage. Dadurch bleiben normale
Chats schemafrei und kurze Profile verlieren keinen unnötigen Kontext. MUA
mit erweiterten Verwaltungsrechten bleibt von der Automatik ausgeschlossen;
eine automatisch bereitgestellte Verbindung erteilt niemals Schreibrechte
oder eine Änderungsfreigabe.
Der GitHub-Container läuft produktiv. Token-Datei, interner
Streamable-HTTP-Handshake, fehlende Host-Portfreigabe und exakt vier
read-only Werkzeuge wurden am 23. August 2026 verifiziert.
+5 -1
View File
@@ -55,6 +55,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] rohe `qwen-*`-Routermodelle sind ausgeblendet
- [ ] Medium ist die gespeicherte Standardauswahl
- [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet
- [ ] Auto Tool Selector wählt bei harmlosen Testfragen Web, GitHub, Home
Assistant, ARR, Navidrome, Unraid read-only und Athena korrekt
- [ ] normale Unterhaltung erhält kein MCP; MUA wird niemals automatisch gewählt
- [ ] SearXNG und TinySearch gesund
- [ ] Websuche liefert kompakte, quellengebundene Ergebnisse
@@ -67,7 +70,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] offizieller GitHub-MCP gesund; exakt vier read-only Repository-Werkzeuge
- [ ] GitHub-Token liegt nur in `/etc/mike-ai/github-mcp.env` (0600), nicht in OpenWebUI
- [ ] Unraid read-only Diagnose geprüft
- [ ] schreibende Werkzeuge standardmäßig nicht geladen
- [ ] schreibende Werkzeuge standardmäßig nicht geladen; automatische Auswahl
gilt nicht als Änderungsfreigabe
- [ ] Tool-Schemas bleiben innerhalb des festgelegten Kontextbudgets
- [ ] kein Secret erscheint in Toolantworten oder Logs
- [ ] `PLATFORM_OVERVIEW.md`, `QWEN_OPERATOR_CONTEXT.md` und der Operator-
+22 -7
View File
@@ -9,14 +9,21 @@ Reihenfolge ist absichtlich festgelegt:
`reasoning_effort=none`.
2. `Thinking`, Priorität 20: läuft nur bei aktiviertem Brain-Schalter und
überschreibt den Standard mit Low, Medium oder High.
3. `MikeAI Stability Guard`, Priorität 30: begrenzt einzelne und gesamte
3. `MikeAI Auto Tool Selector`, Priorität 25: betrachtet ausschließlich die
jüngste Nutzernachricht und stellt pro Anfrage höchstens zwei passende MCPs
bereit. Er erkennt Web, GitHub, Home Assistant, Sonarr/Radarr, Navidrome,
Unraid-Diagnose und Athena-Plattformwissen. Manuell gewählte Werkzeuge
bleiben erhalten. MUA mit erweiterten Verwaltungsrechten wird nie
automatisch zugeschaltet. Die Auswahl eines MCP ist ausdrücklich keine
Freigabe für eine Zustandsänderung.
4. `MikeAI Stability Guard`, Priorität 30: begrenzt einzelne und gesamte
Werkzeugresultate, verdichtet bei Bedarf zuerst alte Tool-Ausgaben und
Dialogteile und stoppt identische beziehungsweise ausufernde Tool-Schleifen.
4. `MikeAI Secret Redaction`, Priorität 40: entfernt übliche API-Keys, Tokens,
5. `MikeAI Secret Redaction`, Priorität 40: entfernt übliche API-Keys, Tokens,
Passwörter, JWTs und private Schlüssel aus Tool-Ergebnissen, bevor sie das
Modell erreichen, sowie aus fertigen Modellantworten. Nutzereingaben und
Authentifizierungswege werden nicht verändert.
5. `MikeAI Spoken Tool Status`, Priorität 80: erkennt den ersten echten
6. `MikeAI Spoken Tool Status`, Priorität 80: erkennt den ersten echten
Werkzeugaufruf eines Antwortlaufs und löst im Browser genau eine kurze,
passende Ansage für Web, Unraid, Home Assistant, Medienverwaltung oder
sonstige Werkzeuge aus. Die fünf MP3-Clips sind vorgerendert und liegen
@@ -24,7 +31,7 @@ Reihenfolge ist absichtlich festgelegt:
weder XTTS noch das Werkzeug. Sie wird nur abgespielt, wenn der Benutzer in
OpenWebUI die automatische Sprachausgabe aktiviert hat. Endet ein sehr
schneller Lauf innerhalb der kurzen Wartezeit, wird die Ansage verworfen.
6. `MikeAI Local Performance Metrics`, Priorität 90: erfasst nach Abschluss
7. `MikeAI Local Performance Metrics`, Priorität 90: erfasst nach Abschluss
ausschließlich technische Zahlen wie Laufzeit, Tokenzähler, Token/s und
Tool-Anzahl. Nutzer-, Chat- und Nachrichten-IDs sowie sämtliche Textinhalte
werden weder geschrieben noch gehasht gespeichert.
@@ -49,9 +56,17 @@ Dadurch erscheinen ausschließlich `MikeAI · Fast`, `MikeAI · Medium`,
fünf erhalten die geprüften Filter und Quick Actions sowie Piper-Stimme
`alloy`. Vision ist bei Fast, Medium, Large und Uncensored aktiviert; Ultra bleibt
bewusst text-only. Bildgenerierung wird erst als Fähigkeit freigeschaltet,
wenn ein echter Generator-Worker im Stack aktiv ist. Websuche und Codewerkzeuge
bleiben verfügbar, werden aber nicht automatisch jeder Nachricht beigelegt,
damit Werkzeugschemas den Kontext nicht unnötig füllen.
wenn ein echter Generator-Worker im Stack aktiv ist. Kein MCP ist statisch an
jedes Profil gebunden. Der Auto Tool Selector stellt nur die zur jüngsten
Anfrage passenden Werkzeuge bereit, damit irrelevante Schemas weder Kontext
verbrauchen noch die Werkzeugwahl des Modells verschlechtern. Eine manuelle
Auswahl im Chat bleibt zusätzlich möglich.
Die Auswahl arbeitet bewusst regelbasiert und lokal. Sie sendet keine Texte an
einen Klassifizierungsdienst, speichert keine Prompts und führt selbst keine
Werkzeugaktion aus. Erkennt sie keine eindeutige Absicht, wird kein MCP
automatisch ergänzt. Schreibende oder kritische Rechte werden weiterhin durch
das jeweilige MCP, Bestätigungsregeln und die manuelle MUA-Auswahl begrenzt.
Alle fünf Modelle erhalten außerdem dieselbe Evidenzregel: Aussagen über
aktuelle externe oder Systemzustände benötigen im aktuellen Turn einen
@@ -0,0 +1,233 @@
"""
title: MikeAI Auto Tool Selector
author: MikeAI
version: 1.0.0
description: Selects a small, relevant set of MCP servers for each user request.
"""
from __future__ import annotations
import re
import unicodedata
from pydantic import BaseModel
class Filter:
class Valves(BaseModel):
priority: int = 25
enabled: bool = True
max_automatic_tools: int = 2
show_selection_status: bool = True
TOOL_IDS = {
"web": "server:mcp:web-local",
"github": "server:mcp:github-local",
"homeassistant": "server:mcp:homeassistant-local",
"arr": "server:mcp:arr-local",
"unraid": "server:mcp:unraid-readonly-local",
"navidrome": "server:mcp:navidrome-local",
"platform": "server:mcp:athena-platform",
}
LABELS = {
"web": "Web",
"github": "GitHub",
"homeassistant": "Home Assistant",
"arr": "Sonarr/Radarr",
"unraid": "Unraid-Diagnose",
"navidrome": "Navidrome",
"platform": "Athena-Plattformwissen",
}
def __init__(self):
self.valves = self.Valves()
self.toggle = False
@staticmethod
def _normalise(text: str) -> str:
text = unicodedata.normalize("NFKC", text or "").casefold()
return re.sub(r"\s+", " ", text).strip()
@staticmethod
def _matches(text: str, patterns: tuple[str, ...]) -> bool:
return any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in patterns)
@staticmethod
def _latest_user_text(body: dict) -> str:
for message in reversed(body.get("messages") or []):
if message.get("role") != "user":
continue
content = message.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and isinstance(part.get("text"), str)
)
return str(content)
return ""
@staticmethod
def _add_system_rule(body: dict, labels: list[str]) -> None:
rule = (
"MikeAI automatic tool selection made these capabilities available for "
f"this request: {', '.join(labels)}. Availability is not authorization. "
"First inspect read-only state. Never perform a state-changing action "
"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."
)
messages = body.setdefault("messages", [])
for message in messages:
if message.get("role") == "system" and isinstance(message.get("content"), str):
message["content"] += "\n\n" + rule
return
messages.insert(0, {"role": "system", "content": rule})
def _classify(self, raw_text: str) -> list[str]:
text = self._normalise(raw_text)
if not text:
return []
selected: list[str] = []
platform = self._matches(
text,
(
r"\bathena\b", r"\bmikeai\b", r"\bki[- ]host\b",
r"\bai[- ]profile[- ]router\b", r"\bprofil[- ]router\b",
r"\brecovery[- ](?:koffer|bundle|skript)\b",
r"\b(?:disaster|bare metal)[- ]recovery\b",
r"\b(?:installations?|reinstall|setup)[- ]skript\b",
r"\bplattform(?:wissen|dokumentation)?\b",
),
)
homeassistant = self._matches(
text,
(
r"\bhome\s*assistant\b", r"\bhomeassistant\b", r"\bhass\b",
r"\bautomatisierung(?:en)?\b", r"\bentit[aä]t(?:en)?\b",
r"\b(?:sensor|light|switch|climate|automation)\.[\w.-]+",
r"\b(?:temperatur|luftfeuchtigkeit|wie warm)\b.*\b"
r"(?:k[uü]che|wohnzimmer|schlafzimmer|bad|toilette|keller|haus)\b",
),
)
arr = self._matches(
text,
(
r"\bsonarr\b", r"\bradarr\b", r"\barr[- ]mcp\b",
r"\b(?:staffel|episode|folge)(?:n)?\b.*\b"
r"(?:fehl|such|download|indexer|release|serie)\w*",
r"\b(?:serie|film)\b.*\b(?:sonarr|radarr|indexer|release)\b",
),
)
navidrome = self._matches(
text,
(
r"\bnavidrome\b", r"\bmusikbibliothek\b", r"\bplaylist\b",
r"\b(?:titel|song|track)\b.*\b(?:[aä]hnlich|empfehl|playlist|bibliothek)\w*",
r"\b(?:album|k[uü]nstler|artist)\b.*\b(?:navidrome|bibliothek|playlist)\b",
),
)
unraid = self._matches(
text,
(
r"\bunraid\b", r"\bhome\s*server\b", r"\bhomeserver\b",
r"\bparity\b", r"\barray\b", r"\bmua\b",
r"\bdocker[- ]container\b", r"\bcontainer(?:n)?\b.*\b"
r"(?:docker|log|update|l[aä]uft|laufen|gestoppt|unraid)\b",
r"\b(?:docker|container)\b.*\b(?:unraid|homeserver|nas)\b",
),
)
github = self._matches(
text,
(
r"\bgithub\b", r"github\.com/", r"\brepositor(?:y|ium|ies)\b",
r"\bgit[- ]repo\b", r"\bsource[- ]tree\b",
r"\b(?:readme|quellcode|sourcecode|code)\b.*\b(?:repo|github)\b",
),
)
# A specialist source is more precise than public web search. Platform
# wins over a generic Docker mention when Athena is explicitly named.
if platform:
selected.append("platform")
elif homeassistant:
selected.append("homeassistant")
elif arr:
selected.append("arr")
elif navidrome:
selected.append("navidrome")
elif unraid:
selected.append("unraid")
elif github:
selected.append("github")
explicit_web = self._matches(
text,
(
r"\b(?:im|ins|das|dem) (?:internet|netz|web)\b",
r"\bwebsuche\b", r"\bonline (?:such|nachschau|recherch|pr[uü]f)\w*",
r"\b(?:internet|web) (?:such|nachschau|recherch|pr[uü]f)\w*",
),
)
current_public = self._matches(
text,
(
r"\b(?:wetter|regen|regnet|regnen|regenradar|vorhersage)\b",
r"\b(?:nachrichten|news|schlagzeilen)\b",
r"\b(?:preis|preise|verf[uü]gbar|verf[uü]gbarkeit)\b",
r"\b(?:neueste|neuestes|neuerungen|release)\b",
),
)
if (explicit_web or (current_public and not selected)) and "web" not in selected:
selected.append("web")
# A direct GitHub reference should still use GitHub even if broader web
# research is also requested.
if github and "github" not in selected:
selected.insert(0, "github")
return selected[: max(0, self.valves.max_automatic_tools)]
async def _notify(self, emitter, labels: list[str]) -> None:
if emitter is None or not self.valves.show_selection_status:
return
try:
await emitter(
{
"type": "status",
"data": {
"description": "Automatisch bereitgestellt: " + ", ".join(labels),
"done": True,
},
}
)
except Exception:
pass
async def inlet(self, body: dict, __event_emitter__=None, **kwargs) -> dict:
if not self.valves.enabled:
return body
selected = self._classify(self._latest_user_text(body))
if not selected:
return body
existing = body.get("tool_ids")
if not isinstance(existing, list):
existing = []
merged = list(existing)
for category in selected:
tool_id = self.TOOL_IDS[category]
if tool_id not in merged:
merged.append(tool_id)
body["tool_ids"] = merged
labels = [self.LABELS[category] for category in selected]
self._add_system_rule(body, labels)
await self._notify(__event_emitter__, labels)
return body
+9 -4
View File
@@ -9,7 +9,7 @@ ACTION_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/actions" && pwd)
die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "Bitte als root ausführen."
for file in reasoning_default_off.py thinking.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do
for file in reasoning_default_off.py thinking.py auto_tool_selector.py stability_guard.py secret_redaction.py spoken_tool_status.py local_performance_metrics.py; do
[[ -s $FILTER_DIR/$file ]] || die "Filterdatei fehlt: $file"
done
[[ -s $ACTION_DIR/quick_actions.py ]] || die "Actiondatei fehlt: quick_actions.py"
@@ -67,9 +67,9 @@ if not {"key", "value", "updated_at"} <= config_columns:
owner = requested_owner
if not owner:
existing = con.execute(
"select user_id from function where id in (?, ?, ?, ?, ?, ?, ?) order by id limit 1",
"select user_id from function where id in (?, ?, ?, ?, ?, ?, ?, ?) order by id limit 1",
(
"reasoning_default_off", "thinking", "stability_guard",
"reasoning_default_off", "thinking", "auto_tool_selector", "stability_guard",
"secret_redaction", "spoken_tool_status", "local_performance_metrics",
"quick_actions",
),
@@ -88,6 +88,11 @@ now = int(time.time())
functions = [
("reasoning_default_off", "Reasoning Default Off", "filter", 10, filter_dir, ""),
("thinking", "Thinking", "filter", 20, filter_dir, ""),
(
"auto_tool_selector", "MikeAI Auto Tool Selector", "filter", 25, filter_dir,
"Stellt pro Anfrage höchstens zwei passende MCP-Werkzeuge bereit. "
"Die Auswahl ist keine Freigabe für schreibende Aktionen.",
),
("stability_guard", "MikeAI Stability Guard", "filter", 30, filter_dir, ""),
("secret_redaction", "MikeAI Secret Redaction", "filter", 40, filter_dir, ""),
(
@@ -312,7 +317,7 @@ with con:
),
)
print(
"OpenWebUI konfiguriert: Default Off=10, Thinking=20, "
"OpenWebUI konfiguriert: Default Off=10, Thinking=20, Auto Tool Selector=25, "
"Stability Guard=30, Secret Redaction=40, Spoken Tool Status=80, Local Metrics=90, "
"Quick Actions=100, Folgefragen=aus, Piper-TTS=aktiv, Werkzeugwahl=optimiert"
)
+6 -8
View File
@@ -93,17 +93,16 @@ now = int(time.time())
filter_ids = [
"reasoning_default_off",
"thinking",
"auto_tool_selector",
"stability_guard",
"secret_redaction",
"local_performance_metrics",
]
# Open WebUI addresses a global MCP server as server:mcp:<connection-id>.
# Attach the bounded web relay and, when its dedicated secret exists, the four
# read-only official GitHub tools. This is not Open WebUI's built-in web search.
default_tool_ids = ["server:mcp:web-local"]
if github_enabled:
default_tool_ids.append("server:mcp:github-local")
# The Auto Tool Selector attaches a bounded, relevant MCP set per request.
# Keeping this list empty prevents unrelated schemas from consuming context.
# Users can still enable additional tools manually in a chat.
default_tool_ids = []
def capabilities(vision: bool) -> dict:
return {
@@ -310,8 +309,7 @@ with con:
"tags": [{"name": tag} for tag in profile["tags"]],
"toolIds": default_tool_ids,
# Built-in features remain available but are not forced on every
# request. Only the bounded web MCP and four read-only GitHub
# repository tools are attached by default.
# request. The Auto Tool Selector supplies relevant MCPs per turn.
"defaultFeatureIds": [],
"filterIds": filter_ids,
"actionIds": ["quick_actions"],