ecadb66414
A server is a row with a URL; its tools are discovered by a button and cached, then offered beside the built-in ones. Written by hand rather than taken from the reference SDK, because that SDK's transport does its own connecting -- and the one thing that must not be bypassed is check_url on every hop. Owning the transport is the point; the framing beside it is the small part. Sessions are per call: initialize, initialized, the call, a best-effort DELETE. Caching one wants an owner, a TTL, eviction, a lock and a shutdown hook, and the server may expire it under all of that anyway -- ToolContext is a session-free snapshot precisely so nothing in a tool holds live state. A server's names and descriptions reach the model as instructions and are bounded before they do; what it returns is escaped preformatted text, never markdown. Tools are namespaced per server, so two servers exposing "search" do not collide and neither shadows a built-in. Also: a round's calls now run together under a semaphore, results indexed so each tool turn stays paired with its call, and generation.status names what is running -- a remote tool is latency-bound, and a silent pause is what a hang looks like. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
497 lines
17 KiB
Python
497 lines
17 KiB
Python
"""The custom-tools admin screen."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select
|
|
|
|
from lembas.db.models import CustomTool, Group, User
|
|
from lembas.security.passwords import hash_password
|
|
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt
|
|
|
|
|
|
@pytest.fixture
|
|
def plain_user(client: TestClient, db, registered):
|
|
"""A second account, which is never an administrator."""
|
|
client.post("/auth/logout")
|
|
client.post(
|
|
"/auth/register",
|
|
data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
|
|
follow_redirects=False,
|
|
)
|
|
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
|
|
user.role = "user"
|
|
user.active = True
|
|
db.commit()
|
|
client.post(
|
|
"/auth/login",
|
|
data={"email": "sam@shire.test", "password": "correct horse battery"},
|
|
follow_redirects=False,
|
|
)
|
|
return user
|
|
|
|
|
|
def _form(**overrides) -> dict:
|
|
base = {
|
|
"name": "Weather",
|
|
"slug": "weather",
|
|
"description": "Look up the weather for a city.",
|
|
"parameters": json.dumps(
|
|
{"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}
|
|
),
|
|
"method": "GET",
|
|
"url_template": "https://api.test/v1/{{city}}",
|
|
"headers": "Accept: application/json",
|
|
"secret_placement": "bearer",
|
|
"secret_name": "Authorization",
|
|
"response_mode": "json",
|
|
"max_chars": "8000",
|
|
"timeout": "20",
|
|
"position": "0",
|
|
"enabled": "true",
|
|
"public": "true",
|
|
}
|
|
base.update(overrides)
|
|
return {key: value for key, value in base.items() if value is not None}
|
|
|
|
|
|
def _create(client: TestClient, **overrides):
|
|
return client.post("/admin/tools", data=_form(**overrides), follow_redirects=False)
|
|
|
|
|
|
# --- Guards ------------------------------------------------------------------
|
|
def test_the_pages_are_refused_to_a_plain_user(client: TestClient, plain_user):
|
|
assert client.get("/admin/tools").status_code == 403
|
|
assert client.get("/admin/tools/new").status_code == 403
|
|
assert client.post("/admin/tools", data=_form()).status_code == 403
|
|
|
|
|
|
def test_a_tool_that_does_not_exist_is_a_404(client: TestClient, registered):
|
|
assert client.get("/admin/tools/nope/edit").status_code == 404
|
|
|
|
|
|
def test_new_is_not_parsed_as_a_tool_id(client: TestClient, registered):
|
|
"""FastAPI matches in registration order; /admin/models has been bitten by
|
|
exactly this."""
|
|
response = client.get("/admin/tools/new")
|
|
assert response.status_code == 200
|
|
assert "New tool" in response.text
|
|
|
|
|
|
# --- Creating and editing ----------------------------------------------------
|
|
def test_creating_a_tool_then_editing_it(client: TestClient, db, registered):
|
|
_create(client, secret="s3cret")
|
|
|
|
tool = db.scalar(select(CustomTool))
|
|
assert tool.slug == "weather"
|
|
assert tool.headers_json == {"Accept": "application/json"}
|
|
assert tool.parameters_json["properties"]["city"] == {"type": "string"}
|
|
assert decrypt(tool.secret_encrypted) == "s3cret"
|
|
assert tool.enabled is True
|
|
|
|
client.post(
|
|
f"/admin/tools/{tool.id}",
|
|
data=_form(name="Forecast", enabled=None, secret=UNCHANGED_SENTINEL),
|
|
follow_redirects=False,
|
|
)
|
|
db.refresh(tool)
|
|
assert tool.name == "Forecast"
|
|
# An unticked checkbox is simply absent from the post, which is the signal.
|
|
assert tool.enabled is False
|
|
assert decrypt(tool.secret_encrypted) == "s3cret", "the dots must keep the secret"
|
|
|
|
|
|
def test_clearing_the_field_removes_the_secret(client: TestClient, db, registered):
|
|
_create(client, secret="s3cret")
|
|
tool = db.scalar(select(CustomTool))
|
|
|
|
client.post(f"/admin/tools/{tool.id}", data=_form(secret=""), follow_redirects=False)
|
|
db.refresh(tool)
|
|
assert tool.secret_encrypted == ""
|
|
|
|
|
|
def test_a_secret_is_never_rendered_in_full(client: TestClient, db, registered):
|
|
_create(client, secret="super-secret-value")
|
|
tool = db.scalar(select(CustomTool))
|
|
|
|
page = client.get(f"/admin/tools/{tool.id}/edit").text
|
|
assert "super-secret-value" not in page
|
|
assert UNCHANGED_SENTINEL in page
|
|
|
|
|
|
# --- Validation reports back into the form -----------------------------------
|
|
def test_bad_parameter_json_is_reported_not_a_422(client: TestClient, db, registered):
|
|
response = _create(client, parameters="{not json")
|
|
assert response.status_code == 200
|
|
assert "not valid JSON" in response.text
|
|
assert db.scalar(select(CustomTool)) is None
|
|
|
|
|
|
def test_parameters_that_are_not_an_object_are_refused(client: TestClient, db, registered):
|
|
response = _create(client, parameters='{"type": "string"}')
|
|
assert "must be a JSON object" in response.text
|
|
assert db.scalar(select(CustomTool)) is None
|
|
|
|
|
|
def test_a_slug_colliding_with_a_builtin_is_refused(client: TestClient, db, registered):
|
|
response = _create(client, slug="web_search")
|
|
assert "built-in tool" in response.text
|
|
assert db.scalar(select(CustomTool)) is None
|
|
|
|
|
|
def test_a_duplicate_slug_is_refused(client: TestClient, db, registered):
|
|
_create(client)
|
|
response = _create(client, name="Other")
|
|
assert "already a tool" in response.text
|
|
assert len(list(db.scalars(select(CustomTool)))) == 1
|
|
|
|
|
|
def test_a_url_whose_host_is_a_variable_is_refused(client: TestClient, db, registered):
|
|
response = _create(client, url_template="https://{{city}}.test/x")
|
|
assert "literal" in response.text
|
|
assert db.scalar(select(CustomTool)) is None
|
|
|
|
|
|
def test_a_rejected_save_keeps_what_was_typed(client: TestClient, db, registered):
|
|
_create(client)
|
|
tool = db.scalar(select(CustomTool))
|
|
|
|
response = client.post(
|
|
f"/admin/tools/{tool.id}",
|
|
data=_form(name="Renamed", parameters="{oops"),
|
|
follow_redirects=False,
|
|
)
|
|
assert "Renamed" in response.text, "the form still holds the submitted name"
|
|
db.refresh(tool)
|
|
assert tool.name == "Weather", "and the stored row is untouched"
|
|
|
|
|
|
# --- Access ------------------------------------------------------------------
|
|
def test_making_a_tool_public_clears_its_groups(client: TestClient, db, registered):
|
|
group = Group(name="Council")
|
|
db.add(group)
|
|
db.commit()
|
|
|
|
_create(client, public=None, group_ids=group.id)
|
|
tool = db.scalar(select(CustomTool))
|
|
assert [g.name for g in tool.groups] == ["Council"]
|
|
|
|
client.post(
|
|
f"/admin/tools/{tool.id}",
|
|
data={**_form(public="true"), "group_ids": group.id},
|
|
follow_redirects=False,
|
|
)
|
|
db.refresh(tool)
|
|
assert tool.groups == []
|
|
|
|
|
|
def test_a_restricted_tool_is_hidden_from_a_user_outside_its_groups(db, registered):
|
|
from lembas.services import tool_access
|
|
|
|
group = Group(name="Council")
|
|
outsider = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
|
|
db.add_all([group, outsider])
|
|
db.add(
|
|
CustomTool(
|
|
slug="weather", name="Weather", url_template="https://api.test/", public=False
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
tool = db.scalar(select(CustomTool))
|
|
tool.groups = [group]
|
|
db.commit()
|
|
|
|
assert tool_access.visible_custom_tools(db, outsider) == []
|
|
outsider.groups = [group]
|
|
db.commit()
|
|
assert len(tool_access.visible_custom_tools(db, outsider)) == 1
|
|
|
|
|
|
# --- Running one by hand -----------------------------------------------------
|
|
def test_the_test_button_shows_what_the_model_would_read(
|
|
client: TestClient, db, registered, mock_http, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
|
|
)
|
|
mock_http(lambda _r: httpx.Response(200, json={"summary": "Sunny"}))
|
|
_create(client, response_mode="json", response_path="summary")
|
|
tool = db.scalar(select(CustomTool))
|
|
|
|
response = client.post(
|
|
f"/admin/tools/{tool.id}/test", data={"arguments": '{"city": "Minas Tirith"}'}
|
|
)
|
|
assert response.status_code == 200
|
|
assert "Sunny" in response.text
|
|
|
|
|
|
def test_a_failing_test_is_recorded_on_the_row(
|
|
client: TestClient, db, registered, mock_http, monkeypatch
|
|
):
|
|
monkeypatch.setattr(
|
|
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
|
|
)
|
|
mock_http(lambda _r: httpx.Response(503, text="down"))
|
|
_create(client)
|
|
tool = db.scalar(select(CustomTool))
|
|
|
|
client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "{}"})
|
|
db.refresh(tool)
|
|
assert "503" in tool.last_error
|
|
assert tool.last_checked_at is not None
|
|
|
|
|
|
def test_bad_test_arguments_are_reported(client: TestClient, db, registered):
|
|
_create(client)
|
|
tool = db.scalar(select(CustomTool))
|
|
response = client.post(f"/admin/tools/{tool.id}/test", data={"arguments": "[1, 2]"})
|
|
assert "JSON object" in response.text
|
|
|
|
|
|
# --- The list ----------------------------------------------------------------
|
|
def test_the_list_searches_and_filters(client: TestClient, db, registered):
|
|
_create(client)
|
|
_create(client, name="Tickets", slug="tickets", url_template="https://jira.test/{{city}}")
|
|
tool = db.scalar(select(CustomTool).where(CustomTool.slug == "tickets"))
|
|
tool.enabled = False
|
|
db.commit()
|
|
|
|
page = client.get("/admin/tools").text
|
|
assert "Weather" in page and "Tickets" in page
|
|
|
|
assert "Tickets" not in client.get("/admin/tools?filter=enabled").text
|
|
assert "Weather" not in client.get("/admin/tools?q=tick").text
|
|
|
|
|
|
def test_deleting_a_tool_leaves_its_prompt_override_alone(client: TestClient, db, registered):
|
|
"""The override outlives the row, which is what lets a tool be recreated
|
|
under the same slug without losing the wording somebody chose."""
|
|
from lembas.services import prompts as prompts_service
|
|
|
|
_create(client, guidance="- Default wording.")
|
|
tool = db.scalar(select(CustomTool))
|
|
prompts_service.save(db, {"tool.custom_weather": "- Edited wording."})
|
|
|
|
client.post(f"/admin/tools/{tool.id}/delete", follow_redirects=False)
|
|
assert db.scalar(select(CustomTool)) is None
|
|
assert prompts_service.stored(db)["tool.custom_weather"] == "- Edited wording."
|
|
|
|
|
|
def test_the_secret_survives_a_round_trip_through_the_form(client: TestClient, db, registered):
|
|
"""A regression guard on the sentinel: the field is rendered with dots, and
|
|
submitting the page unchanged must not overwrite the key with them."""
|
|
db.add(
|
|
CustomTool(
|
|
slug="weather",
|
|
name="Weather",
|
|
url_template="https://api.test/",
|
|
secret_encrypted=encrypt("s3cret"),
|
|
)
|
|
)
|
|
db.commit()
|
|
tool = db.scalar(select(CustomTool))
|
|
|
|
client.post(
|
|
f"/admin/tools/{tool.id}", data=_form(secret=UNCHANGED_SENTINEL), follow_redirects=False
|
|
)
|
|
db.refresh(tool)
|
|
assert decrypt(tool.secret_encrypted) == "s3cret"
|
|
|
|
|
|
# --- MCP servers -------------------------------------------------------------
|
|
def _mcp_form(**overrides) -> dict:
|
|
base = {
|
|
"name": "GitHub",
|
|
"slug": "github",
|
|
"url": "https://mcp.test/rpc",
|
|
"headers": "",
|
|
"secret_placement": "none",
|
|
"secret_name": "Authorization",
|
|
"timeout": "30",
|
|
"max_chars": "8000",
|
|
"position": "0",
|
|
"enabled": "true",
|
|
"public": "true",
|
|
}
|
|
base.update(overrides)
|
|
return {key: value for key, value in base.items() if value is not None}
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_mcp(mock_http, monkeypatch):
|
|
"""A server that answers initialize and tools/list.
|
|
|
|
Invented hostnames resolve to a public address; a literal IP is handed back
|
|
as itself, so a test about a private address is still testing one.
|
|
"""
|
|
import ipaddress
|
|
|
|
def resolve(host, *_args, **_kwargs):
|
|
try:
|
|
ipaddress.ip_address(host)
|
|
except ValueError:
|
|
return [(2, 1, 6, "", ("93.184.216.34", 80))]
|
|
return [(2, 1, 6, "", (host, 80))]
|
|
|
|
monkeypatch.setattr("socket.getaddrinfo", resolve)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
if request.method != "POST":
|
|
return httpx.Response(405)
|
|
message = json.loads(request.content)
|
|
if message.get("method") == "notifications/initialized":
|
|
return httpx.Response(202)
|
|
if message.get("method") == "initialize":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": message["id"],
|
|
"result": {"protocolVersion": "2025-06-18", "serverInfo": {"name": "fake"}},
|
|
},
|
|
headers={"mcp-session-id": "s-1"},
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": message["id"],
|
|
"result": {"tools": [{"name": "search", "description": "Search."}]},
|
|
},
|
|
)
|
|
|
|
mock_http(handler)
|
|
return handler
|
|
|
|
|
|
def test_the_mcp_pages_are_refused_to_a_plain_user(client: TestClient, plain_user):
|
|
assert client.get("/admin/mcp").status_code == 403
|
|
assert client.get("/admin/mcp/new").status_code == 403
|
|
assert client.post("/admin/mcp", data=_mcp_form()).status_code == 403
|
|
|
|
|
|
def test_mcp_new_is_not_parsed_as_a_server_id(client: TestClient, registered):
|
|
response = client.get("/admin/mcp/new")
|
|
assert response.status_code == 200
|
|
assert "New MCP server" in response.text
|
|
|
|
|
|
def test_adding_a_server_discovers_its_tools(client: TestClient, db, registered, fake_mcp):
|
|
from lembas.db.models import McpServer
|
|
|
|
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
|
|
|
server = db.scalar(select(McpServer))
|
|
assert server.slug == "github"
|
|
assert [entry["name"] for entry in server.tools_json] == ["search"]
|
|
assert server.tools_json[0]["offer_name"] == "github_search"
|
|
assert server.protocol_version == "2025-06-18"
|
|
|
|
|
|
def test_refreshing_a_server_swaps_its_row(client: TestClient, db, registered, fake_mcp):
|
|
from lembas.db.models import McpServer
|
|
|
|
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
|
server = db.scalar(select(McpServer))
|
|
|
|
response = client.post(f"/admin/mcp/{server.id}/test")
|
|
assert response.status_code == 200
|
|
assert "found 1 tool." in response.text
|
|
assert f'id="mcp-{server.id}"' in response.text, "the fragment must render standalone"
|
|
|
|
|
|
def test_a_server_that_cannot_be_reached_says_so(
|
|
client: TestClient, db, registered, mock_http, monkeypatch
|
|
):
|
|
from lembas.db.models import McpServer
|
|
|
|
monkeypatch.setattr(
|
|
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
|
|
)
|
|
mock_http(lambda _r: httpx.Response(500, text="down"))
|
|
|
|
response = client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
|
assert response.status_code == 303
|
|
server = db.scalar(select(McpServer))
|
|
assert server is not None, "the row is still saved so the URL can be corrected"
|
|
assert server.last_error
|
|
|
|
|
|
def test_a_private_url_is_not_contacted_unless_the_box_is_ticked(
|
|
client: TestClient, db, registered, fake_mcp
|
|
):
|
|
"""The row still saves, so the URL can be corrected -- but the discovery
|
|
that runs straight after it is refused, and the row says why."""
|
|
from lembas.db.models import McpServer
|
|
|
|
client.post(
|
|
"/admin/mcp", data=_mcp_form(url="http://127.0.0.1:9000/rpc"), follow_redirects=False
|
|
)
|
|
server = db.scalar(select(McpServer))
|
|
assert server.allow_private is False
|
|
assert "private or local" in server.last_error
|
|
assert server.tools_json == []
|
|
|
|
client.post(
|
|
f"/admin/mcp/{server.id}",
|
|
data=_mcp_form(url="http://127.0.0.1:9000/rpc", allow_private="true"),
|
|
follow_redirects=False,
|
|
)
|
|
client.post(f"/admin/mcp/{server.id}/test")
|
|
db.refresh(server)
|
|
assert server.last_error == ""
|
|
assert [entry["name"] for entry in server.tools_json] == ["search"]
|
|
|
|
|
|
def test_a_duplicate_mcp_slug_is_refused(client: TestClient, db, registered, fake_mcp):
|
|
from lembas.db.models import McpServer
|
|
|
|
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
|
response = client.post("/admin/mcp", data=_mcp_form(name="Other"), follow_redirects=False)
|
|
assert "already a server" in response.text
|
|
assert len(list(db.scalars(select(McpServer)))) == 1
|
|
|
|
|
|
def test_a_url_that_is_not_http_is_refused(client: TestClient, db, registered):
|
|
from lembas.db.models import McpServer
|
|
|
|
response = client.post(
|
|
"/admin/mcp", data=_mcp_form(url="ftp://mcp.test/rpc"), follow_redirects=False
|
|
)
|
|
assert "http" in response.text
|
|
assert db.scalar(select(McpServer)) is None
|
|
|
|
|
|
def test_unticking_a_tool_withholds_it(client: TestClient, db, registered, fake_mcp):
|
|
from lembas.db.models import McpServer, User
|
|
from lembas.services.mcp import registry as mcp_registry
|
|
|
|
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
|
server = db.scalar(select(McpServer))
|
|
|
|
client.post(
|
|
f"/admin/mcp/{server.id}",
|
|
data={**_mcp_form(), "tool_choices": "1", "tool_names": "search"},
|
|
follow_redirects=False,
|
|
)
|
|
db.refresh(server)
|
|
assert server.tool_overrides_json == {"search": False}
|
|
|
|
owner = db.scalar(select(User))
|
|
assert mcp_registry.tool_defs(db, owner) == []
|
|
|
|
|
|
def test_deleting_a_server_removes_it(client: TestClient, db, registered, fake_mcp):
|
|
from lembas.db.models import McpServer
|
|
|
|
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
|
server = db.scalar(select(McpServer))
|
|
|
|
client.post(f"/admin/mcp/{server.id}/delete", follow_redirects=False)
|
|
assert db.scalar(select(McpServer)) is None
|