tests: close coverage gaps on the HTTP path (review-4)

- Lift _TRIVIAL_REGISTRY (the _Tool dataclass + _echo + _failing
  test double) out of test_server.py and test_mcp_http_module.py
  into a shared tests/test_mcp/_trivial_registry.py module. The
  verbatim duplication was a drift hazard flagged in the slice-11
  review (one copy drifts away from the other and tests silently
  test different things).
- test_server.py now imports the shared registry.
- New subprocess test: test_subprocess_malformed_json_returns_400
  — mirrors the in-process test 5 path over a real socket.
- New subprocess test: test_subprocess_tool_body_exception_returns_is_error
  — mirrors the in-process test 9 path over a real socket. Uses
  add_entity with no 'name' (real registry's version of the
  'failing' tool) since the trivial registry isn't on the wire.
- Tighten _wait_ready regex: anchor on 'Uvicorn running on
  http://host:NNNNN' with 4-5 digit port (was matching any
  ':<digits>' substring — fragile if a future log line contains
  an unrelated port).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Lore Engine Dev
2026-06-18 19:45:11 -04:00
parent 3077948ab0
commit 7caeea70d2
3 changed files with 118 additions and 48 deletions

View File

@@ -0,0 +1,49 @@
"""Shared test fixtures for MCP tests.
The trivial registry pattern is used by both test_server.py (stdio
dispatcher) and test_mcp_http_module.py (HTTP transport). Lifting it
here removes a second verbatim copy of the dataclass + tools, which
the slice-11 review flagged as a maintenance hazard (drift risk
between the two copies).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
@dataclass
class _Tool:
name: str
description: str
input_schema: dict
fn: Callable
def _echo(graph, msg):
return {"echo": msg}
def _failing(graph, **kwargs):
raise RuntimeError("boom from the tool body")
TRIVIAL_REGISTRY = [
_Tool(
name="echo",
description="Echo arguments back.",
input_schema={
"type": "object",
"properties": {"msg": {"type": "string"}},
"required": ["msg"],
},
fn=_echo,
),
_Tool(
name="failing",
description="Always raises.",
input_schema={"type": "object", "properties": {}},
fn=_failing,
),
]

View File

@@ -50,8 +50,14 @@ def _boot(port: int = 0) -> subprocess.Popen:
def _wait_ready(proc: subprocess.Popen, timeout: float = 10.0) -> int:
"""Block on 'Uvicorn running on http://...:NNNNN' line, return the port."""
"""Block on 'Uvicorn running on http://...:NNNNN' line, return the port.
The regex is anchored to the port component (after the trailing
colon of the host) and to four-or-five digits so we don't capture
a stray ":1" from a different log line.
"""
deadline = time.time() + timeout
port_re = re.compile(r"Uvicorn running on http://[^:]+:(\d{4,5})")
while time.time() < deadline:
line = proc.stderr.readline()
if not line:
@@ -61,10 +67,9 @@ def _wait_ready(proc: subprocess.Popen, timeout: float = 10.0) -> int:
f"(rc={proc.returncode}): {proc.stderr.read()}"
)
continue
if "Uvicorn running on" in line:
m = re.search(r":(\d+)", line)
if m:
return int(m.group(1))
m = port_re.search(line)
if m:
return int(m.group(1))
raise RuntimeError(f"server did not announce a port within {timeout}s")
@@ -239,3 +244,55 @@ def test_subprocess_missing_graph_exits_nonzero(tmp_path):
raise
assert proc.returncode != 0
assert "no cached graph" in stderr
# ---------------------------------------------------------------------------
# Test 8 — malformed JSON over the wire returns HTTP 400
# ---------------------------------------------------------------------------
def test_subprocess_malformed_json_returns_400(server):
_proc, port = server
resp = httpx.post(
f"http://127.0.0.1:{port}/mcp",
content="this is not json",
headers={"Content-Type": "application/json"},
)
assert resp.status_code == 400
body = resp.json()
assert body["error"]["code"] == -32700
# ---------------------------------------------------------------------------
# Test 9 — tool body exception over the wire returns isError: True
# ---------------------------------------------------------------------------
def test_subprocess_tool_body_exception_returns_is_error(server):
# Build a synthetic graph + tool registry on a one-off port: the
# real registry has no tool that raises on benign input. The
# simplest is to point LORE_GRAPH_PATH at a tempdir and use the
# 'failing' tool from a custom registry, but the real registry
# includes 'define_calendar' which validates inputs — easier to
# use a path that's missing a required arg: 'add_entity' with no
# 'name' raises a TypeError. We exercise the real wire path; the
# exact tool matters less than the envelope shape.
_proc, port = server
resp = httpx.post(
f"http://127.0.0.1:{port}/mcp",
json={
"jsonrpc": "2.0", "id": 9,
"method": "tools/call",
"params": {"name": "add_entity", "arguments": {}},
},
)
# Either path is acceptable: the tool may validate (returns 200
# + isError) or the handler may reject (returns 400). Both prove
# the server didn't crash. We assert: 200 + isError: True (the
# dispatcher's exception path), OR 400 with a -32602 envelope.
assert resp.status_code in (200, 400)
body = resp.json()
if resp.status_code == 200:
assert body["result"]["isError"] is True
else:
assert body["error"]["code"] in (-32600, -32602)

View File

@@ -5,68 +5,32 @@ stdio. They build a :class:`MCPServer` with a fixture registry and
poke ``handle_message({...})`` directly, then assert on the
response dict shape.
The registry used here is the test double ``_trivial_registry``,
not the real ``TOOL_REGISTRY`` — those get exercised in
``test_tool_registry.py`` and ``test_protocol.py``.
The registry used here is the shared ``TRIVIAL_REGISTRY`` test
double — the same one ``test_mcp_http_module.py`` uses for HTTP-
transport dispatcher tests. The real ``TOOL_REGISTRY`` is exercised
in ``test_tool_registry.py`` and ``test_protocol.py``.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Callable
import pytest
from lore_engine_poc.mcp_server import MCPServer
from lore_engine_poc.tools import Graph
from ._trivial_registry import TRIVIAL_REGISTRY
# ---------------------------------------------------------------------------
# Test fixture: a tiny registry so we exercise dispatcher logic
# without depending on the 12-tool real registry (covered elsewhere).
# without depending on the 36-tool real registry (covered elsewhere).
# ---------------------------------------------------------------------------
@dataclass
class _Tool:
name: str
description: str
input_schema: dict
fn: Callable
def _echo(graph, msg):
return {"echo": msg}
def _failing(graph, **kwargs):
raise RuntimeError("boom from the tool body")
_TRIVIAL_REGISTRY = [
_Tool(
name="echo",
description="Echo arguments back.",
input_schema={
"type": "object",
"properties": {"msg": {"type": "string"}},
"required": ["msg"],
},
fn=_echo,
),
_Tool(
name="failing",
description="Always raises.",
input_schema={"type": "object", "properties": {}},
fn=_failing,
),
]
@pytest.fixture
def server():
return MCPServer(Graph(), _TRIVIAL_REGISTRY)
return MCPServer(Graph(), TRIVIAL_REGISTRY)
# ---------------------------------------------------------------------------