test(openwebui): make the MCP client open the connection, not just record it

GET /api/v1/tools/ builds its answer for an MCP server out of stored config alone - routers/tools.py:125-126 reads info off the connection row and never touches the network. So the three specs that read it pass unchanged against a provider that is down, refuses the deployed bearer, or serves nothing. What they establish is registration and the OIDC group to access-grant chain, not that the bridge carries.

POST /api/v1/configs/tool_servers/verify is the one route in 0.11.0 that does a real MCP initialize plus tools/list without an LLM, and it authenticates with the key in the body it is handed. The spec reads the stored connection and echoes it back, so the handshake runs over the deployed URL with the deployed credential through Open WebUI's own client, and asserts three things per server: it answers, it serves at least one tool, and the tool set contains the read probe its meta/mcp.yml declares.

Only bearer connections are verified. A system_oauth one resolves the caller's own upstream token per request and the administrator holds none, so it would fail on the caller rather than on the bridge; no provider declares auth: oidc today, but the filter keeps the spec honest when one does. The per-test budget scales with the number of registered servers instead of a fixed guess.

MCP_EXPECTED_TOOLS carries the declared read probe per server. Its rendering is unit-tested by extracting the block from the template itself rather than restating it, and decoded the way a spec decodes it - dotenv_quote emits an escaped JSON string that decodeDotenvQuotedValue unwraps before the spec parses the payload, so comparing after one decode would test a value no spec ever sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-01 21:58:54 +02:00
1 parent 07d73aecef
commit 60a9dca2b1
6 files changed
+192

No files matched your search

@@ -0,0 +1,3 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
@@ -0,0 +1,95 @@
"""``MCP_EXPECTED_TOOLS`` names the read probe each registered server declares.
The bridge spec asserts that a server serves the tool its ``meta/mcp.yml``
declares, and it learns which tool that is from this one env value. Rendered
wrong it does not fail: an empty map makes the spec skip the tool assertion for
every server and pass on the handshake alone, which is the weaker check it was
written to replace.
The block is extracted from the template rather than restated here, so a change
to the template is a change to what this test renders.
"""
from __future__ import annotations
import json
import re
import unittest
from ansible.parsing.dataloader import DataLoader
from ansible.template import Templar, trust_as_template
from utils.cache.applications import get_application_defaults
from utils.cache.files import PROJECT_ROOT, read_text
_TEMPLATE = PROJECT_ROOT / "roles/web-app-openwebui/templates/playwright.env.j2"
_BLOCK = re.compile(
r"\{%\s*set _read_probes.*?^MCP_EXPECTED_TOOLS=.*?$",
re.DOTALL | re.MULTILINE,
)
_SERVERS = ("web-app-gitea", "svc-db-qdrant", "web-svc-libretranslate")
def _render(server_ids: tuple[str, ...]) -> str:
"""Render the template's own read-probe block for the given servers."""
block = _BLOCK.search(read_text(str(_TEMPLATE)))
assert block, f"{_TEMPLATE} no longer carries the read-probe block"
variables = {
"applications": get_application_defaults(roles_dir=PROJECT_ROOT / "roles"),
"OPENWEBUI_MCP_SERVERS": [
{
"id": server_id,
"url": f"http://{server_id}:8080/mcp",
"auth": "bearer_token",
"token": "irrelevant-to-this-test",
}
for server_id in server_ids
],
}
templar = Templar(loader=DataLoader(), variables=variables)
return templar.template(trust_as_template(block.group(0)))
def _rendered_map(server_ids: tuple[str, ...]) -> dict:
"""Return the map a spec reads, decoded the way the spec decodes it.
``dotenv_quote`` emits a quoted string with escaped inner quotes, which
``decodeDotenvQuotedValue`` (personas/utils/dotenv.js:17-30) unwraps with a
JSON parse before the spec parses the payload itself. Decoding it once here
would compare against a string that never reaches a spec.
"""
line = _render(server_ids)
_, _, value = line.partition("=")
value = value.strip()
if value.startswith('"') and value.endswith('"'):
value = json.loads(value)
return json.loads(value)
class TestPlaywrightReadProbes(unittest.TestCase):
def test_every_server_gets_the_tool_its_role_declares(self) -> None:
rendered = _rendered_map(_SERVERS)
expected = {
server: (
get_application_defaults(roles_dir=PROJECT_ROOT / "roles")[server]
.get("mcp", {})
.get("tools", {})
.get("read_probe", {})
.get("tool", "")
)
for server in _SERVERS
}
self.assertEqual(expected, rendered)
def test_the_probes_are_non_empty_for_these_servers(self) -> None:
self.assertEqual([], [k for k, v in _rendered_map(_SERVERS).items() if not v])
def test_a_server_without_a_read_probe_renders_an_empty_string(self) -> None:
self.assertEqual({"web-app-pretix": ""}, _rendered_map(("web-app-pretix",)))
def test_no_discovered_server_renders_an_empty_map(self) -> None:
self.assertEqual({}, _rendered_map(()))
if __name__ == "__main__":
unittest.main()