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>
This commit is contained in:
@@ -31,11 +31,15 @@ from lembas.db.models import (
|
||||
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__)
|
||||
@@ -413,3 +417,245 @@ async def update_tool(request: Request, db: Db, user: AdminUser, tool_id: str) -
|
||||
|
||||
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}.")
|
||||
|
||||
Reference in New Issue
Block a user