MCP server that lets LLMs read Ethereum chain data over JSON-RPC. Read-only, no keys, no signing.
Tools — capability clusters, not endpoint wrappers:
chainspeak_get_chain_status— chain id + name, latest block, base fee, gas tiers (slow/standard/fast), "a transfer costs ~X ETH now", blob fee, sync flag, and the measuredupstreambatch capchainspeak_get_account— balance, nonce, is_contract, EIP-7702 delegation, verified reverse ENS; takes an address OR an ENS name, resolved at the same block as the readchainspeak_get_token— ERC-20 metadata + total supply (no holder needed), optional holder balance, historicalblocksupportchainspeak_get_transaction— status, value, gas_limit + gas_used (+%), fee paid precomputed, confirmations, decoded method + ERC-20/721 transfers; failed txs getfailure: {reason, method, method_note}via debug trace or honest eth_call replay;detail: summary|full|rawchainspeak_get_block— header, tx count, gas fullness, base fee;detailadds tx hashes or full transactions, paginated with steering truncation messageschainspeak_get_events— contract event logs by preset (transfers/approvalsfor an account or a whole token,rawby contract/topics); Transfer/Approval decoded, everything else honestlydecoded: false; any range width (walked server-side in windows the provider accepts), paginatedchainspeak_resolve_name— ENS both directions (name→address, address→name with forward verification), block-pinned, EIP-55 output
Conventions every tool follows: responses echo {chain_id, block_number}; addresses are EIP-55 checksummed; chain quantities are decimal strings with human-readable twins; blocks accept decimal, 0x-hex, or tags; errors are {category, retryable, message, hint} where the hint says what to change — deterministic provider rejections are never labeled retryable.
RPC note: historical-state queries need an archive-capable endpoint — the publicnode default is NOT archive; https://eth.drpc.org (free) is. drpc's free tier caps JSON-RPC batches at 3, which the server respects automatically.
The server does not probe for archive or trace support, and
upstreamhas noarchiveortracefield. Both existed and both lied — each asked a capability question up front and read whatever error came back as the answer. On a free-tier quota message the archive probe failed closed (archive: falsefrom an endpoint whose historical reads worked) and the trace probe failed open (claimingdebug_traceTransactionsupport that was never demonstrated) — and the answers were cached, so they stayed wrong. Just make the call: pruned state comes back asHISTORICAL_STATE_UNAVAILABLEnaming what to change, andget_transactionfalls back toeth_callreplay with the reason infailure.note.
failure.confidence is removed from chainspeak_get_transaction.
It was one word restating another. confidence could only ever agree with
method, so it carried nothing a reader did not already have — and paying for
it in every failed-transaction payload, in output a model has to read, is a bad
trade for a label.
What it meant has moved into method, which now says how the reason was
obtained AND how far it can be trusted:
method |
what it means |
|---|---|
trace |
the revert frame from debug_traceTransaction — what actually happened, at the time it happened |
replay |
re-run with eth_call against top-of-block state, not the state this transaction met. It reverted again and this is that revert, which may not be the same one: anything earlier transactions in the block changed is missing |
replay-not-reproduced |
the replay succeeded. The failure was state- or order-dependent, so no reason for it can be given at all |
none |
neither method produced anything — the transaction is pending, or the endpoint served neither call |
replay-not-reproduced is new, and it is why this is a fold rather than a
deletion: that case used to be method: "replay" with confidence: "none", and
dropping the field without it would have made "no reason exists" read as
"here is the reason, approximately". method_note is unchanged.
- Node.js >= 20
- pnpm
pnpm install
cp .env.example .env # set one RPC_URL_<CHAIN> per chain you want
pnpm buildAdd to your MCP client config, e.g. claude_desktop_config.json:
{
"mcpServers": {
"chainspeak": {
"command": "node",
"args": ["/absolute/path/to/chainspeak-mcp/dist/stdio.mjs"],
"env": {
"RPC_URL_ETHEREUM": "https://eth.drpc.org",
"RPC_URL_BASE": "https://base.drpc.org",
"RPC_URL_ARBITRUM": "https://arbitrum.drpc.org"
}
}
}
}Add one RPC_URL_<CHAIN> per chain. Every tool then takes a chain parameter, and
account, transaction and chain-status queries read all configured chains by default —
so "what does this address hold?" answers across ethereum, base and arbitrum in one call.
ENS names work on every chain but are always resolved on ethereum, so keep
RPC_URL_ETHEREUM set unless you only ever pass 0x addresses.
Or with Claude Code:
claude mcp add chainspeak \
-e RPC_URL_ETHEREUM=https://eth.drpc.org \
-e RPC_URL_BASE=https://base.drpc.org \
-e RPC_URL_ARBITRUM=https://arbitrum.drpc.org \
-- node /absolute/path/to/chainspeak-mcp/dist/stdio.mjspnpm dev:httpClients connect to http://localhost:3000/mcp; GET /healthz is a health probe.
Auth is optional: set MCP_AUTH_TOKEN (e.g. openssl rand -hex 24) and clients must send
Authorization: Bearer <token>; leave it unset and the server runs open, logging a warning —
do that only on a private network or behind reverse-proxy auth, since anyone who can reach the
port can spend your RPC quota.
Or with Docker:
docker compose up --build| Variable | Required | Default | Description |
|---|---|---|---|
RPC_URL_<CHAIN> |
one of these | — | endpoint for that chain, e.g. RPC_URL_BASE |
RPC_URL_<CHAIN>_FALLBACK |
no | — | second endpoint for that chain |
CHAIN |
no | ethereum |
chain used when a call names none; must be configured |
ETH_RPC_URL |
one of these | — | single-chain shorthand: the endpoint for CHAIN |
ETH_RPC_URL_FALLBACK |
no | — | second endpoint for failover |
ETH_RPC_TIMEOUT_MS |
no | 10000 |
per-request timeout |
LOG_LEVEL |
no | info |
pino level, logs go to stderr |
MCP_AUTH_TOKEN |
no | — | bearer token, min 16 chars; when set clients must send it, unset runs open |
HTTP_PORT |
no | 3000 |
HTTP listen port |
HTTP_HOST |
no | 0.0.0.0 |
HTTP listen host |
Known chains: ethereum, base, op-mainnet, arbitrum-one, polygon, bnb-smart-chain,
gnosis, linea-mainnet, sepolia, holesky. Short aliases (op, arbitrum, bnb, linea)
and chain ids (RPC_URL_8453) also work. The server refuses to start if an endpoint reports a
different chain id than the name it was configured under.
pnpm dev # stdio server from source
pnpm check # lint + typecheck + dependency rules + testsMIT