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:
@@ -301,3 +301,196 @@ def test_the_secret_survives_a_round_trip_through_the_form(client: TestClient, d
|
||||
)
|
||||
db.refresh(tool)
|
||||
assert decrypt(tool.secret_encrypted) == "s3cret"
|
||||
|
||||
|
||||
# --- MCP servers -------------------------------------------------------------
|
||||
def _mcp_form(**overrides) -> dict:
|
||||
base = {
|
||||
"name": "GitHub",
|
||||
"slug": "github",
|
||||
"url": "https://mcp.test/rpc",
|
||||
"headers": "",
|
||||
"secret_placement": "none",
|
||||
"secret_name": "Authorization",
|
||||
"timeout": "30",
|
||||
"max_chars": "8000",
|
||||
"position": "0",
|
||||
"enabled": "true",
|
||||
"public": "true",
|
||||
}
|
||||
base.update(overrides)
|
||||
return {key: value for key, value in base.items() if value is not None}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_mcp(mock_http, monkeypatch):
|
||||
"""A server that answers initialize and tools/list.
|
||||
|
||||
Invented hostnames resolve to a public address; a literal IP is handed back
|
||||
as itself, so a test about a private address is still testing one.
|
||||
"""
|
||||
import ipaddress
|
||||
|
||||
def resolve(host, *_args, **_kwargs):
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return [(2, 1, 6, "", ("93.184.216.34", 80))]
|
||||
return [(2, 1, 6, "", (host, 80))]
|
||||
|
||||
monkeypatch.setattr("socket.getaddrinfo", resolve)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method != "POST":
|
||||
return httpx.Response(405)
|
||||
message = json.loads(request.content)
|
||||
if message.get("method") == "notifications/initialized":
|
||||
return httpx.Response(202)
|
||||
if message.get("method") == "initialize":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": {"protocolVersion": "2025-06-18", "serverInfo": {"name": "fake"}},
|
||||
},
|
||||
headers={"mcp-session-id": "s-1"},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": {"tools": [{"name": "search", "description": "Search."}]},
|
||||
},
|
||||
)
|
||||
|
||||
mock_http(handler)
|
||||
return handler
|
||||
|
||||
|
||||
def test_the_mcp_pages_are_refused_to_a_plain_user(client: TestClient, plain_user):
|
||||
assert client.get("/admin/mcp").status_code == 403
|
||||
assert client.get("/admin/mcp/new").status_code == 403
|
||||
assert client.post("/admin/mcp", data=_mcp_form()).status_code == 403
|
||||
|
||||
|
||||
def test_mcp_new_is_not_parsed_as_a_server_id(client: TestClient, registered):
|
||||
response = client.get("/admin/mcp/new")
|
||||
assert response.status_code == 200
|
||||
assert "New MCP server" in response.text
|
||||
|
||||
|
||||
def test_adding_a_server_discovers_its_tools(client: TestClient, db, registered, fake_mcp):
|
||||
from lembas.db.models import McpServer
|
||||
|
||||
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
||||
|
||||
server = db.scalar(select(McpServer))
|
||||
assert server.slug == "github"
|
||||
assert [entry["name"] for entry in server.tools_json] == ["search"]
|
||||
assert server.tools_json[0]["offer_name"] == "github_search"
|
||||
assert server.protocol_version == "2025-06-18"
|
||||
|
||||
|
||||
def test_refreshing_a_server_swaps_its_row(client: TestClient, db, registered, fake_mcp):
|
||||
from lembas.db.models import McpServer
|
||||
|
||||
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
||||
server = db.scalar(select(McpServer))
|
||||
|
||||
response = client.post(f"/admin/mcp/{server.id}/test")
|
||||
assert response.status_code == 200
|
||||
assert "found 1 tool." in response.text
|
||||
assert f'id="mcp-{server.id}"' in response.text, "the fragment must render standalone"
|
||||
|
||||
|
||||
def test_a_server_that_cannot_be_reached_says_so(
|
||||
client: TestClient, db, registered, mock_http, monkeypatch
|
||||
):
|
||||
from lembas.db.models import McpServer
|
||||
|
||||
monkeypatch.setattr(
|
||||
"socket.getaddrinfo", lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 80))]
|
||||
)
|
||||
mock_http(lambda _r: httpx.Response(500, text="down"))
|
||||
|
||||
response = client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
server = db.scalar(select(McpServer))
|
||||
assert server is not None, "the row is still saved so the URL can be corrected"
|
||||
assert server.last_error
|
||||
|
||||
|
||||
def test_a_private_url_is_not_contacted_unless_the_box_is_ticked(
|
||||
client: TestClient, db, registered, fake_mcp
|
||||
):
|
||||
"""The row still saves, so the URL can be corrected -- but the discovery
|
||||
that runs straight after it is refused, and the row says why."""
|
||||
from lembas.db.models import McpServer
|
||||
|
||||
client.post(
|
||||
"/admin/mcp", data=_mcp_form(url="http://127.0.0.1:9000/rpc"), follow_redirects=False
|
||||
)
|
||||
server = db.scalar(select(McpServer))
|
||||
assert server.allow_private is False
|
||||
assert "private or local" in server.last_error
|
||||
assert server.tools_json == []
|
||||
|
||||
client.post(
|
||||
f"/admin/mcp/{server.id}",
|
||||
data=_mcp_form(url="http://127.0.0.1:9000/rpc", allow_private="true"),
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post(f"/admin/mcp/{server.id}/test")
|
||||
db.refresh(server)
|
||||
assert server.last_error == ""
|
||||
assert [entry["name"] for entry in server.tools_json] == ["search"]
|
||||
|
||||
|
||||
def test_a_duplicate_mcp_slug_is_refused(client: TestClient, db, registered, fake_mcp):
|
||||
from lembas.db.models import McpServer
|
||||
|
||||
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
||||
response = client.post("/admin/mcp", data=_mcp_form(name="Other"), follow_redirects=False)
|
||||
assert "already a server" in response.text
|
||||
assert len(list(db.scalars(select(McpServer)))) == 1
|
||||
|
||||
|
||||
def test_a_url_that_is_not_http_is_refused(client: TestClient, db, registered):
|
||||
from lembas.db.models import McpServer
|
||||
|
||||
response = client.post(
|
||||
"/admin/mcp", data=_mcp_form(url="ftp://mcp.test/rpc"), follow_redirects=False
|
||||
)
|
||||
assert "http" in response.text
|
||||
assert db.scalar(select(McpServer)) is None
|
||||
|
||||
|
||||
def test_unticking_a_tool_withholds_it(client: TestClient, db, registered, fake_mcp):
|
||||
from lembas.db.models import McpServer, User
|
||||
from lembas.services.mcp import registry as mcp_registry
|
||||
|
||||
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
||||
server = db.scalar(select(McpServer))
|
||||
|
||||
client.post(
|
||||
f"/admin/mcp/{server.id}",
|
||||
data={**_mcp_form(), "tool_choices": "1", "tool_names": "search"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(server)
|
||||
assert server.tool_overrides_json == {"search": False}
|
||||
|
||||
owner = db.scalar(select(User))
|
||||
assert mcp_registry.tool_defs(db, owner) == []
|
||||
|
||||
|
||||
def test_deleting_a_server_removes_it(client: TestClient, db, registered, fake_mcp):
|
||||
from lembas.db.models import McpServer
|
||||
|
||||
client.post("/admin/mcp", data=_mcp_form(), follow_redirects=False)
|
||||
server = db.scalar(select(McpServer))
|
||||
|
||||
client.post(f"/admin/mcp/{server.id}/delete", follow_redirects=False)
|
||||
assert db.scalar(select(McpServer)) is None
|
||||
|
||||
Reference in New Issue
Block a user