Skip to content

Commit e0aba6f

Browse files
datraditoclaude
andcommitted
feat(mcp): add Python example, integration test, and CI
- examples/mcp_agent.py: LangGraph agent that observes coverage, injects targeted transactions, and resets priorities when stagnating - examples/README.md: brief usage docs (start command, tool table, transaction format) - tests/mcp/test_mcp.py: four integration tests covering the core workflow — status, inject_fuzz_transactions, show_coverage, clear_fuzz_priorities - tests/mcp/contracts/: EchidnaMCPTest + SimpleToken for test campaigns - .github/workflows/mcp-tests.yml: CI job that builds echidna via Nix and runs pytest tests/mcp/test_mcp.py on every push/PR Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3720fba commit e0aba6f

7 files changed

Lines changed: 612 additions & 0 deletions

File tree

.github/workflows/mcp-tests.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: MCP integration tests
2+
3+
on:
4+
push:
5+
branches: ["**"]
6+
pull_request:
7+
8+
jobs:
9+
mcp-tests:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Install Nix
16+
uses: cachix/install-nix-action@v27
17+
with:
18+
nix_path: nixpkgs=channel:nixos-unstable
19+
20+
- name: Build echidna
21+
run: nix build .#echidna --out-link result
22+
23+
- name: Add echidna to PATH
24+
run: echo "$GITHUB_WORKSPACE/result/bin" >> "$GITHUB_PATH"
25+
26+
- name: Set up Python
27+
uses: actions/setup-python@v5
28+
with:
29+
python-version: "3.11"
30+
31+
- name: Install test dependencies
32+
run: pip install pytest httpx
33+
34+
- name: Run MCP integration tests
35+
run: pytest tests/mcp/test_mcp.py -v

examples/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Echidna MCP Agent Example
2+
3+
An AI agent that connects to a live Echidna fuzzing campaign via the [MCP](https://spec.modelcontextprotocol.io/) server and autonomously guides the fuzzer.
4+
5+
## Requirements
6+
7+
```
8+
pip install langchain-anthropic langgraph httpx
9+
```
10+
11+
## Usage
12+
13+
**1. Start Echidna with the MCP server enabled:**
14+
15+
```bash
16+
echidna MyContract.sol --server 8080 --format text
17+
```
18+
19+
The `--format text` flag is required — it disables the interactive TUI so the MCP server thread can run.
20+
21+
**2. Run the agent:**
22+
23+
```bash
24+
export ANTHROPIC_API_KEY=your_key_here
25+
python examples/mcp_agent.py
26+
```
27+
28+
## What the agent does
29+
30+
The agent runs in a loop at a configurable interval. Each step it:
31+
32+
1. **Observes** — calls `status` to read coverage, iterations, and corpus size.
33+
2. **Injects** — when coverage stagnates, asks Claude to suggest targeted transaction sequences and injects them via `inject_fuzz_transactions`.
34+
3. **Resets** — periodically calls `clear_fuzz_priorities` to prevent the fuzzer from getting stuck on a single function.
35+
36+
## Available MCP tools
37+
38+
| Tool | Description |
39+
|------|-------------|
40+
| `status` | Campaign metrics: coverage, iterations, corpus size, last log lines |
41+
| `target` | ABI of the target contract |
42+
| `show_coverage` | Per-contract source coverage with line annotations |
43+
| `dump_lcov` | Export coverage in LCOV format |
44+
| `inject_fuzz_transactions` | Inject a semicolon-separated sequence of function calls |
45+
| `clear_fuzz_priorities` | Reset function call weighting on all workers |
46+
| `reload_corpus` | Reload transaction sequences from the corpus directory |
47+
48+
Transactions passed to `inject_fuzz_transactions` use Solidity call syntax and support `?` as a fuzzer wildcard:
49+
50+
```
51+
transfer(0xdeadbeef00000000000000000000000000000001, 100);approve(?, ?)
52+
```

examples/mcp_agent.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env python3
2+
"""
3+
LangGraph agent for Echidna MCP integration.
4+
5+
Connects to a running Echidna campaign via its MCP server and autonomously
6+
guides the fuzzer: analyzes coverage, injects targeted transactions, and
7+
resets priorities when coverage stagnates.
8+
9+
Requirements:
10+
pip install langchain-anthropic langgraph httpx
11+
12+
Usage:
13+
# Start Echidna with MCP enabled:
14+
echidna MyContract.sol --server 8080 --format text
15+
16+
# Run the agent:
17+
ANTHROPIC_API_KEY=... python examples/mcp_agent.py
18+
"""
19+
20+
import os
21+
import time
22+
import httpx
23+
from typing import TypedDict, List
24+
from langchain_anthropic import ChatAnthropic
25+
from langgraph.graph import StateGraph, END
26+
27+
28+
# ---------------------------------------------------------------------------
29+
# MCP client
30+
# ---------------------------------------------------------------------------
31+
32+
def call_tool(tool: str, args: dict = None) -> str:
33+
"""Call an MCP tool and return the text response."""
34+
payload = {
35+
"jsonrpc": "2.0", "id": 1,
36+
"method": "tools/call",
37+
"params": {"name": tool, "arguments": args or {}},
38+
}
39+
resp = httpx.post("http://localhost:8080/mcp", json=payload, timeout=30)
40+
result = resp.json().get("result", {})
41+
return result.get("content", [{}])[0].get("text", "")
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# Agent state
46+
# ---------------------------------------------------------------------------
47+
48+
class State(TypedDict):
49+
coverage: int
50+
iterations: int
51+
stagnation: int
52+
53+
54+
def parse_int(text: str, key: str) -> int:
55+
for line in text.splitlines():
56+
if key in line:
57+
try:
58+
return int(line.split(":")[-1].strip().split()[0].replace(",", ""))
59+
except ValueError:
60+
pass
61+
return 0
62+
63+
64+
# ---------------------------------------------------------------------------
65+
# Graph nodes
66+
# ---------------------------------------------------------------------------
67+
68+
def observe(state: State) -> State:
69+
"""Read current campaign status."""
70+
status = call_tool("status")
71+
coverage = parse_int(status, "Coverage")
72+
iterations = parse_int(status, "Iterations")
73+
stagnation = state["stagnation"] + 1 if coverage == state["coverage"] else 0
74+
print(f" coverage={coverage} iterations={iterations} stagnation={stagnation}")
75+
return {"coverage": coverage, "iterations": iterations, "stagnation": stagnation}
76+
77+
78+
def inject(state: State) -> State:
79+
"""Ask the LLM to suggest targeted transactions and inject them."""
80+
llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0.7)
81+
coverage_report = call_tool("show_coverage")
82+
83+
prompt = (
84+
"You are a smart-contract security expert helping an Echidna fuzzer.\n"
85+
"The coverage report is:\n\n"
86+
f"{coverage_report[:2000]}\n\n"
87+
"Suggest 3 Solidity function calls that would increase coverage.\n"
88+
"Reply with one call per line, e.g.: transfer(0xABCD..., 100)\n"
89+
)
90+
suggestions = llm.invoke(prompt).content.strip().splitlines()
91+
92+
for tx in suggestions[:3]:
93+
tx = tx.strip()
94+
if tx and "(" in tx and not tx.startswith("#"):
95+
print(f" injecting: {tx}")
96+
call_tool("inject_fuzz_transactions", {"transactions": tx})
97+
98+
return {**state, "stagnation": 0}
99+
100+
101+
def reset(state: State) -> State:
102+
"""Clear function priorities to encourage exploration."""
103+
print(" clearing priorities")
104+
call_tool("clear_fuzz_priorities")
105+
return state
106+
107+
108+
def route(state: State) -> str:
109+
if state["stagnation"] >= 3:
110+
return "inject"
111+
if state["iterations"] > 0 and state["iterations"] % 100_000 == 0:
112+
return "reset"
113+
return END
114+
115+
116+
# ---------------------------------------------------------------------------
117+
# Build and run
118+
# ---------------------------------------------------------------------------
119+
120+
def build_graph() -> StateGraph:
121+
g = StateGraph(State)
122+
g.add_node("observe", observe)
123+
g.add_node("inject", inject)
124+
g.add_node("reset", reset)
125+
g.set_entry_point("observe")
126+
g.add_conditional_edges("observe", route, {"inject": "inject", "reset": "reset", END: END})
127+
g.add_edge("inject", END)
128+
g.add_edge("reset", END)
129+
return g.compile()
130+
131+
132+
def main():
133+
if not os.getenv("ANTHROPIC_API_KEY"):
134+
print("Set ANTHROPIC_API_KEY before running.")
135+
return
136+
137+
# Verify connectivity
138+
try:
139+
status = call_tool("status")
140+
if not status:
141+
raise RuntimeError("empty response")
142+
print("Connected to Echidna MCP server.")
143+
except Exception as e:
144+
print(f"Cannot reach MCP server: {e}")
145+
print("Start Echidna with: echidna MyContract.sol --server 8080 --format text")
146+
return
147+
148+
graph = build_graph()
149+
state: State = {"coverage": 0, "iterations": 0, "stagnation": 0}
150+
151+
duration = int(input("Duration in minutes [10]: ") or 10)
152+
interval = int(input("Check interval in seconds [60]: ") or 60)
153+
end_time = time.time() + duration * 60
154+
155+
step = 0
156+
while time.time() < end_time:
157+
step += 1
158+
print(f"\n--- step {step} ---")
159+
state = graph.invoke(state)
160+
time.sleep(interval)
161+
162+
print(f"\nDone. Final coverage: {state['coverage']} iterations: {state['iterations']}")
163+
164+
165+
if __name__ == "__main__":
166+
main()
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.0;
3+
4+
import "./SimpleToken.sol";
5+
6+
/**
7+
* Echidna MCP Test Contract with Property Tests
8+
* Feature: 001-mcp-agent-commands
9+
* Phase 5, Task T067
10+
*
11+
* Property tests for SimpleToken that can be run with Echidna.
12+
* Tests invariants that should hold during fuzzing.
13+
*/
14+
15+
contract EchidnaMCPTest {
16+
SimpleToken token;
17+
uint256 constant INITIAL_SUPPLY = 1000000 * 10**18;
18+
19+
// Track addresses that have received tokens
20+
address[] knownAddresses;
21+
22+
constructor() {
23+
token = new SimpleToken(INITIAL_SUPPLY);
24+
knownAddresses.push(address(this));
25+
}
26+
27+
/**
28+
* Property: Total supply should remain constant (no burns in SimpleToken)
29+
* Or increase only if mint is called
30+
*/
31+
function echidna_total_supply_never_decreases() public view returns (bool) {
32+
return token.totalSupply() >= INITIAL_SUPPLY;
33+
}
34+
35+
/**
36+
* Property: Sum of all balances should equal total supply
37+
* This tests for balance conservation
38+
*/
39+
function echidna_balances_conserved() public view returns (bool) {
40+
// For simplicity, we check that no address has more than total supply
41+
// (full balance sum requires tracking all addresses)
42+
return token.balanceOf(address(this)) <= token.totalSupply();
43+
}
44+
45+
/**
46+
* Property: No overflow should occur in balance tracking
47+
* Each address balance should be <= total supply
48+
*/
49+
function echidna_no_overflow() public view returns (bool) {
50+
uint256 totalSupply = token.totalSupply();
51+
52+
// Check known addresses
53+
for (uint i = 0; i < knownAddresses.length; i++) {
54+
if (token.balanceOf(knownAddresses[i]) > totalSupply) {
55+
return false;
56+
}
57+
}
58+
59+
return true;
60+
}
61+
62+
/**
63+
* Property: Allowance should never exceed balance
64+
*/
65+
function echidna_allowance_valid() public view returns (bool) {
66+
// This is a basic check - allowance can exceed balance in some designs
67+
// but should never be negative (implicit in uint256)
68+
return true;
69+
}
70+
71+
/**
72+
* Property: Transfer should never create tokens
73+
*/
74+
function echidna_transfer_no_mint() public view returns (bool) {
75+
uint256 supply = token.totalSupply();
76+
return supply >= INITIAL_SUPPLY; // Only mints increase supply
77+
}
78+
79+
// Helper functions for fuzzing
80+
81+
function testTransfer(address to, uint256 amount) public {
82+
if (to == address(0)) return; // Skip invalid addresses
83+
if (amount > token.balanceOf(address(this))) return; // Skip insufficient balance
84+
85+
token.transfer(to, amount);
86+
87+
// Track new addresses
88+
if (!_isKnown(to)) {
89+
knownAddresses.push(to);
90+
}
91+
}
92+
93+
function testApprove(address spender, uint256 amount) public {
94+
if (spender == address(0)) return;
95+
token.approve(spender, amount);
96+
}
97+
98+
function testMint(address to, uint256 amount) public {
99+
if (to == address(0)) return;
100+
if (amount > 1000000 * 10**18) return; // Reasonable limit
101+
102+
token.mint(to, amount);
103+
104+
if (!_isKnown(to)) {
105+
knownAddresses.push(to);
106+
}
107+
}
108+
109+
function _isKnown(address addr) internal view returns (bool) {
110+
for (uint i = 0; i < knownAddresses.length; i++) {
111+
if (knownAddresses[i] == addr) return true;
112+
}
113+
return false;
114+
}
115+
116+
// Getter for token address (for external MCP calls)
117+
function tokenAddress() public view returns (address) {
118+
return address(token);
119+
}
120+
}

0 commit comments

Comments
 (0)