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:
49
tests/test_mcp/_trivial_registry.py
Normal file
49
tests/test_mcp/_trivial_registry.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user