Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
25562c6
first step to define agent interactions
gustavo-grieco Dec 17, 2025
352ab79
first functional MCP command
gustavo-grieco Dec 17, 2025
9005d53
removed redundant code
gustavo-grieco Dec 17, 2025
1f6f93f
verify -> verification
gustavo-grieco Dec 17, 2025
1bf5e2a
priorize_function command
gustavo-grieco Dec 17, 2025
c73d7ea
read_logs command
gustavo-grieco Dec 17, 2025
582e6ad
better logs and coverage_report
gustavo-grieco Dec 18, 2025
c0677a5
hlint fixes
gustavo-grieco Dec 18, 2025
159a7d2
improve show_coverage
gustavo-grieco Dec 18, 2025
33358ad
fixed flake to build correctly
gustavo-grieco Dec 18, 2025
45b90fe
fixed test compilation
gustavo-grieco Dec 18, 2025
d6cb38f
fixed flake to build correctly
gustavo-grieco Dec 18, 2025
f82da87
refactor MCP code
gustavo-grieco Dec 18, 2025
b33baec
refactor MCP code
gustavo-grieco Dec 18, 2025
b4de33a
fix tests
gustavo-grieco Dec 18, 2025
8685e54
new command
gustavo-grieco Dec 18, 2025
1c1bdde
new command
gustavo-grieco Dec 18, 2025
67d103c
fixes
gustavo-grieco Dec 18, 2025
af90c03
simplify get coverage tool
gustavo-grieco Dec 19, 2025
0eefe2d
new command
gustavo-grieco Dec 19, 2025
773b2a3
make sure logs are available for the mcp
gustavo-grieco Dec 19, 2025
9b33fc7
implemented status command
gustavo-grieco Dec 19, 2025
5830818
fixes
gustavo-grieco Dec 19, 2025
b52d729
more fixes
gustavo-grieco Dec 19, 2025
017b181
more fixes
gustavo-grieco Dec 19, 2025
a73c072
allow sequences to be prioritized
gustavo-grieco Dec 20, 2025
b2159a0
clean-up
gustavo-grieco Dec 20, 2025
718f11d
new command
gustavo-grieco Dec 20, 2025
15a7eaf
inject_fuzz_transactions validation
gustavo-grieco Dec 20, 2025
9a85bb2
added target mcp command
gustavo-grieco Dec 20, 2025
2d4a4cd
upgraded haskell-mcp-server
gustavo-grieco Dec 20, 2025
bc5b652
upgraded haskell-mcp-server
gustavo-grieco Dec 20, 2025
1d277a9
added optimization values to the status command
gustavo-grieco Dec 20, 2025
b521a45
allow to intercalate random transactions durign priorized sequence
gustavo-grieco Dec 21, 2025
9df353f
better handling of parsing injected transactions
gustavo-grieco Dec 21, 2025
84c9005
insert transactions in a random part of the ones in the corpus
gustavo-grieco Dec 21, 2025
fb40b77
refactoring and probabily tweaking
gustavo-grieco Dec 23, 2025
0559648
wrap the coverage MCP command output in code tags
gustavo-grieco Jan 9, 2026
0b35c32
merge with master
gustavo-grieco Jan 16, 2026
9739c03
fix
gustavo-grieco Jan 16, 2026
8b0d246
merge
gustavo-grieco Mar 3, 2026
3720fba
merge
gustavo-grieco Mar 13, 2026
af67e07
Merge remote-tracking branch 'origin' into dev-agents
gustavo-grieco Mar 18, 2026
cd27e92
merge
gustavo-grieco Mar 27, 2026
3b0cd48
merge
gustavo-grieco May 14, 2026
f045f7f
added some tests
gustavo-grieco May 14, 2026
0276afe
new operation and converted status to json output
gustavo-grieco May 14, 2026
4385eea
simplify commands
gustavo-grieco May 14, 2026
40592a7
add sample command
gustavo-grieco May 14, 2026
8e781c4
missing file
gustavo-grieco May 14, 2026
71291d8
updated hevm
gustavo-grieco May 15, 2026
e312deb
fixed zero division on verification mode
gustavo-grieco May 15, 2026
155d108
fixes
gustavo-grieco May 18, 2026
9dc0ef6
clean up
gustavo-grieco May 18, 2026
fc83148
avoid executing methods in foundry that are non-test ones when seqLen…
gustavo-grieco May 22, 2026
763e705
code fix
gustavo-grieco Jun 16, 2026
2e68e26
merge with master
gustavo-grieco Jun 16, 2026
1975190
Add MCP integration test scaffolding from crytic/echidna#1509
datradito Jun 16, 2026
8e47660
Add robust external MCP testing: conformance + Claude/Codex client sims
gustavo-grieco Jun 16, 2026
87cd668
gitignore: ignore Python bytecode/pytest caches (tests/mcp)
gustavo-grieco Jun 16, 2026
42f8d26
ci(mcp-tests): set least-privilege GITHUB_TOKEN permissions
gustavo-grieco Jun 16, 2026
b0c7d2c
ci(mcp-tests): use trailofbits Cachix + concurrency guard
gustavo-grieco Jun 16, 2026
ef7eb91
ci(mcp-tests): install crytic-compile + solc so echidna can compile
gustavo-grieco Jun 16, 2026
844c167
flake: pin codex-fixed haskell-mcp-server (3bd3cdc)
gustavo-grieco Jun 16, 2026
5307576
flake: correct haskell-mcp-server sha256 for 3bd3cdc
gustavo-grieco Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/mcp-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: MCP integration tests

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

on:
push:
branches: ["**"]
pull_request:

# Least-privilege GITHUB_TOKEN: this workflow only checks out the repo and runs
# tests, so it needs no write scopes.
permissions:
contents: read

# A new push to the same ref cancels an in-flight run (avoids piling up slow builds).
concurrency:
group: mcp-tests-${{ github.ref }}
cancel-in-progress: true

jobs:
mcp-client-compat:
name: MCP client compatibility (Claude + Codex)
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install Nix
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22

# Pull prebuilt echidna/hevm/deps from the project's Cachix so the build
# takes minutes instead of compiling the whole closure from source.
- name: Configure Cachix
uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: trailofbits
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}

- name: Build echidna
run: nix build .#echidna --out-link result

- name: Add echidna to PATH
run: echo "$GITHUB_WORKSPACE/result/bin" >> "$GITHUB_PATH"

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install test dependencies
run: pip install -r tests/mcp/requirements-test.txt

# Echidna shells out to crytic-compile + solc to compile the test contract.
- name: Install crytic-compile and solc
run: |
pip install crytic-compile solc-select
solc-select install 0.8.25
solc-select use 0.8.25

- name: Run MCP test suite (tools + conformance + Claude + Codex)
run: pytest tests/mcp -v
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ crytic-export/
echidna.cabal
*.swp
profiles/

# Python (MCP test suite under tests/mcp/, helper scripts)
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
52 changes: 52 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Echidna MCP Agent Example

An AI agent that connects to a live Echidna fuzzing campaign via the [MCP](https://spec.modelcontextprotocol.io/) server and autonomously guides the fuzzer.

## Requirements

```
pip install langchain-anthropic langgraph httpx
```

## Usage

**1. Start Echidna with the MCP server enabled:**

```bash
echidna MyContract.sol --server 8080 --format text
```

The `--format text` flag is required — it disables the interactive TUI so the MCP server thread can run.

**2. Run the agent:**

```bash
export ANTHROPIC_API_KEY=your_key_here
python examples/mcp_agent.py
```

## What the agent does

The agent runs in a loop at a configurable interval. Each step it:

1. **Observes** — calls `status` to read coverage, iterations, and corpus size.
2. **Injects** — when coverage stagnates, asks Claude to suggest targeted transaction sequences and injects them via `inject_fuzz_transactions`.
3. **Resets** — periodically calls `clear_fuzz_priorities` to prevent the fuzzer from getting stuck on a single function.

## Available MCP tools

| Tool | Description |
|------|-------------|
| `status` | Campaign metrics: coverage, iterations, corpus size, last log lines |
| `target` | ABI of the target contract |
| `show_coverage` | Per-contract source coverage with line annotations |
| `dump_lcov` | Export coverage in LCOV format |
| `inject_fuzz_transactions` | Inject a semicolon-separated sequence of function calls |
| `clear_fuzz_priorities` | Reset function call weighting on all workers |
| `reload_corpus` | Reload transaction sequences from the corpus directory |

Transactions passed to `inject_fuzz_transactions` use Solidity call syntax and support `?` as a fuzzer wildcard:

```
transfer(0xdeadbeef00000000000000000000000000000001, 100);approve(?, ?)
```
166 changes: 166 additions & 0 deletions examples/mcp_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
LangGraph agent for Echidna MCP integration.

Connects to a running Echidna campaign via its MCP server and autonomously
guides the fuzzer: analyzes coverage, injects targeted transactions, and
resets priorities when coverage stagnates.

Requirements:
pip install langchain-anthropic langgraph httpx

Usage:
# Start Echidna with MCP enabled:
echidna MyContract.sol --server 8080 --format text

# Run the agent:
ANTHROPIC_API_KEY=... python examples/mcp_agent.py
"""

import os
import time
import httpx
from typing import TypedDict, List
from langchain_anthropic import ChatAnthropic
from langgraph.graph import StateGraph, END


# ---------------------------------------------------------------------------
# MCP client
# ---------------------------------------------------------------------------

def call_tool(tool: str, args: dict = None) -> str:
"""Call an MCP tool and return the text response."""
payload = {
"jsonrpc": "2.0", "id": 1,
"method": "tools/call",
"params": {"name": tool, "arguments": args or {}},
}
resp = httpx.post("http://localhost:8080/mcp", json=payload, timeout=30)
result = resp.json().get("result", {})
return result.get("content", [{}])[0].get("text", "")


# ---------------------------------------------------------------------------
# Agent state
# ---------------------------------------------------------------------------

class State(TypedDict):
coverage: int
iterations: int
stagnation: int


def parse_int(text: str, key: str) -> int:
for line in text.splitlines():
if key in line:
try:
return int(line.split(":")[-1].strip().split()[0].replace(",", ""))
except ValueError:
pass
return 0


# ---------------------------------------------------------------------------
# Graph nodes
# ---------------------------------------------------------------------------

def observe(state: State) -> State:
"""Read current campaign status."""
status = call_tool("status")
coverage = parse_int(status, "Coverage")
iterations = parse_int(status, "Iterations")
stagnation = state["stagnation"] + 1 if coverage == state["coverage"] else 0
print(f" coverage={coverage} iterations={iterations} stagnation={stagnation}")
return {"coverage": coverage, "iterations": iterations, "stagnation": stagnation}


def inject(state: State) -> State:
"""Ask the LLM to suggest targeted transactions and inject them."""
llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0.7)
coverage_report = call_tool("show_coverage")

prompt = (
"You are a smart-contract security expert helping an Echidna fuzzer.\n"
"The coverage report is:\n\n"
f"{coverage_report[:2000]}\n\n"
"Suggest 3 Solidity function calls that would increase coverage.\n"
"Reply with one call per line, e.g.: transfer(0xABCD..., 100)\n"
)
suggestions = llm.invoke(prompt).content.strip().splitlines()

for tx in suggestions[:3]:
tx = tx.strip()
if tx and "(" in tx and not tx.startswith("#"):
print(f" injecting: {tx}")
call_tool("inject_fuzz_transactions", {"transactions": tx})

return {**state, "stagnation": 0}


def reset(state: State) -> State:
"""Clear function priorities to encourage exploration."""
print(" clearing priorities")
call_tool("clear_fuzz_priorities")
return state


def route(state: State) -> str:
if state["stagnation"] >= 3:
return "inject"
if state["iterations"] > 0 and state["iterations"] % 100_000 == 0:
return "reset"
return END


# ---------------------------------------------------------------------------
# Build and run
# ---------------------------------------------------------------------------

def build_graph() -> StateGraph:
g = StateGraph(State)
g.add_node("observe", observe)
g.add_node("inject", inject)
g.add_node("reset", reset)
g.set_entry_point("observe")
g.add_conditional_edges("observe", route, {"inject": "inject", "reset": "reset", END: END})
g.add_edge("inject", END)
g.add_edge("reset", END)
return g.compile()


def main():
if not os.getenv("ANTHROPIC_API_KEY"):
print("Set ANTHROPIC_API_KEY before running.")
return

# Verify connectivity
try:
status = call_tool("status")
if not status:
raise RuntimeError("empty response")
print("Connected to Echidna MCP server.")
except Exception as e:
print(f"Cannot reach MCP server: {e}")
print("Start Echidna with: echidna MyContract.sol --server 8080 --format text")
return

graph = build_graph()
state: State = {"coverage": 0, "iterations": 0, "stagnation": 0}

duration = int(input("Duration in minutes [10]: ") or 10)
interval = int(input("Check interval in seconds [60]: ") or 60)
end_time = time.time() + duration * 60

step = 0
while time.time() < end_time:
step += 1
print(f"\n--- step {step} ---")
state = graph.invoke(state)
time.sleep(interval)

print(f"\nDone. Final coverage: {state['coverage']} iterations: {state['iterations']}")


if __name__ == "__main__":
main()
9 changes: 8 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,15 @@
pkgs.haskell.lib.compose.dontCheck
]);

mcp-server = pkgs: pkgs.haskellPackages.callCabal2nix "mcp-server" (pkgs.fetchFromGitHub {
owner = "gustavo-grieco";
repo = "haskell-mcp-server";
rev = "3bd3cdcc29f4ae853290e6c05e1db3dfa6ca2312";
sha256 = "sha256-6RWtvZuD/ABBZ+mlWmot58nOeDDJiHkb2c8IqaSK/WE=";
}) {};

echidna = pkgs: with pkgs; lib.pipe
(haskellPackages.callCabal2nix "echidna" ./. { hevm = hevm pkgs; })
(haskellPackages.callCabal2nix "echidna" ./. { hevm = hevm pkgs; mcp-server = mcp-server pkgs; })
([
# FIXME: figure out solc situation, it conflicts with the one from
# solc-select that is installed with slither, disable tests in the meantime
Expand Down
11 changes: 7 additions & 4 deletions lib/Echidna.hs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module Echidna where

import Control.Concurrent (newChan)
import Control.Concurrent.STM (newBroadcastTChanIO)
import Control.Monad.Catch (MonadThrow(..))
import Control.Monad.IO.Class (liftIO)
import Data.IORef (newIORef)
Expand Down Expand Up @@ -65,7 +66,7 @@ prepareContract cfg solFiles buildOutput selectedContract seed = do

mainContract <- selectMainContract solConf selectedContract contracts
tests <- mkTests solConf campaignConf mainContract
signatureMap <- mkSignatureMap solConf mainContract contracts
signatureMap <- mkSignatureMap solConf campaignConf mainContract contracts

-- run processors
slitherInfo <- runSlither (NE.head solFiles) solConf
Expand All @@ -88,7 +89,7 @@ prepareContract cfg solFiles buildOutput selectedContract seed = do
<> staticAddresses solConf
<> deployedAddresses
deployedSolcContracts = nub $ mapMaybe (findSrcForReal env.dapp) $ Map.elems vm.env.contracts
nonViewPureSigs = concatMap (mapMaybe (\ (Method {name, inputs, mutability}) ->
nonViewPureSigs = concatMap (mapMaybe (\ (Method {name, inputs, mutability}) ->
case mutability of
View -> Nothing
Pure -> Nothing
Expand Down Expand Up @@ -125,6 +126,7 @@ mkEnv cfg buildOutput tests world slitherInfo = do
codehashMap <- newIORef mempty
chainId <- Onchain.fetchChainIdFrom cfg.rpcUrl
eventQueue <- newChan
bus <- newBroadcastTChanIO
coverageRefInit <- newIORef mempty
coverageRefRuntime <- newIORef mempty
corpusRef <- newIORef mempty
Expand All @@ -134,7 +136,8 @@ mkEnv cfg buildOutput tests world slitherInfo = do
useColor <- hNowSupportsANSI stdout
-- TODO put in real path
let dapp = dappInfo "/" buildOutput
pure $ Env { cfg, dapp, codehashMap, fetchSession, contractNameCache
, chainId, eventQueue, coverageRefInit, coverageRefRuntime, corpusRef, testRefs, world
sourceCache = buildOutput.sources
pure $ Env { cfg, dapp, sourceCache, codehashMap, fetchSession, contractNameCache
, chainId, eventQueue, bus, coverageRefInit, coverageRefRuntime, corpusRef, testRefs, world
, slitherInfo, useColor
}
Loading
Loading