Out-of-process tests for the MCP server, with CI and an example agent - #1608
Open
elopez wants to merge 1 commit into
Open
Out-of-process tests for the MCP server, with CI and an example agent#1608elopez wants to merge 1 commit into
elopez wants to merge 1 commit into
Conversation
The MCP server is the one part of Echidna that another program talks to, and the Haskell suite cannot say whether that conversation works: it runs in process, while what matters here is what goes over the wire and what the tools answer about a campaign that is actually fuzzing. So this suite starts one. conftest.py launches a real echidna against a fixture contract and waits for the server to accept an MCP initialize rather than merely for the socket to open, then hands the same campaign to every module. Four layers ride on it: test_mcp.py covers the nine tools -- what each one reports about a live campaign, and what it says when it cannot answer. status while the campaign fuzzes, coverage marked up line by line, an LCOV file that is really on disk, an injected sequence that the workers keep fuzzing through, a replayed sequence reported call by call including the revert reason, a sampled function whose counts show up in status a few seconds later. Anything that steers the campaign is undone afterwards, so no test depends on the order it ran in. test_mcp_conformance.py is the regression guard for "real clients can still connect". A notification gets 202 with no body; a GET asking for a server stream gets 405 rather than the JSON blob a strict client tries to read as SSE; initialize negotiates a version and names the server. These are exactly the two behaviours mcp-server 0.2.0.1 fixed, and the reason that version is the floor. test_mcp_codex.py replays Codex's rmcp handshake step by step, and test_mcp_claude.py drives the server with the reference `mcp` SDK through a real session. Both are transport-level and need no API key, so both run in CI; a live-model run is the LangGraph example instead. That example is here too. It reads status, and when the campaign has gone a minute without finding coverage it hands Claude the ABI and the coverage report and injects the sequences that come back -- a demonstration that those tools are enough to close the loop, not a tuned strategy. Some notes on what differs from the version this is adapted from: The tool tests are written against the server as it is rather than the prototype: status answers JSON, show_coverage takes a contract, and a tool that cannot do what it was asked answers with an MCP error result. They assert on all three, where the originals asserted that some substring appeared somewhere in the text. They also go through the shared wire helpers after a handshake, rather than a second raw client that posted tools/call with no handshake and no headers -- asserting leniency next to a suite that asserts strictness said two different things about what the server promises. The `mcp` SDK's 2.0 renamed its transport helper, dropped the third stream it used to yield, and moved its result models to snake_case. Rather than straddle both majors, requirements-test.txt follows the current one and the test skips itself on an older SDK. The other pins are bounded by major version only, since a compatibility suite should be running against what clients actually ship. The fixture contracts lost their spec-kit headers, two properties that could not fail (one returned a literal `true`), and the `test` prefix on their entry points, which means something else in Foundry mode. What is left is a contract that gives the tools something to report on: calls that succeed, calls that revert with a reason, and properties that hold so the campaign keeps running for as long as the session needs it. The workflow now triggers on master and on pull requests against it, like every other workflow here, and only for paths that can change what the server answers -- it builds the whole closure and then fuzzes, which is not something to do on every push to every branch. Its Cachix token is unavailable to pull requests from forks, so those runs read the public cache and push nothing; that is noted where it will be read. tests/mcp/requirements.txt is gone: nothing referenced it, and it contradicted both its sibling and the example's README on what to install. So is pytest.ini, which was only read when pytest was given the directory as an argument -- the one async test carries an explicit marker instead, which works either way. Co-authored-by: Dani Tradito <datradito@gmail.com> Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The MCP server is the one part of Echidna that another program talks to, and
the Haskell suite cannot say whether that conversation works: it runs in
process, while what matters here is what goes over the wire and what the tools
answer about a campaign that is actually fuzzing. So this suite starts one.
tests/mcp/conftest.pylaunches a realechidnaagainst a fixture contract,waits for the server to accept an MCP
initializerather than merely for thesocket to open, and hands the same campaign to every module. Four layers ride
on it:
test_mcp.py— the nine tools: what each reports about a live campaignand what it says when it cannot answer.
statuswhile the campaign fuzzes,coverage marked up line by line, an LCOV file that is really on disk, an
injected sequence the workers keep fuzzing through, a replayed sequence
reported call by call including the revert reason, a sampled function whose
counts appear in
statusa few seconds later. Anything that steers thecampaign is undone afterwards, so no test depends on the order it ran in.
test_mcp_conformance.py— the regression guard for "real clients canstill connect": a notification gets 202 with no body, a
GETasking for aserver stream gets 405 rather than the JSON blob a strict client tries to
read as SSE,
initializenegotiates a version. These are exactly the twobehaviours
mcp-server0.2.0.1 fixed, and the reason that version is thefloor.
test_mcp_codex.py— replays Codex'srmcphandshake step by step.test_mcp_claude.py— drives the server with the referencemcpSDKthrough a real session.
Both client checks are transport-level and need no API key, so both run in CI.
examples/mcp_agent.pyis the live-model counterpart, and is not in CI: itreads
status, and when the campaign has gone a minute without findingcoverage it hands Claude the ABI and the coverage report and injects the
sequences that come back. A demonstration that those tools are enough to close
the loop, not a tuned strategy.
Running it
ECHIDNA_BINpicks the binary,ECHIDNA_MCP_PORTthe port, andECHIDNA_MCP_URLpoints the transport suites at a campaign you startedyourself. Also documented in the README, which previously said nothing about
this suite — the workflow was the only entry point.
Adapted, not cherry-picked
The tool tests are written against the server as it is rather than the
prototype they come from:
statusanswers JSON,show_coveragetakes acontract, and a tool that cannot do what it was asked answers with an MCPerror result. They assert on all three, where the originals asserted that some
substring appeared somewhere in the text. They also go through the shared wire
helpers after a handshake, rather than a second raw client that posted
tools/callwith no handshake and no headers — asserting leniency next to asuite that asserts strictness said two different things about what the server
promises.
The fixture contracts lost their spec-kit headers, two properties that could
not fail (one returned a literal
true), and thetestprefix on their entrypoints, which means something else in Foundry mode. What is left is a contract
that gives the tools something to report on: calls that succeed, calls that
revert with a reason, and properties that hold so the campaign keeps running
for as long as the session needs it.
tests/mcp/requirements.txtis gone: nothing referenced it, and itcontradicted both its sibling and the example's README on what to install. So
is
pytest.ini, which was only read when pytest was given the directory as anargument — the one async test carries an explicit marker instead, which works
either way.
Worth a look
mcpSDK's 2.0 is a breaking change for this test. It renamed thetransport helper, dropped the third stream it used to yield, and moved its
result models to snake_case (
serverInfo→server_info). Rather thanstraddle both majors,
requirements-test.txtfollows the current one and thetest skips itself on an older SDK. The other pins are bounded by major
version only — a compatibility suite should be running against what clients
actually ship, not against whatever was current when it was written.
triggers on
masterand on pull requests against it, like every otherworkflow here, and only for paths that can change what the server answers.
Its Cachix token is unavailable to pull requests from forks, so those runs
read the public cache and push nothing; a miss makes them slow rather than
red. That is noted in the workflow where it will be read.
Verification
pytest tests/mcp -v— 24 passed, against a campaign the fixture startsitself. The example was driven against a live campaign as well: handshake,
status,target,show_coverage, the error path, and the injection pathwith the model stubbed out.