diff --git a/rpc/mcp/handlers_logs.go b/rpc/mcp/handlers_logs.go index 62b87ae7096..0c3c975b512 100644 --- a/rpc/mcp/handlers_logs.go +++ b/rpc/mcp/handlers_logs.go @@ -3,6 +3,7 @@ package mcp import ( "bufio" "context" + "errors" "fmt" "os" "path/filepath" @@ -11,42 +12,35 @@ import ( "github.com/mark3labs/mcp-go/mcp" ) -// handleLogsTail handles the logs_tail tool -func (e *ErigonMCPServer) handleLogsTail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - logType := req.GetString("log_type", "erigon") - lines := req.GetInt("lines", 100) - filter := req.GetString("filter", "") +// logTools implements the logs_* tool handlers; it is embedded by both the +// embedded and standalone MCP servers. +type logTools struct { + logDir string +} - if lines <= 0 || lines > 10000 { - return mcp.NewToolResultError("lines must be between 1 and 10000"), nil +func (l logTools) resolveLogFile(logType string) (string, error) { + if l.logDir == "" { + return "", errors.New("log directory not configured (use --log.dir or --datadir)") } - - var logFile string switch logType { case "erigon": - logFile = filepath.Join(e.logDir, "erigon.log") + return filepath.Join(l.logDir, "erigon.log"), nil case "torrent": - logFile = filepath.Join(e.logDir, "torrent.log") + return filepath.Join(l.logDir, "torrent.log"), nil default: - return mcp.NewToolResultError("log_type must be 'erigon' or 'torrent'"), nil - } - - logLines, err := readLogTail(logFile, lines, filter) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to read log: %v", err)), nil + return "", errors.New("log_type must be 'erigon' or 'torrent'") } +} - result := fmt.Sprintf("Last %d lines from %s.log", len(logLines), logType) - if filter != "" { - result += fmt.Sprintf(" (filtered by: %s)", filter) - } - result += ":\n\n" + strings.Join(logLines, "\n") +func (l logTools) handleLogsTail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return l.readLogLines(req, "Last", readLogTail) +} - return mcp.NewToolResultText(result), nil +func (l logTools) handleLogsHead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return l.readLogLines(req, "First", readLogHead) } -// handleLogsHead handles the logs_head tool -func (e *ErigonMCPServer) handleLogsHead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (l logTools) readLogLines(req mcp.CallToolRequest, position string, read func(string, int, string) ([]string, error)) (*mcp.CallToolResult, error) { logType := req.GetString("log_type", "erigon") lines := req.GetInt("lines", 100) filter := req.GetString("filter", "") @@ -55,22 +49,17 @@ func (e *ErigonMCPServer) handleLogsHead(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultError("lines must be between 1 and 10000"), nil } - var logFile string - switch logType { - case "erigon": - logFile = filepath.Join(e.logDir, "erigon.log") - case "torrent": - logFile = filepath.Join(e.logDir, "torrent.log") - default: - return mcp.NewToolResultError("log_type must be 'erigon' or 'torrent'"), nil + logFile, err := l.resolveLogFile(logType) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - logLines, err := readLogHead(logFile, lines, filter) + logLines, err := read(logFile, lines, filter) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to read log: %v", err)), nil } - result := fmt.Sprintf("First %d lines from %s.log", len(logLines), logType) + result := fmt.Sprintf("%s %d lines from %s.log", position, len(logLines), logType) if filter != "" { result += fmt.Sprintf(" (filtered by: %s)", filter) } @@ -79,8 +68,7 @@ func (e *ErigonMCPServer) handleLogsHead(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultText(result), nil } -// handleLogsGrep handles the logs_grep tool -func (e *ErigonMCPServer) handleLogsGrep(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (l logTools) handleLogsGrep(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { logType := req.GetString("log_type", "erigon") pattern := req.GetString("pattern", "") maxLines := req.GetInt("max_lines", 1000) @@ -94,14 +82,9 @@ func (e *ErigonMCPServer) handleLogsGrep(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultError("max_lines must be between 1 and 10000"), nil } - var logFile string - switch logType { - case "erigon": - logFile = filepath.Join(e.logDir, "erigon.log") - case "torrent": - logFile = filepath.Join(e.logDir, "torrent.log") - default: - return mcp.NewToolResultError("log_type must be 'erigon' or 'torrent'"), nil + logFile, err := l.resolveLogFile(logType) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } logLines, err := grepLog(logFile, pattern, maxLines, caseInsensitive) @@ -115,18 +98,12 @@ func (e *ErigonMCPServer) handleLogsGrep(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultText(result), nil } -// handleLogsStats handles the logs_stats tool -func (e *ErigonMCPServer) handleLogsStats(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func (l logTools) handleLogsStats(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { logType := req.GetString("log_type", "erigon") - var logFile string - switch logType { - case "erigon": - logFile = filepath.Join(e.logDir, "erigon.log") - case "torrent": - logFile = filepath.Join(e.logDir, "torrent.log") - default: - return mcp.NewToolResultError("log_type must be 'erigon' or 'torrent'"), nil + logFile, err := l.resolveLogFile(logType) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } stats, err := getLogStats(logFile) diff --git a/rpc/mcp/mcp.go b/rpc/mcp/mcp.go index 8a5a3ad96be..69fc1bd1810 100644 --- a/rpc/mcp/mcp.go +++ b/rpc/mcp/mcp.go @@ -39,7 +39,7 @@ type ErigonMCPServer struct { ethAPI jsonrpc.EthAPI erigonAPI jsonrpc.ErigonAPI otsAPI jsonrpc.OtterscanAPI - logDir string + logTools } // NewErigonMCPServer creates a new MCP server for Erigon. @@ -48,7 +48,7 @@ func NewErigonMCPServer(ethAPI jsonrpc.EthAPI, erigonAPI jsonrpc.ErigonAPI, otsA ethAPI: ethAPI, erigonAPI: erigonAPI, otsAPI: otsAPI, - logDir: logDir, + logTools: logTools{logDir: logDir}, } e.mcpServer = server.NewMCPServer( @@ -61,9 +61,10 @@ func NewErigonMCPServer(ethAPI jsonrpc.EthAPI, erigonAPI jsonrpc.ErigonAPI, otsA server.WithRecovery(), ) - e.registerTools() - e.registerPrompts() - e.registerResources() + registerTools(e.mcpServer, e.toolHandlers()) + e.mcpServer.AddTool(storageValuesTool(), e.handleGetStorageValues) + registerPrompts(e.mcpServer) + registerResources(e.mcpServer, e.resourceHandlers()) return e } @@ -77,366 +78,67 @@ func parseBlockNumberOrHash(s string) (rpc.BlockNumberOrHash, error) { return result, result.UnmarshalJSON(b) } -// registerTools registers all MCP tools -func (e *ErigonMCPServer) registerTools() { - // eth_blockNumber - e.mcpServer.AddTool(mcp.NewTool("eth_blockNumber", - mcp.WithDescription("Get the current block number"), - ), e.handleBlockNumber) - - // eth_getBlockByNumber - e.mcpServer.AddTool(mcp.NewTool("eth_getBlockByNumber", - mcp.WithDescription("Get block by number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), - mcp.WithBoolean("fullTransactions", mcp.Description("Return full tx objects")), - ), e.handleGetBlockByNumber) - - // eth_getBlockByHash - e.mcpServer.AddTool(mcp.NewTool("eth_getBlockByHash", - mcp.WithDescription("Get block by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - mcp.WithBoolean("fullTransactions", mcp.Description("Return full tx objects")), - ), e.handleGetBlockByHash) - - // eth_getBlockTransactionCountByNumber - e.mcpServer.AddTool(mcp.NewTool("eth_getBlockTransactionCountByNumber", - mcp.WithDescription("Get transaction count in block by number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - ), e.handleGetBlockTransactionCountByNumber) - - // eth_getBlockTransactionCountByHash - e.mcpServer.AddTool(mcp.NewTool("eth_getBlockTransactionCountByHash", - mcp.WithDescription("Get transaction count in block by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), e.handleGetBlockTransactionCountByHash) - - // eth_getBalance - e.mcpServer.AddTool(mcp.NewTool("eth_getBalance", - mcp.WithDescription("Get address balance"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("blockNumber", mcp.Description("Block number (default: latest)")), - ), e.handleGetBalance) - - // eth_getTransactionByHash - e.mcpServer.AddTool(mcp.NewTool("eth_getTransactionByHash", - mcp.WithDescription("Get transaction by hash"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), e.handleGetTransactionByHash) - - // eth_getTransactionByBlockHashAndIndex - e.mcpServer.AddTool(mcp.NewTool("eth_getTransactionByBlockHashAndIndex", - mcp.WithDescription("Get transaction by block hash and index"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Transaction index")), - ), e.handleGetTransactionByBlockHashAndIndex) - - // eth_getTransactionByBlockNumberAndIndex - e.mcpServer.AddTool(mcp.NewTool("eth_getTransactionByBlockNumberAndIndex", - mcp.WithDescription("Get transaction by block number and index"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Transaction index")), - ), e.handleGetTransactionByBlockNumberAndIndex) - - // eth_getTransactionReceipt - e.mcpServer.AddTool(mcp.NewTool("eth_getTransactionReceipt", - mcp.WithDescription("Get transaction receipt"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), e.handleGetTransactionReceipt) - - // eth_getBlockReceipts - e.mcpServer.AddTool(mcp.NewTool("eth_getBlockReceipts", - mcp.WithDescription("Get all receipts for a block"), - mcp.WithString("blockNumberOrHash", mcp.Required(), mcp.Description("Block number or hash")), - ), e.handleGetBlockReceipts) - - // eth_getLogs - e.mcpServer.AddTool(mcp.NewTool("eth_getLogs", - mcp.WithDescription("Get logs matching filter"), - mcp.WithString("fromBlock", mcp.Description("Start block")), - mcp.WithString("toBlock", mcp.Description("End block")), - mcp.WithString("address", mcp.Description("Contract address(es)")), - mcp.WithString("topics", mcp.Description("Topics array (JSON)")), - mcp.WithString("blockHash", mcp.Description("Single block hash")), - ), e.handleGetLogs) - - // eth_getCode - e.mcpServer.AddTool(mcp.NewTool("eth_getCode", - mcp.WithDescription("Get contract code"), - mcp.WithString("address", mcp.Required(), mcp.Description("Contract address")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), e.handleGetCode) - - // eth_getStorageAt - e.mcpServer.AddTool(mcp.NewTool("eth_getStorageAt", - mcp.WithDescription("Get storage at position"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("position", mcp.Required(), mcp.Description("Storage position")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), e.handleGetStorageAt) - - // eth_getStorageValues - e.mcpServer.AddTool(mcp.NewTool("eth_getStorageValues", - mcp.WithDescription("Get multiple storage slot values for multiple accounts in a single request"), - mcp.WithString("requests", mcp.Required(), mcp.Description("JSON object mapping addresses to arrays of storage slot keys")), - mcp.WithString("blockNumber", mcp.Description("Block number or tag (latest, earliest, pending)")), - ), e.handleGetStorageValues) - - // eth_getTransactionCount - e.mcpServer.AddTool(mcp.NewTool("eth_getTransactionCount", - mcp.WithDescription("Get nonce (transaction count)"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), e.handleGetTransactionCount) - - // eth_call - e.mcpServer.AddTool(mcp.NewTool("eth_call", - mcp.WithDescription("Execute call without transaction"), - mcp.WithString("to", mcp.Required(), mcp.Description("Contract address")), - mcp.WithString("data", mcp.Required(), mcp.Description("Call data")), - mcp.WithString("from", mcp.Description("Sender address")), - mcp.WithString("value", mcp.Description("Value (hex)")), - mcp.WithString("gas", mcp.Description("Gas limit (hex)")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), e.handleCall) - - // eth_estimateGas - e.mcpServer.AddTool(mcp.NewTool("eth_estimateGas", - mcp.WithDescription("Estimate gas for transaction"), - mcp.WithString("to", mcp.Description("To address")), - mcp.WithString("data", mcp.Description("Call data")), - mcp.WithString("from", mcp.Description("From address")), - mcp.WithString("value", mcp.Description("Value (hex)")), - ), e.handleEstimateGas) - - // eth_gasPrice - e.mcpServer.AddTool(mcp.NewTool("eth_gasPrice", - mcp.WithDescription("Get current gas price"), - ), e.handleGasPrice) - - // eth_chainId - e.mcpServer.AddTool(mcp.NewTool("eth_chainId", - mcp.WithDescription("Get chain ID"), - ), e.handleChainId) - - // eth_syncing - e.mcpServer.AddTool(mcp.NewTool("eth_syncing", - mcp.WithDescription("Get sync status"), - ), e.handleSyncing) - - // eth_accounts - e.mcpServer.AddTool(mcp.NewTool("eth_accounts", - mcp.WithDescription("Get accounts"), - ), e.handleAccounts) - - // eth_getProof - e.mcpServer.AddTool(mcp.NewTool("eth_getProof", - mcp.WithDescription("Get Merkle proof"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("storageKeys", mcp.Description("Storage keys (JSON array)")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), e.handleGetProof) - - // eth_coinbase - e.mcpServer.AddTool(mcp.NewTool("eth_coinbase", - mcp.WithDescription("Get coinbase address"), - ), e.handleCoinbase) - - // eth_mining - e.mcpServer.AddTool(mcp.NewTool("eth_mining", - mcp.WithDescription("Check if mining"), - ), e.handleMining) - - // eth_hashrate - e.mcpServer.AddTool(mcp.NewTool("eth_hashrate", - mcp.WithDescription("Get hashrate"), - ), e.handleHashrate) - - // eth_protocolVersion - e.mcpServer.AddTool(mcp.NewTool("eth_protocolVersion", - mcp.WithDescription("Get protocol version"), - ), e.handleProtocolVersion) - - // Uncle methods - e.mcpServer.AddTool(mcp.NewTool("eth_getUncleByBlockNumberAndIndex", - mcp.WithDescription("Get uncle by block number and index"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Uncle index")), - ), e.handleGetUncleByBlockNumberAndIndex) - - e.mcpServer.AddTool(mcp.NewTool("eth_getUncleByBlockHashAndIndex", - mcp.WithDescription("Get uncle by block hash and index"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Uncle index")), - ), e.handleGetUncleByBlockHashAndIndex) - - e.mcpServer.AddTool(mcp.NewTool("eth_getUncleCountByBlockNumber", - mcp.WithDescription("Get uncle count by block number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - ), e.handleGetUncleCountByBlockNumber) - - e.mcpServer.AddTool(mcp.NewTool("eth_getUncleCountByBlockHash", - mcp.WithDescription("Get uncle count by block hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), e.handleGetUncleCountByBlockHash) - - // Erigon-specific tools - e.mcpServer.AddTool(mcp.NewTool("erigon_forks", - mcp.WithDescription("Get fork information"), - ), e.handleErigonForks) - - e.mcpServer.AddTool(mcp.NewTool("erigon_blockNumber", - mcp.WithDescription("Get block number (Erigon)"), - mcp.WithString("blockNumber", mcp.Description("Block tag")), - ), e.handleErigonBlockNumber) - - e.mcpServer.AddTool(mcp.NewTool("erigon_getHeaderByNumber", - mcp.WithDescription("Get header by number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - ), e.handleErigonGetHeaderByNumber) - - e.mcpServer.AddTool(mcp.NewTool("erigon_getHeaderByHash", - mcp.WithDescription("Get header by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), e.handleErigonGetHeaderByHash) - - e.mcpServer.AddTool(mcp.NewTool("erigon_getBlockByTimestamp", - mcp.WithDescription("Get block by timestamp"), - mcp.WithString("timestamp", mcp.Required(), mcp.Description("Unix timestamp")), - mcp.WithBoolean("fullTransactions", mcp.Description("Full tx objects")), - ), e.handleErigonGetBlockByTimestamp) - - e.mcpServer.AddTool(mcp.NewTool("erigon_getBalanceChangesInBlock", - mcp.WithDescription("Get all balance changes in block"), - mcp.WithString("blockNumberOrHash", mcp.Required(), mcp.Description("Block")), - ), e.handleErigonGetBalanceChangesInBlock) - - e.mcpServer.AddTool(mcp.NewTool("erigon_getLogsByHash", - mcp.WithDescription("Get logs by block hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), e.handleErigonGetLogsByHash) - - e.mcpServer.AddTool(mcp.NewTool("erigon_getLogs", - mcp.WithDescription("Get logs (Erigon format)"), - mcp.WithString("fromBlock", mcp.Description("From block")), - mcp.WithString("toBlock", mcp.Description("To block")), - mcp.WithString("address", mcp.Description("Address")), - mcp.WithString("topics", mcp.Description("Topics (JSON)")), - ), e.handleErigonGetLogs) - - e.mcpServer.AddTool(mcp.NewTool("erigon_getBlockReceiptsByBlockHash", - mcp.WithDescription("Get block receipts by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), e.handleErigonGetBlockReceiptsByBlockHash) - - e.mcpServer.AddTool(mcp.NewTool("erigon_nodeInfo", - mcp.WithDescription("Get P2P node info"), - ), e.handleErigonNodeInfo) - - // Metrics tools - e.mcpServer.AddTool(mcp.NewTool("metrics_list", - mcp.WithDescription("List all available metric names"), - ), e.handleMetricsList) - - e.mcpServer.AddTool(mcp.NewTool("metrics_get", - mcp.WithDescription("Get metrics with optional filtering by pattern (supports wildcards like 'db_*', '*_size', etc.)"), - mcp.WithString("pattern", mcp.Description("Metric name pattern (optional, empty = all metrics)")), - ), e.handleMetricsGet) - - // Log tools - e.mcpServer.AddTool(mcp.NewTool("logs_tail", - mcp.WithDescription("Get last N lines from erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - mcp.WithNumber("lines", mcp.Description("Number of lines to retrieve (default: 100, max: 10000)")), - mcp.WithString("filter", mcp.Description("Optional string to filter log lines")), - ), e.handleLogsTail) - - e.mcpServer.AddTool(mcp.NewTool("logs_head", - mcp.WithDescription("Get first N lines from erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - mcp.WithNumber("lines", mcp.Description("Number of lines to retrieve (default: 100, max: 10000)")), - mcp.WithString("filter", mcp.Description("Optional string to filter log lines")), - ), e.handleLogsHead) - - e.mcpServer.AddTool(mcp.NewTool("logs_grep", - mcp.WithDescription("Search for a pattern in erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - mcp.WithString("pattern", mcp.Required(), mcp.Description("Search pattern")), - mcp.WithNumber("max_lines", mcp.Description("Maximum matching lines to return (default: 1000, max: 10000)")), - mcp.WithBoolean("case_insensitive", mcp.Description("Case-insensitive search (default: false)")), - ), e.handleLogsGrep) - - e.mcpServer.AddTool(mcp.NewTool("logs_stats", - mcp.WithDescription("Get statistics about erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - ), e.handleLogsStats) - - // Otterscan tools - e.mcpServer.AddTool(mcp.NewTool("ots_getApiLevel", - mcp.WithDescription("Get Otterscan API level"), - ), e.handleOtsGetApiLevel) - - e.mcpServer.AddTool(mcp.NewTool("ots_getInternalOperations", - mcp.WithDescription("Get internal operations (internal txs) for a transaction"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), e.handleOtsGetInternalOperations) - - e.mcpServer.AddTool(mcp.NewTool("ots_searchTransactionsBefore", - mcp.WithDescription("Search transactions before a given block for an address"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithNumber("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), - ), e.handleOtsSearchTransactionsBefore) - - e.mcpServer.AddTool(mcp.NewTool("ots_searchTransactionsAfter", - mcp.WithDescription("Search transactions after a given block for an address"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithNumber("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), - ), e.handleOtsSearchTransactionsAfter) - - e.mcpServer.AddTool(mcp.NewTool("ots_getBlockDetails", - mcp.WithDescription("Get detailed block information"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), - ), e.handleOtsGetBlockDetails) - - e.mcpServer.AddTool(mcp.NewTool("ots_getBlockDetailsByHash", - mcp.WithDescription("Get detailed block information by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), e.handleOtsGetBlockDetailsByHash) - - e.mcpServer.AddTool(mcp.NewTool("ots_getBlockTransactions", - mcp.WithDescription("Get paginated transactions for a block"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), - mcp.WithNumber("pageNumber", mcp.Description("Page number (default: 0)")), - mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), - ), e.handleOtsGetBlockTransactions) - - e.mcpServer.AddTool(mcp.NewTool("ots_hasCode", - mcp.WithDescription("Check if an address has code (is a contract)"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("blockNumber", mcp.Description("Block number or tag")), - ), e.handleOtsHasCode) - - e.mcpServer.AddTool(mcp.NewTool("ots_traceTransaction", - mcp.WithDescription("Get trace for a transaction"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), e.handleOtsTraceTransaction) - - e.mcpServer.AddTool(mcp.NewTool("ots_getTransactionError", - mcp.WithDescription("Get transaction error/revert reason"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), e.handleOtsGetTransactionError) - - e.mcpServer.AddTool(mcp.NewTool("ots_getTransactionBySenderAndNonce", - mcp.WithDescription("Get transaction hash by sender address and nonce"), - mcp.WithString("address", mcp.Required(), mcp.Description("Sender address")), - mcp.WithNumber("nonce", mcp.Required(), mcp.Description("Nonce")), - ), e.handleOtsGetTransactionBySenderAndNonce) - - e.mcpServer.AddTool(mcp.NewTool("ots_getContractCreator", - mcp.WithDescription("Get contract creator address and transaction"), - mcp.WithString("address", mcp.Required(), mcp.Description("Contract address")), - ), e.handleOtsGetContractCreator) +func (e *ErigonMCPServer) toolHandlers() map[string]server.ToolHandlerFunc { + return map[string]server.ToolHandlerFunc{ + "eth_blockNumber": e.handleBlockNumber, + "eth_getBlockByNumber": e.handleGetBlockByNumber, + "eth_getBlockByHash": e.handleGetBlockByHash, + "eth_getBlockTransactionCountByNumber": e.handleGetBlockTransactionCountByNumber, + "eth_getBlockTransactionCountByHash": e.handleGetBlockTransactionCountByHash, + "eth_getBalance": e.handleGetBalance, + "eth_getTransactionByHash": e.handleGetTransactionByHash, + "eth_getTransactionByBlockHashAndIndex": e.handleGetTransactionByBlockHashAndIndex, + "eth_getTransactionByBlockNumberAndIndex": e.handleGetTransactionByBlockNumberAndIndex, + "eth_getTransactionReceipt": e.handleGetTransactionReceipt, + "eth_getBlockReceipts": e.handleGetBlockReceipts, + "eth_getLogs": e.handleGetLogs, + "eth_getCode": e.handleGetCode, + "eth_getStorageAt": e.handleGetStorageAt, + "eth_getTransactionCount": e.handleGetTransactionCount, + "eth_call": e.handleCall, + "eth_estimateGas": e.handleEstimateGas, + "eth_gasPrice": e.handleGasPrice, + "eth_chainId": e.handleChainId, + "eth_syncing": e.handleSyncing, + "eth_accounts": e.handleAccounts, + "eth_getProof": e.handleGetProof, + "eth_coinbase": e.handleCoinbase, + "eth_mining": e.handleMining, + "eth_hashrate": e.handleHashrate, + "eth_protocolVersion": e.handleProtocolVersion, + "eth_getUncleByBlockNumberAndIndex": e.handleGetUncleByBlockNumberAndIndex, + "eth_getUncleByBlockHashAndIndex": e.handleGetUncleByBlockHashAndIndex, + "eth_getUncleCountByBlockNumber": e.handleGetUncleCountByBlockNumber, + "eth_getUncleCountByBlockHash": e.handleGetUncleCountByBlockHash, + "erigon_forks": e.handleErigonForks, + "erigon_blockNumber": e.handleErigonBlockNumber, + "erigon_getHeaderByNumber": e.handleErigonGetHeaderByNumber, + "erigon_getHeaderByHash": e.handleErigonGetHeaderByHash, + "erigon_getBlockByTimestamp": e.handleErigonGetBlockByTimestamp, + "erigon_getBalanceChangesInBlock": e.handleErigonGetBalanceChangesInBlock, + "erigon_getLogsByHash": e.handleErigonGetLogsByHash, + "erigon_getLogs": e.handleErigonGetLogs, + "erigon_getBlockReceiptsByBlockHash": e.handleErigonGetBlockReceiptsByBlockHash, + "erigon_nodeInfo": e.handleErigonNodeInfo, + "metrics_list": e.handleMetricsList, + "metrics_get": e.handleMetricsGet, + "logs_tail": e.handleLogsTail, + "logs_head": e.handleLogsHead, + "logs_grep": e.handleLogsGrep, + "logs_stats": e.handleLogsStats, + "ots_getApiLevel": e.handleOtsGetApiLevel, + "ots_getInternalOperations": e.handleOtsGetInternalOperations, + "ots_searchTransactionsBefore": e.handleOtsSearchTransactionsBefore, + "ots_searchTransactionsAfter": e.handleOtsSearchTransactionsAfter, + "ots_getBlockDetails": e.handleOtsGetBlockDetails, + "ots_getBlockDetailsByHash": e.handleOtsGetBlockDetailsByHash, + "ots_getBlockTransactions": e.handleOtsGetBlockTransactions, + "ots_hasCode": e.handleOtsHasCode, + "ots_traceTransaction": e.handleOtsTraceTransaction, + "ots_getTransactionError": e.handleOtsGetTransactionError, + "ots_getTransactionBySenderAndNonce": e.handleOtsGetTransactionBySenderAndNonce, + "ots_getContractCreator": e.handleOtsGetContractCreator, + } } // ===== ETH API HANDLERS ===== diff --git a/rpc/mcp/mcp_test.go b/rpc/mcp/mcp_test.go index 3e3345f1850..de827ec668e 100644 --- a/rpc/mcp/mcp_test.go +++ b/rpc/mcp/mcp_test.go @@ -1,10 +1,17 @@ package mcp import ( + "context" "encoding/json" "errors" + "maps" + "slices" "testing" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/rpc" ) @@ -135,6 +142,47 @@ func TestParseBlockNumberOrHash(t *testing.T) { } } +func mapKeys[V any](m map[string]V) []string { + return slices.Collect(maps.Keys(m)) +} + +func resourceTemplateURIs(t *testing.T, srv *server.MCPServer) []string { + t.Helper() + resp := srv.HandleMessage(context.Background(), []byte(`{"jsonrpc":"2.0","id":1,"method":"resources/templates/list"}`)) + jsonResp, ok := resp.(mcp.JSONRPCResponse) + require.True(t, ok, "unexpected response type %T", resp) + result, ok := jsonResp.Result.(mcp.ListResourceTemplatesResult) + require.True(t, ok, "unexpected result type %T", jsonResp.Result) + uris := make([]string, 0, len(result.ResourceTemplates)) + for _, tmpl := range result.ResourceTemplates { + uris = append(uris, tmpl.URITemplate.Raw()) + } + return uris +} + +// TestEmbeddedAndStandaloneCatalogsMatch guards against the two servers +// drifting apart: they must expose the same tools (embedded additionally has +// eth_getStorageValues), prompts, resources, and resource templates. +func TestEmbeddedAndStandaloneCatalogsMatch(t *testing.T) { + embedded := NewErigonMCPServer(nil, nil, nil, "") + standalone := NewStandaloneMCPServer(nil, "") + + embeddedTools := embedded.mcpServer.ListTools() + require.Contains(t, embeddedTools, "eth_getStorageValues") + delete(embeddedTools, "eth_getStorageValues") + require.ElementsMatch(t, mapKeys(embeddedTools), mapKeys(standalone.mcpServer.ListTools())) + + require.ElementsMatch(t, mapKeys(embedded.mcpServer.ListPrompts()), mapKeys(standalone.mcpServer.ListPrompts())) + require.NotEmpty(t, embedded.mcpServer.ListPrompts()) + + require.ElementsMatch(t, mapKeys(embedded.mcpServer.ListResources()), mapKeys(standalone.mcpServer.ListResources())) + require.NotEmpty(t, embedded.mcpServer.ListResources()) + + embeddedTemplates := resourceTemplateURIs(t, embedded.mcpServer) + require.ElementsMatch(t, embeddedTemplates, resourceTemplateURIs(t, standalone.mcpServer)) + require.NotEmpty(t, embeddedTemplates) +} + func TestMCPServerCreation(t *testing.T) { // This tests that we can create the server struct without panicking // We can't fully test without mock APIs diff --git a/rpc/mcp/prompts.go b/rpc/mcp/prompts.go index bb382027f10..1eeb83fa9ea 100644 --- a/rpc/mcp/prompts.go +++ b/rpc/mcp/prompts.go @@ -5,11 +5,13 @@ import ( "fmt" "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" ) -// registerPrompts registers all MCP prompts -func (e *ErigonMCPServer) registerPrompts() { - e.mcpServer.AddPrompt(mcp.NewPrompt("analyze_transaction", +// registerPrompts registers the prompts shared by the embedded and standalone +// servers. +func registerPrompts(srv *server.MCPServer) { + srv.AddPrompt(mcp.NewPrompt("analyze_transaction", mcp.WithPromptDescription("Analyze a transaction"), mcp.WithArgument("txHash", mcp.ArgumentDescription("Transaction hash"), mcp.RequiredArgument())), func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { @@ -25,7 +27,7 @@ func (e *ErigonMCPServer) registerPrompts() { }, nil }) - e.mcpServer.AddPrompt(mcp.NewPrompt("investigate_address", + srv.AddPrompt(mcp.NewPrompt("investigate_address", mcp.WithPromptDescription("Investigate an address"), mcp.WithArgument("address", mcp.ArgumentDescription("Address"), mcp.RequiredArgument())), func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { @@ -41,7 +43,7 @@ func (e *ErigonMCPServer) registerPrompts() { }, nil }) - e.mcpServer.AddPrompt(mcp.NewPrompt("analyze_block", + srv.AddPrompt(mcp.NewPrompt("analyze_block", mcp.WithPromptDescription("Analyze a block"), mcp.WithArgument("blockNumber", mcp.ArgumentDescription("Block number"), mcp.RequiredArgument())), func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { @@ -57,7 +59,7 @@ func (e *ErigonMCPServer) registerPrompts() { }, nil }) - e.mcpServer.AddPrompt(mcp.NewPrompt("gas_analysis", + srv.AddPrompt(mcp.NewPrompt("gas_analysis", mcp.WithPromptDescription("Analyze gas prices")), func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { return &mcp.GetPromptResult{ @@ -72,7 +74,7 @@ func (e *ErigonMCPServer) registerPrompts() { }, nil }) - e.mcpServer.AddPrompt(mcp.NewPrompt("debug_logs", + srv.AddPrompt(mcp.NewPrompt("debug_logs", mcp.WithPromptDescription("Debug issues using Erigon logs"), mcp.WithArgument("issue", mcp.ArgumentDescription("Issue description (optional)"))), func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { @@ -105,7 +107,7 @@ func (e *ErigonMCPServer) registerPrompts() { }, nil }) - e.mcpServer.AddPrompt(mcp.NewPrompt("torrent_status", + srv.AddPrompt(mcp.NewPrompt("torrent_status", mcp.WithPromptDescription("Check torrent/snapshot download status")), func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { return &mcp.GetPromptResult{ @@ -124,7 +126,7 @@ func (e *ErigonMCPServer) registerPrompts() { }, nil }) - e.mcpServer.AddPrompt(mcp.NewPrompt("sync_analysis", + srv.AddPrompt(mcp.NewPrompt("sync_analysis", mcp.WithPromptDescription("Analyze Erigon sync status and performance")), func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { return &mcp.GetPromptResult{ diff --git a/rpc/mcp/resources.go b/rpc/mcp/resources.go index 66095591463..1965788af2e 100644 --- a/rpc/mcp/resources.go +++ b/rpc/mcp/resources.go @@ -3,92 +3,125 @@ package mcp import ( "context" "fmt" + "reflect" "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/hexutil" "github.com/erigontech/erigon/rpc" ) -// registerResources registers all MCP resources -func (e *ErigonMCPServer) registerResources() { +// resourceHandlerSet holds one handler per resource shared by the embedded and +// standalone servers. +type resourceHandlerSet struct { + nodeInfo server.ResourceHandlerFunc + chainConfig server.ResourceHandlerFunc + recentBlocks server.ResourceHandlerFunc + networkStatus server.ResourceHandlerFunc + gasInfo server.ResourceHandlerFunc + addressSummary server.ResourceTemplateHandlerFunc + blockSummary server.ResourceTemplateHandlerFunc + transactionAnalysis server.ResourceTemplateHandlerFunc +} + +func registerResources(srv *server.MCPServer, h resourceHandlerSet) { + hv := reflect.ValueOf(h) + for i := range hv.NumField() { + if hv.Field(i).IsNil() { + panic(fmt.Sprintf("mcp: missing resource handler %s", hv.Type().Field(i).Name)) + } + } + // Static resources - e.mcpServer.AddResource( + srv.AddResource( mcp.NewResource("erigon://node/info", "node info", mcp.WithResourceDescription("Get node information and capabilities"), mcp.WithMIMEType("application/json"), ), - e.handleResourceNodeInfo, + h.nodeInfo, ) - e.mcpServer.AddResource( + srv.AddResource( mcp.NewResource("erigon://chain/config", "chain config", mcp.WithResourceDescription("Get chain configuration"), mcp.WithMIMEType("application/json"), ), - e.handleResourceChainConfig, + h.chainConfig, ) - // Dynamic resources - e.mcpServer.AddResource( + srv.AddResource( mcp.NewResource("erigon://blocks/recent", "recent blocks", mcp.WithResourceDescription("Get recent blocks (default: last 10)"), mcp.WithMIMEType("application/json"), ), - e.handleResourceRecentBlocks, + h.recentBlocks, ) - e.mcpServer.AddResource( + srv.AddResource( mcp.NewResource("erigon://network/status", "network status", mcp.WithResourceDescription("Get network sync status and peer info"), mcp.WithMIMEType("application/json"), ), - e.handleResourceNetworkStatus, + h.networkStatus, ) - e.mcpServer.AddResource( + srv.AddResource( mcp.NewResource("erigon://gas/current", "gas current", mcp.WithResourceDescription("Get current gas price information"), mcp.WithMIMEType("application/json"), ), - e.handleResourceGasInfo, + h.gasInfo, ) // Resource templates (with parameters) - e.mcpServer.AddResourceTemplate( + srv.AddResourceTemplate( mcp.NewResourceTemplate("erigon://address/{address}/summary", "address summary", mcp.WithTemplateDescription("Get address summary (balance, nonce, code)"), mcp.WithTemplateMIMEType("application/json"), ), - e.handleResourceAddressSummary, + h.addressSummary, ) - e.mcpServer.AddResourceTemplate( + srv.AddResourceTemplate( mcp.NewResourceTemplate("erigon://block/{number}/summary", "block summary", mcp.WithTemplateDescription("Get block summary"), mcp.WithTemplateMIMEType("application/json"), ), - e.handleResourceBlockSummary, + h.blockSummary, ) - e.mcpServer.AddResourceTemplate( + srv.AddResourceTemplate( mcp.NewResourceTemplate("erigon://transaction/{hash}/analysis", "transaction analysis", mcp.WithTemplateDescription("Get transaction analysis"), mcp.WithTemplateMIMEType("application/json"), ), - e.handleResourceTransactionAnalysis, + h.transactionAnalysis, ) } +func (e *ErigonMCPServer) resourceHandlers() resourceHandlerSet { + return resourceHandlerSet{ + nodeInfo: e.handleResourceNodeInfo, + chainConfig: e.handleResourceChainConfig, + recentBlocks: e.handleResourceRecentBlocks, + networkStatus: e.handleResourceNetworkStatus, + gasInfo: e.handleResourceGasInfo, + addressSummary: e.handleResourceAddressSummary, + blockSummary: e.handleResourceBlockSummary, + transactionAnalysis: e.handleResourceTransactionAnalysis, + } +} + // Resource handlers func (e *ErigonMCPServer) handleResourceNodeInfo(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { info, err := e.erigonAPI.NodeInfo(ctx) @@ -158,10 +191,15 @@ func (e *ErigonMCPServer) handleResourceNetworkStatus(ctx context.Context, req m currentBlock, _ := e.ethAPI.BlockNumber(ctx) + syncing, err := e.ethAPI.Syncing(ctx) + if err != nil || syncing == nil { + syncing = false + } + status := map[string]any{ "node_info": nodeInfo, "current_block": currentBlock, - "syncing": false, // Would check actual sync status + "syncing": syncing, } return []mcp.ResourceContents{ @@ -200,9 +238,10 @@ func (e *ErigonMCPServer) handleResourceGasInfo(ctx context.Context, req mcp.Rea // Resource template handlers func (e *ErigonMCPServer) handleResourceAddressSummary(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - // Extract address from URI - // This is a simplified example - you'd need proper URI parsing - address := req.Params.URI // Would extract {address} parameter + address := extractURIParam(req.Params.URI, "erigon://address/", "/summary") + if address == "" { + return nil, fmt.Errorf("missing address parameter in URI: %s", req.Params.URI) + } latest := rpc.BlockNumberOrHashWithNumber(rpc.LatestBlockNumber) balance, _ := e.ethAPI.GetBalance(ctx, common.HexToAddress(address), &latest) @@ -226,9 +265,10 @@ func (e *ErigonMCPServer) handleResourceAddressSummary(ctx context.Context, req } func (e *ErigonMCPServer) handleResourceBlockSummary(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - // Extract block number from URI - // Simplified - needs proper parsing - blockNumStr := req.Params.URI + blockNumStr := extractURIParam(req.Params.URI, "erigon://block/", "/summary") + if blockNumStr == "" { + return nil, fmt.Errorf("missing block number parameter in URI: %s", req.Params.URI) + } blockNum, err := parseBlockNumber(blockNumStr) if err != nil { @@ -250,16 +290,28 @@ func (e *ErigonMCPServer) handleResourceBlockSummary(ctx context.Context, req mc } func (e *ErigonMCPServer) handleResourceTransactionAnalysis(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - // Extract tx hash from URI - txHash := req.Params.URI + txHash := extractURIParam(req.Params.URI, "erigon://transaction/", "/analysis") + if txHash == "" { + return nil, fmt.Errorf("missing transaction hash parameter in URI: %s", req.Params.URI) + } + + hash := common.HexToHash(txHash) + tx, _ := e.ethAPI.GetTransactionByHash(ctx, hash) + receipt, _ := e.ethAPI.GetTransactionReceipt(ctx, hash) - tx, _ := e.ethAPI.GetTransactionByHash(ctx, common.HexToHash(txHash)) - receipt, _ := e.ethAPI.GetTransactionReceipt(ctx, common.HexToHash(txHash)) + status := "unknown" + if receiptStatus, ok := receipt["status"].(hexutil.Uint64); ok { + if receiptStatus == 0 { + status = "reverted" + } else { + status = "success" + } + } analysis := map[string]any{ "transaction": tx, "receipt": receipt, - "status": "success", // Would check receipt status + "status": status, } return []mcp.ResourceContents{ diff --git a/rpc/mcp/resources_test.go b/rpc/mcp/resources_test.go new file mode 100644 index 00000000000..846d3cf6393 --- /dev/null +++ b/rpc/mcp/resources_test.go @@ -0,0 +1,226 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/hexutil" + "github.com/erigontech/erigon/p2p" + "github.com/erigontech/erigon/rpc" + "github.com/erigontech/erigon/rpc/ethapi" + "github.com/erigontech/erigon/rpc/jsonrpc" +) + +type fakeEthAPI struct { + jsonrpc.EthAPI + blockNumber hexutil.Uint64 + syncing any + balance *hexutil.Big + nonce hexutil.Uint64 + code hexutil.Bytes + blocks map[rpc.BlockNumber]map[string]any + tx *ethapi.RPCTransaction + receipt map[string]any + + lastAddress common.Address + lastBlockNumber rpc.BlockNumber + lastTxHash common.Hash +} + +func (f *fakeEthAPI) BlockNumber(ctx context.Context) (hexutil.Uint64, error) { + return f.blockNumber, nil +} + +func (f *fakeEthAPI) Syncing(ctx context.Context) (any, error) { + return f.syncing, nil +} + +func (f *fakeEthAPI) GetBalance(ctx context.Context, address common.Address, blockNrOrHash *rpc.BlockNumberOrHash) (*hexutil.Big, error) { + f.lastAddress = address + return f.balance, nil +} + +func (f *fakeEthAPI) GetTransactionCount(ctx context.Context, address common.Address, blockNrOrHash *rpc.BlockNumberOrHash) (*hexutil.Uint64, error) { + return &f.nonce, nil +} + +func (f *fakeEthAPI) GetCode(ctx context.Context, address common.Address, blockNrOrHash *rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + return f.code, nil +} + +func (f *fakeEthAPI) GetBlockByNumber(ctx context.Context, number rpc.BlockNumber, fullTx bool) (map[string]any, error) { + f.lastBlockNumber = number + return f.blocks[number], nil +} + +func (f *fakeEthAPI) GetTransactionByHash(ctx context.Context, hash common.Hash) (*ethapi.RPCTransaction, error) { + f.lastTxHash = hash + return f.tx, nil +} + +func (f *fakeEthAPI) GetTransactionReceipt(ctx context.Context, hash common.Hash) (map[string]any, error) { + f.lastTxHash = hash + return f.receipt, nil +} + +type fakeErigonAPI struct { + jsonrpc.ErigonAPI +} + +func (f *fakeErigonAPI) NodeInfo(ctx context.Context) ([]p2p.NodeInfo, error) { + return nil, nil +} + +func readResourceRequest(uri string) mcp.ReadResourceRequest { + var req mcp.ReadResourceRequest + req.Params.URI = uri + return req +} + +func resourceJSON(t *testing.T, contents []mcp.ResourceContents) map[string]any { + t.Helper() + require.Len(t, contents, 1) + text, ok := contents[0].(mcp.TextResourceContents) + require.True(t, ok) + var m map[string]any + require.NoError(t, json.Unmarshal([]byte(text.Text), &m)) + return m +} + +func TestExtractURIParam(t *testing.T) { + tests := []struct { + uri, prefix, suffix, want string + }{ + {"erigon://address/0xabc/summary", "erigon://address/", "/summary", "0xabc"}, + {"erigon://block/latest/summary", "erigon://block/", "/summary", "latest"}, + {"erigon://node/info", "erigon://node/", "", "info"}, + {"erigon://address//summary", "erigon://address/", "/summary", ""}, + {"erigon://address/0xabc", "erigon://address/", "/summary", ""}, + {"mainnet://address/0xabc/summary", "erigon://address/", "/summary", ""}, + } + for _, tt := range tests { + require.Equal(t, tt.want, extractURIParam(tt.uri, tt.prefix, tt.suffix), "uri=%s", tt.uri) + } +} + +func TestResourceNetworkStatusReportsSyncProgress(t *testing.T) { + progress := map[string]any{"currentBlock": "0x10", "highestBlock": "0x20"} + e := &ErigonMCPServer{ + ethAPI: &fakeEthAPI{syncing: progress}, + erigonAPI: &fakeErigonAPI{}, + } + + contents, err := e.handleResourceNetworkStatus(context.Background(), readResourceRequest("erigon://network/status")) + require.NoError(t, err) + + status := resourceJSON(t, contents) + require.Equal(t, progress, status["syncing"]) +} + +func TestResourceNetworkStatusWhenSynced(t *testing.T) { + e := &ErigonMCPServer{ + ethAPI: &fakeEthAPI{syncing: false}, + erigonAPI: &fakeErigonAPI{}, + } + + contents, err := e.handleResourceNetworkStatus(context.Background(), readResourceRequest("erigon://network/status")) + require.NoError(t, err) + + status := resourceJSON(t, contents) + require.Equal(t, false, status["syncing"]) +} + +func TestResourceAddressSummaryExtractsAddress(t *testing.T) { + addr := "0x00000000000000000000000000000000deadbeef" + fake := &fakeEthAPI{ + balance: (*hexutil.Big)(hexutil.MustDecodeBig("0x2a")), + nonce: 7, + code: hexutil.Bytes{0x60, 0x00}, + } + e := &ErigonMCPServer{ethAPI: fake} + + contents, err := e.handleResourceAddressSummary(context.Background(), readResourceRequest("erigon://address/"+addr+"/summary")) + require.NoError(t, err) + + summary := resourceJSON(t, contents) + require.Equal(t, addr, summary["address"]) + require.Equal(t, common.HexToAddress(addr), fake.lastAddress) + require.Equal(t, true, summary["is_contract"]) +} + +func TestResourceAddressSummaryMissingAddress(t *testing.T) { + e := &ErigonMCPServer{ethAPI: &fakeEthAPI{}} + + _, err := e.handleResourceAddressSummary(context.Background(), readResourceRequest("erigon://address//summary")) + require.Error(t, err) +} + +func TestResourceBlockSummaryExtractsNumber(t *testing.T) { + fake := &fakeEthAPI{ + blocks: map[rpc.BlockNumber]map[string]any{5: {"number": "0x5"}}, + } + e := &ErigonMCPServer{ethAPI: fake} + + contents, err := e.handleResourceBlockSummary(context.Background(), readResourceRequest("erigon://block/0x5/summary")) + require.NoError(t, err) + require.Equal(t, rpc.BlockNumber(5), fake.lastBlockNumber) + + block := resourceJSON(t, contents) + require.Equal(t, "0x5", block["number"]) +} + +func TestResourceTransactionAnalysisDerivesStatus(t *testing.T) { + txHash := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") + uri := "erigon://transaction/" + txHash.Hex() + "/analysis" + + tests := []struct { + name string + receipt map[string]any + want string + }{ + {"reverted", map[string]any{"status": hexutil.Uint64(0)}, "reverted"}, + {"success", map[string]any{"status": hexutil.Uint64(1)}, "success"}, + {"pre-byzantium", map[string]any{"root": hexutil.Bytes{0x01}}, "unknown"}, + {"not found", nil, "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeEthAPI{receipt: tt.receipt} + e := &ErigonMCPServer{ethAPI: fake} + + contents, err := e.handleResourceTransactionAnalysis(context.Background(), readResourceRequest(uri)) + require.NoError(t, err) + require.Equal(t, txHash, fake.lastTxHash) + + analysis := resourceJSON(t, contents) + require.Equal(t, tt.want, analysis["status"]) + }) + } +} + +func TestRegisterResourcesPanicsOnMissingHandler(t *testing.T) { + tests := []struct { + name string + blank func(h *resourceHandlerSet) + }{ + {"resource handler", func(h *resourceHandlerSet) { h.networkStatus = nil }}, + {"template handler", func(h *resourceHandlerSet) { h.blockSummary = nil }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := (&ErigonMCPServer{}).resourceHandlers() + tt.blank(&h) + require.Panics(t, func() { + registerResources(server.NewMCPServer("test", "0"), h) + }) + }) + } +} diff --git a/rpc/mcp/standalone.go b/rpc/mcp/standalone.go index 9754ee28d71..1f2b828d097 100644 --- a/rpc/mcp/standalone.go +++ b/rpc/mcp/standalone.go @@ -38,7 +38,7 @@ import ( type StandaloneMCPServer struct { rpcClient *rpc.Client mcpServer *server.MCPServer - logDir string + logTools } // NewStandaloneMCPServer creates a new standalone MCP server that proxies @@ -46,7 +46,7 @@ type StandaloneMCPServer struct { func NewStandaloneMCPServer(rpcClient *rpc.Client, logDir string) *StandaloneMCPServer { s := &StandaloneMCPServer{ rpcClient: rpcClient, - logDir: logDir, + logTools: logTools{logDir: logDir}, } s.mcpServer = server.NewMCPServer( @@ -59,9 +59,9 @@ func NewStandaloneMCPServer(rpcClient *rpc.Client, logDir string) *StandaloneMCP server.WithRecovery(), ) - s.registerTools() - s.registerPrompts() - s.registerResources() + registerTools(s.mcpServer, s.toolHandlers()) + registerPrompts(s.mcpServer) + registerResources(s.mcpServer, s.resourceHandlers()) return s } @@ -79,349 +79,67 @@ func toJSONIndent(raw json.RawMessage) string { return string(formatted) } -// extractURIParam extracts a path parameter from an MCP resource template URI. -// For example, given URI "erigon://address/0xABC/summary" and template prefix -// "erigon://address/" with suffix "/summary", it returns "0xABC". -func extractURIParam(uri, prefix, suffix string) string { - s := strings.TrimPrefix(uri, prefix) - if suffix != "" { - s = strings.TrimSuffix(s, suffix) +func (s *StandaloneMCPServer) toolHandlers() map[string]server.ToolHandlerFunc { + return map[string]server.ToolHandlerFunc{ + "eth_blockNumber": s.handleBlockNumber, + "eth_getBlockByNumber": s.handleGetBlockByNumber, + "eth_getBlockByHash": s.handleGetBlockByHash, + "eth_getBlockTransactionCountByNumber": s.handleGetBlockTransactionCountByNumber, + "eth_getBlockTransactionCountByHash": s.handleGetBlockTransactionCountByHash, + "eth_getBalance": s.handleGetBalance, + "eth_getTransactionByHash": s.handleGetTransactionByHash, + "eth_getTransactionByBlockHashAndIndex": s.handleGetTransactionByBlockHashAndIndex, + "eth_getTransactionByBlockNumberAndIndex": s.handleGetTransactionByBlockNumberAndIndex, + "eth_getTransactionReceipt": s.handleGetTransactionReceipt, + "eth_getBlockReceipts": s.handleGetBlockReceipts, + "eth_getLogs": s.handleGetLogs, + "eth_getCode": s.handleGetCode, + "eth_getStorageAt": s.handleGetStorageAt, + "eth_getTransactionCount": s.handleGetTransactionCount, + "eth_call": s.handleCall, + "eth_estimateGas": s.handleEstimateGas, + "eth_gasPrice": s.handleGasPrice, + "eth_chainId": s.handleChainId, + "eth_syncing": s.handleSyncing, + "eth_accounts": s.handleAccounts, + "eth_getProof": s.handleGetProof, + "eth_coinbase": s.handleCoinbase, + "eth_mining": s.handleMining, + "eth_hashrate": s.handleHashrate, + "eth_protocolVersion": s.handleProtocolVersion, + "eth_getUncleByBlockNumberAndIndex": s.handleGetUncleByBlockNumberAndIndex, + "eth_getUncleByBlockHashAndIndex": s.handleGetUncleByBlockHashAndIndex, + "eth_getUncleCountByBlockNumber": s.handleGetUncleCountByBlockNumber, + "eth_getUncleCountByBlockHash": s.handleGetUncleCountByBlockHash, + "erigon_forks": s.handleErigonForks, + "erigon_blockNumber": s.handleErigonBlockNumber, + "erigon_getHeaderByNumber": s.handleErigonGetHeaderByNumber, + "erigon_getHeaderByHash": s.handleErigonGetHeaderByHash, + "erigon_getBlockByTimestamp": s.handleErigonGetBlockByTimestamp, + "erigon_getBalanceChangesInBlock": s.handleErigonGetBalanceChangesInBlock, + "erigon_getLogsByHash": s.handleErigonGetLogsByHash, + "erigon_getLogs": s.handleErigonGetLogs, + "erigon_getBlockReceiptsByBlockHash": s.handleErigonGetBlockReceiptsByBlockHash, + "erigon_nodeInfo": s.handleErigonNodeInfo, + "metrics_list": s.handleMetricsList, + "metrics_get": s.handleMetricsGet, + "logs_tail": s.handleLogsTail, + "logs_head": s.handleLogsHead, + "logs_grep": s.handleLogsGrep, + "logs_stats": s.handleLogsStats, + "ots_getApiLevel": s.handleOtsGetApiLevel, + "ots_getInternalOperations": s.handleOtsGetInternalOperations, + "ots_searchTransactionsBefore": s.handleOtsSearchTransactionsBefore, + "ots_searchTransactionsAfter": s.handleOtsSearchTransactionsAfter, + "ots_getBlockDetails": s.handleOtsGetBlockDetails, + "ots_getBlockDetailsByHash": s.handleOtsGetBlockDetailsByHash, + "ots_getBlockTransactions": s.handleOtsGetBlockTransactions, + "ots_hasCode": s.handleOtsHasCode, + "ots_traceTransaction": s.handleOtsTraceTransaction, + "ots_getTransactionError": s.handleOtsGetTransactionError, + "ots_getTransactionBySenderAndNonce": s.handleOtsGetTransactionBySenderAndNonce, + "ots_getContractCreator": s.handleOtsGetContractCreator, } - return s -} - -// registerTools registers all MCP tools matching the embedded server. -func (s *StandaloneMCPServer) registerTools() { - // ===== ETH TOOLS ===== - - s.mcpServer.AddTool(mcp.NewTool("eth_blockNumber", - mcp.WithDescription("Get the current block number"), - ), s.handleBlockNumber) - - s.mcpServer.AddTool(mcp.NewTool("eth_getBlockByNumber", - mcp.WithDescription("Get block by number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), - mcp.WithBoolean("fullTransactions", mcp.Description("Return full tx objects")), - ), s.handleGetBlockByNumber) - - s.mcpServer.AddTool(mcp.NewTool("eth_getBlockByHash", - mcp.WithDescription("Get block by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - mcp.WithBoolean("fullTransactions", mcp.Description("Return full tx objects")), - ), s.handleGetBlockByHash) - - s.mcpServer.AddTool(mcp.NewTool("eth_getBlockTransactionCountByNumber", - mcp.WithDescription("Get transaction count in block by number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - ), s.handleGetBlockTransactionCountByNumber) - - s.mcpServer.AddTool(mcp.NewTool("eth_getBlockTransactionCountByHash", - mcp.WithDescription("Get transaction count in block by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), s.handleGetBlockTransactionCountByHash) - - s.mcpServer.AddTool(mcp.NewTool("eth_getBalance", - mcp.WithDescription("Get address balance"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("blockNumber", mcp.Description("Block number (default: latest)")), - ), s.handleGetBalance) - - s.mcpServer.AddTool(mcp.NewTool("eth_getTransactionByHash", - mcp.WithDescription("Get transaction by hash"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), s.handleGetTransactionByHash) - - s.mcpServer.AddTool(mcp.NewTool("eth_getTransactionByBlockHashAndIndex", - mcp.WithDescription("Get transaction by block hash and index"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Transaction index")), - ), s.handleGetTransactionByBlockHashAndIndex) - - s.mcpServer.AddTool(mcp.NewTool("eth_getTransactionByBlockNumberAndIndex", - mcp.WithDescription("Get transaction by block number and index"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Transaction index")), - ), s.handleGetTransactionByBlockNumberAndIndex) - - s.mcpServer.AddTool(mcp.NewTool("eth_getTransactionReceipt", - mcp.WithDescription("Get transaction receipt"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), s.handleGetTransactionReceipt) - - s.mcpServer.AddTool(mcp.NewTool("eth_getBlockReceipts", - mcp.WithDescription("Get all receipts for a block"), - mcp.WithString("blockNumberOrHash", mcp.Required(), mcp.Description("Block number or hash")), - ), s.handleGetBlockReceipts) - - s.mcpServer.AddTool(mcp.NewTool("eth_getLogs", - mcp.WithDescription("Get logs matching filter"), - mcp.WithString("fromBlock", mcp.Description("Start block")), - mcp.WithString("toBlock", mcp.Description("End block")), - mcp.WithString("address", mcp.Description("Contract address(es)")), - mcp.WithString("topics", mcp.Description("Topics array (JSON)")), - mcp.WithString("blockHash", mcp.Description("Single block hash")), - ), s.handleGetLogs) - - s.mcpServer.AddTool(mcp.NewTool("eth_getCode", - mcp.WithDescription("Get contract code"), - mcp.WithString("address", mcp.Required(), mcp.Description("Contract address")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), s.handleGetCode) - - s.mcpServer.AddTool(mcp.NewTool("eth_getStorageAt", - mcp.WithDescription("Get storage at position"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("position", mcp.Required(), mcp.Description("Storage position")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), s.handleGetStorageAt) - - s.mcpServer.AddTool(mcp.NewTool("eth_getTransactionCount", - mcp.WithDescription("Get nonce (transaction count)"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), s.handleGetTransactionCount) - - s.mcpServer.AddTool(mcp.NewTool("eth_call", - mcp.WithDescription("Execute call without transaction"), - mcp.WithString("to", mcp.Required(), mcp.Description("Contract address")), - mcp.WithString("data", mcp.Required(), mcp.Description("Call data")), - mcp.WithString("from", mcp.Description("Sender address")), - mcp.WithString("value", mcp.Description("Value (hex)")), - mcp.WithString("gas", mcp.Description("Gas limit (hex)")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), s.handleCall) - - s.mcpServer.AddTool(mcp.NewTool("eth_estimateGas", - mcp.WithDescription("Estimate gas for transaction"), - mcp.WithString("to", mcp.Description("To address")), - mcp.WithString("data", mcp.Description("Call data")), - mcp.WithString("from", mcp.Description("From address")), - mcp.WithString("value", mcp.Description("Value (hex)")), - ), s.handleEstimateGas) - - s.mcpServer.AddTool(mcp.NewTool("eth_gasPrice", - mcp.WithDescription("Get current gas price"), - ), s.handleGasPrice) - - s.mcpServer.AddTool(mcp.NewTool("eth_chainId", - mcp.WithDescription("Get chain ID"), - ), s.handleChainId) - - s.mcpServer.AddTool(mcp.NewTool("eth_syncing", - mcp.WithDescription("Get sync status"), - ), s.handleSyncing) - - s.mcpServer.AddTool(mcp.NewTool("eth_accounts", - mcp.WithDescription("Get accounts"), - ), s.handleAccounts) - - s.mcpServer.AddTool(mcp.NewTool("eth_coinbase", - mcp.WithDescription("Get coinbase address"), - ), s.handleCoinbase) - - s.mcpServer.AddTool(mcp.NewTool("eth_mining", - mcp.WithDescription("Check if mining"), - ), s.handleMining) - - s.mcpServer.AddTool(mcp.NewTool("eth_hashrate", - mcp.WithDescription("Get hashrate"), - ), s.handleHashrate) - - s.mcpServer.AddTool(mcp.NewTool("eth_protocolVersion", - mcp.WithDescription("Get protocol version"), - ), s.handleProtocolVersion) - - s.mcpServer.AddTool(mcp.NewTool("eth_getUncleByBlockNumberAndIndex", - mcp.WithDescription("Get uncle by block number and index"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Uncle index")), - ), s.handleGetUncleByBlockNumberAndIndex) - - s.mcpServer.AddTool(mcp.NewTool("eth_getUncleByBlockHashAndIndex", - mcp.WithDescription("Get uncle by block hash and index"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - mcp.WithNumber("index", mcp.Required(), mcp.Description("Uncle index")), - ), s.handleGetUncleByBlockHashAndIndex) - - s.mcpServer.AddTool(mcp.NewTool("eth_getUncleCountByBlockNumber", - mcp.WithDescription("Get uncle count by block number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - ), s.handleGetUncleCountByBlockNumber) - - s.mcpServer.AddTool(mcp.NewTool("eth_getUncleCountByBlockHash", - mcp.WithDescription("Get uncle count by block hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), s.handleGetUncleCountByBlockHash) - - s.mcpServer.AddTool(mcp.NewTool("eth_getProof", - mcp.WithDescription("Get Merkle proof"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("storageKeys", mcp.Description("Storage keys (JSON array)")), - mcp.WithString("blockNumber", mcp.Description("Block number")), - ), s.handleGetProof) - - // ===== ERIGON TOOLS ===== - - s.mcpServer.AddTool(mcp.NewTool("erigon_forks", - mcp.WithDescription("Get fork information"), - ), s.handleErigonForks) - - s.mcpServer.AddTool(mcp.NewTool("erigon_blockNumber", - mcp.WithDescription("Get block number (Erigon)"), - mcp.WithString("blockNumber", mcp.Description("Block tag")), - ), s.handleErigonBlockNumber) - - s.mcpServer.AddTool(mcp.NewTool("erigon_getHeaderByNumber", - mcp.WithDescription("Get header by number"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), - ), s.handleErigonGetHeaderByNumber) - - s.mcpServer.AddTool(mcp.NewTool("erigon_getHeaderByHash", - mcp.WithDescription("Get header by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), s.handleErigonGetHeaderByHash) - - s.mcpServer.AddTool(mcp.NewTool("erigon_getBlockByTimestamp", - mcp.WithDescription("Get block by timestamp"), - mcp.WithString("timestamp", mcp.Required(), mcp.Description("Unix timestamp")), - mcp.WithBoolean("fullTransactions", mcp.Description("Full tx objects")), - ), s.handleErigonGetBlockByTimestamp) - - s.mcpServer.AddTool(mcp.NewTool("erigon_getBalanceChangesInBlock", - mcp.WithDescription("Get all balance changes in block"), - mcp.WithString("blockNumberOrHash", mcp.Required(), mcp.Description("Block")), - ), s.handleErigonGetBalanceChangesInBlock) - - s.mcpServer.AddTool(mcp.NewTool("erigon_getLogsByHash", - mcp.WithDescription("Get logs by block hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), s.handleErigonGetLogsByHash) - - s.mcpServer.AddTool(mcp.NewTool("erigon_getLogs", - mcp.WithDescription("Get logs (Erigon format)"), - mcp.WithString("fromBlock", mcp.Description("From block")), - mcp.WithString("toBlock", mcp.Description("To block")), - mcp.WithString("address", mcp.Description("Address")), - mcp.WithString("topics", mcp.Description("Topics (JSON)")), - ), s.handleErigonGetLogs) - - s.mcpServer.AddTool(mcp.NewTool("erigon_getBlockReceiptsByBlockHash", - mcp.WithDescription("Get block receipts by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), s.handleErigonGetBlockReceiptsByBlockHash) - - s.mcpServer.AddTool(mcp.NewTool("erigon_nodeInfo", - mcp.WithDescription("Get P2P node info"), - ), s.handleErigonNodeInfo) - - // ===== OTTERSCAN TOOLS ===== - - s.mcpServer.AddTool(mcp.NewTool("ots_getApiLevel", - mcp.WithDescription("Get Otterscan API level"), - ), s.handleOtsGetApiLevel) - - s.mcpServer.AddTool(mcp.NewTool("ots_getInternalOperations", - mcp.WithDescription("Get internal operations (internal txs) for a transaction"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), s.handleOtsGetInternalOperations) - - s.mcpServer.AddTool(mcp.NewTool("ots_searchTransactionsBefore", - mcp.WithDescription("Search transactions before a given block for an address"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithNumber("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), - ), s.handleOtsSearchTransactionsBefore) - - s.mcpServer.AddTool(mcp.NewTool("ots_searchTransactionsAfter", - mcp.WithDescription("Search transactions after a given block for an address"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithNumber("blockNumber", mcp.Required(), mcp.Description("Block number")), - mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), - ), s.handleOtsSearchTransactionsAfter) - - s.mcpServer.AddTool(mcp.NewTool("ots_getBlockDetails", - mcp.WithDescription("Get detailed block information"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), - ), s.handleOtsGetBlockDetails) - - s.mcpServer.AddTool(mcp.NewTool("ots_getBlockDetailsByHash", - mcp.WithDescription("Get detailed block information by hash"), - mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), - ), s.handleOtsGetBlockDetailsByHash) - - s.mcpServer.AddTool(mcp.NewTool("ots_getBlockTransactions", - mcp.WithDescription("Get paginated transactions for a block"), - mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), - mcp.WithNumber("pageNumber", mcp.Description("Page number (default: 0)")), - mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), - ), s.handleOtsGetBlockTransactions) - - s.mcpServer.AddTool(mcp.NewTool("ots_hasCode", - mcp.WithDescription("Check if an address has code (is a contract)"), - mcp.WithString("address", mcp.Required(), mcp.Description("Address")), - mcp.WithString("blockNumber", mcp.Description("Block number or tag")), - ), s.handleOtsHasCode) - - s.mcpServer.AddTool(mcp.NewTool("ots_traceTransaction", - mcp.WithDescription("Get trace for a transaction"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), s.handleOtsTraceTransaction) - - s.mcpServer.AddTool(mcp.NewTool("ots_getTransactionError", - mcp.WithDescription("Get transaction error/revert reason"), - mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), - ), s.handleOtsGetTransactionError) - - s.mcpServer.AddTool(mcp.NewTool("ots_getTransactionBySenderAndNonce", - mcp.WithDescription("Get transaction hash by sender address and nonce"), - mcp.WithString("address", mcp.Required(), mcp.Description("Sender address")), - mcp.WithNumber("nonce", mcp.Required(), mcp.Description("Nonce")), - ), s.handleOtsGetTransactionBySenderAndNonce) - - s.mcpServer.AddTool(mcp.NewTool("ots_getContractCreator", - mcp.WithDescription("Get contract creator address and transaction"), - mcp.WithString("address", mcp.Required(), mcp.Description("Contract address")), - ), s.handleOtsGetContractCreator) - - // ===== METRICS TOOLS ===== - - s.mcpServer.AddTool(mcp.NewTool("metrics_list", - mcp.WithDescription("List all available metric names"), - ), s.handleMetricsList) - - s.mcpServer.AddTool(mcp.NewTool("metrics_get", - mcp.WithDescription("Get metrics with optional filtering by pattern (supports wildcards like 'db_*', '*_size', etc.)"), - mcp.WithString("pattern", mcp.Description("Metric name pattern (optional, empty = all metrics)")), - ), s.handleMetricsGet) - - // ===== LOG TOOLS ===== - - s.mcpServer.AddTool(mcp.NewTool("logs_tail", - mcp.WithDescription("Get last N lines from erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - mcp.WithNumber("lines", mcp.Description("Number of lines to retrieve (default: 100, max: 10000)")), - mcp.WithString("filter", mcp.Description("Optional string to filter log lines")), - ), s.handleLogsTail) - - s.mcpServer.AddTool(mcp.NewTool("logs_head", - mcp.WithDescription("Get first N lines from erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - mcp.WithNumber("lines", mcp.Description("Number of lines to retrieve (default: 100, max: 10000)")), - mcp.WithString("filter", mcp.Description("Optional string to filter log lines")), - ), s.handleLogsHead) - - s.mcpServer.AddTool(mcp.NewTool("logs_grep", - mcp.WithDescription("Search for a pattern in erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - mcp.WithString("pattern", mcp.Required(), mcp.Description("Search pattern")), - mcp.WithNumber("max_lines", mcp.Description("Maximum matching lines to return (default: 1000, max: 10000)")), - mcp.WithBoolean("case_insensitive", mcp.Description("Case-insensitive search (default: false)")), - ), s.handleLogsGrep) - - s.mcpServer.AddTool(mcp.NewTool("logs_stats", - mcp.WithDescription("Get statistics about erigon or torrent logs"), - mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), - ), s.handleLogsStats) } // ===== ETH API HANDLERS ===== @@ -1135,344 +853,19 @@ func (s *StandaloneMCPServer) handleMetricsGet(ctx context.Context, req mcp.Call return mcp.NewToolResultText("Metrics are not available in standalone mode. Use the embedded MCP server (via Erigon's built-in --mcp flag) for metrics access."), nil } -// ===== LOG HANDLERS ===== -// Log handlers reuse the package-level functions from handlers_logs.go since -// they read files directly and do not need RPC. - -func (s *StandaloneMCPServer) handleLogsTail(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - logType := req.GetString("log_type", "erigon") - lines := req.GetInt("lines", 100) - filter := req.GetString("filter", "") - - if lines <= 0 || lines > 10000 { - return mcp.NewToolResultError("lines must be between 1 and 10000"), nil - } - - logFile, err := s.resolveLogFile(logType) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - logLines, err := readLogTail(logFile, lines, filter) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to read log: %v", err)), nil - } - - result := fmt.Sprintf("Last %d lines from %s.log", len(logLines), logType) - if filter != "" { - result += fmt.Sprintf(" (filtered by: %s)", filter) - } - result += ":\n\n" + strings.Join(logLines, "\n") - - return mcp.NewToolResultText(result), nil -} - -func (s *StandaloneMCPServer) handleLogsHead(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - logType := req.GetString("log_type", "erigon") - lines := req.GetInt("lines", 100) - filter := req.GetString("filter", "") - - if lines <= 0 || lines > 10000 { - return mcp.NewToolResultError("lines must be between 1 and 10000"), nil - } - - logFile, err := s.resolveLogFile(logType) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - logLines, err := readLogHead(logFile, lines, filter) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to read log: %v", err)), nil - } - - result := fmt.Sprintf("First %d lines from %s.log", len(logLines), logType) - if filter != "" { - result += fmt.Sprintf(" (filtered by: %s)", filter) - } - result += ":\n\n" + strings.Join(logLines, "\n") - - return mcp.NewToolResultText(result), nil -} - -func (s *StandaloneMCPServer) handleLogsGrep(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - logType := req.GetString("log_type", "erigon") - pattern := req.GetString("pattern", "") - maxLines := req.GetInt("max_lines", 1000) - caseInsensitive := req.GetBool("case_insensitive", false) - - if pattern == "" { - return mcp.NewToolResultError("pattern is required"), nil - } - - if maxLines <= 0 || maxLines > 10000 { - return mcp.NewToolResultError("max_lines must be between 1 and 10000"), nil - } - - logFile, err := s.resolveLogFile(logType) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - logLines, err := grepLog(logFile, pattern, maxLines, caseInsensitive) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to grep log: %v", err)), nil - } - - result := fmt.Sprintf("Found %d matching lines in %s.log for pattern '%s':\n\n", len(logLines), logType, pattern) - result += strings.Join(logLines, "\n") - - return mcp.NewToolResultText(result), nil -} - -func (s *StandaloneMCPServer) handleLogsStats(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - logType := req.GetString("log_type", "erigon") - - logFile, err := s.resolveLogFile(logType) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - stats, err := getLogStats(logFile) - if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get log stats: %v", err)), nil - } - - return mcp.NewToolResultText(toJSONText(stats)), nil -} - -// resolveLogFile returns the path to the log file for the given log type. -func (s *StandaloneMCPServer) resolveLogFile(logType string) (string, error) { - if s.logDir == "" { - return "", fmt.Errorf("log directory not configured (use --log.dir or --datadir)") - } - switch logType { - case "erigon": - return s.logDir + "/erigon.log", nil - case "torrent": - return s.logDir + "/torrent.log", nil - default: - return "", fmt.Errorf("log_type must be 'erigon' or 'torrent'") - } -} - -// ===== PROMPTS ===== - -// registerPrompts registers all MCP prompts (identical to the embedded server). -func (s *StandaloneMCPServer) registerPrompts() { - s.mcpServer.AddPrompt(mcp.NewPrompt("analyze_transaction", - mcp.WithPromptDescription("Analyze a transaction"), - mcp.WithArgument("txHash", mcp.ArgumentDescription("Transaction hash"), mcp.RequiredArgument())), - func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return &mcp.GetPromptResult{ - Description: "Analyze transaction", - Messages: []mcp.PromptMessage{{ - Role: mcp.RoleUser, - Content: mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Analyze transaction %s using eth_getTransactionByHash and eth_getTransactionReceipt", req.Params.Arguments["txHash"]), - }, - }}, - }, nil - }) - - s.mcpServer.AddPrompt(mcp.NewPrompt("investigate_address", - mcp.WithPromptDescription("Investigate an address"), - mcp.WithArgument("address", mcp.ArgumentDescription("Address"), mcp.RequiredArgument())), - func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return &mcp.GetPromptResult{ - Description: "Investigate address", - Messages: []mcp.PromptMessage{{ - Role: mcp.RoleUser, - Content: mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Investigate %s using eth_getBalance, eth_getTransactionCount, eth_getCode", req.Params.Arguments["address"]), - }, - }}, - }, nil - }) - - s.mcpServer.AddPrompt(mcp.NewPrompt("analyze_block", - mcp.WithPromptDescription("Analyze a block"), - mcp.WithArgument("blockNumber", mcp.ArgumentDescription("Block number"), mcp.RequiredArgument())), - func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return &mcp.GetPromptResult{ - Description: "Analyze block", - Messages: []mcp.PromptMessage{{ - Role: mcp.RoleUser, - Content: mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Analyze block %s using eth_getBlockByNumber with fullTransactions=true", req.Params.Arguments["blockNumber"]), - }, - }}, - }, nil - }) - - s.mcpServer.AddPrompt(mcp.NewPrompt("gas_analysis", - mcp.WithPromptDescription("Analyze gas prices")), - func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return &mcp.GetPromptResult{ - Description: "Gas analysis", - Messages: []mcp.PromptMessage{{ - Role: mcp.RoleUser, - Content: mcp.TextContent{ - Type: "text", - Text: "Analyze gas using eth_gasPrice and latest block's baseFeePerGas", - }, - }}, - }, nil - }) - - s.mcpServer.AddPrompt(mcp.NewPrompt("debug_logs", - mcp.WithPromptDescription("Debug issues using Erigon logs"), - mcp.WithArgument("issue", mcp.ArgumentDescription("Issue description (optional)"))), - func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - issue := "" - if req.Params.Arguments != nil { - if val, ok := req.Params.Arguments["issue"]; ok { - issue = val - } - } - - text := "Debug Erigon issues by:\n" - text += "1. Check recent errors: Use logs_grep with pattern='error' or 'ERROR'\n" - text += "2. Check warnings: Use logs_grep with pattern='warn' or 'WARN'\n" - text += "3. Get log statistics: Use logs_stats to see error/warning counts\n" - text += "4. Review recent activity: Use logs_tail to see latest log entries\n" - - if issue != "" { - text += fmt.Sprintf("\nFocus on investigating: %s", issue) - } - - return &mcp.GetPromptResult{ - Description: "Debug logs", - Messages: []mcp.PromptMessage{{ - Role: mcp.RoleUser, - Content: mcp.TextContent{ - Type: "text", - Text: text, - }, - }}, - }, nil - }) - - s.mcpServer.AddPrompt(mcp.NewPrompt("torrent_status", - mcp.WithPromptDescription("Check torrent/snapshot download status")), - func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return &mcp.GetPromptResult{ - Description: "Torrent status", - Messages: []mcp.PromptMessage{{ - Role: mcp.RoleUser, - Content: mcp.TextContent{ - Type: "text", - Text: "Check torrent download status by:\n" + - "1. Review torrent logs: Use logs_tail with log_type='torrent'\n" + - "2. Search for download progress: Use logs_grep with pattern='download' or 'progress'\n" + - "3. Check for errors: Use logs_grep with pattern='error' on torrent log\n" + - "4. Get torrent log stats: Use logs_stats with log_type='torrent'", - }, - }}, - }, nil - }) - - s.mcpServer.AddPrompt(mcp.NewPrompt("sync_analysis", - mcp.WithPromptDescription("Analyze Erigon sync status and performance")), - func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { - return &mcp.GetPromptResult{ - Description: "Sync analysis", - Messages: []mcp.PromptMessage{{ - Role: mcp.RoleUser, - Content: mcp.TextContent{ - Type: "text", - Text: "Analyze Erigon sync by:\n" + - "1. Get current block: Use eth_blockNumber\n" + - "2. Check sync progress in logs: Use logs_grep with pattern='stage' or 'progress'\n" + - "3. Look for performance metrics: Use logs_grep with pattern='block/s' or 'tx/s'\n" + - "4. Check for sync issues: Use logs_grep with pattern='reorg' or 'fork'\n" + - "5. Review recent sync activity: Use logs_tail with filter='sync' or 'stage'", - }, - }}, - }, nil - }) -} - // ===== RESOURCES ===== -// registerResources registers all MCP resources using JSON-RPC calls. -func (s *StandaloneMCPServer) registerResources() { - // Static resources - s.mcpServer.AddResource( - mcp.NewResource("erigon://node/info", - "node info", - mcp.WithResourceDescription("Get node information and capabilities"), - mcp.WithMIMEType("application/json"), - ), - s.handleResourceNodeInfo, - ) - - s.mcpServer.AddResource( - mcp.NewResource("erigon://chain/config", - "chain config", - mcp.WithResourceDescription("Get chain configuration"), - mcp.WithMIMEType("application/json"), - ), - s.handleResourceChainConfig, - ) - - s.mcpServer.AddResource( - mcp.NewResource("erigon://blocks/recent", - "recent blocks", - mcp.WithResourceDescription("Get recent blocks (default: last 10)"), - mcp.WithMIMEType("application/json"), - ), - s.handleResourceRecentBlocks, - ) - - s.mcpServer.AddResource( - mcp.NewResource("erigon://network/status", - "network status", - mcp.WithResourceDescription("Get network sync status and peer info"), - mcp.WithMIMEType("application/json"), - ), - s.handleResourceNetworkStatus, - ) - - s.mcpServer.AddResource( - mcp.NewResource("erigon://gas/current", - "gas current", - mcp.WithResourceDescription("Get current gas price information"), - mcp.WithMIMEType("application/json"), - ), - s.handleResourceGasInfo, - ) - - // Resource templates - s.mcpServer.AddResourceTemplate( - mcp.NewResourceTemplate("erigon://address/{address}/summary", - "address summary", - mcp.WithTemplateDescription("Get address summary (balance, nonce, code)"), - mcp.WithTemplateMIMEType("application/json"), - ), - s.handleResourceAddressSummary, - ) - - s.mcpServer.AddResourceTemplate( - mcp.NewResourceTemplate("erigon://block/{number}/summary", - "block summary", - mcp.WithTemplateDescription("Get block summary"), - mcp.WithTemplateMIMEType("application/json"), - ), - s.handleResourceBlockSummary, - ) - - s.mcpServer.AddResourceTemplate( - mcp.NewResourceTemplate("erigon://transaction/{hash}/analysis", - "transaction analysis", - mcp.WithTemplateDescription("Get transaction analysis"), - mcp.WithTemplateMIMEType("application/json"), - ), - s.handleResourceTransactionAnalysis, - ) +func (s *StandaloneMCPServer) resourceHandlers() resourceHandlerSet { + return resourceHandlerSet{ + nodeInfo: s.handleResourceNodeInfo, + chainConfig: s.handleResourceChainConfig, + recentBlocks: s.handleResourceRecentBlocks, + networkStatus: s.handleResourceNetworkStatus, + gasInfo: s.handleResourceGasInfo, + addressSummary: s.handleResourceAddressSummary, + blockSummary: s.handleResourceBlockSummary, + transactionAnalysis: s.handleResourceTransactionAnalysis, + } } // Resource handlers diff --git a/rpc/mcp/tools.go b/rpc/mcp/tools.go new file mode 100644 index 00000000000..27a6a91d8af --- /dev/null +++ b/rpc/mcp/tools.go @@ -0,0 +1,370 @@ +package mcp + +import ( + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// registerTools registers the shared tool set; handlers must cover exactly the +// tools returned by toolSpecs. +func registerTools(srv *server.MCPServer, handlers map[string]server.ToolHandlerFunc) { + specs := toolSpecs() + if len(handlers) != len(specs) { + panic(fmt.Sprintf("mcp: %d tool handlers for %d tools", len(handlers), len(specs))) + } + for _, tool := range specs { + handler, ok := handlers[tool.Name] + if !ok { + panic(fmt.Sprintf("mcp: missing tool handler for %s", tool.Name)) + } + srv.AddTool(tool, handler) + } +} + +// toolSpecs returns the tool definitions common to the embedded and standalone +// servers. +func toolSpecs() []mcp.Tool { + return []mcp.Tool{ + // ===== ETH TOOLS ===== + + mcp.NewTool("eth_blockNumber", + mcp.WithDescription("Get the current block number"), + ), + + mcp.NewTool("eth_getBlockByNumber", + mcp.WithDescription("Get block by number"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), + mcp.WithBoolean("fullTransactions", mcp.Description("Return full tx objects")), + ), + + mcp.NewTool("eth_getBlockByHash", + mcp.WithDescription("Get block by hash"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + mcp.WithBoolean("fullTransactions", mcp.Description("Return full tx objects")), + ), + + mcp.NewTool("eth_getBlockTransactionCountByNumber", + mcp.WithDescription("Get transaction count in block by number"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), + ), + + mcp.NewTool("eth_getBlockTransactionCountByHash", + mcp.WithDescription("Get transaction count in block by hash"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + ), + + mcp.NewTool("eth_getBalance", + mcp.WithDescription("Get address balance"), + mcp.WithString("address", mcp.Required(), mcp.Description("Address")), + mcp.WithString("blockNumber", mcp.Description("Block number (default: latest)")), + ), + + mcp.NewTool("eth_getTransactionByHash", + mcp.WithDescription("Get transaction by hash"), + mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), + ), + + mcp.NewTool("eth_getTransactionByBlockHashAndIndex", + mcp.WithDescription("Get transaction by block hash and index"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("Transaction index")), + ), + + mcp.NewTool("eth_getTransactionByBlockNumberAndIndex", + mcp.WithDescription("Get transaction by block number and index"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("Transaction index")), + ), + + mcp.NewTool("eth_getTransactionReceipt", + mcp.WithDescription("Get transaction receipt"), + mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), + ), + + mcp.NewTool("eth_getBlockReceipts", + mcp.WithDescription("Get all receipts for a block"), + mcp.WithString("blockNumberOrHash", mcp.Required(), mcp.Description("Block number or hash")), + ), + + mcp.NewTool("eth_getLogs", + mcp.WithDescription("Get logs matching filter"), + mcp.WithString("fromBlock", mcp.Description("Start block")), + mcp.WithString("toBlock", mcp.Description("End block")), + mcp.WithString("address", mcp.Description("Contract address(es)")), + mcp.WithString("topics", mcp.Description("Topics array (JSON)")), + mcp.WithString("blockHash", mcp.Description("Single block hash")), + ), + + mcp.NewTool("eth_getCode", + mcp.WithDescription("Get contract code"), + mcp.WithString("address", mcp.Required(), mcp.Description("Contract address")), + mcp.WithString("blockNumber", mcp.Description("Block number")), + ), + + mcp.NewTool("eth_getStorageAt", + mcp.WithDescription("Get storage at position"), + mcp.WithString("address", mcp.Required(), mcp.Description("Address")), + mcp.WithString("position", mcp.Required(), mcp.Description("Storage position")), + mcp.WithString("blockNumber", mcp.Description("Block number")), + ), + + mcp.NewTool("eth_getTransactionCount", + mcp.WithDescription("Get nonce (transaction count)"), + mcp.WithString("address", mcp.Required(), mcp.Description("Address")), + mcp.WithString("blockNumber", mcp.Description("Block number")), + ), + + mcp.NewTool("eth_call", + mcp.WithDescription("Execute call without transaction"), + mcp.WithString("to", mcp.Required(), mcp.Description("Contract address")), + mcp.WithString("data", mcp.Required(), mcp.Description("Call data")), + mcp.WithString("from", mcp.Description("Sender address")), + mcp.WithString("value", mcp.Description("Value (hex)")), + mcp.WithString("gas", mcp.Description("Gas limit (hex)")), + mcp.WithString("blockNumber", mcp.Description("Block number")), + ), + + mcp.NewTool("eth_estimateGas", + mcp.WithDescription("Estimate gas for transaction"), + mcp.WithString("to", mcp.Description("To address")), + mcp.WithString("data", mcp.Description("Call data")), + mcp.WithString("from", mcp.Description("From address")), + mcp.WithString("value", mcp.Description("Value (hex)")), + ), + + mcp.NewTool("eth_gasPrice", + mcp.WithDescription("Get current gas price"), + ), + + mcp.NewTool("eth_chainId", + mcp.WithDescription("Get chain ID"), + ), + + mcp.NewTool("eth_syncing", + mcp.WithDescription("Get sync status"), + ), + + mcp.NewTool("eth_accounts", + mcp.WithDescription("Get accounts"), + ), + + mcp.NewTool("eth_getProof", + mcp.WithDescription("Get Merkle proof"), + mcp.WithString("address", mcp.Required(), mcp.Description("Address")), + mcp.WithString("storageKeys", mcp.Description("Storage keys (JSON array)")), + mcp.WithString("blockNumber", mcp.Description("Block number")), + ), + + mcp.NewTool("eth_coinbase", + mcp.WithDescription("Get coinbase address"), + ), + + mcp.NewTool("eth_mining", + mcp.WithDescription("Check if mining"), + ), + + mcp.NewTool("eth_hashrate", + mcp.WithDescription("Get hashrate"), + ), + + mcp.NewTool("eth_protocolVersion", + mcp.WithDescription("Get protocol version"), + ), + + mcp.NewTool("eth_getUncleByBlockNumberAndIndex", + mcp.WithDescription("Get uncle by block number and index"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("Uncle index")), + ), + + mcp.NewTool("eth_getUncleByBlockHashAndIndex", + mcp.WithDescription("Get uncle by block hash and index"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("Uncle index")), + ), + + mcp.NewTool("eth_getUncleCountByBlockNumber", + mcp.WithDescription("Get uncle count by block number"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), + ), + + mcp.NewTool("eth_getUncleCountByBlockHash", + mcp.WithDescription("Get uncle count by block hash"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + ), + + // ===== ERIGON TOOLS ===== + + mcp.NewTool("erigon_forks", + mcp.WithDescription("Get fork information"), + ), + + mcp.NewTool("erigon_blockNumber", + mcp.WithDescription("Get block number (Erigon)"), + mcp.WithString("blockNumber", mcp.Description("Block tag")), + ), + + mcp.NewTool("erigon_getHeaderByNumber", + mcp.WithDescription("Get header by number"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number")), + ), + + mcp.NewTool("erigon_getHeaderByHash", + mcp.WithDescription("Get header by hash"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + ), + + mcp.NewTool("erigon_getBlockByTimestamp", + mcp.WithDescription("Get block by timestamp"), + mcp.WithString("timestamp", mcp.Required(), mcp.Description("Unix timestamp")), + mcp.WithBoolean("fullTransactions", mcp.Description("Full tx objects")), + ), + + mcp.NewTool("erigon_getBalanceChangesInBlock", + mcp.WithDescription("Get all balance changes in block"), + mcp.WithString("blockNumberOrHash", mcp.Required(), mcp.Description("Block")), + ), + + mcp.NewTool("erigon_getLogsByHash", + mcp.WithDescription("Get logs by block hash"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + ), + + mcp.NewTool("erigon_getLogs", + mcp.WithDescription("Get logs (Erigon format)"), + mcp.WithString("fromBlock", mcp.Description("From block")), + mcp.WithString("toBlock", mcp.Description("To block")), + mcp.WithString("address", mcp.Description("Address")), + mcp.WithString("topics", mcp.Description("Topics (JSON)")), + ), + + mcp.NewTool("erigon_getBlockReceiptsByBlockHash", + mcp.WithDescription("Get block receipts by hash"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + ), + + mcp.NewTool("erigon_nodeInfo", + mcp.WithDescription("Get P2P node info"), + ), + + // ===== METRICS TOOLS ===== + + mcp.NewTool("metrics_list", + mcp.WithDescription("List all available metric names"), + ), + + mcp.NewTool("metrics_get", + mcp.WithDescription("Get metrics with optional filtering by pattern (supports wildcards like 'db_*', '*_size', etc.)"), + mcp.WithString("pattern", mcp.Description("Metric name pattern (optional, empty = all metrics)")), + ), + + // ===== LOG TOOLS ===== + + mcp.NewTool("logs_tail", + mcp.WithDescription("Get last N lines from erigon or torrent logs"), + mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), + mcp.WithNumber("lines", mcp.Description("Number of lines to retrieve (default: 100, max: 10000)")), + mcp.WithString("filter", mcp.Description("Optional string to filter log lines")), + ), + + mcp.NewTool("logs_head", + mcp.WithDescription("Get first N lines from erigon or torrent logs"), + mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), + mcp.WithNumber("lines", mcp.Description("Number of lines to retrieve (default: 100, max: 10000)")), + mcp.WithString("filter", mcp.Description("Optional string to filter log lines")), + ), + + mcp.NewTool("logs_grep", + mcp.WithDescription("Search for a pattern in erigon or torrent logs"), + mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), + mcp.WithString("pattern", mcp.Required(), mcp.Description("Search pattern")), + mcp.WithNumber("max_lines", mcp.Description("Maximum matching lines to return (default: 1000, max: 10000)")), + mcp.WithBoolean("case_insensitive", mcp.Description("Case-insensitive search (default: false)")), + ), + + mcp.NewTool("logs_stats", + mcp.WithDescription("Get statistics about erigon or torrent logs"), + mcp.WithString("log_type", mcp.Description("Log type: 'erigon' or 'torrent' (default: erigon)")), + ), + + // ===== OTTERSCAN TOOLS ===== + + mcp.NewTool("ots_getApiLevel", + mcp.WithDescription("Get Otterscan API level"), + ), + + mcp.NewTool("ots_getInternalOperations", + mcp.WithDescription("Get internal operations (internal txs) for a transaction"), + mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), + ), + + mcp.NewTool("ots_searchTransactionsBefore", + mcp.WithDescription("Search transactions before a given block for an address"), + mcp.WithString("address", mcp.Required(), mcp.Description("Address")), + mcp.WithNumber("blockNumber", mcp.Required(), mcp.Description("Block number")), + mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), + ), + + mcp.NewTool("ots_searchTransactionsAfter", + mcp.WithDescription("Search transactions after a given block for an address"), + mcp.WithString("address", mcp.Required(), mcp.Description("Address")), + mcp.WithNumber("blockNumber", mcp.Required(), mcp.Description("Block number")), + mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), + ), + + mcp.NewTool("ots_getBlockDetails", + mcp.WithDescription("Get detailed block information"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), + ), + + mcp.NewTool("ots_getBlockDetailsByHash", + mcp.WithDescription("Get detailed block information by hash"), + mcp.WithString("blockHash", mcp.Required(), mcp.Description("Block hash")), + ), + + mcp.NewTool("ots_getBlockTransactions", + mcp.WithDescription("Get paginated transactions for a block"), + mcp.WithString("blockNumber", mcp.Required(), mcp.Description("Block number or tag")), + mcp.WithNumber("pageNumber", mcp.Description("Page number (default: 0)")), + mcp.WithNumber("pageSize", mcp.Description("Page size (default: 25)")), + ), + + mcp.NewTool("ots_hasCode", + mcp.WithDescription("Check if an address has code (is a contract)"), + mcp.WithString("address", mcp.Required(), mcp.Description("Address")), + mcp.WithString("blockNumber", mcp.Description("Block number or tag")), + ), + + mcp.NewTool("ots_traceTransaction", + mcp.WithDescription("Get trace for a transaction"), + mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), + ), + + mcp.NewTool("ots_getTransactionError", + mcp.WithDescription("Get transaction error/revert reason"), + mcp.WithString("txHash", mcp.Required(), mcp.Description("Transaction hash")), + ), + + mcp.NewTool("ots_getTransactionBySenderAndNonce", + mcp.WithDescription("Get transaction hash by sender address and nonce"), + mcp.WithString("address", mcp.Required(), mcp.Description("Sender address")), + mcp.WithNumber("nonce", mcp.Required(), mcp.Description("Nonce")), + ), + + mcp.NewTool("ots_getContractCreator", + mcp.WithDescription("Get contract creator address and transaction"), + mcp.WithString("address", mcp.Required(), mcp.Description("Contract address")), + ), + } +} + +// storageValuesTool is registered by the embedded server only. +func storageValuesTool() mcp.Tool { + return mcp.NewTool("eth_getStorageValues", + mcp.WithDescription("Get multiple storage slot values for multiple accounts in a single request"), + mcp.WithString("requests", mcp.Required(), mcp.Description("JSON object mapping addresses to arrays of storage slot keys")), + mcp.WithString("blockNumber", mcp.Description("Block number or tag (latest, earliest, pending)")), + ) +} diff --git a/rpc/mcp/utils.go b/rpc/mcp/utils.go index 1f9ceb2a8ed..eeeeb809e96 100644 --- a/rpc/mcp/utils.go +++ b/rpc/mcp/utils.go @@ -3,6 +3,7 @@ package mcp import ( "encoding/json" "fmt" + "strings" "github.com/erigontech/erigon/rpc" ) @@ -27,3 +28,19 @@ func parseBlockNumber(s string) (rpc.BlockNumber, error) { } return blockNum, blockNum.UnmarshalJSON(b) } + +// extractURIParam extracts a path parameter from an MCP resource template URI. +// For example, given URI "erigon://address/0xABC/summary" and template prefix +// "erigon://address/" with suffix "/summary", it returns "0xABC". It returns +// "" if the URI does not match the prefix and suffix. +func extractURIParam(uri, prefix, suffix string) string { + s, ok := strings.CutPrefix(uri, prefix) + if !ok { + return "" + } + s, ok = strings.CutSuffix(s, suffix) + if !ok { + return "" + } + return s +}