Skip to content

Commit 8e47660

Browse files
Add robust external MCP testing: conformance + Claude/Codex client sims
Layers an out-of-process protocol-conformance and client-compatibility suite on top of the integration scaffolding vendored from #1509: - conftest.py: one session-scoped Echidna+MCP server shared by all modules (honors ECHIDNA_MCP_URL to target an already-running server) - _mcp_client.py: dependency-light wire-level JSON-RPC/HTTP helpers - test_mcp_conformance.py: asserts the transport guarantees strict clients need (notification -> 202 empty body, GET SSE -> 405, handshake, error codes) - test_mcp_codex.py: replays Codex's strict rmcp handshake - test_mcp_claude.py: drives the server with the official `mcp` SDK (Claude) - test_mcp.py: refactored to share the conftest fixture (originally @datradito) - mcp-tests.yml: CI runs the full suite (tools + conformance + Claude + Codex) Verified end-to-end: 12/12 pass against the current (fixed) server; against the pre-fix haskell-mcp-server (9fd60af) the conformance + Codex tests fail on the `200 {}` notification (the bug that broke Codex) while the lenient Claude SDK passes -- which is exactly why the strict conformance/Codex layer is needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1975190 commit 8e47660

9 files changed

Lines changed: 415 additions & 79 deletions

File tree

.github/workflows/mcp-tests.yml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
name: MCP integration tests
22

3+
# Out-of-process MCP testing: spins up a real Echidna campaign with the MCP
4+
# server and drives it the way real agent clients do. Besides the tool/semantic
5+
# tests it runs the client-compatibility suite:
6+
# * test_mcp_conformance.py -- wire-protocol regression guard (202/405/handshake)
7+
# * test_mcp_codex.py -- replays Codex's strict rmcp handshake
8+
# * test_mcp_claude.py -- drives the server with the official `mcp` SDK
9+
# Both client checks are transport-level and need no API keys, so they run on
10+
# every push/PR. (A live-model smoke using examples/mcp_agent.py needs
11+
# ANTHROPIC_API_KEY / Codex auth and is intentionally not part of CI.)
12+
313
on:
414
push:
515
branches: ["**"]
616
pull_request:
717

818
jobs:
9-
mcp-tests:
19+
mcp-client-compat:
20+
name: MCP client compatibility (Claude + Codex)
1021
runs-on: ubuntu-latest
1122

1223
steps:
@@ -29,7 +40,7 @@ jobs:
2940
python-version: "3.11"
3041

3142
- name: Install test dependencies
32-
run: pip install pytest httpx
43+
run: pip install -r tests/mcp/requirements-test.txt
3344

34-
- name: Run MCP integration tests
35-
run: pytest tests/mcp/test_mcp.py -v
45+
- name: Run MCP test suite (tools + conformance + Claude + Codex)
46+
run: pytest tests/mcp -v

tests/mcp/_mcp_client.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Minimal, dependency-light MCP-over-HTTP client helpers shared by the
3+
conformance and Codex-replay test layers.
4+
5+
These intentionally do NOT use a high-level MCP SDK: they speak the wire
6+
protocol directly so the tests can assert on exact status codes, headers and
7+
bodies — the things real clients (Codex's rmcp, Anthropic's MCP client) are
8+
strict about and that a lenient `httpx.post(...).json()` would silently hide.
9+
"""
10+
11+
import httpx
12+
13+
PROTOCOL_VERSION = "2025-06-18"
14+
SUPPORTED_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
15+
16+
17+
def rpc(url, method, params=None, id=1, protocol_version=PROTOCOL_VERSION,
18+
accept="application/json, text/event-stream", timeout=30):
19+
"""POST a single JSON-RPC message; return the raw httpx.Response (never parsed)."""
20+
body = {"jsonrpc": "2.0", "method": method}
21+
if id is not None:
22+
body["id"] = id
23+
if params is not None:
24+
body["params"] = params
25+
headers = {"Content-Type": "application/json", "Accept": accept}
26+
# Per the spec the MCP-Protocol-Version header is sent on requests *after*
27+
# initialization, not on `initialize` itself.
28+
if protocol_version is not None and method != "initialize":
29+
headers["MCP-Protocol-Version"] = protocol_version
30+
return httpx.post(url, json=body, headers=headers, timeout=timeout)
31+
32+
33+
def http_get(url, accept="text/event-stream", timeout=10):
34+
"""GET the MCP endpoint (clients use this to open the server->client SSE stream)."""
35+
return httpx.get(url, headers={"Accept": accept}, timeout=timeout)
36+
37+
38+
def handshake(url, protocol_version=PROTOCOL_VERSION):
39+
"""Run the client half of the MCP handshake; return the InitializeResult dict.
40+
41+
initialize -> (read result) -> notifications/initialized
42+
"""
43+
resp = rpc(url, "initialize", {
44+
"protocolVersion": protocol_version,
45+
"capabilities": {},
46+
"clientInfo": {"name": "echidna-mcp-tests", "version": "0"},
47+
}, id=0, protocol_version=None)
48+
resp.raise_for_status()
49+
result = resp.json()["result"]
50+
negotiated = result.get("protocolVersion", protocol_version)
51+
rpc(url, "notifications/initialized", id=None, protocol_version=negotiated)
52+
return result
53+
54+
55+
def call_tool(url, name, arguments=None, protocol_version=PROTOCOL_VERSION):
56+
"""tools/call helper that returns the parsed JSON-RPC `result`."""
57+
resp = rpc(url, "tools/call",
58+
{"name": name, "arguments": arguments or {}},
59+
id=2, protocol_version=protocol_version)
60+
resp.raise_for_status()
61+
return resp.json().get("result", {})

tests/mcp/conftest.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Shared pytest fixtures for the out-of-process MCP test suite.
3+
4+
A single session-scoped Echidna campaign (with the MCP server enabled) is
5+
started once and reused by every test module: the tool/semantic tests
6+
(test_mcp.py), the wire-protocol conformance tests (test_mcp_conformance.py),
7+
and the client-simulation tests (test_mcp_claude.py, test_mcp_codex.py).
8+
9+
The Echidna launch/wait logic was extracted from the original test_mcp.py
10+
contributed by Dani Tradito (@datradito) in crytic/echidna#1509.
11+
"""
12+
13+
import os
14+
import subprocess
15+
import time
16+
17+
import pytest
18+
19+
from _mcp_client import PROTOCOL_VERSION, rpc
20+
21+
MCP_PORT = int(os.environ.get("ECHIDNA_MCP_PORT", "8080"))
22+
MCP_URL = f"http://127.0.0.1:{MCP_PORT}/mcp"
23+
ECHIDNA_BIN = os.environ.get("ECHIDNA_BIN", "echidna")
24+
CONTRACT = os.path.join(os.path.dirname(__file__), "contracts", "EchidnaMCPTest.sol")
25+
26+
27+
def _server_ready() -> bool:
28+
"""True once the MCP server answers a real `initialize` request."""
29+
try:
30+
resp = rpc(MCP_URL, "initialize", {
31+
"protocolVersion": PROTOCOL_VERSION,
32+
"capabilities": {},
33+
"clientInfo": {"name": "readiness-probe", "version": "0"},
34+
}, id=0, protocol_version=None, timeout=2)
35+
return resp.status_code == 200 and "result" in resp.json()
36+
except Exception:
37+
return False
38+
39+
40+
@pytest.fixture(scope="session")
41+
def echidna_server(tmp_path_factory):
42+
"""Start an Echidna campaign with the MCP server for the whole test session.
43+
44+
If ECHIDNA_MCP_URL is set, use that already-running server instead of
45+
starting one (handy for pointing the suite at a live campaign or, in CI, a
46+
deliberately broken server to prove the conformance tests have teeth).
47+
"""
48+
external = os.environ.get("ECHIDNA_MCP_URL")
49+
if external:
50+
yield {"url": external, "port": None, "protocol_version": PROTOCOL_VERSION}
51+
return
52+
53+
corpus_dir = tmp_path_factory.mktemp("corpus")
54+
55+
env = os.environ.copy()
56+
env["PATH"] = os.path.expanduser("~/.local/bin") + os.pathsep + env.get("PATH", "")
57+
# Echidna shells out to solc/crytic-compile; an active VIRTUAL_ENV breaks that.
58+
env.pop("VIRTUAL_ENV", None)
59+
60+
cmd = [
61+
ECHIDNA_BIN, CONTRACT,
62+
"--contract", "EchidnaMCPTest",
63+
"--server", str(MCP_PORT),
64+
"--format", "text",
65+
"--test-limit", "1000000000",
66+
"--corpus-dir", str(corpus_dir),
67+
]
68+
proc = subprocess.Popen(
69+
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env
70+
)
71+
72+
# Wait (compiling the contract can take a while) for the server to accept a
73+
# real MCP initialize, not merely an open socket.
74+
deadline = time.time() + 120
75+
while time.time() < deadline:
76+
if proc.poll() is not None:
77+
out, err = proc.communicate()
78+
raise RuntimeError(f"Echidna exited early.\nstdout:\n{out}\nstderr:\n{err}")
79+
if _server_ready():
80+
break
81+
time.sleep(0.5)
82+
else:
83+
proc.terminate()
84+
out, err = proc.communicate(timeout=5)
85+
raise RuntimeError(f"MCP server did not become ready.\nstdout:\n{out}\nstderr:\n{err}")
86+
87+
yield {"url": MCP_URL, "port": MCP_PORT, "protocol_version": PROTOCOL_VERSION}
88+
89+
proc.terminate()
90+
try:
91+
proc.communicate(timeout=5)
92+
except subprocess.TimeoutExpired:
93+
proc.kill()
94+
proc.wait()

tests/mcp/pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
# The Claude client test (test_mcp_claude.py) uses the async `mcp` SDK.
3+
asyncio_mode = auto

tests/mcp/requirements-test.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Dependencies for the out-of-process MCP test suite (tests/mcp/).
2+
#
3+
# Kept separate from requirements.txt (the heavier LangGraph/Claude agent demo
4+
# deps) so CI installs only what the tests need.
5+
6+
httpx>=0.27,<0.29
7+
pytest>=8.0,<9.0
8+
pytest-asyncio>=0.24,<0.26
9+
10+
# Official Model Context Protocol SDK -- used as the reference "Claude-family"
11+
# client in test_mcp_claude.py (transport only; no API key required).
12+
mcp>=1.2,<2

tests/mcp/test_mcp.py

Lines changed: 17 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,26 @@
11
"""
2-
Basic integration tests for the Echidna MCP server.
2+
Tool/semantic integration tests for the Echidna MCP server.
33
4-
Tests the core workflow: inject transactions, check coverage,
5-
reset priorities, and verify status.
4+
Verifies the core MCP tools return sensible results against a live Echidna
5+
campaign: status, inject_fuzz_transactions, show_coverage, clear_fuzz_priorities.
6+
7+
Originally contributed by Dani Tradito (@datradito) in crytic/echidna#1509;
8+
adapted here to share the session-scoped `echidna_server` fixture in conftest.py.
69
710
Run with:
811
pytest tests/mcp/test_mcp.py
912
"""
1013

11-
import pytest
12-
import socket
13-
import time
14-
import os
15-
import subprocess
1614
import httpx
1715

1816

19-
MCP_PORT = 8080
20-
MCP_URL = f"http://localhost:{MCP_PORT}/mcp"
21-
CONTRACT = "tests/mcp/contracts/EchidnaMCPTest.sol"
22-
23-
24-
# ---------------------------------------------------------------------------
25-
# Fixtures
26-
# ---------------------------------------------------------------------------
27-
28-
def _call_tool(tool: str, args: dict = None) -> dict:
17+
def _call_tool(url: str, tool: str, args: dict = None) -> dict:
2918
payload = {
3019
"jsonrpc": "2.0", "id": 1,
3120
"method": "tools/call",
3221
"params": {"name": tool, "arguments": args or {}},
3322
}
34-
resp = httpx.post(MCP_URL, json=payload, timeout=30)
23+
resp = httpx.post(url, json=payload, timeout=30)
3524
resp.raise_for_status()
3625
return resp.json().get("result", {})
3726

@@ -40,66 +29,19 @@ def _text(result: dict) -> str:
4029
return result.get("content", [{}])[0].get("text", "")
4130

4231

43-
@pytest.fixture(scope="module")
44-
def echidna(tmp_path_factory):
45-
"""Start an Echidna campaign with the MCP server for the whole test module."""
46-
corpus_dir = tmp_path_factory.mktemp("corpus")
47-
env = os.environ.copy()
48-
env["PATH"] = os.path.expanduser("~/.local/bin") + ":" + env.get("PATH", "")
49-
50-
cmd = [
51-
"echidna", CONTRACT,
52-
"--contract", "EchidnaMCPTest",
53-
"--server", str(MCP_PORT),
54-
"--format", "text",
55-
"--test-limit", "1000000000",
56-
"--corpus-dir", str(corpus_dir),
57-
]
58-
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
59-
text=True, env=env)
60-
61-
# Wait up to 10 s for the server to accept connections
62-
deadline = time.time() + 10
63-
while time.time() < deadline:
64-
try:
65-
with socket.create_connection(("localhost", MCP_PORT), timeout=1):
66-
break
67-
except OSError:
68-
time.sleep(0.5)
69-
else:
70-
proc.terminate()
71-
out, err = proc.communicate(timeout=5)
72-
raise RuntimeError(
73-
f"Echidna MCP server did not start.\nstdout: {out}\nstderr: {err}"
74-
)
75-
76-
yield proc
77-
78-
proc.terminate()
79-
try:
80-
proc.communicate(timeout=5)
81-
except subprocess.TimeoutExpired:
82-
proc.kill()
83-
proc.wait()
84-
85-
86-
# ---------------------------------------------------------------------------
87-
# Tests
88-
# ---------------------------------------------------------------------------
89-
90-
def test_status(echidna):
32+
def test_status(echidna_server):
9133
"""status tool returns campaign metrics."""
92-
result = _call_tool("status")
34+
result = _call_tool(echidna_server["url"], "status")
9335
text = _text(result)
9436
assert text, "status returned empty response"
9537
assert "corpus" in text.lower() or "coverage" in text.lower(), (
9638
f"Unexpected status text: {text[:200]}"
9739
)
9840

9941

100-
def test_inject_transactions(echidna):
42+
def test_inject_transactions(echidna_server):
10143
"""inject_fuzz_transactions accepts a call sequence and confirms injection."""
102-
result = _call_tool("inject_fuzz_transactions", {
44+
result = _call_tool(echidna_server["url"], "inject_fuzz_transactions", {
10345
"transactions": (
10446
"transfer(0x1111111111111111111111111111111111111111, 100);"
10547
"approve(0x2222222222222222222222222222222222222222, 50)"
@@ -112,23 +54,23 @@ def test_inject_transactions(echidna):
11254
)
11355

11456

115-
def test_show_coverage(echidna):
57+
def test_show_coverage(echidna_server):
11658
"""show_coverage returns a non-empty coverage report."""
117-
result = _call_tool("show_coverage")
59+
result = _call_tool(echidna_server["url"], "show_coverage")
11860
text = _text(result)
11961
assert isinstance(text, str), "show_coverage response is not a string"
12062
assert len(text) > 0, "show_coverage returned empty report"
12163

12264

123-
def test_clear_priorities_and_status(echidna):
65+
def test_clear_priorities_and_status(echidna_server):
12466
"""clear_fuzz_priorities succeeds, then status is still reachable."""
125-
clear = _call_tool("clear_fuzz_priorities")
67+
clear = _call_tool(echidna_server["url"], "clear_fuzz_priorities")
12668
clear_text = _text(clear)
12769
assert clear_text, "clear_fuzz_priorities returned empty response"
12870
assert "requested" in clear_text.lower() or "clear" in clear_text.lower(), (
12971
f"Unexpected clear response: {clear_text[:200]}"
13072
)
13173

13274
# Status should still work after clearing
133-
status = _call_tool("status")
75+
status = _call_tool(echidna_server["url"], "status")
13476
assert _text(status), "status failed after clear_fuzz_priorities"

0 commit comments

Comments
 (0)