MCP Etherscan Server

  • src
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 } from "./services/etherscanService.js"; import { z } from "zod"; // Load environment variables config(); const apiKey = process.env.ETHERSCAN_API_KEY; if (!apiKey) { throw new Error("ETHERSCAN_API_KEY environment variable is required"); } // Initialize Etherscan service const etherscanService = new EtherscanService(apiKey); // Create server instance const server = new Server( { name: "etherscan-server", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); // Define schemas for validation const AddressSchema = z.object({ address: z .string() .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format"), }); const TransactionHistorySchema = z.object({ address: z .string() .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format"), 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"), 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"), }); // 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}$", }, }, 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}$", }, 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}$", }, 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}$", }, }, required: ["address"], }, }, { name: "get-gas-prices", description: "Get current gas prices in Gwei", inputSchema: { type: "object", properties: {}, }, }, { name: "get-ens-name", description: "Get the ENS name for an Ethereum address", inputSchema: { type: "object", properties: { address: { type: "string", description: "Ethereum address (0x format)", pattern: "^0x[a-fA-F0-9]{40}$", }, }, required: ["address"], }, }, ], }; }); // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "check-balance") { try { const { address } = AddressSchema.parse(args); const balance = await etherscanService.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 etherscanService.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 etherscanService.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 etherscanService.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 etherscanService.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); const ensName = await etherscanService.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; } } throw new Error(`Unknown tool: ${name}`); }); // Start the server export async function startServer() { const transport = new StdioServerTransport(); try { await server.connect(transport); console.error("Server started successfully"); } catch (error) { console.error("Error starting server:", error); throw error; } }