fix(tools): harden OpenWebUI tool workflows

This commit is contained in:
Mikei386 committed 2026-08-24 00:40:32 +02:00
1 parent bfb990a4fd
commit e24839bfe1
24 files changed
+505 -100

No files matched your search

+9 -8
View File
@@ -123,12 +123,13 @@ echo "$RESP" | python3 -c '
import json,sys
d=json.load(sys.stdin)
ids={m["id"]:m for m in d["data"]}
assert set(ids)=={"qwen-fast","qwen-medium","qwen-large","qwen-ultra"}, ids
assert set(ids)=={"qwen-fast","qwen-medium","qwen-large","qwen-ultra","qwen-uncensored"}, ids
assert ids["qwen-fast"]["context_length"]==76800
assert ids["qwen-medium"]["context_length"]==160000
assert ids["qwen-large"]["context_length"]==192000
assert ids["qwen-ultra"]["context_length"]==262144
' && ok "vier virtuelle Modelle mit korrekten Context Windows" || bad "/v1/models"
assert ids["qwen-uncensored"]["context_length"]==80000
' && ok "fünf virtuelle Modelle mit korrekten Context Windows" || bad "/v1/models"
# --- 2. /status -----------------------------------------------------------------
echo "== Test 2: /status"
@@ -365,23 +366,23 @@ d=json.load(sys.stdin)
assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d
' && ok "Chat funktioniert nach Bildgenerierung" || bad "Chat nach Bild"
# --- 18. quality=standard → 30 Steps -------------------------------------------------------------
echo "== Test 18: quality=standard → 30 Steps"
# --- 18. quality=standard stays on the validated four-step FLUX path ----------------------------
echo "== Test 18: quality=standard → 4 Steps"
rm -f /tmp/test_worker_requests.jsonl
RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \
-d '{"prompt":"standard test","size":"1024x1024","quality":"standard"}')
sleep 0.3
STEPS=$(tail -1 /tmp/test_worker_requests.jsonl 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["steps"])' 2>/dev/null || echo "?")
[ "$STEPS" = "30" ] && ok "quality=standard → 30 Steps" || bad "erwartet 30 Steps, bekam $STEPS"
[ "$STEPS" = "4" ] && ok "quality=standard → 4 Steps" || bad "erwartet 4 Steps, bekam $STEPS"
# --- 19. quality=high → 50 Steps -------------------------------------------------------------------
echo "== Test 19: quality=high → 50 Steps"
# --- 19. quality=high stays bounded for the distilled four-step model -----------------------------
echo "== Test 19: quality=high → 4 Steps"
rm -f /tmp/test_worker_requests.jsonl
RESP=$(curl -sf "$BASE/v1/images/generations" -H "Content-Type: application/json" \
-d '{"prompt":"high test","size":"1024x1024","quality":"high"}')
sleep 0.3
STEPS=$(tail -1 /tmp/test_worker_requests.jsonl 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["steps"])' 2>/dev/null || echo "?")
[ "$STEPS" = "50" ] && ok "quality=high → 50 Steps" || bad "erwartet 50 Steps, bekam $STEPS"
[ "$STEPS" = "4" ] && ok "quality=high → 4 Steps" || bad "erwartet 4 Steps, bekam $STEPS"
# --- 20. ungültige Qualität → 400 ------------------------------------------------------------------
echo "== Test 20: ungültige Qualität → 400"
+53 -6
View File
@@ -116,6 +116,47 @@ class StabilityGuardTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result["messages"][-1]["content"], "Aktuelle wichtige Frage")
self.assertLess(len(result["messages"][1]["content"]), 3000)
async def test_private_csv_disables_web_and_requires_local_table_analysis(self):
body = {
"model": "qwen-fast",
"features": {"web_search": True, "code_interpreter": True},
"tool_ids": ["server:mcp:web-local", "server:mcp:arr-local"],
"tools": [
{"type": "function", "function": {"name": "search_web"}},
{"type": "function", "function": {"name": "execute_code"}},
],
"metadata": {"files": [{"name": "private-bank.csv"}]},
"messages": [
{"role": "user", "content": "Sortiere Ein- und Ausgänge."},
],
}
result = await self.guard.inlet(body)
self.assertEqual(result["tool_ids"], [])
self.assertFalse(result["features"]["web_search"])
self.assertTrue(result["features"]["code_interpreter"])
self.assertEqual(
[tool["function"]["name"] for tool in result["tools"]],
["execute_code"],
)
self.assertIn("private table rule", result["messages"][0]["content"])
async def test_private_csv_is_detected_from_user_text_without_metadata(self):
body = {
"model": "qwen-fast",
"tools": [
{"type": "function", "function": {"name": "search_web"}},
{"type": "function", "function": {"name": "execute_code"}},
],
"messages": [
{"role": "user", "content": "Werte bitte diese CSV meines Bankkontos aus."},
],
}
result = await self.guard.inlet(body)
self.assertEqual(
[tool["function"]["name"] for tool in result["tools"]],
["execute_code"],
)
class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
@@ -137,15 +178,15 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
result["tool_ids"], ["server:mcp:homeassistant-local"]
)
async def test_weather_uses_web_not_homeassistant(self):
async def test_weather_uses_native_web_without_mcp(self):
result = await self._select("Soll es heute in Rastatt regnen?")
self.assertEqual(result["tool_ids"], ["server:mcp:web-local"])
self.assertNotIn("tool_ids", result)
async def test_youtube_channel_question_uses_web(self):
async def test_youtube_channel_question_uses_native_web(self):
result = await self._select(
"Welches Video steht aktuell oben auf dem YouTube-Kanal The Proper People?"
)
self.assertEqual(result["tool_ids"], ["server:mcp:web-local"])
self.assertNotIn("tool_ids", result)
async def test_unraid_uses_readonly_not_mua(self):
result = await self._select(
@@ -203,15 +244,21 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
)
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_only_adds_github_mcp(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"],
["server:mcp:github-local"],
)
async def test_plain_public_web_request_does_not_attach_legacy_web_mcp(self):
result = await self._select(
"Suche im Netz auf MakerWorld einen Schlümpfe-Schlüsselanhänger."
)
self.assertNotIn("server:mcp:web-local", result.get("tool_ids", []))
async def test_manual_tool_is_preserved(self):
result = await self._select(
"Prüfe Sonarr.", ["server:mcp:manually-selected"]
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Verify a Streamable HTTP MCP catalogue without invoking any tool."""
from __future__ import annotations
import argparse
import asyncio
import json
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
def invalid_patterns(value, path: str = "schema") -> list[str]:
issues = []
if isinstance(value, dict):
pattern = value.get("pattern")
if isinstance(pattern, str) and not (
pattern.startswith("^") and pattern.endswith("$")
):
issues.append(f"{path}.pattern={pattern!r}")
for key, item in value.items():
issues.extend(invalid_patterns(item, f"{path}.{key}"))
elif isinstance(value, list):
for index, item in enumerate(value):
issues.extend(invalid_patterns(item, f"{path}[{index}]"))
return issues
async def verify(
url: str,
required: set[str],
forbidden: set[str],
max_tools: int,
max_schema_chars: int,
timeout: float,
) -> None:
async with asyncio.timeout(timeout):
async with streamablehttp_client(url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
response = await session.list_tools()
names = {tool.name for tool in response.tools}
schemas = [tool.inputSchema for tool in response.tools]
schema_chars = len(json.dumps(schemas, ensure_ascii=False, separators=(",", ":")))
pattern_issues = [
issue
for index, schema in enumerate(schemas)
for issue in invalid_patterns(schema, f"tools[{index}]")
]
missing = required - names
exposed = forbidden & names
if missing or exposed or len(names) > max_tools or schema_chars > max_schema_chars or pattern_issues:
raise SystemExit(
f"MCP catalogue mismatch: missing={sorted(missing)}, "
f"forbidden={sorted(exposed)}, count={len(names)}/{max_tools}, "
f"schema_chars={schema_chars}/{max_schema_chars}, "
f"invalid_patterns={pattern_issues}, actual={sorted(names)}"
)
print(
f"MCP_CATALOG_OK count={len(names)} schema_chars={schema_chars} tools="
+ ",".join(sorted(names))
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("--require", action="append", default=[])
parser.add_argument("--forbid", action="append", default=[])
parser.add_argument("--max-tools", type=int, default=64)
parser.add_argument("--max-schema-chars", type=int, default=100_000)
parser.add_argument("--timeout", type=float, default=10.0)
args = parser.parse_args()
asyncio.run(
verify(
args.url,
set(args.require),
set(args.forbid),
args.max_tools,
args.max_schema_chars,
args.timeout,
)
)
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
# Read-only MCP catalogue TÜV. It copies the verifier into Open WebUI's
# temporary filesystem and only performs initialize + tools/list. No business
# tool is ever invoked and no prompt, chat or secret is read.
OWUI_CONTAINER="${OWUI_CONTAINER:-mike-ai-open-webui}"
VERIFY_REMOTE=/tmp/verify_mcp_catalog.py
docker inspect "$OWUI_CONTAINER" >/dev/null
docker cp "$(dirname "$0")/verify_mcp_catalog.py" "$OWUI_CONTAINER:$VERIFY_REMOTE"
verify() {
docker exec "$OWUI_CONTAINER" python3 "$VERIFY_REMOTE" "$@"
}
verify http://mike-ai-mcp-platform-context:8000/mcp \
--max-tools 12 --max-schema-chars 12000
verify http://mike-ai-mcp-athena-operator:8000/mcp \
--max-tools 8 --max-schema-chars 12000
verify http://mike-ai-mcp-web:8000/mcp \
--max-tools 8 --max-schema-chars 12000
if docker inspect mike-ai-mcp-github >/dev/null 2>&1; then
verify http://mike-ai-mcp-github:8000/mcp \
--require search_repositories \
--require get_file_contents \
--require search_code \
--forbid get_repository_tree \
--max-tools 3 --max-schema-chars 6000
fi
if docker inspect mike-ai-mcp-homeassistant >/dev/null 2>&1; then
verify http://mike-ai-mcp-homeassistant:8000/mcp \
--max-tools 32 --max-schema-chars 24000
fi
if docker inspect mike-ai-mcp-arr >/dev/null 2>&1; then
verify http://mike-ai-mcp-arr:8000/mcp \
--max-tools 4 --max-schema-chars 8000
fi
if docker inspect mike-ai-mcp-navidrome >/dev/null 2>&1; then
verify http://mike-ai-mcp-navidrome:3000/mcp \
--max-tools 50 --max-schema-chars 36000
fi
echo MCP_CATALOG_SUITE_OK