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:
Jaroslav Beneš
2026-08-01 16:44:29 +02:00
parent bc84fec21d
commit ecb52e9978
17 changed files with 2270 additions and 28 deletions
+54 -5
View File
@@ -338,13 +338,19 @@ async def _run(generation: Generation) -> None:
*payload["messages"],
tools_service.assistant_turn(calls, "".join(round_text)),
]
for call in calls:
outcome = await tools_service.run_tool(
tool_context, call["name"], call["arguments"]
)
generation.tool_events.append(outcome.event)
generation.status = _tool_status(calls)
generation.touch()
try:
outcomes = await _run_calls(tool_context, calls)
finally:
generation.status = ""
generation.touch()
for call, outcome in zip(calls, outcomes, strict=True):
generation.tool_events.append(outcome.event)
messages.append(tools_service.tool_turn(call, outcome.content))
generation.touch()
payload = {**payload, "messages": messages}
@@ -465,6 +471,49 @@ async def _maybe_compact(generation: Generation) -> None:
generation.touch()
# How many of a round's tool calls may be in flight at once. A bound rather
# than none: a model that asks for eight would otherwise open eight sockets and
# eight database sessions at the same moment.
MAX_PARALLEL_TOOLS = 4
def _tool_status(calls: list[dict]) -> str:
"""What to show while tools run.
A remote tool -- an HTTP endpoint, an MCP server -- can take seconds with
nothing streaming, and a silent pause is exactly what a hang looks like.
"""
if len(calls) == 1:
return f"Running {calls[0]['name']}"
return f"Running {len(calls)} tools…"
async def _run_calls(context, calls: list[dict]) -> list:
"""Run one round's calls together, results in call order.
Sequential was right when every tool was a local database read. A remote one
is latency-bound, and three two-second calls in a row are six seconds of a
reply looking hung -- while the model has already been told it may ask for
several at once.
The results are indexed rather than appended as they finish, because each
tool turn has to line up with the assistant turn's `tool_calls`: an endpoint
matching on `tool_call_id` would otherwise pair the right id with the wrong
content the moment two calls came back out of order.
Safe to run together because `run_tool` never raises, so no failure cancels
its siblings, and each runner opens its own `session_scope()` against a
database in WAL mode with a busy timeout.
"""
limit = asyncio.Semaphore(MAX_PARALLEL_TOOLS)
async def one(call: dict):
async with limit:
return await tools_service.run_tool(context, call["name"], call["arguments"])
return list(await asyncio.gather(*(one(call) for call in calls)))
def _pending_text(db, message: Message) -> str:
"""The user turn this reply is answering, for the size estimate."""
previous = db.scalars(