Files
LLeMbas/src/lembas/api/admin_tools.py
T
Jaroslav Beneš ecb52e9978 MCP servers, over streamable HTTP
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>
2026-08-01 16:44:29 +02:00

662 lines
24 KiB
Python

"""Administration for the tools an administrator defines.
List-plus-detail, like `/admin/models` and for the same reason: a tool has
fifteen fields and a page that renders fifteen fields per row is unusable. The
list is compact and searchable; the whole form lives at `/admin/tools/{id}/edit`.
Validation reports back into the form rather than raising a 422. The fields here
are a JSON schema, a URL template and a secret; getting one wrong is normal, and
losing the other fourteen because of it is not acceptable. So a rejected save
re-renders the form from what was submitted, with the reason.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from lembas.api.deps import AdminUser, Db
from lembas.db.models import (
RESPONSE_JSON,
RESPONSE_MODES,
RESPONSE_RAW,
RESPONSE_TEXT,
SECRET_NONE,
SECRET_PLACEMENTS,
CustomTool,
Group,
McpServer,
)
from lembas.services import custom_tools
from lembas.services import prompts as prompts_service
from lembas.services import tools as tools_service
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
from lembas.services.fetch import FetchError, check_url
from lembas.services.mcp import client as mcp_client
from lembas.services.mcp import registry as mcp_registry
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(tags=["admin-tools"])
PAGE_SIZE = 40
# The slug is the function name sent to the endpoint, so it is bound by the
# charset those accept, and it is half of this tool's prompt-fragment key, so it
# is bound by that pattern too. The intersection is this.
SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,47}$")
FILTERS: dict[str, tuple[str, object]] = {
"all": ("All", lambda t: True),
"enabled": ("Enabled", lambda t: t.enabled),
"disabled": ("Disabled", lambda t: not t.enabled),
"restricted": ("Restricted", lambda t: not t.public),
}
RESPONSE_LABELS = (
(RESPONSE_TEXT, "Text — HTML reduced to prose"),
(RESPONSE_JSON, "JSON — parsed, narrowed by the path below"),
(RESPONSE_RAW, "Raw — exactly as it arrived"),
)
SECRET_LABELS = (
(SECRET_NONE, "None — this endpoint needs no credential"),
("bearer", "Bearer token in a header"),
("header", "The header named below, verbatim"),
("query", "A query parameter named below"),
)
def _tool(db: Db, tool_id: str) -> CustomTool:
tool = db.get(CustomTool, tool_id)
if tool is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That tool no longer exists.")
return tool
def _ordered(db: Db) -> list[CustomTool]:
return list(db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)))
def _back(message: str = "") -> Response:
target = f"/admin/tools?saved={message}" if message else "/admin/tools"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
# --- Form <-> row ------------------------------------------------------------
def _headers_text(headers: dict) -> str:
return "\n".join(f"{name}: {value}" for name, value in (headers or {}).items())
def _parse_headers(text: str) -> dict[str, str]:
"""One `Name: value` per line. Blank lines and lines with no colon are dropped."""
out: dict[str, str] = {}
for line in (text or "").splitlines():
name, _, value = line.partition(":")
if name.strip() and _:
out[name.strip()] = value.strip()
return out
def _number(raw: str, *, default: int, low: int, high: int) -> int:
text = str(raw or "").strip()
if not text.lstrip("-").isdigit():
return default
return min(max(int(text), low), high)
def _populate(tool: CustomTool, form) -> None:
"""Copy a submitted form onto a row (or a draft of one).
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
`x`, and an absent one is exactly what an unticked box sends.
"""
tool.name = str(form.get("name") or "").strip()[:120]
tool.description = str(form.get("description") or "").strip()
tool.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
tool.method = str(form.get("method") or "GET").strip().upper()
tool.url_template = str(form.get("url_template") or "").strip()[:1000]
tool.body_template = str(form.get("body_template") or "").replace("\r\n", "\n")
tool.headers_json = _parse_headers(str(form.get("headers") or ""))
placement = str(form.get("secret_placement") or SECRET_NONE)
tool.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
tool.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
mode = str(form.get("response_mode") or RESPONSE_TEXT)
tool.response_mode = mode if mode in RESPONSE_MODES else RESPONSE_TEXT
tool.response_path = str(form.get("response_path") or "").strip()[:300]
tool.max_chars = _number(
form.get("max_chars"),
default=8000,
low=custom_tools.MIN_CHARS,
high=custom_tools.MAX_CHARS,
)
tool.timeout = _number(
form.get("timeout"),
default=20,
low=custom_tools.MIN_TIMEOUT,
high=custom_tools.MAX_TIMEOUT,
)
tool.position = _number(form.get("position"), default=tool.position or 0, low=0, high=999)
tool.allow_private = "allow_private" in form
tool.enabled = "enabled" in form
tool.public = "public" in form
def _problem(db: Db, tool: CustomTool, form, *, existing_id: str = "") -> str:
"""Why this cannot be saved, or an empty string."""
if not tool.name:
return "A tool needs a name."
slug = str(form.get("slug") or "").strip().lower()
if not SLUG_PATTERN.match(slug):
return (
"The identifier must be lowercase letters, digits, hyphens or "
"underscores, start with a letter or digit, and be at most 48 "
"characters. It is the name the model calls."
)
if slug in tools_service.REGISTRY:
return f"“{slug}” is the name of a built-in tool. Choose another."
clash = db.scalar(select(CustomTool).where(CustomTool.slug == slug))
if clash is not None and clash.id != existing_id:
return f"There is already a tool called “{slug}”."
tool.slug = slug
if tool.method not in custom_tools.ALLOWED_METHODS:
return f"{tool.method} is not a method this can send."
raw = str(form.get("parameters") or "").strip() or '{"type": "object", "properties": {}}'
try:
parameters = json.loads(raw)
except json.JSONDecodeError as exc:
return f"The parameters are not valid JSON: {exc}"
if not isinstance(parameters, dict) or parameters.get("type") != "object":
return 'The parameters must be a JSON object whose "type" is "object".'
tool.parameters_json = parameters
# The same check the runner makes, so a template that could never be called
# is refused here rather than at the first call.
try:
custom_tools.fill_url(custom_tools.spec_from(tool), {})
except Exception as exc: # noqa: BLE001 - any refusal is a message for the form
return str(getattr(exc, "message", exc))
return ""
def _detail(request: Request, db: Db, tool: CustomTool, *, is_new: bool, error: str = "", **extra):
key = f"tool.custom_{tool.slug}" if tool.slug else ""
return render(
request,
"admin/tool_detail.html",
{
"tool": tool,
"is_new": is_new,
"error": error,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"selected_groups": extra.pop(
"selected_groups", {group.id for group in (tool.groups if tool.id else [])}
),
"headers_text": extra.pop("headers_text", _headers_text(tool.headers_json)),
"parameters_text": extra.pop(
"parameters_text", json.dumps(tool.parameters_json or {}, indent=2)
),
"masked": mask(decrypt(tool.secret_encrypted)) if tool.secret_encrypted else "",
"unchanged": UNCHANGED_SENTINEL,
"methods": custom_tools.ALLOWED_METHODS,
"response_modes": RESPONSE_LABELS,
"secret_placements": SECRET_LABELS,
"prompt_key": key,
"prompt_overridden": key in prompts_service.stored(db),
**extra,
},
)
# --- The list ----------------------------------------------------------------
@router.get("/admin/tools")
async def tools_page(
request: Request,
db: Db,
user: AdminUser,
saved: str = "",
q: str = "",
filter: str = "all",
page: int = 1,
):
everything = _ordered(db)
predicate = FILTERS.get(filter, FILTERS["all"])[1]
needle = q.strip().lower()
matching = [
tool
for tool in everything
if predicate(tool)
and (not needle or needle in tool.slug.lower() or needle in (tool.name or "").lower())
]
pages = max(1, -(-len(matching) // PAGE_SIZE))
page = max(1, min(page, pages))
start = (page - 1) * PAGE_SIZE
return render(
request,
"admin/tools.html",
{
"tools": matching[start : start + PAGE_SIZE],
"total": len(everything),
"matched": len(matching),
"page": page,
"pages": pages,
"page_start": start,
"counts": {
key: sum(1 for tool in everything if rule(tool))
for key, (_label, rule) in FILTERS.items()
},
"filters": {key: label for key, (label, _rule) in FILTERS.items()},
"active_filter": filter if filter in FILTERS else "all",
"q": q,
"saved": saved,
},
)
# Registered before /{tool_id}: FastAPI matches in registration order, so with
# the parameterised route first "new" is captured as an id and the handler 404s
# on a tool that does not exist. This has already been a bug once, in
# /admin/models.
@router.get("/admin/tools/new")
async def new_tool_page(request: Request, db: Db, user: AdminUser):
draft = CustomTool(
name="",
slug="",
method="GET",
url_template="https://",
parameters_json={"type": "object", "properties": {}, "required": []},
secret_placement=SECRET_NONE,
response_mode=RESPONSE_TEXT,
max_chars=8000,
timeout=20,
enabled=True,
public=True,
position=0,
)
return _detail(request, db, draft, is_new=True)
@router.post("/admin/tools")
async def create_tool(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
draft = CustomTool(headers_json={}, parameters_json={})
_populate(draft, form)
draft.position = db.scalar(select(func.coalesce(func.max(CustomTool.position), -1))) + 1
problem = _problem(db, draft, form)
if problem:
return _detail(
request,
db,
draft,
is_new=True,
error=problem,
headers_text=str(form.get("headers") or ""),
parameters_text=str(form.get("parameters") or ""),
selected_groups=set(form.getlist("group_ids")),
)
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
draft.groups = _chosen_groups(db, form, public=draft.public)
db.add(draft)
db.commit()
log.info("%s added custom tool %s", user.email, draft.slug)
return _back(f"Added {draft.name}.")
def _chosen_groups(db: Db, form, *, public: bool) -> list[Group]:
"""A public tool holds no groups, the way a public model holds none."""
if public:
return []
ids = set(form.getlist("group_ids"))
return list(db.scalars(select(Group).where(Group.id.in_(ids)))) if ids else []
@router.get("/admin/tools/{tool_id}/edit")
async def edit_tool_page(request: Request, db: Db, user: AdminUser, tool_id: str):
return _detail(request, db, _tool(db, tool_id), is_new=False)
@router.post("/admin/tools/{tool_id}/test")
async def test_tool(request: Request, db: Db, user: AdminUser, tool_id: str):
"""Call the stored row once, with arguments the administrator typed.
The stored row rather than the submitted form, so what is tested is what a
chat would actually do -- the same reason `/admin/search/test` reads the
saved provider settings.
"""
tool = _tool(db, tool_id)
form = await request.form()
raw = str(form.get("arguments") or "").strip() or "{}"
try:
arguments = json.loads(raw)
if not isinstance(arguments, dict):
raise ValueError("Arguments must be a JSON object.")
except (json.JSONDecodeError, ValueError) as exc:
return render(
request,
"admin/_tool_test.html",
{"tool": tool, "error": f"Those arguments are not a JSON object: {exc}"},
)
outcome = await custom_tools.call(custom_tools.spec_from(tool), arguments)
tool.last_error = str(outcome.event.get("error") or "")
tool.last_checked_at = datetime.now(UTC)
db.commit()
return render(
request,
"admin/_tool_test.html",
{
"tool": tool,
"outcome": outcome,
"error": outcome.event.get("error") or "",
"detail": outcome.event.get("detail") or "",
},
)
@router.post("/admin/tools/{tool_id}/delete")
async def delete_tool(db: Db, user: AdminUser, tool_id: str) -> Response:
tool = _tool(db, tool_id)
name = tool.name
db.delete(tool)
db.commit()
log.info("%s deleted custom tool %s", user.email, name)
return _back(f"Deleted {name}.")
@router.post("/admin/tools/{tool_id}")
async def update_tool(request: Request, db: Db, user: AdminUser, tool_id: str) -> Response:
tool = _tool(db, tool_id)
form = await request.form()
# Validated against a draft so that a rejected save leaves the stored row
# untouched and the form still holds what was typed.
draft = CustomTool(headers_json={}, parameters_json={}, position=tool.position)
_populate(draft, form)
problem = _problem(db, draft, form, existing_id=tool.id)
if problem:
draft.id = tool.id
draft.secret_encrypted = tool.secret_encrypted
return _detail(
request,
db,
draft,
is_new=False,
error=problem,
headers_text=str(form.get("headers") or ""),
parameters_text=str(form.get("parameters") or ""),
selected_groups=set(form.getlist("group_ids")),
)
_populate(tool, form)
tool.slug = draft.slug
tool.parameters_json = draft.parameters_json
tool.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), tool.secret_encrypted)
tool.groups = _chosen_groups(db, form, public=tool.public)
db.commit()
log.info("%s updated custom tool %s", user.email, tool.slug)
return _back(f"Saved {tool.name}.")
# --- MCP servers -------------------------------------------------------------
MCP_SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,23}$")
def _server(db: Db, server_id: str) -> McpServer:
server = db.get(McpServer, server_id)
if server is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That server no longer exists.")
return server
def _mcp_back(message: str = "") -> Response:
target = f"/admin/mcp?saved={message}" if message else "/admin/mcp"
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
def _populate_server(server: McpServer, form) -> None:
server.name = str(form.get("name") or "").strip()[:120]
server.url = str(form.get("url") or "").strip()[:1000]
server.guidance = str(form.get("guidance") or "").replace("\r\n", "\n").strip()
server.headers_json = _parse_headers(str(form.get("headers") or ""))
placement = str(form.get("secret_placement") or SECRET_NONE)
server.secret_placement = placement if placement in SECRET_PLACEMENTS else SECRET_NONE
server.secret_name = str(form.get("secret_name") or "Authorization").strip()[:120]
server.timeout = _number(
form.get("timeout"), default=30, low=mcp_client.MIN_TIMEOUT, high=mcp_client.MAX_TIMEOUT
)
server.max_chars = _number(
form.get("max_chars"), default=8000, low=mcp_client.MIN_CHARS, high=mcp_client.MAX_CHARS
)
server.position = _number(form.get("position"), default=server.position or 0, low=0, high=999)
server.allow_private = "allow_private" in form
server.enabled = "enabled" in form
server.public = "public" in form
# One checkbox per advertised tool, so an unticked one is absent. The
# stored map holds only the refusals; absent means on.
if "tool_choices" in form:
offered = set(form.getlist("tool_names"))
chosen = set(form.getlist("tool_names_on"))
server.tool_overrides_json = dict.fromkeys(offered - chosen, False)
def _server_problem(db: Db, server: McpServer, form, *, existing_id: str = "") -> str:
if not server.name:
return "A server needs a name."
slug = str(form.get("slug") or "").strip().lower()
if not MCP_SLUG_PATTERN.match(slug):
return (
"The identifier must be lowercase letters, digits, hyphens or "
"underscores, and at most 24 characters. It prefixes every tool "
"name this server offers."
)
clash = db.scalar(select(McpServer).where(McpServer.slug == slug))
if clash is not None and clash.id != existing_id:
return f"There is already a server called “{slug}”."
server.slug = slug
try:
check_url(server.url, allow_private=True)
except FetchError as exc:
return exc.message
return ""
def _server_detail(
request: Request, db: Db, server: McpServer, *, is_new: bool, error: str = "", **extra
):
key = f"tool.mcp_{server.slug}" if server.slug else ""
overrides = server.tool_overrides_json or {}
return render(
request,
"admin/mcp_detail.html",
{
"server": server,
"is_new": is_new,
"error": error,
"groups": list(db.scalars(select(Group).order_by(Group.name))),
"selected_groups": extra.pop(
"selected_groups", {group.id for group in (server.groups if server.id else [])}
),
"headers_text": extra.pop("headers_text", _headers_text(server.headers_json)),
"tools": [
{**entry, "on": overrides.get(entry.get("name"), True)}
for entry in (server.tools_json or [])
if isinstance(entry, dict)
],
"masked": mask(decrypt(server.secret_encrypted)) if server.secret_encrypted else "",
"unchanged": UNCHANGED_SENTINEL,
"secret_placements": SECRET_LABELS,
"prompt_key": key,
"prompt_overridden": key in prompts_service.stored(db),
**extra,
},
)
@router.get("/admin/mcp")
async def mcp_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
servers = list(db.scalars(select(McpServer).order_by(McpServer.position, McpServer.slug)))
return render(
request,
"admin/mcp.html",
{
"servers": servers,
"counts": {server.id: len(server.tools_json or []) for server in servers},
"saved": saved,
},
)
# Registered before /{server_id}, for the reason given above.
@router.get("/admin/mcp/new")
async def new_server_page(request: Request, db: Db, user: AdminUser):
draft = McpServer(
name="",
slug="",
url="https://",
secret_placement=SECRET_NONE,
timeout=30,
max_chars=8000,
enabled=True,
public=True,
position=0,
tools_json=[],
tool_overrides_json={},
)
return _server_detail(request, db, draft, is_new=True)
@router.post("/admin/mcp")
async def create_server(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
draft = McpServer(headers_json={}, tools_json=[], tool_overrides_json={})
_populate_server(draft, form)
draft.position = db.scalar(select(func.coalesce(func.max(McpServer.position), -1))) + 1
problem = _server_problem(db, draft, form)
if problem:
return _server_detail(
request,
db,
draft,
is_new=True,
error=problem,
headers_text=str(form.get("headers") or ""),
selected_groups=set(form.getlist("group_ids")),
)
draft.secret_encrypted = keep_or_replace(str(form.get("secret") or ""), "")
draft.groups = _chosen_groups(db, form, public=draft.public)
db.add(draft)
db.commit()
# Discovered immediately, the way a new connection's models are: an
# administrator who has just typed a URL wants to know whether it answered.
count, error = await mcp_registry.refresh(db, draft)
log.info("%s added MCP server %s (%d tools)", user.email, draft.slug, count)
if error:
return _mcp_back(f"Added {draft.name}, but it could not be reached: {error}")
return _mcp_back(f"Added {draft.name}{count} tool(s).")
@router.get("/admin/mcp/{server_id}/edit")
async def edit_server_page(request: Request, db: Db, user: AdminUser, server_id: str):
return _server_detail(request, db, _server(db, server_id), is_new=False)
@router.post("/admin/mcp/{server_id}/test")
async def test_server(request: Request, db: Db, user: AdminUser, server_id: str):
"""Contact the server and cache what it advertises.
Returns the row fragment, swapped in place, exactly as "Test & refresh"
does for a connection.
"""
server = _server(db, server_id)
count, error = await mcp_registry.refresh(db, server)
message = (
f"{server.name}: {error}"
if error
else f"{server.name}: found {count} tool{'s' if count != 1 else ''}."
)
return render(
request,
"admin/_mcp_row.html",
{
"server": server,
"tool_count": len(server.tools_json or []),
"message": message,
"message_kind": "error" if error else "success",
},
)
@router.post("/admin/mcp/{server_id}/delete")
async def delete_server(db: Db, user: AdminUser, server_id: str) -> Response:
server = _server(db, server_id)
name = server.name
db.delete(server)
db.commit()
log.info("%s deleted MCP server %s", user.email, name)
return _mcp_back(f"Deleted {name}.")
@router.post("/admin/mcp/{server_id}")
async def update_server(request: Request, db: Db, user: AdminUser, server_id: str) -> Response:
server = _server(db, server_id)
form = await request.form()
draft = McpServer(headers_json={}, tools_json=[], position=server.position)
_populate_server(draft, form)
problem = _server_problem(db, draft, form, existing_id=server.id)
if problem:
draft.id = server.id
draft.secret_encrypted = server.secret_encrypted
draft.tools_json = server.tools_json
return _server_detail(
request,
db,
draft,
is_new=False,
error=problem,
headers_text=str(form.get("headers") or ""),
selected_groups=set(form.getlist("group_ids")),
)
_populate_server(server, form)
server.slug = draft.slug
server.secret_encrypted = keep_or_replace(
str(form.get("secret") or ""), server.secret_encrypted
)
server.groups = _chosen_groups(db, form, public=server.public)
db.commit()
log.info("%s updated MCP server %s", user.email, server.slug)
return _mcp_back(f"Saved {server.name}.")