Replace the Server-Sent Events server with an MCP server - #1607
Open
elopez wants to merge 2 commits into
Open
Conversation
Something driving a campaign from outside it needs a way to name a
sequence of calls, and the natural one is how the calls would be written
in Solidity, with a hole wherever the fuzzer should choose:
approve(0x10, ?); transferFrom(?, ?, 100)
Echidna.MCP.Parse reads that into the list of SolCallPrototypes the
preceding commits taught the fuzzer to generate from. Parsing stops at an
argument's shape rather than its type -- an integer becomes a uint256, an
0x-prefixed literal an address -- because an argument left open carries no
type to compare against anyway, so a prototype is resolved against the
ABI by name and arity either way.
It is a module of its own rather than part of the server that will use
it. This is the one piece of the interface with a set of edge cases worth
enumerating, and Tests.MCPParse enumerates them without needing a
campaign to run against.
Three things differ from the version this is adapted from. `foo(1` no
longer parses as a call with no arguments: the closing parenthesis is
checked rather than assumed, which is what that silently relied on. An
empty sequence is rejected instead of accepted as a sequence of no calls.
And splitting on `;` is Data.List.Split's job rather than a hand-rolled
splitter's -- the bracket-aware split of an argument list still needs its
own, since a comma inside `[1,2]` does not separate arguments.
Nothing parses a sequence yet; the MCP server that does arrives next.
Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
--server PORT has run a Server-Sent Events server: a one-way stream of campaign events, enough to watch a campaign with but not to ask it anything. Replace it with a Model Context Protocol server on the same flag and the same port, so what is on the other end can both read the campaign and steer it. Nine tools. Four report: status (corpus size, iterations, coverage, tests, optimization values, how long since the last coverage, the functions that found it, and whatever is being sampled), target, show_coverage and dump_lcov. Five act: inject_fuzz_transactions and clear_fuzz_priorities aim the fuzzer at an ordering, sample starts and stops recording what a function does, execute_sequence replays a concrete sequence without disturbing the campaign, and reload_corpus picks up whatever was written to the corpus directory since it started. The five that act do so over the inter-worker bus, which is why this comes last: the commands they send, the sampling they switch on and the replay they ask for all landed in the preceding commits with nothing to send them. AgentId gains ServerId to say where they come from. Retiring SSE is the point of the commit rather than a side effect of it. Echidna.Server had no other caller, so it goes, and wai-extra with it. The "Waiting until all SSE are received..." drain at the end of a non-interactive run goes too, and with no reader left the MVar the SIGINT handler filled goes as well. --server and the `server` config key keep their names and their meaning -- a port to serve the campaign on -- so there is no point at which the flag does nothing. On the dependency: mcp-server comes from Hackage at 0.2.0.1, not from the personal fork this was prototyped against. Upstream has since released the two fixes that fork existed for (202 with no body for a notification, 405 for a server-stream GET), so there is no reason to carry a fork of a library in the closure of a security tool. It needs http-types >= 0.12.6, which nixpkgs and lts-23.24 both predate: only that version re-exports `hOrigin` from the umbrella Network.HTTP.Types module that mcp-server imports unqualified, and http-types has to move for the whole package set rather than for mcp-server alone, since wai and warp exchange its Status type with it. Everything in the closure builds against 0.12.6. Some notes on the shape of it: The server runs mcpApplication under Warp directly instead of runMcpServerHttpWithConfig, which announces itself with a bare putStrLn. Going through a ServerLog event instead keeps the line timestamped and prefixed like every other one, and leaves Warp's settings to us -- which matters for binding to loopback. Origin validation is on with an empty allow-list. The campaign is reachable from a browser on the same machine, and a page must not be able to drive a fuzzer through DNS rebinding; a client that sends no Origin at all, which is every CLI agent, is unaffected. A tool that cannot do what it was asked answers with an MCP error result rather than with a report whose text begins "Error:", so a model does not have to read prose to notice. That is what the Either in ToolRun is. reload_corpus reads the same two directories the campaign read at startup, through loadInitialCorpus, rather than the corpus directory itself -- what is directly in there is those directories, and reading one as a file is an exception. It decides what is new and adds it inside a single atomicModifyIORef', because workers are adding to the corpus while it runs and a read followed by a write would drop whatever arrived in between. Sequences from disk take the weight of the best entry already there, never zero, which selectFromCorpus would never draw. The upstream version's Env.sourceCache is not here: DappInfo already carries the source cache, so env.dapp.sources is the same thing without a second copy in Env. Its DumpLcov bus command is not here either -- the tool calls saveLcovSnapshot directly, and a command with no sender is what the earlier commits have been leaving out. Its markLines change is not here: dropping the line number and column separator to save an agent's tokens would have regressed every text and HTML coverage report Echidna writes, and an agent that cannot cite a line number is worse off than one reading a few more tokens. The recently-covered functions are kept with takeStrict, not take. They are appended to on every coverage event and read only when a client asks, so a lazy tail would retain every sequence the campaign ever found coverage with -- the same trap the sampling state was written around. One limitation worth knowing: execute_sequence sends every call to the contract under test, so a sequence naming a function of some other deployed contract goes to the wrong address. The sequence syntax has no way to spell an address, so there is nothing to fix it with yet. Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
elopez
force-pushed
the
feat/mcp-server
branch
from
August 14, 2026 22:27
fc9af38 to
6a460fc
Compare
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.
Sixth rung of landing #1502 in reviewable pieces, after #1596 (campaign
decomposition) and #1603 (
Agentsum type). Stacked onfeat/sequence-replay— review that first; the diff below is only the two commits on top of it.
The preceding rungs built an inter-worker bus and taught a fuzzing worker three
things to do with what arrives on it: enable sampling, fuzz an injected call
ordering, and replay a concrete sequence. None of them had a sender. This adds
the sender.
What changes for users
--server PORTstops serving Server-Sent Events and starts serving MCP athttp://127.0.0.1:PORT/mcp. The flag and theserverconfig key keep theirnames and their meaning — a port to serve the campaign on — so there is no
point, in any commit, at which the flag does nothing.
Nine tools. Four report:
statustargetshow_coveragedump_lcovFive act, over the bus:
inject_fuzz_transactionsclear_fuzz_prioritiessampleexecute_sequencereload_corpusAnything consuming the SSE stream has to move to the
statustool. The eventsthemselves are unchanged in the text, JSON and UI outputs. There is a CHANGELOG
entry, since SSE was never in the README and the changelog is the only place
users will see this.
Retiring SSE is the point, not a side effect
On
dev-agents-3the swap happens by omission —UI.hsdrops the import andrunSSEServeris left in the tree with no callers. Here it is deliberate:Echidna.Serveris deleted,wai-extragoes with it, the"Waiting until all SSE are received..."drain goes, and the MVar the SIGINThandler filled goes too once nothing reads it.
The dependency is upstream, not a fork
The prototype pinned
mcp-servertogustavo-grieco/haskell-mcp-server@3bd3cdcin both
flake.nixandstack.yaml. That rev exists for two Streamable HTTPconformance fixes —
202with no body for a notification,405for aserver-stream GET — and upstream has since shipped both in 0.2.0.1 on
Hackage, so there is no reason to carry a personal fork of a library in the
closure of a security tool. No
fetchFromGitHub.The cost is one extra pin: 0.2.0.1 needs
http-types >= 0.12.6, which bothnixpkgs and
lts-23.24predate. Only 0.12.6 re-exportshOriginfrom theumbrella
Network.HTTP.Typesthat mcp-server imports unqualified, andhttp-typeshas to move for the whole package set rather than for mcp-serveralone, because wai and warp exchange its
Statustype with it. Everything inthe closure builds against 0.12.6 — but this does mean CI rebuilds that
closure once. The
flake.nixoverride follows the same pattern as theexisting
tlspin, with the reasoning in a comment.Review guide
lib/Echidna/MCP.hsis the bulk. A few decisions in it that are worth a lookrather than a skim:
mcpApplicationunder Warp directly, notrunMcpServerHttpWithConfig,which announces itself with a bare
putStrLn. Going through aServerLogevent keeps the line timestamped and prefixed like every other one, and
leaves Warp's settings to us — which is what binds it to loopback.
browser-reachable on localhost, and a page must not be able to drive a fuzzer
through DNS rebinding. A client that sends no
Origin— every CLI agent — isunaffected.
report whose text begins
"Error:", so a model doesn't have to read prose tonotice. That's the
EitherinToolRun.reload_corpusdecides what's new and adds it in oneatomicModifyIORef'.Workers append to the corpus while it runs, so a read followed by a write
would drop whatever arrived in between. Sequences from disk take the weight of
the best entry already there, never zero — which
selectFromCorpuswouldnever draw.
recentFunctionsusestakeStrict, nottake. Appended on every coverageevent, read only when a client asks; a lazy tail would retain every sequence
the campaign ever found coverage with. Same trap the sampling state was
written around.
Three things from the prototype are deliberately not here:
Env.sourceCache—DappInfoalready carries the source cache, soenv.dapp.sourcesis the same thing without a second copy inEnv.DumpLcovbus command — it has no sender even upstream (the tool callssaveLcovSnapshotdirectly), so it falls under the same rule the earlierrungs used.
markLineschange — dropping the line number and column separator tosave an agent's tokens would have regressed every text and HTML coverage
report Echidna writes, and an agent that can't cite a line number is worse
off than one reading a few more tokens.
Verification
cabal buildclean,hlint lib srcclean for every file touched (the oneremaining hint is pre-existing in untouched
SourceMapping.hs), all 234 testspass.
Beyond that, I drove a real 4-worker campaign over HTTP and exercised all nine
tools, happy path and error paths:
Confirmed: the
[Server]banner appears;tools/listreports 9 tools with theright required arguments; sampling turns on and shows up in
statuswith areturn-value range;
execute_sequenceclassifies completed vs reverted, returnsa trace tree when asked, and leaves coverage and the corpus alone;
inject_fuzz_transactionsdoesn't stall the campaign;dump_lcovwrites avalid file;
reload_corpusadds a hand-written sequence and is idempotent.Transport:
202with an empty body for a notification,405for atext/event-streamGET,403for any request carrying anOrigin. A runwithout
--serverexits normally now the drain is gone, and SIGINT stillstops a campaign with the server up.
Two things came out of driving it:
reload_corpusreturned HTTP 500. It pointedloadTxsat the corpusdirectory itself, whose contents are the
coverage/andreproducers/subdirectories —
BS.readFileon a directory throws. It now goes throughloadInitialCorpus, the same path the campaign reads at startup. Fixed here.tracefield turned out to be a false alarm — my probe sequenceended on a call that emits nothing — but the existing assertion in
Tests/Replay.hswould not have caught a real one: it checksisJust, andJust ""satisfies that. Worth tightening; not in this PR.Known limitations
execute_sequencesends every call tocontractAddr, so a sequence naming afunction on some other deployed contract goes to the wrong address. The
sequence syntax has no way to spell an address, and the bus command is already
fixed as
[Tx], so there is nothing to fix it with yet.arity only, same as a fuzzed prototype. Documented in
Echidna.MCP.Parse.