This file is a merged representation of the entire codebase, combining all repository files into a single document.
Generated by Repomix on: 2025-01-13T18:58:54.279Z
================================================================
File Summary
================================================================
Purpose:
--------
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
File Format:
------------
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Multiple file entries, each consisting of:
a. A separator line (================)
b. The file path (File: path/to/file)
c. Another separator line
d. The full contents of the file
e. A blank line
Usage Guidelines:
-----------------
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
Notes:
------
- Some files may have been excluded based on .gitignore rules and Repomix's
configuration.
- Binary files are not included in this packed representation. Please refer to
the Repository Structure section for a complete list of file paths, including
binary files.
Additional Info:
----------------
================================================================
Directory Structure
================================================================
src/
services/
etherscanService.ts
exampleService.ts
index.ts
server.ts
.env.example
.gitignore
LICENSE
package.json
README.md
tsconfig.json
================================================================
Files
================================================================
================
File: src/services/etherscanService.ts
================
import { ethers } from 'ethers';
export type SupportedNetwork =
| 'mainnet'
| 'goerli'
| 'sepolia'
| 'arbitrum'
| 'optimism'
| 'polygon';
export const NETWORK_CHAIN_IDS: Record<SupportedNetwork, number> = {
mainnet: 1,
goerli: 5,
sepolia: 11155111,
arbitrum: 42161,
optimism: 10,
polygon: 137
};
export const NETWORK_API_URLS: Record<SupportedNetwork, string> = {
mainnet: 'https://api.etherscan.io/api',
goerli: 'https://api-goerli.etherscan.io/api',
sepolia: 'https://api-sepolia.etherscan.io/api',
arbitrum: 'https://api.arbiscan.io/api',
optimism: 'https://api-optimistic.etherscan.io/api',
polygon: 'https://api.polygonscan.com/api'
};
export interface Transaction {
hash: string;
from: string;
to: string;
value: string;
timestamp: number;
blockNumber: number;
}
export interface TokenTransfer {
token: string;
tokenName: string;
tokenSymbol: string;
from: string;
to: string;
value: string;
timestamp: number;
blockNumber: number;
}
export interface GasPrice {
safeGwei: string;
proposeGwei: string;
fastGwei: string;
}
export interface MinedBlock {
blockNumber: number;
blockReward: string;
timestamp: number;
blockMiner: string;
blockType: 'blocks' | 'uncles';
}
export interface InternalTransaction {
hash: string;
from: string;
to: string;
value: string;
timestamp: number;
blockNumber: number;
type: string;
traceId: string;
isError: boolean;
errCode: string;
}
export interface BlockInfo {
number: number;
timestamp: number;
hash: string;
parentHash: string;
nonce: string;
sha3Uncles: string;
logsBloom: string;
transactionsRoot: string;
stateRoot: string;
receiptsRoot: string;
miner: string;
difficulty: string;
totalDifficulty: string;
size: number;
extraData: string;
gasLimit: string;
gasUsed: string;
uncles: string[];
transactions: number;
}
export interface ContractSourceCode {
sourceName: string;
sourceCode: string;
abi: string;
contractName: string;
compilerVersion: string;
optimizationUsed: boolean;
runs: number;
constructorArguments: string;
evmVersion: string;
library: string;
licenseType: string;
proxy: boolean;
implementation: string;
swarmSource: string;
}
export interface VerifiedContract {
address: string;
name: string;
compiler: string;
version: string;
balance: string;
txCount: number;
timestamp: number;
optimization: boolean;
license: string;
}
export class EtherscanService {
private provider: ethers.EtherscanProvider;
private network: SupportedNetwork;
private apiUrl: string;
constructor(apiKey: string, network: SupportedNetwork = 'mainnet') {
this.network = network;
this.apiUrl = NETWORK_API_URLS[network];
this.provider = new ethers.EtherscanProvider(network, apiKey);
}
private async makeRequest(module: string, action: string, params: Record<string, string | number> = {}): Promise<any> {
const queryParams = new URLSearchParams({
module,
action,
...Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value.toString()])
),
apikey: this.provider.apiKey as string
});
const response = await fetch(`${this.apiUrl}?${queryParams}`);
const data = await response.json();
if (data.status !== "1" || !data.result) {
throw new Error(data.message || `Failed to fetch ${action} data`);
}
return data.result;
}
async getAddressBalance(address: string): Promise<{
address: string;
balanceInWei: bigint;
balanceInEth: string;
}> {
try {
// Validate the address
const validAddress = ethers.getAddress(address);
// Get balance in Wei
const balanceInWei = await this.provider.getBalance(validAddress);
// Convert to ETH
const balanceInEth = ethers.formatEther(balanceInWei);
return {
address: validAddress,
balanceInWei,
balanceInEth
};
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get balance: ${error.message}`);
}
throw error;
}
}
async getTransactionHistory(address: string, limit: number = 10): Promise<Transaction[]> {
try {
const validAddress = ethers.getAddress(address);
const result = await this.makeRequest('account', 'txlist', {
address: validAddress,
startblock: 0,
endblock: 99999999,
page: 1,
offset: limit,
sort: 'desc'
});
// Format the results
return result.slice(0, limit).map((tx: any) => ({
hash: tx.hash,
from: tx.from,
to: tx.to || 'Contract Creation',
value: ethers.formatEther(tx.value),
timestamp: parseInt(tx.timeStamp) || 0,
blockNumber: parseInt(tx.blockNumber) || 0
}));
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get transaction history: ${error.message}`);
}
throw error;
}
}
async getTokenTransfers(address: string, limit: number = 10): Promise<TokenTransfer[]> {
try {
const validAddress = ethers.getAddress(address);
const result = await this.makeRequest('account', 'tokentx', {
address: validAddress,
page: 1,
offset: limit,
sort: 'desc'
});
// Format the results
return result.slice(0, limit).map((tx: any) => ({
token: tx.contractAddress,
tokenName: tx.tokenName,
tokenSymbol: tx.tokenSymbol,
from: tx.from,
to: tx.to,
value: ethers.formatUnits(tx.value, parseInt(tx.tokenDecimal)),
timestamp: parseInt(tx.timeStamp) || 0,
blockNumber: parseInt(tx.blockNumber) || 0
}));
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get token transfers: ${error.message}`);
}
throw error;
}
}
async getContractABI(address: string): Promise<string> {
try {
const validAddress = ethers.getAddress(address);
return await this.makeRequest('contract', 'getabi', {
address: validAddress
});
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get contract ABI: ${error.message}`);
}
throw error;
}
}
async getGasOracle(): Promise<GasPrice> {
try {
const result = await this.makeRequest('gastracker', 'gasoracle');
return {
safeGwei: result.SafeGasPrice,
proposeGwei: result.ProposeGasPrice,
fastGwei: result.FastGasPrice
};
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get gas prices: ${error.message}`);
}
throw error;
}
}
async getENSName(address: string): Promise<string | null> {
try {
const validAddress = ethers.getAddress(address);
return await this.provider.lookupAddress(validAddress);
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get ENS name: ${error.message}`);
}
throw error;
}
}
async getMinedBlocks(
address: string,
blockType: 'blocks' | 'uncles' = 'blocks',
page: number = 1,
offset: number = 10,
startBlock?: number,
endBlock?: number
): Promise<MinedBlock[]> {
try {
const validAddress = ethers.getAddress(address);
const params: Record<string, string | number> = {
address: validAddress,
blocktype: blockType,
page,
offset
};
if (startBlock !== undefined) {
params.startblock = startBlock;
}
if (endBlock !== undefined) {
params.endblock = endBlock;
}
const result = await this.makeRequest('account', 'getminedblocks', params);
// Format the results
return result.map((block: any) => ({
blockNumber: parseInt(block.blockNumber) || 0,
blockReward: ethers.formatEther(block.blockReward || '0'),
timestamp: parseInt(block.timeStamp) || 0,
blockMiner: block.blockMiner,
blockType
}));
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get mined blocks: ${error.message}`);
}
throw error;
}
}
async getInternalTransactions(
address: string,
page: number = 1,
offset: number = 10,
startBlock?: number,
endBlock?: number
): Promise<InternalTransaction[]> {
try {
const validAddress = ethers.getAddress(address);
const params: Record<string, string | number> = {
address: validAddress,
page,
offset
};
if (startBlock !== undefined) {
params.startblock = startBlock;
}
if (endBlock !== undefined) {
params.endblock = endBlock;
}
const result = await this.makeRequest('account', 'txlistinternal', params);
// Format the results
return result.map((tx: any) => ({
hash: tx.hash,
from: tx.from,
to: tx.to || 'Contract Creation',
value: ethers.formatEther(tx.value),
timestamp: parseInt(tx.timeStamp) || 0,
blockNumber: parseInt(tx.blockNumber) || 0,
type: tx.type,
traceId: tx.traceId,
isError: tx.isError === '1',
errCode: tx.errCode
}));
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get internal transactions: ${error.message}`);
}
throw error;
}
}
async getBlockByNumber(blockNumber: number): Promise<BlockInfo> {
try {
const hexBlockNumber = '0x' + blockNumber.toString(16);
const result = await this.makeRequest('proxy', 'eth_getBlockByNumber', {
tag: hexBlockNumber,
boolean: 'true'
});
if (!result) {
throw new Error('No data returned from proxy endpoint');
}
return {
number: blockNumber,
timestamp: parseInt(result.timestamp, 16),
hash: result.hash,
parentHash: result.parentHash,
nonce: result.nonce,
sha3Uncles: result.sha3Uncles,
logsBloom: result.logsBloom,
transactionsRoot: result.transactionsRoot,
stateRoot: result.stateRoot,
receiptsRoot: result.receiptsRoot,
miner: result.miner,
difficulty: result.difficulty,
totalDifficulty: result.totalDifficulty,
size: parseInt(result.size, 16),
extraData: result.extraData,
gasLimit: result.gasLimit,
gasUsed: result.gasUsed,
uncles: result.uncles || [],
transactions: Array.isArray(result.transactions) ? result.transactions.length : 0,
};
}
catch (proxyError) {
// Fallback to getblockreward endpoint, although it might not have all required data
try {
const rewardInfo = await this.makeRequest('block', 'getblockreward', {
blockno: blockNumber
});
return {
number: blockNumber,
timestamp: parseInt(rewardInfo.timeStamp) || 0,
hash: rewardInfo.blockHash,
parentHash: '',
nonce: '',
sha3Uncles: '',
logsBloom: '',
transactionsRoot: '',
stateRoot: '',
receiptsRoot: '',
miner: rewardInfo.blockMiner,
difficulty: '0',
totalDifficulty: '0',
size: 0,
extraData: '',
gasLimit: '0',
gasUsed: '0',
uncles: [],
transactions: 0,
}
}
catch (error) {
if(error instanceof Error) {
throw new Error(`Failed to get block information: ${error.message} (and failed to fetch from block reward endpoint too).`);
}
throw error;
}
}
}
async getContractSourceCode(address: string): Promise<ContractSourceCode> {
try {
const validAddress = ethers.getAddress(address);
const result = await this.makeRequest('contract', 'getsourcecode', {
address: validAddress
});
// Etherscan returns an array with a single item
const sourceInfo = result[0];
return {
sourceName: sourceInfo.SourceCode ? 'Contract.sol' : '',
sourceCode: sourceInfo.SourceCode || 'Source code not verified',
abi: sourceInfo.ABI,
contractName: sourceInfo.ContractName,
compilerVersion: sourceInfo.CompilerVersion,
optimizationUsed: sourceInfo.OptimizationUsed === '1',
runs: parseInt(sourceInfo.Runs) || 0,
constructorArguments: sourceInfo.ConstructorArguments,
evmVersion: sourceInfo.EVMVersion,
library: sourceInfo.Library,
licenseType: sourceInfo.LicenseType,
proxy: sourceInfo.Proxy === '1',
implementation: sourceInfo.Implementation,
swarmSource: sourceInfo.SwarmSource
};
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get contract source code: ${error.message}`);
}
throw error;
}
}
async getVerifiedContracts(
page: number = 1,
offset: number = 10,
sortBy: 'asc' | 'desc' = 'desc'
): Promise<VerifiedContract[]> {
try {
const result = await this.makeRequest('contract', 'listverifiedcontracts', {
page,
offset,
sort: sortBy,
filterby: '',
startblock: 0,
endblock: 99999999
});
return result.map((contract: any) => ({
address: contract.Address || contract.ContractAddress,
name: contract.ContractName,
compiler: contract.Compiler,
version: contract.CompilerVersion,
balance: ethers.formatEther(contract.Balance || '0'),
txCount: parseInt(contract.TxCount) || 0,
timestamp: parseInt(contract.VerifiedTimestamp || contract.TimeStamp) || 0,
optimization: contract.OptimizationUsed === '1',
license: contract.License || 'Unknown'
}));
} catch (error) {
if (error instanceof Error && error.message.includes('NOTOK')) {
console.error("Etherscan API returned NOTOK for verified contract list:", error);
throw new Error(`Failed to get verified contracts: Etherscan API returned an error. Check if your API key is valid or if the network supports this endpoint.`)
}
if (error instanceof Error) {
throw new Error(`Failed to get verified contracts: ${error.message}`);
}
throw error;
}
}
}
================
File: src/services/exampleService.ts
================
// Add your service logic here
export class ExampleService {
constructor() {
// Initialize your service
}
}
================
File: src/index.ts
================
#!/usr/bin/env node
import { startServer } from './server.js';
startServer().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
================
File: src/server.ts
================
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { config } from 'dotenv';
import { EtherscanService, SupportedNetwork, NETWORK_CHAIN_IDS } from './services/etherscanService.js';
import { z } from 'zod';
// Load environment variables
config();
const apiKey = process.env.ETHERSCAN_API_KEY;
const network = (process.env.NETWORK || 'mainnet') as SupportedNetwork;
if (!apiKey) {
throw new Error('ETHERSCAN_API_KEY environment variable is required');
}
// Initialize Etherscan service
const etherscanService = new EtherscanService(apiKey, network);
// Create server instance
const server = new Server(
{
name: "mcp-etherscan-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Define schemas for validation
const NetworkSchema = z.enum(['mainnet', 'goerli', 'sepolia', 'arbitrum', 'optimism', 'polygon'] as const);
const BlockTypeSchema = z.enum(['blocks', 'uncles'] as const);
const SortOrderSchema = z.enum(['asc', 'desc'] as const);
const AddressSchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'),
network: NetworkSchema.optional(),
});
const TransactionHistorySchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'),
network: NetworkSchema.optional(),
limit: z.number().min(1).max(100).optional(),
});
const TokenTransferSchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'),
network: NetworkSchema.optional(),
limit: z.number().min(1).max(100).optional(),
});
const ContractSchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'),
network: NetworkSchema.optional(),
});
const MinedBlocksSchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'),
network: NetworkSchema.optional(),
blockType: BlockTypeSchema.optional(),
page: z.number().min(1).optional(),
offset: z.number().min(1).max(100).optional(),
startBlock: z.number().min(0).optional(),
endBlock: z.number().min(0).optional(),
});
const InternalTransactionsSchema = z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, 'Invalid Ethereum address format'),
network: NetworkSchema.optional(),
page: z.number().min(1).optional(),
offset: z.number().min(1).max(100).optional(),
startBlock: z.number().min(0).optional(),
endBlock: z.number().min(0).optional(),
});
const BlockSchema = z.object({
blockNumber: z.number().min(0),
network: NetworkSchema.optional(),
});
const VerifiedContractsSchema = z.object({
network: NetworkSchema.optional(),
page: z.number().min(1).optional(),
offset: z.number().min(1).max(100).optional(),
sortBy: SortOrderSchema.optional(),
});
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "check-balance",
description: "Check the ETH balance of an Ethereum address",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Ethereum address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
},
required: ["address"],
},
},
{
name: "get-transactions",
description: "Get recent transactions for an Ethereum address",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Ethereum address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
limit: {
type: "number",
description: "Number of transactions to return (max 100)",
minimum: 1,
maximum: 100
},
},
required: ["address"],
},
},
{
name: "get-token-transfers",
description: "Get ERC20 token transfers for an Ethereum address",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Ethereum address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
limit: {
type: "number",
description: "Number of transfers to return (max 100)",
minimum: 1,
maximum: 100
},
},
required: ["address"],
},
},
{
name: "get-contract-abi",
description: "Get the ABI for a smart contract",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Contract address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
},
required: ["address"],
},
},
{
name: "get-gas-prices",
description: "Get current gas prices in Gwei",
inputSchema: {
type: "object",
properties: {
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
},
},
},
{
name: "get-ens-name",
description: "Get the ENS name for an Ethereum address (mainnet only)",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Ethereum address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
},
required: ["address"],
},
},
{
name: "get-mined-blocks",
description: "Get blocks mined by an address",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Ethereum address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
blockType: {
type: "string",
description: "Type of blocks to query",
enum: ["blocks", "uncles"],
default: "blocks"
},
page: {
type: "number",
description: "Page number for pagination",
minimum: 1
},
offset: {
type: "number",
description: "Number of results per page (max 100)",
minimum: 1,
maximum: 100
},
startBlock: {
type: "number",
description: "Starting block number",
minimum: 0
},
endBlock: {
type: "number",
description: "Ending block number",
minimum: 0
}
},
required: ["address"],
},
},
{
name: "get-internal-transactions",
description: "Get internal transactions for an Ethereum address",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Ethereum address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
page: {
type: "number",
description: "Page number for pagination",
minimum: 1
},
offset: {
type: "number",
description: "Number of results per page (max 100)",
minimum: 1,
maximum: 100
},
startBlock: {
type: "number",
description: "Starting block number",
minimum: 0
},
endBlock: {
type: "number",
description: "Ending block number",
minimum: 0
}
},
required: ["address"],
},
},
{
name: "get-block",
description: "Get detailed information about a specific block",
inputSchema: {
type: "object",
properties: {
blockNumber: {
type: "number",
description: "Block number to query",
minimum: 0
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
},
required: ["blockNumber"],
},
},
{
name: "get-contract-source",
description: "Get the source code and metadata for a verified smart contract",
inputSchema: {
type: "object",
properties: {
address: {
type: "string",
description: "Contract address (0x format)",
pattern: "^0x[a-fA-F0-9]{40}$"
},
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
},
required: ["address"],
},
},
{
name: "get-verified-contracts",
description: "Get a list of recently verified contracts",
inputSchema: {
type: "object",
properties: {
network: {
type: "string",
description: "Network to query (default: mainnet)",
enum: ["mainnet", "goerli", "sepolia", "arbitrum", "optimism", "polygon"],
},
page: {
type: "number",
description: "Page number for pagination",
minimum: 1
},
offset: {
type: "number",
description: "Number of results per page (max 100)",
minimum: 1,
maximum: 100
},
sortBy: {
type: "string",
description: "Sort order by verification time",
enum: ["asc", "desc"],
default: "desc"
}
},
},
},
],
};
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
// Create a new service instance if network is specified
const serviceForRequest = args && 'network' in args
? new EtherscanService(apiKey, args.network as SupportedNetwork)
: etherscanService;
if (name === "check-balance") {
try {
const { address } = AddressSchema.parse(args);
const balance = await serviceForRequest.getAddressBalance(address);
const response = `Address: ${balance.address}\nBalance: ${balance.balanceInEth} ETH`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-transactions") {
try {
const { address, limit } = TransactionHistorySchema.parse(args);
const transactions = await serviceForRequest.getTransactionHistory(address, limit);
const formattedTransactions = transactions.map(tx => {
const date = new Date(tx.timestamp * 1000).toLocaleString();
return `Block ${tx.blockNumber} (${date}):\n` +
`Hash: ${tx.hash}\n` +
`From: ${tx.from}\n` +
`To: ${tx.to}\n` +
`Value: ${tx.value} ETH\n` +
`---`;
}).join('\n');
const response = transactions.length > 0
? `Recent transactions for ${address}:\n\n${formattedTransactions}`
: `No transactions found for ${address}`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-token-transfers") {
try {
const { address, limit } = TokenTransferSchema.parse(args);
const transfers = await serviceForRequest.getTokenTransfers(address, limit);
const formattedTransfers = transfers.map(tx => {
const date = new Date(tx.timestamp * 1000).toLocaleString();
return `Block ${tx.blockNumber} (${date}):\n` +
`Token: ${tx.tokenName} (${tx.tokenSymbol})\n` +
`From: ${tx.from}\n` +
`To: ${tx.to}\n` +
`Value: ${tx.value}\n` +
`Contract: ${tx.token}\n` +
`---`;
}).join('\n');
const response = transfers.length > 0
? `Recent token transfers for ${address}:\n\n${formattedTransfers}`
: `No token transfers found for ${address}`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-contract-abi") {
try {
const { address } = ContractSchema.parse(args);
const abi = await serviceForRequest.getContractABI(address);
return {
content: [{ type: "text", text: `Contract ABI for ${address}:\n\n${abi}` }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-gas-prices") {
try {
const prices = await serviceForRequest.getGasOracle();
const response = `Current Gas Prices:\n` +
`Safe Low: ${prices.safeGwei} Gwei\n` +
`Standard: ${prices.proposeGwei} Gwei\n` +
`Fast: ${prices.fastGwei} Gwei`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
throw error;
}
}
if (name === "get-ens-name") {
try {
const { address } = AddressSchema.parse(args);
// ENS is only available on mainnet
const mainnetService = new EtherscanService(apiKey, 'mainnet');
const ensName = await mainnetService.getENSName(address);
const response = ensName
? `ENS name for ${address}: ${ensName}`
: `No ENS name found for ${address}`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-mined-blocks") {
try {
const { address, blockType = 'blocks', page, offset, startBlock, endBlock } = MinedBlocksSchema.parse(args);
const blocks = await serviceForRequest.getMinedBlocks(address, blockType, page, offset, startBlock, endBlock);
const formattedBlocks = blocks.map(block => {
const date = new Date(block.timestamp * 1000).toLocaleString();
return `Block ${block.blockNumber} (${date}):\n` +
`Type: ${block.blockType}\n` +
`Miner: ${block.blockMiner}\n` +
`Reward: ${block.blockReward} ETH\n` +
`---`;
}).join('\n');
const response = blocks.length > 0
? `Mined blocks for ${address}:\n\n${formattedBlocks}`
: `No mined blocks found for ${address}`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-internal-transactions") {
try {
const { address, page, offset, startBlock, endBlock } = InternalTransactionsSchema.parse(args);
const transactions = await serviceForRequest.getInternalTransactions(address, page, offset, startBlock, endBlock);
const formattedTransactions = transactions.map(tx => {
const date = new Date(tx.timestamp * 1000).toLocaleString();
const status = tx.isError ? `Error: ${tx.errCode}` : 'Success';
return `Block ${tx.blockNumber} (${date}):\n` +
`Hash: ${tx.hash}\n` +
`From: ${tx.from}\n` +
`To: ${tx.to}\n` +
`Value: ${tx.value} ETH\n` +
`Type: ${tx.type}\n` +
`Status: ${status}\n` +
`Trace ID: ${tx.traceId}\n` +
`---`;
}).join('\n');
const response = transactions.length > 0
? `Internal transactions for ${address}:\n\n${formattedTransactions}`
: `No internal transactions found for ${address}`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-block") {
try {
const { blockNumber } = BlockSchema.parse(args);
const block = await serviceForRequest.getBlockByNumber(blockNumber);
const date = new Date(block.timestamp * 1000).toLocaleString();
const response = `Block ${block.number} (${date}):\n` +
`Hash: ${block.hash}\n` +
`Parent Hash: ${block.parentHash}\n` +
`Miner: ${block.miner}\n` +
`Size: ${block.size} bytes\n` +
`Gas Used: ${parseInt(block.gasUsed, 16).toLocaleString()} (${((parseInt(block.gasUsed, 16) / parseInt(block.gasLimit, 16)) * 100).toFixed(2)}%)\n` +
`Gas Limit: ${parseInt(block.gasLimit, 16).toLocaleString()}\n` +
`Transactions: ${block.transactions}\n` +
`Uncles: ${block.uncles.length}\n` +
`Nonce: ${block.nonce}\n` +
`Difficulty: ${parseInt(block.difficulty, 16).toLocaleString()}\n` +
`Extra Data: ${block.extraData}`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-contract-source") {
try {
const { address } = ContractSchema.parse(args);
const sourceCode = await serviceForRequest.getContractSourceCode(address);
const proxyInfo = sourceCode.proxy
? `\nProxy: Yes\nImplementation: ${sourceCode.implementation}`
: '\nProxy: No';
const response = `Contract Source Code for ${address}:\n\n` +
`Name: ${sourceCode.contractName}\n` +
`Compiler: ${sourceCode.compilerVersion}\n` +
`License: ${sourceCode.licenseType}\n` +
`Optimization: ${sourceCode.optimizationUsed ? `Yes (${sourceCode.runs} runs)` : 'No'}\n` +
`EVM Version: ${sourceCode.evmVersion}${proxyInfo}\n\n` +
`Source Code:\n${sourceCode.sourceCode}`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
if (name === "get-verified-contracts") {
try {
const { page, offset, sortBy } = VerifiedContractsSchema.parse(args);
const contracts = await serviceForRequest.getVerifiedContracts(page, offset, sortBy);
const formattedContracts = contracts.map(contract => {
const date = new Date(contract.timestamp * 1000).toLocaleString();
return `Contract: ${contract.name}\n` +
`Address: ${contract.address}\n` +
`Verified: ${date}\n` +
`Compiler: ${contract.compiler} v${contract.version}\n` +
`Balance: ${contract.balance} ETH\n` +
`Transactions: ${contract.txCount}\n` +
`License: ${contract.license}\n` +
`Optimization: ${contract.optimization ? 'Yes' : 'No'}\n` +
`---`;
}).join('\n');
const response = contracts.length > 0
? `Recently verified contracts:\n\n${formattedContracts}`
: `No verified contracts found`;
return {
content: [{ type: "text", text: response }],
};
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Invalid input: ${error.errors.map(e => e.message).join(", ")}`);
}
throw error;
}
}
throw new Error(`Unknown tool: ${name}`);
});
// Start the server
export async function startServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`Etherscan MCP Server running on stdio (Network: ${network})`);
}
================
File: .env.example
================
PORT=3000
ETHERSCAN_API_KEY=your_api_key_here
# Optional: Network to use (default: mainnet)
# Supported networks: mainnet, goerli, sepolia, arbitrum, optimism, polygon
NETWORK=mainnet
================
File: .gitignore
================
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
build/
dist/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
================
File: LICENSE
================
MIT License
Copyright (c) 2024
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================
File: package.json
================
{
"name": "mcp-etherscan-server",
"version": "1.0.0",
"description": "A TypeScript server boilerplate",
"type": "module",
"main": "build/index.js",
"types": "build/index.d.ts",
"bin": {
"etherscan-server": "build/index.js"
},
"scripts": {
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
"prepublishOnly": "npm run build",
"start": "node build/index.js"
},
"files": [
"build",
"README.md",
"LICENSE"
],
"keywords": [
"typescript",
"server",
"boilerplate"
],
"author": "",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"dotenv": "^16.0.0",
"ethers": "^6.9.0",
"zod": "^3.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=18"
}
}
================
File: README.md
================
# MCP Etherscan Server
An MCP (Model Context Protocol) server that provides Ethereum blockchain data tools via Etherscan's API. Features include checking ETH balances, viewing transaction history, tracking ERC20 transfers, fetching contract ABIs, monitoring gas prices, and resolving ENS names.
## Features
- **Balance Checking**: Get ETH balance for any Ethereum address
- **Transaction History**: View recent transactions with detailed information
- **Token Transfers**: Track ERC20 token transfers with token details
- **Contract ABI**: Fetch smart contract ABIs for development
- **Gas Prices**: Monitor current gas prices (Safe Low, Standard, Fast)
- **ENS Resolution**: Resolve Ethereum addresses to ENS names
## Prerequisites
- Node.js >= 18
- An Etherscan API key (get one at https://etherscan.io/apis)
## Installation
1. Clone the repository:
```bash
git clone [your-repo-url]
cd mcp-etherscan-server
```
2. Install dependencies:
```bash
npm install
```
3. Create a `.env` file in the root directory:
```bash
ETHERSCAN_API_KEY=your_api_key_here
```
4. Build the project:
```bash
npm run build
```
## Running the Server
Start the server:
```bash
npm start
```
The server will run on stdio, making it compatible with MCP clients like Claude Desktop.
## How It Works
This server implements the Model Context Protocol (MCP) to provide tools for interacting with Ethereum blockchain data through Etherscan's API. Each tool is exposed as an MCP endpoint that can be called by compatible clients.
### Available Tools
1. `check-balance`
- Input: Ethereum address
- Output: ETH balance in both Wei and ETH
2. `get-transactions`
- Input: Ethereum address, optional limit
- Output: Recent transactions with timestamps, values, and addresses
3. `get-token-transfers`
- Input: Ethereum address, optional limit
- Output: Recent ERC20 token transfers with token details
4. `get-contract-abi`
- Input: Contract address
- Output: Contract ABI in JSON format
5. `get-gas-prices`
- Input: None
- Output: Current gas prices in Gwei
6. `get-ens-name`
- Input: Ethereum address
- Output: Associated ENS name if available
## Using with Claude Desktop
To add this server to Claude Desktop:
1. Start the server using `npm start`
2. In Claude Desktop:
- Go to Settings
- Navigate to the MCP Servers section
- Click "Add Server"
- Enter the following configuration:
```json
{
"name": "Etherscan Tools",
"transport": "stdio",
"command": "node /path/to/mcp-etherscan-server/build/index.js"
}
```
- Save the configuration
3. The Etherscan tools will now be available in your Claude conversations
### Example Usage in Claude
You can use commands like:
```
Check the balance of 0x742d35Cc6634C0532925a3b844Bc454e4438f44e
```
or
```
Show me recent transactions for vitalik.eth
```
## Development
To add new features or modify existing ones:
1. The main server logic is in `src/server.ts`
2. Etherscan API interactions are handled in `src/services/etherscanService.ts`
3. Build after changes: `npm run build`
## License
MIT License - See LICENSE file for details
================
File: tsconfig.json
================
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}