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:
@@ -207,3 +207,126 @@ async def _empty_search(_config, _query, *, limit=None):
|
||||
async def _never_called_title(*_args, **_kwargs):
|
||||
"""Auto-titling makes its own request; these tests are about the tool loop."""
|
||||
return "A title"
|
||||
|
||||
|
||||
# --- Progress and concurrency ------------------------------------------------
|
||||
async def test_the_status_names_the_running_tool_and_is_cleared(db, user_id, monkeypatch):
|
||||
"""A remote tool can take seconds with nothing streaming, and a silent
|
||||
pause is exactly what a hang looks like."""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
chat_id, message_id = _chat_with_tools(db, user_id)
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
async def fake_search(_config, _query, *, limit=None):
|
||||
seen.append(generation.status)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("lembas.services.search.run", fake_search)
|
||||
monkeypatch.setattr(
|
||||
generation_service,
|
||||
"stream_chat",
|
||||
_stub_stream(
|
||||
[[_tool_call_chunk("web_search", '{"query": "mallorn"}')], [_text_chunk("Done.")]],
|
||||
[],
|
||||
),
|
||||
)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
assert seen == ["Running web_search…"]
|
||||
assert generation.status == "", "and it is cleared once they are done"
|
||||
|
||||
|
||||
async def test_results_stay_paired_with_their_calls_when_run_together(db, user_id, monkeypatch):
|
||||
"""Indexed rather than appended as they finish: an endpoint matching on
|
||||
tool_call_id would otherwise pair the right id with the wrong content."""
|
||||
import asyncio
|
||||
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
chat_id, message_id = _chat_with_tools(db, user_id)
|
||||
|
||||
async def slow_first(_config, query, *, limit=None):
|
||||
# The first call finishes last, which is the whole point of the test.
|
||||
await asyncio.sleep(0.02 if query == "first" else 0)
|
||||
return [SearchResult(f"result for {query}", f"https://t.test/{query}", "")]
|
||||
|
||||
monkeypatch.setattr("lembas.services.search.run", slow_first)
|
||||
|
||||
two_calls = {
|
||||
"choices": [
|
||||
{"delta": {"tool_calls": [
|
||||
{"index": 0, "id": "a", "function": {
|
||||
"name": "web_search", "arguments": '{"query": "first"}'}},
|
||||
{"index": 1, "id": "b", "function": {
|
||||
"name": "web_search", "arguments": '{"query": "second"}'}},
|
||||
]}}
|
||||
]
|
||||
}
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
generation_service,
|
||||
"stream_chat",
|
||||
_stub_stream([[two_calls], [_text_chunk("Done.")]], payloads),
|
||||
)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
turns = [m for m in payloads[1]["messages"] if m.get("role") == "tool"]
|
||||
assert [turn["tool_call_id"] for turn in turns] == ["a", "b"]
|
||||
assert "first" in turns[0]["content"] and "second" in turns[1]["content"]
|
||||
# And the transcript keeps the same order.
|
||||
assert [event["query"] for event in generation.tool_events] == ["first", "second"]
|
||||
|
||||
|
||||
async def test_a_custom_tool_runs_inside_the_loop(db, user_id, monkeypatch, mock_http):
|
||||
"""End to end: a row becomes an offered schema, the model calls it, and the
|
||||
result comes back in the next request's messages."""
|
||||
import httpx
|
||||
|
||||
from lembas.db.models import CustomTool
|
||||
|
||||
monkeypatch.setattr(
|
||||
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
|
||||
)
|
||||
mock_http(lambda _r: httpx.Response(200, json={"summary": "Sunny in Minas Tirith."}))
|
||||
|
||||
db.add(
|
||||
CustomTool(
|
||||
slug="weather",
|
||||
name="Weather",
|
||||
description="Look up the weather.",
|
||||
url_template="https://api.test/{{city}}",
|
||||
parameters_json={"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
response_mode="json",
|
||||
response_path="summary",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
chat_id, message_id = _chat_with_tools(db, user_id)
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
generation_service,
|
||||
"stream_chat",
|
||||
_stub_stream(
|
||||
[
|
||||
[_tool_call_chunk("weather", '{"city": "Minas Tirith"}')],
|
||||
[_text_chunk("It is sunny.")],
|
||||
],
|
||||
payloads,
|
||||
),
|
||||
)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||
await generation_service._run(generation)
|
||||
|
||||
offered = {tool["function"]["name"] for tool in payloads[0]["tools"]}
|
||||
assert "weather" in offered
|
||||
|
||||
tool_turns = [m for m in payloads[1]["messages"] if m.get("role") == "tool"]
|
||||
assert tool_turns[0]["content"] == "Sunny in Minas Tirith."
|
||||
assert generation.tool_events[0]["kind"] == "custom"
|
||||
assert generation.tool_events[0]["label"] == "Weather"
|
||||
|
||||
Reference in New Issue
Block a user