Skip to content

Replace the Server-Sent Events server with an MCP server - #1607

Open
elopez wants to merge 9 commits into
feat/sequence-replayfrom
feat/mcp-server
Open

Replace the Server-Sent Events server with an MCP server#1607
elopez wants to merge 9 commits into
feat/sequence-replayfrom
feat/mcp-server

Conversation

@elopez

@elopez elopez commented Aug 14, 2026

Copy link
Copy Markdown
Member

Sixth rung of landing #1502 in reviewable pieces, after #1596 (campaign
decomposition) and #1603 (Agent sum type). Stacked on feat/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 PORT stops serving Server-Sent Events and starts serving MCP at
http://127.0.0.1:PORT/mcp. The flag and the server config key keep their
names 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:

tool answers
status corpus size, iterations, coverage, tests, optimization values, how long since the last coverage, which functions found it, and whatever is being sampled — as JSON
target the contract under test and its canonical signatures
show_coverage one contract's source, marked up line by line
dump_lcov writes a timestamped LCOV file, returns its path

Five act, over the bus:

tool does
inject_fuzz_transactions spends part of every worker's budget on a specific call ordering
clear_fuzz_priorities undoes the above
sample starts/stops recording what one function returns and how often it reverts
execute_sequence replays a concrete sequence and reports on it, leaving the campaign untouched
reload_corpus picks up whatever was written to the corpus directory since startup

Anything consuming the SSE stream has to move to the status tool. The events
themselves 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-3 the swap happens by omission — UI.hs drops the import and
runSSEServer is left in the tree with no callers. Here it is deliberate:
Echidna.Server is deleted, wai-extra goes with it, the
"Waiting until all SSE are received..." drain goes, and the MVar the SIGINT
handler filled goes too once nothing reads it.

The dependency is upstream, not a fork

The prototype pinned mcp-server to gustavo-grieco/haskell-mcp-server@3bd3cdc
in both flake.nix and stack.yaml. That rev exists for two Streamable HTTP
conformance fixes — 202 with no body for a notification, 405 for a
server-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 both
nixpkgs and lts-23.24 predate. Only 0.12.6 re-exports hOrigin from the
umbrella Network.HTTP.Types that mcp-server imports unqualified, and
http-types has to move for the whole package set rather than for mcp-server
alone, because wai and warp exchange its Status type with it. Everything in
the closure builds against 0.12.6 — but this does mean CI rebuilds that
closure once.
The flake.nix override follows the same pattern as the
existing tls pin, with the reasoning in a comment.

Review guide

lib/Echidna/MCP.hs is the bulk. A few decisions in it that are worth a look
rather than a skim:

  • mcpApplication under Warp directly, not runMcpServerHttpWithConfig,
    which announces itself with a bare putStrLn. Going through a ServerLog
    event keeps the line timestamped and prefixed like every other one, and
    leaves Warp's settings to us — which is what binds it to loopback.
  • Origin validation on with an empty allow-list. The port is
    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 — is
    unaffected.
  • A tool that can't do what it was asked returns an MCP error result, not a
    report whose text begins "Error:", so a model doesn't have to read prose to
    notice. That's the Either in ToolRun.
  • reload_corpus decides what's new and adds it in one atomicModifyIORef'.
    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 selectFromCorpus would
    never draw.
  • recentFunctions uses takeStrict, not take. Appended on every coverage
    event, 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.sourceCacheDappInfo already carries the source cache, so
    env.dapp.sources is the same thing without a second copy in Env.
  • The DumpLcov bus command — it has no sender even upstream (the tool calls
    saveLcovSnapshot directly), so it falls under the same rule the earlier
    rungs used.
  • The markLines change — 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 can't cite a line number is worse
    off than one reading a few more tokens.

Verification

cabal build clean, hlint lib src clean for every file touched (the one
remaining hint is pre-existing in untouched SourceMapping.hs), all 234 tests
pass.

Beyond that, I drove a real 4-worker campaign over HTTP and exercised all nine
tools, happy path and error paths:

echidna Vault.sol --contract Vault --config config.yaml --server 8123 --format text
# then: initialize, tools/list, and every tool over JSON-RPC

Confirmed: the [Server] banner appears; tools/list reports 9 tools with the
right required arguments; sampling turns on and shows up in status with a
return-value range; execute_sequence classifies completed vs reverted, returns
a trace tree when asked, and leaves coverage and the corpus alone;
inject_fuzz_transactions doesn't stall the campaign; dump_lcov writes a
valid file; reload_corpus adds a hand-written sequence and is idempotent.
Transport: 202 with an empty body for a notification, 405 for a
text/event-stream GET, 403 for any request carrying an Origin. A run
without --server exits normally now the drain is gone, and SIGINT still
stops a campaign with the server up.

Two things came out of driving it:

  • reload_corpus returned HTTP 500. It pointed loadTxs at the corpus
    directory itself, whose contents are the coverage/ and reproducers/
    subdirectories — BS.readFile on a directory throws. It now goes through
    loadInitialCorpus, the same path the campaign reads at startup. Fixed here.
  • An empty trace field turned out to be a false alarm — my probe sequence
    ended on a call that emits nothing — but the existing assertion in
    Tests/Replay.hs would not have caught a real one: it checks isJust, and
    Just "" satisfies that. Worth tightening; not in this PR.

Known limitations

  • execute_sequence sends every call to contractAddr, so a sequence naming a
    function 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.
  • Nothing checks argument types — a call is matched against the ABI by name and
    arity only, same as a fuzzed prototype. Documented in Echidna.MCP.Parse.

elopez and others added 6 commits August 14, 2026 15:36
A worker can now be asked to watch a handful of functions and keep, for
each of them, how often it was called, how often the call did not return
successfully, the range its return value spanned, and a short tail of
revert summaries. This is what an outside observer needs to answer "is
this function reachable, and what does it do when it is" without reading
the corpus.

The state hangs off WorkerState.sampledFunctions, keyed by canonical
signature. Nothing populates that map yet -- the command that enables
sampling arrives with the inter-worker bus -- so callseq's cost is a
Map.null check per sequence, and the per-call work only happens for
functions someone asked about.

The interesting part is the per-event algebra, so it is factored out of
the worker: applySampleEvent folds one call result into a SampleStats,
abiCompare gives the partial ordering the range tracking needs, and
mergeSampleStats combines two workers' views. All three are pure and
covered by Tests.Sample; updateSampleStats in Worker/Sequence.hs is left
with just the decode-and-dispatch.

Every SampleStats field is strict, and the revert list is built with a
take that forces its spine and elements. These are updated on the fuzzing
hot path and only read when a client asks, so a lazy field would quietly
retain the entire call history: a plain `take n (new : old)` keeps `old`
alive through its unevaluated tail, and `old` keeps the one before it.

Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
Workers have so far been able to communicate in exactly one direction:
each pushes events onto the campaign's event queue, which the UI and the
listener read. Nothing can talk back to a running worker, and a worker
cannot tell another one what it just learned.

Add a bus to Env alongside the event queue: a broadcast STM channel, so
readers take their own dupTChan of it and every reader sees every
message rather than racing for it. Echidna.Types.InterWorker holds the
protocol -- a message, its sender, and the commands a fuzzing worker
accepts.

Two things use it. callseq broadcasts NewCoverageInfo whenever a
sequence finds coverage, carrying the sequence and whether it came from
replaying the corpus, which is the new argument threaded through
callseq's callers. And a fuzzing worker checks the bus once per loop
iteration for commands addressed to it, which is how sampling gets
turned on and off.

The two commands land here rather than with a sender because they are
what makes the bus worth having; the client that issues them, and the
listener for NewCoverageInfo, arrive with the MCP server. Everything
else in the upstream protocol -- symbolic commands, request/response,
the other broadcasts -- has neither a sender nor a reader, so it is left
out until it has both.

The command handler lives in its own module rather than in another
where-block inside runFuzzWorker, which is the shape the preceding
decomposition was aiming for and the natural home for the handlers that
follow.

Two details worth calling out:

checkMessages drains its queue rather than taking one message per
iteration. Every broadcast is copied to every worker's duplicate, so
with N workers finding coverage a worker receives roughly N messages per
iteration; consuming one would let the queue grow without bound during
the early phase of a campaign, when almost every sequence is new
coverage. Commands come from outside the campaign at human pace, so
there is nothing to starve fuzzing with.

The broadcast's sender is resolved through workerIDToType, the same way
pushWorkerEvent decides which worker type to attribute an event to. The
symbolic worker calls callseq too, and it is not a fuzzer.

Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
genTx does three things in sequence: resolve the deployed contracts to
the ABIs Echidna knows how to call, pick one of them and generate a call
to it, then wrap that call in a transaction with a random sender, value
and delay. Only the middle step is about choosing what to call; the two
ends are the same for any generator that has already decided.

Split them into callableContracts, genRandomCall and toTx, leaving genTx
as the composition of the three. No behaviour changes -- this is the
groundwork for a generator that picks its own call and reuses both ends
rather than repeating them.

Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
A prototype is a SolCall with holes: a function name and an argument
list where Nothing marks an argument left for the fuzzer to fill in. It
is how something outside the campaign says "call transfer, I don't care
with what", or "call transfer with this recipient and any amount".

genTxFromPrototype resolves one against the deployed contracts and hands
the result to the same tail genTx uses. Matching is on name and arity
only: an argument left open carries no type to compare against, so
same-arity overloads all qualify and one is picked at random.
matchingContracts cuts each contract down to just the signatures that
match, which keeps the subsequent pick total -- there is no filtered
list that can turn out to be empty after a contract was already chosen.

When nothing deployed exposes such a function it falls back to a fully
random transaction, so a prototype naming a function that isn't there
costs diversity rather than stalling the worker.

Nothing produces prototypes yet; the command that injects them follows.

Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
Add the other half of prototypes: a worker can be handed a sequence of
them together with a probability, and will fuzz that sequence instead of
a corpus-mutated one that often. This is what lets something outside the
campaign spend part of a worker's budget on a specific ordering -- an
ordering it has reason to believe is interesting, but that the corpus
mutators are unlikely to stumble into.

FuzzSequence and ClearPrioritization join the commands a fuzzing worker
accepts over the bus, and what they inject accumulates in
WorkerState.prioritizedSequences. randseq now chooses between the
prioritized path and the standard one; its previous body moves to
genStandardSeq unchanged.

genPrioritizedSeq turns a sequence of prototypes into a real one. The
calls are generated in order with up to maxInterleavedTxs random
transactions between consecutive ones, so what gets pinned is the
ordering rather than the whole sequence. It is then prefixed with the
start of a corpus entry, so the ordering also runs from a state the
campaign has already reached -- except on worker 0, which always takes
the empty prefix so the initial state stays covered. The result is
padded with random transactions, or truncated, to respect seqLen.

Nothing sends these commands yet; the client that does arrives with the
MCP server.

Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
Add ExecuteSequence, the command that answers "what does this sequence
of calls actually do?". A caller hands a worker a concrete sequence and
gets back a JSON report: what each call was, whether it completed,
reverted or failed an assertion, the gas it burned and the events it
emitted, then the block number and timestamp the sequence ended on and,
if asked for, the EVM trace.

The replay goes through execTx rather than callseq, so the campaign is
left exactly as it was: no coverage recorded, nothing added to the
corpus, no test falsified. Answering a question about the contract must
not change what the campaign does next, and a test pins that down.

The report summarises the sequence by its worst transaction rather than
its first failure. An assertion failure anywhere outranks a revert, even
one that happened earlier -- reverts are common enough in a random
sequence that reporting one would bury the thing the caller was looking
for. Assertion failures are recognised with checkAssertionEvent and
checkPanicEvent, the same pair an assertion test uses, so both the
emit-AssertionFailed convention and solc's Panic(1) are covered; the
tests exercise one path each.

The command is addressed to a single worker, which answers through a
one-shot Reply channel. The replay runs on that worker's own thread, so
it stops fuzzing for as long as the caller is waiting on it.

Nothing sends this command yet; the MCP server that does arrives next.

Co-authored-by: gustavo-grieco <gustavo.grieco+github@gmail.com>
@elopez elopez changed the title Feat/mcp server Replace the Server-Sent Events server with an MCP server Aug 14, 2026
elopez and others added 3 commits August 14, 2026 19:25
GHC 9.8 turned head into a -Wall warning, and CI builds the test suite
with -Werror, so the single-transaction assertion in the Panic(1) test
failed the Windows and Linux builds.

Match on the list instead. The test replays exactly one transaction, so
pinning that down says what the assertion already assumed and reports a
useful failure if it ever stops holding.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant