From bc84fec21dae0e6050420ee1f18911102c6c1dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 1 Aug 2026 16:26:47 +0200 Subject: [PATCH] Custom HTTP tools an administrator defines A row in custom_tools becomes a ToolDef like any built-in, offered beside the thirteen. The registry had to stop being an import-time constant for that: `resolve_tools` now returns the schemas *and* the runners together, carried to the loop on the ToolContext. That closes a hole on the way. `run_tool` looked names up in the global REGISTRY with no reference to what had been offered, so a model naming a tool its chat was gated out of -- a family switched off, a permission the reader lacks -- had it run anyway. The resolved set is now authoritative. Arguments come from a model, so an argument may fill a hole but never move the target: the scheme and host of a URL template are literal, values are escaped for where they land, and the origin is pinned afterwards. Every redirect hop is checked the way services/fetch.py checks one, and the secret is dropped if a hop leaves the origin it was issued for. Also fixes the tool-activity block claiming every library tool had "searched the web", which it has done since the second family landed. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- src/lembas/__init__.py | 2 +- src/lembas/api/admin_models.py | 14 +- src/lembas/api/admin_prompts.py | 23 +- src/lembas/api/admin_tools.py | 415 +++++++++++++++++ src/lembas/db/models/__init__.py | 28 ++ src/lembas/db/models/tool.py | 187 ++++++++ src/lembas/db/models/user.py | 7 + src/lembas/main.py | 2 + src/lembas/security/permissions.py | 16 + src/lembas/services/custom_tools.py | 431 ++++++++++++++++++ src/lembas/services/generation.py | 8 +- src/lembas/services/harness.py | 22 +- src/lembas/services/tool_access.py | 63 +++ src/lembas/services/tools.py | 198 +++++++- src/lembas/web/static/css/chat.css | 17 + src/lembas/web/templates/admin/_layout.html | 8 +- .../web/templates/admin/_tool_test.html | 21 + .../web/templates/admin/tool_detail.html | 308 +++++++++++++ src/lembas/web/templates/admin/tools.html | 110 +++++ .../web/templates/chat/_tool_activity.html | 68 ++- tests/test_admin_tools.py | 303 ++++++++++++ tests/test_custom_tools.py | 382 ++++++++++++++++ tests/test_harness.py | 21 + tests/test_tool_activity.py | 124 +++++ tests/test_tools.py | 51 +++ 26 files changed, 2771 insertions(+), 60 deletions(-) create mode 100644 src/lembas/api/admin_tools.py create mode 100644 src/lembas/db/models/tool.py create mode 100644 src/lembas/services/custom_tools.py create mode 100644 src/lembas/services/tool_access.py create mode 100644 src/lembas/web/templates/admin/_tool_test.html create mode 100644 src/lembas/web/templates/admin/tool_detail.html create mode 100644 src/lembas/web/templates/admin/tools.html create mode 100644 tests/test_admin_tools.py create mode 100644 tests/test_custom_tools.py create mode 100644 tests/test_tool_activity.py diff --git a/pyproject.toml b/pyproject.toml index 7c331c7..344eb54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lembas" -version = "0.3.1" +version = "0.4.0" description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints" readme = "README.md" requires-python = ">=3.11" diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 7816266..9d37d29 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.3.1" +__version__ = "0.4.0" diff --git a/src/lembas/api/admin_models.py b/src/lembas/api/admin_models.py index 7a3175e..6e4ed10 100644 --- a/src/lembas/api/admin_models.py +++ b/src/lembas/api/admin_models.py @@ -24,15 +24,23 @@ router = APIRouter(tags=["admin-models"]) # these are an administrator's assertion. PROTOCOL_CAPABILITIES = ("reasoning", "vision", "tools") -# Which built-in tools this model is given. Distinct from the above: `tools` is -# whether a tools array may be sent at all, these are what goes in it. Every one -# of them is meaningless unless `tools` is on. +# Which tools this model is given. Distinct from the above: `tools` is whether a +# tools array may be sent at all, these are what goes in it. Every one of them is +# meaningless unless `tools` is on. +# +# The last two are gates rather than single tools: one covers every custom HTTP +# tool an administrator has defined, the other every MCP server. Which of those +# a particular person gets is the tool's own group list, not a flag here -- a +# server can advertise forty tools, and a model page listing all of them is a +# page nobody can read. TOOL_CAPABILITIES = ( ("tool_web_search", "Web search"), ("tool_knowledge", "Knowledge"), ("tool_notes", "Notes"), ("tool_memory", "Memory"), ("tool_skills", "Skills"), + ("tool_custom", "Custom tools"), + ("tool_mcp", "MCP servers"), ) CAPABILITIES = PROTOCOL_CAPABILITIES + tuple(key for key, _ in TOOL_CAPABILITIES) diff --git a/src/lembas/api/admin_prompts.py b/src/lembas/api/admin_prompts.py index 6d1281b..8816629 100644 --- a/src/lembas/api/admin_prompts.py +++ b/src/lembas/api/admin_prompts.py @@ -29,15 +29,20 @@ router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"]) SAMPLE_DOCUMENTS = "report.pdf, notes.txt" -def _families_of(names: list[str]) -> list[str]: - """Keep only real family names, in the registry's order.""" +def _families_of(db: Db, names: list[str]) -> list[str]: + """Keep only real family names, in the registry's order. + + Read from the database rather than the constant: a family can belong to an + administrator-defined tool, and one the preview cannot name is one whose + guidance cannot be checked here. + """ wanted = set(names) - return [family for family in tools_service.FAMILIES if family in wanted] + return [family for family in tools_service.families(db) if family in wanted] -def _tool_names(families: list[str]) -> str: +def _tool_names(db: Db, families: list[str]) -> str: return ", ".join( - name for name, tool in tools_service.REGISTRY.items() if tool.family in families + name for name, tool in tools_service.registry(db).items() if tool.family in families ) @@ -67,7 +72,7 @@ def _variables( values.update( { "model_name": model_name, - "tool_names": _tool_names(families), + "tool_names": _tool_names(db, families), "memories": memories_service.block(db, user) if "memory" in families else "", "skills": skills_service.index_block(db, user) if "skills" in families else "", "knowledge_bases": bases if "knowledge" in families else "", @@ -89,7 +94,7 @@ def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict: async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False): stored = prompts_service.stored(db) models = chat_service.available_models(db, user) - families = list(tools_service.FAMILIES) + families = list(tools_service.families(db)) return render( request, @@ -115,7 +120,7 @@ async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = "models": models, "families": families, "registry": sorted( - tools_service.REGISTRY.values(), key=lambda t: (t.family, t.name) + tools_service.registry(db).values(), key=lambda t: (t.family, t.name) ), "max_harness_chars": settings_store.get( db, "max_harness_chars", key=settings_store.PROMPTS @@ -164,7 +169,7 @@ async def preview(request: Request, db: Db, user: AdminUser): """ form = await request.form() overrides = _submitted(db, form) - families = _families_of([str(value) for value in form.getlist("preview_family")]) + families = _families_of(db, [str(value) for value in form.getlist("preview_family")]) model_name = str(form.get("preview_model") or "") bases = str(form.get("preview_bases") or "").strip() documents = str(form.get("preview_documents") or "").strip() diff --git a/src/lembas/api/admin_tools.py b/src/lembas/api/admin_tools.py new file mode 100644 index 0000000..62af912 --- /dev/null +++ b/src/lembas/api/admin_tools.py @@ -0,0 +1,415 @@ +"""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, +) +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.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}.") diff --git a/src/lembas/db/models/__init__.py b/src/lembas/db/models/__init__.py index 3aff3bd..1f5d752 100644 --- a/src/lembas/db/models/__init__.py +++ b/src/lembas/db/models/__init__.py @@ -42,6 +42,21 @@ from lembas.db.models.library import ( ) from lembas.db.models.setting import Setting from lembas.db.models.suggestion import Suggestion +from lembas.db.models.tool import ( + RESPONSE_JSON, + RESPONSE_MODES, + RESPONSE_RAW, + RESPONSE_TEXT, + SECRET_BEARER, + SECRET_HEADER, + SECRET_NONE, + SECRET_PLACEMENTS, + SECRET_QUERY, + CustomTool, + McpServer, + custom_tool_groups, + mcp_server_groups, +) from lembas.db.models.user import ( ROLE_ADMIN, ROLE_PENDING, @@ -63,20 +78,31 @@ __all__ = [ "RESOURCE_BASE", "RESOURCE_NOTE", "RESOURCE_SKILL", + "RESPONSE_JSON", + "RESPONSE_MODES", + "RESPONSE_RAW", + "RESPONSE_TEXT", "ROLE_ADMIN", "ROLE_ASSISTANT", "ROLE_PENDING", "ROLE_SYSTEM", "ROLE_TOOL", "ROLE_USER", + "SECRET_BEARER", + "SECRET_HEADER", + "SECRET_NONE", + "SECRET_PLACEMENTS", + "SECRET_QUERY", "SOURCE_LINK", "SOURCE_UPLOAD", "Chat", "Connection", + "CustomTool", "Document", "Folder", "Group", "KnowledgeBase", + "McpServer", "Memory", "Message", "Model", @@ -89,6 +115,8 @@ __all__ = [ "Suggestion", "User", "chat_knowledge_bases", + "custom_tool_groups", + "mcp_server_groups", "model_groups", "user_groups", ] diff --git a/src/lembas/db/models/tool.py b/src/lembas/db/models/tool.py new file mode 100644 index 0000000..5ac1c93 --- /dev/null +++ b/src/lembas/db/models/tool.py @@ -0,0 +1,187 @@ +"""Tools an administrator defined: HTTP endpoints and remote MCP servers. + +Both are instance configuration rather than someone's content, so access is +shaped like `Model` and not like a note: a row is either public or reachable +through the groups it names, resolved the way `permissions.models_visible_to` +resolves a model. There is deliberately no per-user tool. A tool is a credential +pointed at a third party, and "anyone may define one" is a different feature +with a different threat model. + +The two tables are near-twins on purpose -- name, slug, secret, group list, +last check -- because an administrator adding one should not have to learn a +second screen. What differs is what sits between the row and the model: a +custom tool *is* one call, described here in full, while an MCP server is a +conversation whose tools are discovered and cached. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from lembas.db.base import Base, Timestamps, UUIDPrimaryKey +from lembas.db.types import JSONDict, JSONList + +if TYPE_CHECKING: + # Annotation only; SQLAlchemy resolves the real class from its registry. + from lembas.db.models.user import Group + +# How a row's secret is attached to a request. Stored values, so these are +# schema rather than presentation. +SECRET_NONE = "none" +SECRET_BEARER = "bearer" +SECRET_HEADER = "header" +SECRET_QUERY = "query" + +SECRET_PLACEMENTS = (SECRET_NONE, SECRET_BEARER, SECRET_HEADER, SECRET_QUERY) + +# How a response becomes text for the model. +RESPONSE_TEXT = "text" # prose; HTML reduced by fetch.html_to_text +RESPONSE_JSON = "json" # parsed, narrowed by response_path, pretty-printed +RESPONSE_RAW = "raw" # verbatim, truncated -- CSV, plain logs + +RESPONSE_MODES = (RESPONSE_TEXT, RESPONSE_JSON, RESPONSE_RAW) + +custom_tool_groups = Table( + "custom_tool_groups", + Base.metadata, + Column( + "tool_id", String(32), ForeignKey("custom_tools.id", ondelete="CASCADE"), primary_key=True + ), + Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True), +) + +mcp_server_groups = Table( + "mcp_server_groups", + Base.metadata, + Column( + "server_id", String(32), ForeignKey("mcp_servers.id", ondelete="CASCADE"), primary_key=True + ), + Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True), +) + + +class CustomTool(UUIDPrimaryKey, Timestamps, Base): + """One HTTP call, described well enough for a model to decide to make it.""" + + __tablename__ = "custom_tools" + + # `slug` IS the function name sent to the endpoint, so it is bound by the + # charset those accept and is fixed once the row exists: it is also half of + # this tool's prompt-fragment key. `name` is the human label, shown in the + # admin list and in the transcript. + slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(120), nullable=False) + + # Sent verbatim in the tools array. The only thing the model has to decide + # with, which is why the form insists on it. + description: Mapped[str] = mapped_column(Text, default="") + parameters_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + # The *default* text of this tool's harness fragment. An administrator's + # edit on /admin/prompts is an override stored in the settings group like + # any other, so a tool deleted and recreated under the same slug keeps the + # wording somebody chose for it. + guidance: Mapped[str] = mapped_column(Text, default="") + + method: Mapped[str] = mapped_column(String(8), default="GET", nullable=False) + # {{name}} placeholders, filled from the call's arguments. The scheme and + # the host must be literal -- see services/custom_tools.py for why. + url_template: Mapped[str] = mapped_column(String(1000), nullable=False) + body_template: Mapped[str] = mapped_column(Text, default="") + headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + secret_encrypted: Mapped[str] = mapped_column(Text, default="") + secret_placement: Mapped[str] = mapped_column( + String(16), default=SECRET_BEARER, nullable=False + ) + secret_name: Mapped[str] = mapped_column(String(120), default="Authorization") + + response_mode: Mapped[str] = mapped_column(String(16), default=RESPONSE_TEXT, nullable=False) + # A dotted path into a JSON response: "data.items.0.title". Empty is the + # whole document. Not JSONPath -- that is a dependency and a syntax nobody + # would remember for the one field they want. + response_path: Mapped[str] = mapped_column(String(300), default="") + max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False) + timeout: Mapped[int] = mapped_column(Integer, default=20, nullable=False) + + # Whether this row may reach loopback, private or link-local addresses. Per + # row rather than the instance-wide search setting: an administrator naming + # http://127.0.0.1:11434 by hand is not the same act as a model handing the + # fetcher a URL it read on a page. + allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error: Mapped[str] = mapped_column(Text, default="") + + groups: Mapped[list[Group]] = relationship( + "Group", secondary=custom_tool_groups, back_populates="custom_tools" + ) + + def __repr__(self) -> str: + return f"" + + +class McpServer(UUIDPrimaryKey, Timestamps, Base): + """A remote MCP server, reached over streamable HTTP. + + The tools it advertises are cached in `tools_json` rather than given a table + of their own. A discovered tool carries exactly one administrator decision + -- offered or not, which `tool_overrides_json` holds -- while credentials, + guidance and access are all per server; and the whole list is replaced on + every refresh, so a table would mean reconciling rows against a cache of + somebody else's document. + """ + + __tablename__ = "mcp_servers" + + # Prefixed onto every tool name this server advertises, so that two servers + # both exposing "search" do not collide and neither shadows a built-in. + slug: Mapped[str] = mapped_column(String(24), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(120), nullable=False) + url: Mapped[str] = mapped_column(String(1000), nullable=False) + + guidance: Mapped[str] = mapped_column(Text, default="") + + secret_encrypted: Mapped[str] = mapped_column(Text, default="") + secret_placement: Mapped[str] = mapped_column( + String(16), default=SECRET_BEARER, nullable=False + ) + secret_name: Mapped[str] = mapped_column(String(120), default="Authorization") + headers_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + timeout: Mapped[int] = mapped_column(Integer, default=30, nullable=False) + max_chars: Mapped[int] = mapped_column(Integer, default=8000, nullable=False) + allow_private: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # The last tools/list, cached. One entry per tool: + # {"name", "offer_name", "description", "schema"}. + tools_json: Mapped[list[Any]] = mapped_column(JSONList, default=list) + # Per-tool switch, keyed by the server's own name for it. Absent means on, + # the same rule the model capability flags follow, so a newly advertised + # tool works rather than silently doing nothing. + tool_overrides_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + # What the server answered at initialize, for the admin list. + protocol_version: Mapped[str] = mapped_column(String(32), default="") + server_info: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) + + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error: Mapped[str] = mapped_column(Text, default="") + + groups: Mapped[list[Group]] = relationship( + "Group", secondary=mcp_server_groups, back_populates="mcp_servers" + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/lembas/db/models/user.py b/src/lembas/db/models/user.py index 0f6e30c..e9bb1fe 100644 --- a/src/lembas/db/models/user.py +++ b/src/lembas/db/models/user.py @@ -14,6 +14,7 @@ from lembas.db.types import JSONDict if TYPE_CHECKING: # Annotation only; SQLAlchemy resolves the real class from its registry. from lembas.db.models.connection import Model + from lembas.db.models.tool import CustomTool, McpServer # Roles are a simple ordered ladder rather than a permission matrix. Groups # (below) carry finer-grained permissions once the users/groups UI lands. @@ -72,6 +73,12 @@ class Group(UUIDPrimaryKey, Timestamps, Base): models: Mapped[list[Model]] = relationship( "Model", secondary="model_groups", back_populates="groups" ) + custom_tools: Mapped[list[CustomTool]] = relationship( + "CustomTool", secondary="custom_tool_groups", back_populates="groups" + ) + mcp_servers: Mapped[list[McpServer]] = relationship( + "McpServer", secondary="mcp_server_groups", back_populates="groups" + ) class Session(UUIDPrimaryKey, Timestamps, Base): diff --git a/src/lembas/main.py b/src/lembas/main.py index d63fd6f..60da32f 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -19,6 +19,7 @@ from lembas.api import ( admin_prompts, admin_search, admin_suggestions, + admin_tools, admin_users, audio, auth, @@ -120,6 +121,7 @@ def create_app() -> FastAPI: app.include_router(admin_search.router) app.include_router(admin_prompts.router) app.include_router(admin_suggestions.router) + app.include_router(admin_tools.router) register_error_handlers(app) return app diff --git a/src/lembas/security/permissions.py b/src/lembas/security/permissions.py index 0b6dc90..bf223a9 100644 --- a/src/lembas/security/permissions.py +++ b/src/lembas/security/permissions.py @@ -86,6 +86,22 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = ( True, "Chat", ), + PermissionDef( + "tools.custom", + "Use custom tools", + "Let a model call the HTTP tools an administrator has defined. Which " + "ones depends on the groups each tool is restricted to.", + True, + "Chat", + ), + PermissionDef( + "tools.mcp", + "Use MCP servers", + "Let a model call tools from the MCP servers an administrator has " + "added. Which ones depends on the groups each server is restricted to.", + True, + "Chat", + ), PermissionDef( "audio.transcribe", "Dictate messages", diff --git a/src/lembas/services/custom_tools.py b/src/lembas/services/custom_tools.py new file mode 100644 index 0000000..08f13b6 --- /dev/null +++ b/src/lembas/services/custom_tools.py @@ -0,0 +1,431 @@ +"""Running the HTTP tools an administrator defined. + +A row in `custom_tools` becomes a `ToolDef` like any built-in: same schema in +the same array, same `ToolOutcome` back. What is different is that the arguments +come from a model and the destination comes from a template, so two things have +to hold. + +**An argument may fill a hole; it may not move the target.** The scheme and host +of the template are literal, checked when the row is saved and again here in +case a row predates the check, and every value is escaped for the position it +lands in -- percent-encoded with nothing safe in a URL, JSON-escaped in a body, +stripped of line breaks in a header. `quote(value, safe="")` is what stops a +value adding a path segment, a query parameter or a fragment; pinning the origin +afterwards is what catches anything that got past it. + +**Every hop is checked.** This is the same request-forgery problem +`services/fetch.py` exists to solve, and the same answer: resolve and check the +address, follow redirects by hand, refuse private ranges unless this particular +row was allowed them. `fetch.fetch` itself cannot be reused -- it is GET-only, +has no body, and refuses any content type that is not HTML or text, which is +every JSON API there is -- so its redirect loop is deliberately copied rather +than the module bent into a general HTTP client. + +The secret is decrypted into the snapshot and goes nowhere else: not into the +event, not into a log line, and not across a redirect that leaves the origin it +was issued for. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import quote, urlparse + +import httpx +from sqlalchemy.orm import Session as DBSession + +from lembas.db.models import ( + RESPONSE_JSON, + RESPONSE_RAW, + RESPONSE_TEXT, + SECRET_BEARER, + SECRET_HEADER, + SECRET_QUERY, + CustomTool, + User, +) +from lembas.services import fetch as fetch_service +from lembas.services import tool_access +from lembas.services.crypto import decrypt +from lembas.services.prompts import VARIABLE_PATTERN +from lembas.services.tools import ToolContext, ToolDef, ToolOutcome + +log = logging.getLogger(__name__) + +# What a response may weigh before it is cut off. Well below the page fetcher's +# ceiling, because this is text that will be sent back to a model rather than +# stored for a person to read. +MAX_RESPONSE_BYTES = 2 * 1024 * 1024 + +# How much of the response is kept on the message row for the transcript. Capped +# separately from `max_chars`: what the model reads is spent once, what the event +# holds is stored on every message forever. +MAX_EVENT_CHARS = 2000 + +# How much of the arguments the transcript summarises. +MAX_SUMMARY_CHARS = 200 + +ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") + +# Bounds an administrator's number is clamped into. A tool that may return +# 400 000 characters is a tool that can fill the context window in one call. +MIN_CHARS, MAX_CHARS = 200, 40_000 +MIN_TIMEOUT, MAX_TIMEOUT = 1, 120 + + +@dataclass(frozen=True) +class HttpSpec: + """Everything one custom tool needs, read while the session was open. + + A frozen snapshot rather than the row, for the reason `Endpoint` is one: a + generation outlives the request that started it, and a detached SQLAlchemy + instance is a trap. The decrypted secret lives here and nowhere else. + """ + + slug: str + label: str + method: str + url_template: str + body_template: str = "" + headers: dict[str, str] = field(default_factory=dict) + secret: str = "" + secret_placement: str = SECRET_BEARER + secret_name: str = "Authorization" + response_mode: str = RESPONSE_TEXT + response_path: str = "" + max_chars: int = 8000 + timeout: int = 20 + allow_private: bool = False + parameters: dict[str, Any] = field(default_factory=dict) + + @property + def secret_header(self) -> str: + """The header the secret rides in, if it rides in one.""" + if not self.secret or self.secret_placement not in (SECRET_BEARER, SECRET_HEADER): + return "" + return self.secret_name or "Authorization" + + +def spec_from(row: CustomTool) -> HttpSpec: + """Snapshot a row, decrypting its secret. Call this with a session open.""" + return HttpSpec( + slug=row.slug, + label=row.name or row.slug, + method=(row.method or "GET").upper(), + url_template=row.url_template or "", + body_template=row.body_template or "", + headers=dict(row.headers_json or {}), + secret=decrypt(row.secret_encrypted), + secret_placement=row.secret_placement, + secret_name=row.secret_name or "Authorization", + response_mode=row.response_mode, + response_path=row.response_path or "", + max_chars=min(max(int(row.max_chars or 0), MIN_CHARS), MAX_CHARS), + timeout=min(max(int(row.timeout or 0), MIN_TIMEOUT), MAX_TIMEOUT), + allow_private=bool(row.allow_private), + parameters=dict(row.parameters_json or {}), + ) + + +def tool_defs( + db: DBSession, user: User | None, *, everything: bool = False +) -> list[ToolDef]: + """One `ToolDef` per custom tool this user may be offered.""" + return [ + ToolDef( + name=row.slug, + family=f"custom:{row.slug}", + description=row.description or f"Call the {row.name} tool.", + parameters=_schema_of(row), + run=_runner(spec_from(row)), + ) + for row in tool_access.visible_custom_tools(db, user, everything=everything) + ] + + +def _schema_of(row: CustomTool) -> dict[str, Any]: + schema = dict(row.parameters_json or {}) + if schema.get("type") != "object": + # An endpoint expects an object here; anything else it will reject + # outright, which fails the whole request rather than the one tool. + return {"type": "object", "properties": {}} + return schema + + +def _runner(spec: HttpSpec): + async def run(context: ToolContext, args: dict[str, Any]) -> ToolOutcome: + return await call(spec, args) + + return run + + +# --- Filling the template ---------------------------------------------------- +def _scalar(value: Any) -> str: + """One argument as text, before it is escaped for wherever it is going.""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return value + if isinstance(value, int | float): + return str(value) + return json.dumps(value, ensure_ascii=False) + + +def _for_url(value: str) -> str: + # safe="" is the whole point: an argument must not be able to introduce a + # path segment, a query separator or a fragment. + return quote(value, safe="") + + +def _for_body(value: str) -> str: + # The inside of a JSON string, so a quote or a backslash in an argument + # cannot end it early and add a field of its own. + return json.dumps(value, ensure_ascii=False)[1:-1] + + +def _for_header(value: str) -> str: + # A newline in a header value is header injection. Other control characters + # go with it; none of them mean anything in a header. + return "".join(character for character in value if character.isprintable()) + + +def _substitute(template: str, spec: HttpSpec, args: dict[str, Any], escape) -> str: + """Fill `{{name}}` from the call's arguments. + + Not `prompts.substitute`, though the grammar is shared. The rules differ, + and the differences are the point: a name the tool does not declare never + substitutes, an unrecognised one becomes nothing rather than passing through + verbatim -- a literal `{{x}}` in a URL is not a feature -- and every value + is escaped for where it lands. + """ + declared = set(spec.parameters.get("properties") or {}) + + def swap(match) -> str: + name = match.group(1) + if name not in declared: + return "" + return escape(_scalar(args.get(name))) + + return VARIABLE_PATTERN.sub(swap, template) + + +def _origin(url: str) -> tuple[str, str]: + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise fetch_service.FetchError("A tool's URL must start with http:// or https://") + if not parsed.netloc: + raise fetch_service.FetchError("A tool's URL has no host.") + return parsed.scheme, parsed.netloc + + +def fill_url(spec: HttpSpec, args: dict[str, Any]) -> str: + """Fill the URL template, refusing anything that moved the host. + + Checked twice over: the template's own scheme and authority must be literal, + and the filled URL must still point at them. The first check is what stops + `https://{{host}}/x` from ever being saved; the second is what catches a row + that predates it, or an escaping mistake. + """ + template = spec.url_template.strip() + scheme, netloc = _origin(template) + if VARIABLE_PATTERN.search(f"{scheme}://{netloc}"): + raise fetch_service.FetchError( + "A tool's scheme and host must be literal, not filled from an argument." + ) + + filled = _substitute(template, spec, args, _for_url) + if _origin(filled) != (scheme, netloc): + raise fetch_service.FetchError("That call would have pointed somewhere else.") + return filled + + +def _prepare(spec: HttpSpec, args: dict[str, Any]) -> tuple[str, dict[str, str], bytes | None]: + """The URL, headers and body for one call, secret included.""" + url = fill_url(spec, args) + headers = { + "User-Agent": fetch_service.USER_AGENT, + "Accept": "application/json, text/*;q=0.9, */*;q=0.5", + } + for name, value in spec.headers.items(): + clean = _for_header(str(name)).strip() + if clean: + headers[clean] = _substitute(str(value), spec, args, _for_header) + + body: bytes | None = None + if spec.body_template.strip() and spec.method != "GET": + body = _substitute(spec.body_template, spec, args, _for_body).encode("utf-8") + headers.setdefault("Content-Type", "application/json") + + if spec.secret: + if spec.secret_placement == SECRET_BEARER: + headers[spec.secret_name or "Authorization"] = f"Bearer {spec.secret}" + elif spec.secret_placement == SECRET_HEADER: + headers[spec.secret_name or "Authorization"] = spec.secret + elif spec.secret_placement == SECRET_QUERY: + # Only on the URL this call starts at. A redirect's Location + # replaces the query, so the credential does not travel on by + # itself -- which is the behaviour wanted anyway. + joiner = "&" if urlparse(url).query else "?" + url = f"{url}{joiner}{quote(spec.secret_name)}={quote(spec.secret, safe='')}" + + return url, headers, body + + +# --- Reading the response ---------------------------------------------------- +def _narrow(payload: Any, path: str) -> Any: + """Walk a dotted path into a decoded JSON document. + + Integer segments index a list, so "data.0.title" works. A path that does not + lead anywhere yields the whole document rather than nothing: an unhelpful + answer beats a silent empty one when the model has to explain itself. + """ + current = payload + for segment in [part for part in path.split(".") if part]: + if isinstance(current, dict) and segment in current: + current = current[segment] + elif isinstance(current, list) and segment.lstrip("-").isdigit(): + try: + current = current[int(segment)] + except IndexError: + return payload + else: + return payload + return current + + +def _decode(payload: bytes, response: httpx.Response) -> str: + return payload.decode(response.encoding or "utf-8", "replace") + + +def _as_text(spec: HttpSpec, payload: bytes, response: httpx.Response) -> str: + content_type = response.headers.get("content-type", "") + + if spec.response_mode == RESPONSE_JSON: + try: + document = json.loads(_decode(payload, response)) + except (json.JSONDecodeError, UnicodeDecodeError): + # Falling back rather than failing: a JSON API answering with an + # HTML error page is a thing the model can report usefully. + return _decode(payload, response) + value = _narrow(document, spec.response_path) + if isinstance(value, str): + return value + return json.dumps(value, indent=2, ensure_ascii=False) + + if spec.response_mode == RESPONSE_RAW: + return _decode(payload, response) + + text = _decode(payload, response) + if "html" in content_type or text.lstrip()[:1] == "<": + _, text = fetch_service.html_to_text(text) + return text + + +def _clip(text: str, limit: int) -> str: + if len(text) <= limit: + return text + return text[:limit].rstrip() + "\n… (truncated)" + + +def _summary(args: dict[str, Any]) -> str: + """What the transcript shows the tool was asked for.""" + parts = [f"{name}={_scalar(value)!r}" for name, value in args.items()] + return _clip(", ".join(parts), MAX_SUMMARY_CHARS) + + +def _event(spec: HttpSpec, args: dict[str, Any], *, status: str, **extra: Any) -> dict[str, Any]: + return { + "name": spec.slug, + "kind": "custom", + "label": spec.label, + "query": _summary(args), + # The host, never the filled URL: a path or query segment can carry an + # argument, and the event is rendered and stored. + "detail": f"{spec.method} {urlparse(spec.url_template).netloc}", + "status": status, + "results": [], + **extra, + } + + +# --- Making the call --------------------------------------------------------- +async def call(spec: HttpSpec, args: dict[str, Any]) -> ToolOutcome: + """Run one custom tool. Reports its own failures rather than raising.""" + try: + url, headers, body = _prepare(spec, args) + current = fetch_service.check_url(url, allow_private=spec.allow_private) + origin = _origin(current) + response = await _send(spec, current, headers, body, origin) + except fetch_service.FetchError as exc: + return ToolOutcome( + f"The {spec.label} tool could not be called: {exc.message}", + _event(spec, args, status="error", error=exc.message), + ) + except httpx.RequestError as exc: + message = f"Could not reach the {spec.label} tool: {exc}" + return ToolOutcome(message, _event(spec, args, status="error", error=str(exc)[:200])) + + payload = response.content[:MAX_RESPONSE_BYTES] + text = _clip(_as_text(spec, payload, response).strip(), spec.max_chars) + + if response.status_code >= 400: + note = f"{spec.label} returned HTTP {response.status_code}." + return ToolOutcome( + f"{note}\n\n{text}" if text else note, + _event( + spec, + args, + status="error", + error=f"HTTP {response.status_code}", + text=text[:MAX_EVENT_CHARS], + ), + ) + + if not text: + return ToolOutcome( + f"{spec.label} returned nothing.", + _event(spec, args, status="ok", text=""), + ) + + return ToolOutcome(text, _event(spec, args, status="ok", text=text[:MAX_EVENT_CHARS])) + + +async def _send( + spec: HttpSpec, + url: str, + headers: dict[str, str], + body: bytes | None, + origin: tuple[str, str], +) -> httpx.Response: + """Send the request, following redirects by hand so each hop is checked.""" + current = url + async with httpx.AsyncClient(timeout=spec.timeout, follow_redirects=False) as client: + for _ in range(fetch_service.MAX_REDIRECTS + 1): + response = await client.request( + spec.method, current, headers=headers, content=body + ) + if not response.is_redirect: + return response + + location = response.headers.get("location", "") + if not location: + raise fetch_service.FetchError("That tool redirected to nowhere.") + current = fetch_service.check_url( + str(response.url.join(location)), allow_private=spec.allow_private + ) + if _origin(current) != origin: + # A server that can redirect us anywhere must not be able to + # redirect us at somebody else carrying the key. + if spec.secret_header: + headers.pop(spec.secret_header, None) + origin = _origin(current) + + raise fetch_service.FetchError("That tool redirected too many times.") + + +__all__ = ["HttpSpec", "call", "fill_url", "spec_from", "tool_defs"] diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index 88e9572..14bc816 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -239,7 +239,11 @@ async def _run(generation: Generation) -> None: owner = db.get(User, chat.user_id) # Read while the session is open: everything below outlives it. - offered = tools_service.enabled_tools(db, chat, owner) + # Resolved once, so that what the loop is allowed to *run* is the + # same set the endpoint was *offered* -- not whatever happens to + # exist by the time a call comes back. + toolset = tools_service.resolve_tools(db, chat, owner) + offered = toolset.schemas payload = chat_service.build_request( db, chat, upto=message, tools=offered, user=owner ) @@ -248,7 +252,7 @@ async def _run(generation: Generation) -> None: # Read here, with the rest, because titling happens after this # session has closed and must not open another one. title_prompt = prompts_service.resolve(db, "task.title") - tool_context = tools_service.context_for(db, owner, chat) + tool_context = tools_service.context_for(db, owner, chat, tools=toolset) model = chat_service.model_for(db, chat) generation.context_limit = model.context_length if model is not None else 0 diff --git a/src/lembas/services/harness.py b/src/lembas/services/harness.py index f6a707e..3939447 100644 --- a/src/lembas/services/harness.py +++ b/src/lembas/services/harness.py @@ -57,16 +57,22 @@ MAX_HARNESS_CHARS = 8000 MAX_NAMED_DOCUMENTS = 5 -def _families(tools: list[dict[str, Any]]) -> list[str]: - """Which families are represented in an offered tool list, in a fixed order.""" - from lembas.services.tools import FAMILIES, REGISTRY +def _families(db: DBSession, tools: list[dict[str, Any]]) -> list[str]: + """Which families are represented in an offered tool list, in a fixed order. + Resolved against the database rather than the import-time registry, because + an administrator-defined tool is a row and would otherwise contribute no + family at all -- which is to say its guidance would never be admitted. + """ + from lembas.services import tools as tools_service + + book = tools_service.registry(db) offered = { - REGISTRY[name].family + book[name].family for tool in tools - if (name := (tool.get("function") or {}).get("name")) in REGISTRY + if (name := (tool.get("function") or {}).get("name")) in book } - return [family for family in FAMILIES if family in offered] + return [family for family in tools_service.families(db) if family in offered] def _tool_names(tools: list[dict[str, Any]]) -> str: @@ -109,7 +115,7 @@ def context_variables( from lembas.services import tools as tools_service offered = tools or [] - families = _families(offered) + families = _families(db, offered) stamp = datetime.now().astimezone() values: dict[str, str] = { @@ -185,7 +191,7 @@ def compose( return compose_from( db, variables=context_variables(db, user, offered, chat), - families=_families(offered), + families=_families(db, offered), has_tools=bool(offered), ) diff --git a/src/lembas/services/tool_access.py b/src/lembas/services/tool_access.py new file mode 100644 index 0000000..1105b50 --- /dev/null +++ b/src/lembas/services/tool_access.py @@ -0,0 +1,63 @@ +"""Who may be offered which administrator-defined tool. + +One definition, used by the resolver that builds a chat's tool set and by the +admin screens that say what a row will reach. It mirrors +`permissions.models_visible_to` rather than `services/sharing.py`, and the +difference matters: sharing has no admin branch because reading someone's +private notes is not something a role should grant, while a tool is instance +configuration an administrator could give themselves in one click anyway. + +`everything=True` is for callers that need to map a tool *name* back to what it +belongs to -- the harness, the prompt preview -- rather than to decide what to +offer. Gating on visibility there would mean an administrator's own preview +disagreeing with what a reader actually gets. +""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session as DBSession + +from lembas.db.models import CustomTool, McpServer, User + + +def _permitted(rows: list, user: User | None, *, everything: bool) -> list: + if everything or (user is not None and user.is_admin): + return rows + if user is None: + return [] + member_of = {group.id for group in user.groups} + return [ + row for row in rows if row.public or member_of.intersection({g.id for g in row.groups}) + ] + + +def visible_custom_tools( + db: DBSession, user: User | None, *, everything: bool = False +) -> list[CustomTool]: + """Enabled custom tools this user may be offered, in position order.""" + rows = list( + db.scalars( + select(CustomTool) + .where(CustomTool.enabled.is_(True)) + .order_by(CustomTool.position, CustomTool.slug) + ) + ) + return _permitted(rows, user, everything=everything) + + +def visible_mcp_servers( + db: DBSession, user: User | None, *, everything: bool = False +) -> list[McpServer]: + """Enabled MCP servers this user may be offered, in position order.""" + rows = list( + db.scalars( + select(McpServer) + .where(McpServer.enabled.is_(True)) + .order_by(McpServer.position, McpServer.slug) + ) + ) + return _permitted(rows, user, everything=everything) + + +__all__ = ["visible_custom_tools", "visible_mcp_servers"] diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py index f23a108..27dbe09 100644 --- a/src/lembas/services/tools.py +++ b/src/lembas/services/tools.py @@ -29,10 +29,12 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import Any +from sqlalchemy import select from sqlalchemy.orm import Session as DBSession from lembas.db.models import AUTHOR_MODEL, Chat, User from lembas.db.session import session_scope +from lembas.services import prompts as prompts_service from lembas.services import search as search_service from lembas.services import settings_store from lembas.services.library import documents as documents_service @@ -58,8 +60,24 @@ FAMILY_NOTES = "notes" FAMILY_MEMORY = "memory" FAMILY_SKILLS = "skills" +# A tool that is a database row gets a family of its own, so that it can carry +# its own guidance -- "custom:weather", "mcp:github". Everything before the +# colon is the *gate*: the capability flag and the permission are per gate, not +# per row, because a server advertising forty tools must not mean forty +# checkboxes on every model. +FAMILY_CUSTOM = "custom" +FAMILY_MCP = "mcp" + +# The built-in families, in the order they are offered. FAMILIES = (FAMILY_SEARCH, FAMILY_KNOWLEDGE, FAMILY_NOTES, FAMILY_MEMORY, FAMILY_SKILLS) +GATES = (*FAMILIES, FAMILY_CUSTOM, FAMILY_MCP) + + +def gate_of(family: str) -> str: + """The part a capability flag and a permission are named after.""" + return family.split(":", 1)[0] + @dataclass class ToolContext: @@ -72,10 +90,14 @@ class ToolContext: owner_id: str search_config: dict[str, Any] = field(default_factory=dict) - allow_private_fetch: bool = False # Which knowledge bases this chat is scoped to. Empty means "everything the # owner can see", which is what a chat with none attached should do. base_ids: list[str] = field(default_factory=list) + # Name -> definition for the tools actually offered on this request. None + # means nobody resolved a set, and only then does `run_tool` fall back to + # the import-time registry. A dict, *even an empty one*, is authoritative: + # a model naming a tool it was not offered must not get it run. + tools: dict[str, ToolDef] | None = None @dataclass @@ -114,6 +136,31 @@ class ToolDef: } +@dataclass(frozen=True) +class ToolSet: + """What one request may call: the schemas to send, and how to run them. + + The two halves have to travel together. `enabled_tools` used to return + schemas alone, which worked only because every runner was reachable through + the import-time `REGISTRY`. A tool that is a database row is not, so the + resolution has to be carried from the session that made it to the loop that + uses it. + """ + + defs: tuple[ToolDef, ...] = () + + @property + def schemas(self) -> list[dict[str, Any]]: + return [tool.schema for tool in self.defs] + + @property + def by_name(self) -> dict[str, ToolDef]: + return {tool.name: tool for tool in self.defs} + + def __bool__(self) -> bool: + return bool(self.defs) + + def _object(properties: dict[str, Any], required: list[str]) -> dict[str, Any]: return {"type": "object", "properties": properties, "required": required} @@ -645,21 +692,69 @@ def _family_allowed( Absent counts as on when `tools` is on, so an upgrade does not silently take web search away from every model already set up for it. """ + gate = gate_of(family) default = bool(capabilities.get("tools")) - if not capabilities.get(f"tool_{family}", default): + if not capabilities.get(f"tool_{gate}", default): return False - if family == FAMILY_SEARCH: + if gate == FAMILY_SEARCH: return bool( allowed.get("tools.web_search") and config.get("enabled") and not search_service.availability(str(config.get("provider") or "ddgs")) ) - return bool(allowed.get(f"tools.{family}") and allowed.get("library.use")) + if gate in (FAMILY_CUSTOM, FAMILY_MCP): + # Deliberately without `library.use`: an HTTP endpoint an administrator + # wrote has nothing to do with this person's own documents and notes, + # and requiring the library permission for it would be a coincidence of + # naming rather than a rule. + return bool(allowed.get(f"tools.{gate}")) + return bool(allowed.get(f"tools.{gate}") and allowed.get("library.use")) -def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]: - """The tool schemas to offer for this chat.""" +def _row_defs(db: DBSession, user: User | None, *, everything: bool = False) -> list[ToolDef]: + """Tool definitions built from rows, in the order they claim names. + + Imported here rather than at the top because `custom_tools` needs `ToolDef` + from this module. + """ + from lembas.services import custom_tools + + return custom_tools.tool_defs(db, user, everything=everything) + + +def _book(defs: list[ToolDef]) -> dict[str, ToolDef]: + """Keyed by name, first claim winning. + + The built-ins are laid down first, so a row can never shadow one -- a tool + called `notes_delete` that turns out to be somebody's HTTP endpoint is the + kind of surprise that has no good failure mode. + """ + book = dict(REGISTRY) + for tool in defs: + book.setdefault(tool.name, tool) + return book + + +def registry(db: DBSession) -> dict[str, ToolDef]: + """Every tool that exists on this instance, keyed by name, ungated. + + `REGISTRY` holds the built-ins alone, because it is built at import time and + an administrator-defined tool is a row. Callers that only need to map a name + back to a family use this; callers deciding what to *offer* use + `resolve_tools`, which applies the gates as well. + """ + return _book(_row_defs(db, None, everything=True)) + + +def families(db: DBSession) -> tuple[str, ...]: + """Every family that exists, the built-ins in their fixed order first.""" + rows = tuple(tool.family for tool in _row_defs(db, None, everything=True)) + return (*FAMILIES, *rows) + + +def resolve_tools(db: DBSession, chat: Chat, user: User | None) -> ToolSet: + """Every tool this chat may call right now, with its runner attached.""" from lembas.security import permissions from lembas.services import chat as chat_service @@ -669,25 +764,47 @@ def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str capabilities = model.capabilities_json or {} if not capabilities.get("tools"): - return [] + return ToolSet() allowed = permissions.resolve(db, user) config = settings_store.search(db) - families = { - family - for family in FAMILIES - if _family_allowed(family, config=config, capabilities=capabilities, allowed=allowed) - } - return [tool.schema for tool in REGISTRY.values() if tool.family in families] + # Resolved against what this reader may see, not against everything that + # exists: a tool restricted to a group is not offered outside it. + book = _book(_row_defs(db, user)) + return ToolSet( + tuple( + tool + for tool in book.values() + if _family_allowed( + tool.family, config=config, capabilities=capabilities, allowed=allowed + ) + ) + ) -def context_for(db: DBSession, user: User | None, chat: Chat | None = None) -> ToolContext: +def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]: + """The tool schemas to offer for this chat. + + The shape is unchanged on purpose: the inspector and `build_request` want + exactly this. Anything that will also *run* a tool wants `resolve_tools`. + """ + return resolve_tools(db, chat, user).schemas + + +def context_for( + db: DBSession, + user: User | None, + chat: Chat | None = None, + *, + tools: ToolSet | None = None, +) -> ToolContext: """The snapshot a running tool needs, taken while the session is open.""" return ToolContext( owner_id=user.id if user else "", search_config=settings_store.search(db), base_ids=[base.id for base in chat.knowledge_bases] if chat is not None else [], + tools=tools.by_name if tools is not None else None, ) @@ -697,8 +814,15 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco Never raises. A tool that fails hands the model an explanation and lets it carry on -- a failed lookup should produce "I could not find that" rather than killing the whole reply. + + The lookup is against what was *offered*, not against everything that + exists. Reaching for the registry directly meant a model naming a tool its + chat was gated out of -- a family switched off for the model, a permission + the reader does not have -- had it run anyway, because only the offer was + ever filtered. """ - tool = REGISTRY.get(name) + book = REGISTRY if context.tools is None else context.tools + tool = book.get(name) if tool is None: return ToolOutcome( f"There is no tool called {name!r}.", @@ -710,9 +834,12 @@ async def run_tool(context: ToolContext, name: str, arguments: str) -> ToolOutco except json.JSONDecodeError: # Small models emit malformed argument JSON often enough that this is a # normal path, not an exceptional one. Treat the whole string as the - # first required argument rather than giving up. - required = tool.parameters.get("required") or ["query"] - parsed = {required[0]: arguments.strip()} + # tool's first argument rather than giving up: what it says is required, + # else the first thing it declares, and only then a guess -- a schema + # somebody else wrote need not have either. + properties = tool.parameters.get("properties") or {} + names = tool.parameters.get("required") or list(properties) or ["query"] + parsed = {str(names[0]): arguments.strip()} if not isinstance(parsed, dict): parsed = {"query": str(parsed)} @@ -808,6 +935,31 @@ def tool_turn(call: dict[str, Any], content: str) -> dict[str, Any]: } +def _row_source(db: DBSession): + """One harness fragment per administrator-defined tool. + + The seam `prompts.register_source` exists for. The row supplies the default + text and the admin page supplies the override, which is why a tool deleted + and recreated under the same slug keeps whatever wording somebody chose for + it -- the override outlives the row. + + Gated on the tool's own family, so the guidance appears exactly when the + tool it describes is offered and never otherwise. + """ + from lembas.db.models import CustomTool + + for row in db.scalars(select(CustomTool).order_by(CustomTool.position, CustomTool.slug)): + yield prompts_service.Fragment( + key=f"tool.custom_{row.slug}", + label=row.name or row.slug, + group=prompts_service.GROUP_TOOLS, + order=500 + row.position, + families=(f"{FAMILY_CUSTOM}:{row.slug}",), + hint=f"Appears when the {row.slug} tool is offered.", + default=row.guidance or "", + ) + + __all__ = [ "FAMILIES", "MAX_ROUNDS", @@ -816,9 +968,19 @@ __all__ = [ "ToolContext", "ToolDef", "ToolOutcome", + "ToolSet", "assistant_turn", "context_for", "enabled_tools", + "families", + "registry", + "resolve_tools", "run_tool", "tool_turn", ] + + +# Registered at import. `services.tools` is imported by the chat routes, the +# generation service and the prompts admin, so the source is in place before +# anything renders a catalogue. +prompts_service.register_source(_row_source) diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index a907820..29cf9d5 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -371,6 +371,23 @@ line-height: var(--leading-relaxed); } +/* What a tool returned, verbatim. Preformatted rather than rendered: this is + third-party text and markdown is the one path allowed to emit HTML. */ +.tool-result__text { + margin: 0; + padding: var(--sp-3); + border-radius: var(--radius-sm); + background: var(--bg-sunken); + color: var(--ink-muted); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: var(--leading-relaxed); + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 22em; + overflow-y: auto; +} + /* --- Stop, notes and editing ---------------------------------------------- */ .msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; } .msg__status:empty { display: none; } diff --git a/src/lembas/web/templates/admin/_layout.html b/src/lembas/web/templates/admin/_layout.html index b446059..a943f6a 100644 --- a/src/lembas/web/templates/admin/_layout.html +++ b/src/lembas/web/templates/admin/_layout.html @@ -43,6 +43,10 @@ {{ icon("globe", "icon--sm") }} Web search + + {{ icon("link", "icon--sm") }} + Tools + {{ icon("sparkle", "icon--sm") }} Prompts @@ -64,10 +68,6 @@