fix(tools): harden OpenWebUI tool workflows
This commit is contained in:
1 parent
bfb990a4fd
commit
e24839bfe1
24 files changed
+505
-100
No files matched your search
+9
-8
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
Executable
+48
@@ -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
|
||||
Reference in new issue
Block a user