A production-ready Model Context Protocol (MCP) server that probabilistically links EVM wallets across seven chains using pure onchain behavioral heuristics. No offchain data. No CEX info. No social profiles. Pure cryptographic and behavioral inference only.
A public Streamable HTTP endpoint is live — no install, no RPC keys, no .env:
Claude / Streamable HTTP URL: https://filament-production-84b7.up.railway.app/mcp
Drop this into Claude or any MCP-compatible client that supports remote Streamable HTTP servers:
{
"mcpServers": {
"filament": {
"type": "http",
"url": "https://filament-production-84b7.up.railway.app/mcp"
}
}
}
⚠️ This is a shared instance. Be polite — no tight loops, no bulk cluster sweeps. For heavy / production use, run your own (see Running the Server below).
# 1. Confirm the server is reachable
curl https://filament-production-84b7.up.railway.app/health
# → {"status":"ok","transport":["stdio","streamable-http","sse"],"tools":11,...}
# 2. Modern MCP clients POST JSON-RPC to /mcp.
curl -sS -X POST https://filament-production-84b7.up.railway.app/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'/sse is retained only for older MCP clients that do not support
Streamable HTTP. Do not use it for Claude's remote connector.
Legacy endpoint:
https://filament-production-84b7.up.railway.app/sse
SSE clients open GET /sse, receive a sessionId, then send JSON-RPC
to POST /messages?sessionId=<id>.
Operators of meaningful onchain capital rarely keep all their assets behind a single address. They bridge, deploy contracts, fund counterparties, and fan out across L2s — sometimes to obscure themselves, often just because EVM wallets are free and chains are cheap. The result: a single underlying entity controls dozens of addresses, and the only way to stitch them back together is to read the chain itself.
Filament provides that stitching as a set of deterministic, auditable heuristics exposed over MCP. Every score comes with evidence; no signal is asserted on faith.
The core MCP surface is healthy when /mcp is used as the remote
connector URL: /health returns the expected transport list, and a
JSON-RPC tools/list POST returns all eleven tools. A browser GET to
/mcp intentionally returns 405 because Streamable HTTP clients use
POST.
Production dependencies currently audit clean with:
npm audit --omit=devFull npm audit may still report a moderate js-yaml issue through
Jest's development dependency tree. Do not use npm audit fix --force
blindly here; npm may propose a breaking Jest downgrade.
Live wallet tools can return no_data even when the connector is
healthy. That usually means the configured RPC cannot serve the needed
history/log query, or the wallet has no recent activity in the bounded
scan window. Check RPC reachability first:
npm run check:rpcFilament exposes eleven MCP tools that, together, answer a single question: given a wallet, which other wallets on which chains are likely controlled by the same operator, and how confident are we?
The tools cover identity stitching, deployment DNA matching, bridge fund-flow tracing, gas bidding fingerprints, EOA cluster analysis, contract interaction overlap, temporal correlation, address-entropy scoring, sanctions exposure mapping, and an aggregate confidence report.
+----------+ +-----------+ +-------------+
| Agent | <--> | MCP / | <--> | Heuristics |
| (LLM) | | stdio / | | (9 modules)|
| | | SSE | | |
+----------+ +-----------+ +-------------+
|
v
+--------------+
| Confidence |
| Engine |
+--------------+
|
v
+--------------+
| RPC layer | -- ethers v6 -> user-supplied RPCs
| (provider + |
| fallback) |
+--------------+
|
v
+--------------+
| Graph |
| (BFS + UF) |
+--------------+
src/server.jsregisters the eleven tool adapters and connects over either transport. stdio is the default (no env var needed); SSE is enabled withMCP_TRANSPORT=sseand serves onPORT(default3000) withGET /sse,POST /messages, andGET /health.src/rpc/provider.jsresolves per-chainJsonRpcProviders from environment variables. Missing URLs are tolerated — the chain is just skipped.src/rpc/fallback.jsretries transient failures up to 3 times and surfaces a typedRpcError.src/heuristics/*are pure functions:async run(input, ctx)returning{score, evidence, fired}.src/scoring/confidenceEngine.jsaggregates the per-heuristic scores using declared weights and projects them onto a tier (High / Probable / Speculative).src/graph/*provides bounded BFS traversal and connected-component clustering.
| Chain | Chain ID | Env variable | Native symbol |
|---|---|---|---|
| Ethereum | 1 | RPC_ETHEREUM |
ETH |
| Arbitrum | 42161 | RPC_ARBITRUM |
ETH |
| Optimism | 10 | RPC_OPTIMISM |
ETH |
| Base | 8453 | RPC_BASE |
ETH |
| Mantle | 5000 | RPC_MANTLE |
MNT |
| Polygon | 137 | RPC_POLYGON |
MATIC |
| BNB | 56 | RPC_BNB |
BNB |
A chain is "supported" if it appears in src/config/chains.js. Whether
Filament actually queries it depends on whether the corresponding
RPC_* environment variable is set.
Primary identity stitching across chains. Runs all heuristics and returns a confidence-scored cluster.
Input:
{ "wallet": "0x000000000000000000000000000000000000a11e", "chains": ["ethereum", "arbitrum", "base"] }Example output (truncated):
{
"wallet": "0x000000000000000000000000000000000000a11e",
"chains": ["ethereum", "arbitrum", "base"],
"depth": 2,
"perChain": {
"ethereum": { "ok": true, "depth": 2 },
"arbitrum": { "ok": true, "depth": 2 },
"base": { "skipped": true, "reason": "no_rpc_configured" }
},
"heuristicScores": {
"noncePattern": { "score": 0.62, "fired": true, "evidence": { "similarity": 0.62 } },
"deploymentDna": { "score": 0.20, "fired": true, "evidence": { "collisions": 1 } },
"bridgeHop": { "score": 0.65, "fired": true, "evidence": { "hops": 4 } },
"gasBehavior": { "score": 0.55, "fired": true, "evidence": { "bestCosine": 0.55 } },
"eoaCluster": { "score": 0.71, "fired": true, "evidence": { "reciprocity": 0.7 } },
"contractOverlap": { "score": 0.00, "fired": false, "evidence": { "reason": "pairwise_heuristic", "tool": "contract_interaction_overlap" } },
"temporalCorrelation": { "score": 0.60, "fired": true, "evidence": { "topPairs": [] } },
"entropyScorer": { "score": 0.10, "fired": false, "evidence": { "reason": "no_data" } },
"sanctionProximity": { "score": 0.40, "fired": true, "evidence": { "hop": 1 } }
},
"score": 0.46,
"tier": "Probable",
"breakdown": [
{ "heuristic": "noncePattern", "weight": 0.10, "score": 0.62, "contribution": 0.062, "fired": true },
{ "heuristic": "deploymentDna", "weight": 0.15, "score": 0.20, "contribution": 0.030, "fired": true }
],
"linkedWallets": ["0x000000000000000000000000000000000000b0bb"],
"graphPayload": { "nodes": ["0x…a11e", "0x…b0bb"], "edges": [] },
"weights": { "noncePattern": 0.10, "deploymentDna": 0.15, "bridgeHop": 0.10, "gasBehavior": 0.10, "eoaCluster": 0.15, "contractOverlap": 0.15, "temporalCorrelation": 0.10, "entropyScorer": 0.05, "sanctionProximity": 0.10 }
}The pairwise contract_interaction_overlap heuristic is intentionally
not invoked from this tool (it requires a second wallet); callers
needing it should use the dedicated contract_interaction_overlap
tool.
Compares per-chain nonce sequences to score temporal fingerprint similarity. Detects sleep cycles and burst patterns.
Example input:
{ "wallet": "0x000000000000000000000000000000000000a11e", "chains": ["ethereum", "base"] }Example output (truncated):
{
"wallet": "0x000000000000000000000000000000000000a11e",
"chains": ["ethereum", "base"],
"similarity": 0.95,
"pattern": [
{ "chain": "ethereum", "mean": 1.0 },
{ "chain": "base", "mean": 1.0 }
],
"fired": true
}Extracts constructor byte patterns, CREATE2 salts, and init-code hashes across chains. Wallets that deploy contracts with identical DNA almost certainly share an operator.
Input: { "wallet": "0x..." }
Follows bridge interactions across Stargate, Across, Hop, and LayerZero.
Returns a directed graph of fund flows up to depth hops.
Example input:
{ "wallet": "0x000000000000000000000000000000000000a11e", "depth": 2 }Example output (truncated):
{
"wallet": "0x000000000000000000000000000000000000a11e",
"depth": 2,
"graph": {
"nodes": ["0x0000...a11e", "0x0000...0a01", "0x0000...0a02"],
"edges": [
{ "from": "0x0000...a11e", "to": "0x0000...0a01", "fromChain": "ethereum", "toChain": "arbitrum", "bridge": "stargate", "amount": 5 },
{ "from": "0x0000...0a01", "to": "0x0000...0a02", "fromChain": "arbitrum", "toChain": "base", "bridge": "hop", "amount": 4 }
]
},
"score": 0.65
}Profiles gas-price bidding behaviour — fast gas preference, base-fee hugging, tip precision. Gas behaviour is unique per operator and a strong cross-chain signal.
Builds the funded/funder adjacency graph and surfaces central wallets inside the cluster.
Given two wallets, scores overlap in obscure contract interactions. Long tail is signal; Uniswap-level apps are noise.
Cross-correlates activity timestamps for a list of wallets. Wallets active at identical unusual hours (e.g. 04:00 UTC) score high.
Detects low-entropy derivation patterns in wallet-generated child addresses — sequential suffixes, repeated bytes, vanity patterns.
Maps cluster exposure to sanctioned addresses up to N hops. Returns an exposure tree per cluster member.
Aggregates every heuristic score into a single structured report. The final confidence tier plus the supporting evidence per linked wallet.
git clone https://github.com/comzzy-comzzy/filament.git
cd filament
npm install
cp .env.example .env # then fill in your RPC URLsFilament targets Node.js >= 18 and has no native dependencies.
RPC_ETHEREUM=https://...
RPC_ARBITRUM=https://...
RPC_OPTIMISM=https://...
RPC_BASE=https://...
RPC_MANTLE=https://...
RPC_POLYGON=https://...
RPC_BNB=https://...
RATE_LIMIT_MS=200
RPC_TIMEOUT_MS=10000
MAX_GRAPH_DEPTH=3
CACHE_TTL_SECONDS=300
HISTORY_BLOCKS=50000
MAX_LOG_BLOCK_RANGE=50000
MAX_LOGS_PER_CHAIN=500
MAX_EDGES=250
MAX_BLOCK_LOOKUPS=25
MAX_GAS_SAMPLES=5
MCP_TRANSPORT=sse # "stdio" (default) or "sse"
PORT=3000 # only used in SSE mode
All RPC_* entries are optional; missing URLs cause the chain to be
skipped, not the server to crash. The numeric variables control
rate limiting, per-RPC timeout, graph depth, cache TTL, and the amount of chain history
queried for each hosted tool call. Raise the history and result caps on
dedicated deep-scan deployments; keep the defaults for Claude/Railway
connectors that need to return quickly. The two transport variables
select stdio vs. HTTP mode and the HTTP port.
Filament asks RPCs for recent logs, transaction details, and block
timestamps. Public/free endpoints may accept simple block-number calls
but reject broad getLogs windows, return sparse history, or rate-limit
quickly. For reliable wallet investigation, prefer an indexed provider
such as Alchemy or another paid/full-history RPC for the chains you care
about.
Use the built-in checker after editing .env:
npm run check:rpcIt prints one line per configured chain and exits non-zero if a configured RPC cannot return a block number. It masks endpoint URLs in the output.
Filament supports two transports, selected by the MCP_TRANSPORT env
var. stdio is the default — no config required.
| Mode | Command | Endpoint | Use case |
|---|---|---|---|
stdio |
npm start |
local child process | Local agent, Cursor, IDE |
sse |
npm run start:sse |
http://localhost:3000/mcp (legacy /sse also available) |
Claude / remote / hosted use |
npm start # or: npm run start:stdioFilament speaks MCP over stdio, so any MCP-compatible client can connect to it directly:
{
"mcpServers": {
"filament": {
"command": "node",
"args": ["/absolute/path/to/filament/src/server.js"]
}
}
}npm run start:sse # MCP_TRANSPORT=sse PORT=3000 node src/server.jsThis starts an Express server on PORT (default 3000) exposing:
| Method | Path | Purpose |
|---|---|---|
POST |
/mcp |
Streamable HTTP MCP endpoint |
GET |
/sse |
Open an MCP SSE session (returns sessionId) |
POST |
/messages?sessionId=… |
Send a JSON-RPC message to that session |
GET |
/health |
Liveness + tool count |
* |
* |
CORS is enabled on every endpoint |
On startup the server logs:
Filament MCP server running on http://localhost:3000/mcp
Legacy SSE endpoint available at http://localhost:3000/sse
Connect from an MCP client:
{
"mcpServers": {
"filament": {
"type": "http",
"url": "http://localhost:3000/mcp"
}
}
}Some older clients still require the legacy SSE endpoint:
{
"mcpServers": {
"filament": {
"type": "sse",
"url": "http://localhost:3000/sse"
}
}
}Smoke-test the endpoints manually:
# 1. Liveness
curl http://localhost:3000/health
# → {"status":"ok","transport":["stdio","streamable-http","sse"],"tools":11,...}
# 2. Streamable HTTP tools list
curl -sS -X POST http://localhost:3000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# 3. Legacy SSE stream
curl -N http://localhost:3000/sse
# → event: endpoint
# data: /messages?sessionId=8e0c...Skip the install entirely and point any MCP client at:
https://filament-production-84b7.up.railway.app/mcp
{
"mcpServers": {
"filament": {
"type": "http",
"url": "https://filament-production-84b7.up.railway.app/mcp"
}
}
}This is the same server, hosted on Railway, with the operator's own RPC URLs already wired in. Be polite — it's a shared instance.
MCP_TRANSPORT=sseswitches the server to HTTP mode. Anything else (or unset) keeps stdio mode active.PORTis only consumed in HTTP mode and defaults to3000.npm startandnpm run start:stdioare aliases for the same stdio command — pick whichever fits your tooling./mcpis the preferred remote endpoint./sseand/messagesremain for older MCP clients that only support HTTP+SSE.- CORS is wide-open (
*) on every route. Tighten it for production by replacing the bareapp.use(cors())call insrc/server.js.
Three runnable examples ship in examples/:
npm run example:basic # stitch + report + sanctions
npm run example:trace # bridge trace + temporal + nonce match
npm run example:sanctions # cluster + per-member sanctions + final reportEach example uses an in-process FakeMcpClient so it runs without an
MCP transport. example:basic is fully seeded with example context, so
its stitch_identity output demonstrates a positive identity result
without requiring live RPC access.
npm testThe Jest suite covers address validation, cache TTL, rate-limit pacing, graph depth cap, clustering, confidence engine aggregation, all nine heuristics (mocked), and tool schema validation.
filament/
├── src/
│ ├── server.js # MCP protocol entry point
│ ├── heuristics/ # 9 scoring modules
│ ├── graph/ # BFS + clustering
│ ├── rpc/ # provider + fallback
│ ├── scoring/ # confidence engine
│ ├── tools/ # 11 MCP tool adapters
│ ├── utils/ # checksum, cache, rateLimit, errors
│ ├── config/ # chain + bridge registries
│ └── data/ # static sanctions sample
├── examples/ # 3 agent workflow scripts
├── tests/ # Jest suites
├── docs/architecture.md # design document
├── .env.example
├── package.json
└── README.md
- Pure onchain signals only. Filament never consults CEX KYC, social profiles, or any offchain API. Every heuristic is reproducible from chain data alone.
- Custom BFS instead of a graph library. Wallet graphs are small (≤ a few thousand nodes) and the visit-order is part of the contract; pulling in a heavyweight library is not worth it.
- Per-call TTL cache, not a shared store. Cache keys are derived
from
(wallet, chain, depth, heuristic)and expired in-process; no disk persistence and no cross-instance invalidation. - Serial rate limiter. The RPC hot path is sequential async/await,
so a queue-based gate is overkill. We enforce
RATE_LIMIT_MSbetween consecutive outbound calls and tolerate concurrent callers. - Weighted score + tier. Heuristic scores in
[0, 1]are combined with declared weights summing to 1.0, then projected onto High / Probable / Speculative. - Typed errors.
InvalidAddressError,RpcError,SchemaError,RateLimitErrorall derive fromFilamentErrorso callers can pattern-match onerr.name. - JSON-only across the MCP boundary. Buffers, classes, and streams are never serialised to the client.
MIT. See LICENSE.