Skip to content

Repository files navigation

onchain-intel

An on-chain analytics engine exposed as an MCP server: provider adapters (CoinGecko, DexScreener, DeFiLlama, EVM/Solana RPC, Nansen, …) → normalization into one canonical schema → two-level cache + credit budget guard → 13 workflow-oriented tools your agent can call.

Русская версия: README.ru.md

Every example in this file was executed against the built server. Outputs are real, trimmed only for length. The three paid tools are shown from recorded fixtures — they were not called live while writing this.


Table of contents


What you get

13 MCP tools. One liveness check, one chain-registry tool, eight free/keyless data tools, and three paid Nansen-backed alpha tools.

Two chains: ethereum and solana. Every tool takes an explicit chain.

Canonical output. Provider DTOs never leak — each tool returns a versioned zod-validated domain object, so swapping a provider does not change your agent's contract.

Money safety. Nansen is the only paid provider. Every paid call is priced from a static cost table and atomically reserved against a daily ledger before the network request. No call can exceed the vendor balance or your own ceiling.

Works with no keys at all. An empty .env is a valid configuration: keyless tools work, paid tools return an explicit error instead of silently returning nothing.


Requirements

Requirement Version Note
Node.js ≥ 22 uses process.loadEnvFile(), no dotenv
pnpm 11.15.1 corepack enable pnpm picks it up
OS any native module better-sqlite3 needs a prebuild

No Docker, no database server. State is a single SQLite file.


Install and build

corepack enable pnpm
pnpm install --frozen-lockfile
pnpm build

The build produces packages/mcp-server/dist/index.js — that file is the server.

Confirm the artifact runs before wiring anything into an agent:

pnpm --filter @onchain-intel/mcp-server run smoke:dist
# smoke-dist: PASS: onchain_ping OK over dist/index.js (version 0.1.0)

Configuration

Create .env in the project root and lock it down:

cp .env.example .env && chmod 600 .env

Every key is optional. An empty .env starts a working server. Keys are read inside the call that needs them, never at module load, never logged, and never part of a cache key.

Key Needed for Default
NANSEN_API_KEY the 3 paid tools unset → those tools return isError
NANSEN_DAILY_CREDIT_CAP self-imposed spend ceiling derived, see below
NANSEN_VELOCITY_CREDITS_PER_MIN spend-rate brake derived, see below
NANSEN_BUDGET_WARN_RATIO stderr warning threshold 0.8
COINGECKO_API_KEY higher CoinGecko limits (demo tier) keyless works
COINGECKO_PRO_API_KEY CoinGecko Pro host
ONCHAIN_PG_URL historical snapshots (read-only DSN) history falls back to a free source
DATA_DIR cache + budget ledger location ~/.onchain-intel
LOG_LEVEL reserved, no effect yet

DUNE_API_KEY is reserved: the adapter is a stub and reports unavailable even when the key is set.

Endpoints, hosts, rate limits and TTLs are deliberately NOT env-configurable. They are declared in packages/core/src/providers.config.ts, because an env-overridable URL would be a hole in the SSRF allowlist.


Connect to Claude Code

Add a .mcp.json in the project root (it is gitignored — it may hold other servers' secrets):

{
  "mcpServers": {
    "onchain-intel": {
      "command": "node",
      "args": ["packages/mcp-server/dist/index.js"]
    }
  }
}

No env block is needed: the server loads .env from its working directory itself.

Restart Claude Code, then confirm the tools appear:

/mcp

You should see 13 tools under onchain-intel.


Verify it works

Cheapest possible check — no network, no keys:

Call onchain_ping
{ "ok": true, "service": "onchain-intel-mcp-server", "version": "0.1.0", "ts": 1785013396663 }

Then a real keyless lookup:

What is Uniswap's TVL on ethereum?

The agent calls onchain_protocol_tvl and gets back real data plus cache metadata.


Tool reference

Cost is in Nansen credits. Free tools cost nothing and need no key.

Tool Cost TTL Key required
onchain_ping no
onchain_get_token free 60s no
onchain_wallet_balances free 60s no
onchain_new_pairs free 30s no
onchain_protocol_tvl free 300s no
onchain_list_chains free no
onchain_chain_tvl free 300s no
onchain_dex_volume free 3600s no
onchain_token_holders free 3600s no
onchain_chain_supply free 600s no
onchain_smart_money_flows 10 cr 300s NANSEN_API_KEY
onchain_entity_label 0/5/100 cr 3600s NANSEN_API_KEY
onchain_token_risk 6 cr 1800s NANSEN_API_KEY

onchain_ping

Deterministic liveness check. No arguments, no network.

Input:

{}

onchain_get_token

Token metadata and USD price for a contract address (CoinGecko-backed).

Input:

{ "chain": "ethereum", "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }

structuredContent — real output:

{
  "chain": "ethereum",
  "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  "symbol": "USDC",
  "name": "USDC",
  "decimals": 6,
  "priceUsd": 0.999812,
  "marketCapUsd": 72541470656,
  "source": "coingecko",
  "fetchedAt": 1785013418631
}

onchain_wallet_balances

Native asset balance (ETH or SOL) for a wallet, via keyless JSON-RPC.

Input:

{ "chain": "ethereum", "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }

onchain_new_pairs

Recently active DEX trading pairs (DexScreener-backed). limit is optional.

Input:

{ "chain": "ethereum", "limit": 2 }

structuredContent — real output, trimmed:

{
  "chain": "ethereum",
  "pairs": [
    {
      "id": "ethereum:0x2287a9620adcbf6250dc71be9ee9b2d3a1ec85a464fc6f5c06669e8d07b61bba",
      "chain": "ethereum",
      "dexId": "uniswap",
      "baseTokenSymbol": "ETH",
      "quoteTokenSymbol": "USDT",
      "pairAddress": "0x2287a9620adcbf6250dc71be9ee9b2d3a1ec85a464fc6f5c06669e8d07b61bba"
    }
  ],
  "source": "dexscreener",
  "fetchedAt": 1785013418631
}

onchain_protocol_tvl

Protocol TVL, chain-scoped and total, for a DeFiLlama slug.

Input:

{ "chain": "ethereum", "protocolSlug": "uniswap" }

structuredContent — real output:

{
  "protocol": "Uniswap",
  "chain": "ethereum",
  "tvlUsd": 2181604650,
  "totalTvlUsd": 3155131415,
  "source": "defillama",
  "fetchedAt": 1785013396663
}

onchain_list_chains

Which chains this server knows, and which capabilities are actually served on each. Answers from a local registry of 458 networks — no network call, no key. Use it to find the right chain value before calling anything else, or to check whether a capability reaches the chain you care about.

Input — everything is optional; capability narrows the answer to chains where that capability is really covered:

{ "capability": "token.price", "limit": 20 }

onchain_chain_tvl

Total value locked of a whole chain, from DeFiLlama. For a single protocol use onchain_protocol_tvl instead — the two answer different questions and are easy to confuse.

Input:

{ "chain": "ethereum" }

onchain_dex_volume

Daily DEX trading volume for a chain, plus the aggregates DeFiLlama publishes alongside it (24h, 7d, 30d, all-time). The window matters: gapDays reports how many days inside it carry no data, which is how a vendor that quietly stopped publishing becomes visible instead of looking like a quiet market.

Input — days is the window, includeSeries controls whether the daily points come back or only the aggregates:

{ "chain": "ethereum", "days": 30, "includeSeries": true }

onchain_token_holders

The largest holders of a token and their exact balances, from Blockscout's public explorer API.

Two fields decide whether the answer means what it looks like. truncated says the list is not the complete tail — either more holders exist beyond the page, or rows were dropped. droppedRows counts rows the server refused to publish because the vendor sent something it would not stand behind. Check both before reading the list as a concentration measure.

Balances arrive as exact base-unit strings with no decimals applied — a token balance routinely exceeds what a JSON number can hold without losing digits. Get decimals from onchain_get_token.

Input:

{ "chain": "ethereum", "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }

onchain_chain_supply

How much of a chain's native asset exists. Bitcoin only today.

The answer carries two figures that are not interchangeable: emissionRaw is what the issuance schedule has released, circulatingRaw is what was actually claimed. They differ by unclaimed block subsidy — miners who never spent their reward — and using one where the other belongs misstates supply. Both are exact integer strings in the smallest unit; the *Btc fields beside them are lossy conveniences for display.

Input:

{ "chain": "bitcoin" }

onchain_smart_money_flows (paid)

Smart-money net flow over four windows plus top holders. 10 credits per cache miss.

Input:

{ "chain": "ethereum", "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }

structuredContent — shape and values from the recorded live fixture:

{
  "chain": "ethereum",
  "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  "tokenSymbol": "USDC",
  "netflow1hUsd": 0,
  "netflow24hUsd": -325939.1223102985,
  "netflow7dUsd": -4545283.010605299,
  "netflow30dUsd": -10796443.662655927,
  "traderCount": 142,
  "tokenAgeDays": 2912,
  "tokenSectors": ["Stablecoin"],
  "topHolders": [{ "address": "0x…", "addressLabel": "", "ownershipPercentage": 1.23 }],
  "source": "nansen",
  "fetchedAt": 1785013396663
}

onchain_entity_label (paid)

Entity and address labels. Three price tiers, chosen by the arguments you pass:

Arguments Cost What it does
query only 0 cr entity/token search
tokenAddressquery) 5 cr adds holder-derived labels
exhaustive: true 100 cr full profiler labels for an address

At least one of query or tokenAddress is required. exhaustive: true additionally requires tokenAddress, and is never enabled automatically — it costs an entire free-plan balance.

Input — free tier:

{ "chain": "ethereum", "query": "wintermute" }

Input — the 100 cr escalation, opt-in only:

{ "chain": "ethereum", "tokenAddress": "0xA0b8…eB48", "exhaustive": true }

onchain_token_risk (paid)

Risk and reward indicators for a token, plus token metadata. 6 credits per cache miss.

Input:

{ "chain": "ethereum", "tokenAddress": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }

structuredContent — shape and values from the recorded live fixture:

{
  "chain": "ethereum",
  "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  "marketCapUsd": 72947689222,
  "marketCapGroup": "largecap",
  "isStablecoin": true,
  "name": "USD Coin",
  "symbol": "USDC",
  "deploymentDate": "2018-08-03 19:28:24",
  "fdvUsd": 72949375871,
  "circulatingSupply": 72993578820.9766,
  "totalSupply": 72995577672.29245,
  "liquidityUsd": 536369088.28717536,
  "totalHolders": 3105691,
  "riskIndicators": [{ "indicatorType": "btc-reflexivity", "score": "low", "signal": 0.01 }],
  "rewardIndicators": [{ "indicatorType": "funding-rate", "score": "bullish", "signal": 1 }],
  "source": "nansen",
  "fetchedAt": 1785013396663
}

riskIndicators and rewardIndicators stay two separate arrays — never flattened, because a risk signal and a reward signal are not interchangeable.


Response envelope

Every tool returns MCP's standard content + structuredContent, plus a _meta sibling that never changes the output schema.

"_meta": {
  "cache": { "status": "miss", "provider": "defillama", "capability": "protocol.tvl" }
}

On a cache hit you also get the age:

"_meta": {
  "cache": { "status": "hit", "ageMs": 2, "provider": "defillama", "capability": "protocol.tvl" }
}

Paid tools add budgetonly on a cache miss, because a cache hit spends nothing:

"_meta": {
  "cache": { "status": "miss", "provider": "nansen", "capability": "smart-money.flows" },
  "budget": { "provider": "nansen", "creditsUsedToday": 16 }
}

The absence of budget on a hit is deliberate, not an omission: it is how you can tell a free answer from a paid one.


Credit budget guard

Nansen calls spend real money, so the engine will not let them run unchecked.

Before every paid call:

  1. the exact price is looked up in a static cost table — never an estimate, and an unknown price fails closed at infinity rather than defaulting to zero;
  2. a live /account check (0 credits) supplies the vendor's real remaining balance;
  3. the cost is atomically reserved in a SQLite ledger inside a single transaction;
  4. only then does the HTTP request go out.

After the call, the vendor's reported charge is reconciled against the reservation, so the ledger converges on what was actually billed.

NANSEN_DAILY_CREDIT_CAP — three states

Value Behaviour
unset (default) derived: max(30, 25% of the balance at the start of the UTC day)
a positive integer your explicit ceiling
off no self-imposed ceiling — only the vendor balance binds

The derived default means the guard is on out of the box. It scales with your plan: a balance of 100 gives a cap of 30, a balance of 10 000 gives 2500. 0 is deliberately rejected — it is one typo away from silently disabling a money guard, and semantically ought to mean "spend nothing".

A refusal is loud and names which bound stopped it:

nansen budget gate refused: self-imposed cap (derived): need 10, allows 30 …

NANSEN_VELOCITY_CREDITS_PER_MIN — the rate brake

The daily cap bounds spend per day, which is a damage ceiling, not a brake: the throttle permits roughly 50 credits/second, so a large cap could be consumed in under a minute by a runaway loop — the ceiling held, but nobody got a chance to notice. A second limit bounds the rate (SEC-1, fixed).

Value Behaviour
unset (default) derived: max(100, ceiling-in-force / 20) credits per 60s window
a positive integer your explicit per-minute allowance
off no rate brake — only the daily ceiling and the vendor balance bind

The divisor of 20 means a full day's budget takes at least ~20 minutes of sustained spending to exhaust. The floor of 100 is the price of the dearest single call (entity.labels at its exhaustive tier) — a limit below one call's cost would make that capability impossible rather than rate-limited. Where the floor exceeds what the daily cap allows, the daily cap binds first: the two guards compose, and the tighter one wins.

Checked and reserved inside the same transaction as the daily reservation, against a usage_window table in DATA_DIR — so two Claude Code sessions sharing one machine cannot each pass their own window check, and a process restart does not reset it.

The refusal says which limit stopped you, because the two call for opposite responses:

nansen budget gate refused: velocity limit (derived): 125 credits per 60s, need 10 —
the DAILY budget is not exhausted; retry after the window rolls over, or set
NANSEN_VELOCITY_CREDITS_PER_MIN to raise it, or off to disable it.

Known limitation: the window is tumbling, not sliding, so a burst straddling a boundary can reach 2× the allowance. That does not undermine the goal of buying a human time to notice.

NANSEN_MAX_CALLS_PER_MIN — the limit that can see a free call

Both limits above count credits, and entity.labels' query tier costs zero of them. For a 0-credit call, used + 0 > ceiling is false for the entire life of any bucket, under any cap — so no credit-denominated guard can ever refuse it, however low you set it. That is not a bug in the ceiling; it is what "denominated in credits" means. The fix is a different unit (Q-3, fixed).

Value Behaviour
unset (default) 60 calls per 60s window
a positive integer your explicit allowance
off calls are unbounded — only the credit limits apply

A fixed default, not a derived one — the asymmetry from the two credit limits is deliberate. Credit limits are derived because a free balance and a Pro balance differ by orders of magnitude. A call is a call on either plan: neither the vendor's rate limits nor cache-row pressure scales with your balance, so there is nothing to derive from. 60/minute is one sustained call per second — well above any interactive session, ~5× below what the throttle alone would permit.

It also bounds cache growth as a side effect: at 60 calls/min against a 3600s TTL, rows for that capability settle at ~3600 instead of growing without limit.

A call is never refunded. Reconciliation adjusts credits; the call count only goes up. The vendor round trip happened, and refunding it would let cheap-then-refunded calls walk past the limit that exists to bound exactly that traffic.

The refusal says so explicitly, because the credit knob will not help here:

nansen budget gate refused: call rate limit (default): 60 calls per 60s — this bound counts
CALLS, not credits, so it applies to zero-credit tiers too and raising a credit ceiling will
not move it.

Provenance gate

docs/provenance.json pins the sha256 of the golden test and every live-recorded vendor fixture. pnpm test checks the working tree; .githooks/pre-commit checks what is actually staged. Editing a pinned file without re-baselining in the same commit turns both red (RF-2, fixed).

Enable the hook once per clone — git hooks are local and cannot ship enabled:

git config core.hooksPath .githooks

Re-baseline deliberately, in the same commit as the change, so a reviewer sees both halves:

node scripts/verify-provenance.mjs --update

Behaviour without API keys

Missing keys degrade explicitly. The three paid tools return an MCP error naming the missing key; nothing else in the engine is affected:

{
  "content": [
    {
      "type": "text",
      "text": "capability unavailable: smart-money.flows on ethereum — tried: nansen (needs NANSEN_API_KEY)"
    }
  ],
  "isError": true
}

The key's value never appears in any error, log or cache key.

This is a deliberate design choice over silently falling back to a free provider: there is no free equivalent that means the same thing, and a quiet substitution would be worse than a loud error.


Caching

Two layers, one policy: an in-memory LRU in front of SQLite in DATA_DIR. The cache key is (provider, capability, normalized args)API keys are never part of it, so rotating a key does not invalidate your cache.

Capability TTL Why
pairs.new 30s freshness is the whole point
token.price 60s moves continuously
wallet.balances.* 60s moves continuously
protocol.tvl 300s slow-moving aggregate
smart-money.flows 300s contains a rolling 1-hour window
token.risk 1800s daily-ish scores, 6 cr per miss
token.metadata 3600s rarely changes
entity.labels 3600s labels change over days; up to 100 cr/miss

Negative caching. If a vendor answers successfully but the response cannot be normalized, that verdict is remembered for 60 seconds — so a retry fails the same way without paying again. Only deterministic failures are cached; transport errors, 429s and 5xx are not, because those can legitimately succeed on the next attempt.


Development and testing

pnpm lint          # eslint
pnpm format:check  # prettier
pnpm typecheck     # tsc --noEmit, strict + noUncheckedIndexedAccess
pnpm test          # vitest (run it for the count — a frozen number here only rots)
pnpm build         # tsup + declarations

Run the server from source without building:

pnpm --filter @onchain-intel/mcp-server dev

The whole suite runs offline and costs zero credits. Every provider response in the tests comes from a recorded fixture. You can prove it by blocking the network — the suite stays green with zero outgoing calls.


Troubleshooting

Tools do not appear in Claude Code. Run pnpm build first — .mcp.json points at dist/index.js, which does not exist until you build. Restart Claude Code after editing .mcp.json.

A paid tool returns needs NANSEN_API_KEY. The server reads .env from its working directory. Under Claude Code that is the project root, so .env must live there.

A paid tool is refused with a budget message. Expected — it means the guard is working. Check _meta.budget.creditsUsedToday, then either wait for the UTC day to roll over or raise NANSEN_DAILY_CREDIT_CAP.

Two machines, one Nansen account. Each installation keeps its own ledger in its own DATA_DIR, so a self-imposed cap is counted per installation. The vendor balance still binds both.

Everything looks stale. DATA_DIR defaults to ~/.onchain-intel. Deleting that directory resets the cache — and also the usage ledger, so the daily cap starts from zero.


Project layout

packages/core/          engine: adapters, canonical types, cache, budget guard
packages/mcp-server/    MCP server: 13 tools, env validation, stdio transport
docs/                   architecture, ADR, roadmap, issue ledger
n8n-workflows/          exported snapshotter workflows (separate always-on system)

Where to read more:

License: Apache-2.0.

About

Onchain intelligence platform for AI-agents

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages