@polymerdao/mcp-polymer
Version:
A Model Context Protocol (MCP) server for blockchain event verification using Polymer's Prove API.
201 lines (200 loc) • 8.24 kB
JavaScript
import axios from 'axios';
import { z } from 'zod';
export class EthereumToolsManager {
constructor(chainlistClient) {
this.ethereumSpec = null;
this.specFetchTime = 0;
this.CACHE_DURATION = 60 * 60 * 1000; // 1 hour
this.chainlistClient = chainlistClient;
}
async fetchEthereumSpec() {
if (this.ethereumSpec && (Date.now() - this.specFetchTime < this.CACHE_DURATION)) {
return this.ethereumSpec;
}
const response = await axios.get('https://raw.githubusercontent.com/etclabscore/ethereum-json-rpc-specification/refs/heads/master/openrpc.json');
this.ethereumSpec = response.data;
this.specFetchTime = Date.now();
return this.ethereumSpec;
}
convertSchemaToZod(schema) {
if (!schema) {
return z.unknown();
}
switch (schema.type) {
case 'string':
if (schema.format === 'hex') {
return this.createHexValidation();
}
if (schema.enum) {
return z.enum(schema.enum);
}
return z.string();
case 'boolean':
return z.boolean();
case 'integer':
return z.number().int();
case 'number':
return z.number();
case 'array':
if (schema.items) {
return z.array(this.convertSchemaToZod(schema.items));
}
return z.array(z.unknown());
case 'object':
return z.object({}).passthrough();
default:
return z.unknown();
}
}
createHexValidation() {
return z.string().refine((value) => {
// Basic hex format validation
if (!/^0x[0-9a-fA-F]*$/.test(value)) {
return false;
}
const hexPart = value.slice(2); // Remove '0x' prefix
// Allow empty hex (0x)
if (hexPart.length === 0) {
return true;
}
// Validate specific hex value lengths
switch (hexPart.length) {
case 8: // 32-bit integers (4 bytes)
case 16: // 64-bit integers (8 bytes)
case 40: // Ethereum addresses (20 bytes)
case 64: // Block hashes, transaction hashes, storage values (32 bytes)
case 130: // Public keys (65 bytes)
return true;
// Allow variable length for:
// - Transaction data (any length)
// - ABI encoded data (multiples of 32 bytes)
// - Block numbers (1-8 bytes)
default:
// Allow 1-8 bytes for block numbers and similar values
if (hexPart.length >= 2 && hexPart.length <= 16 && hexPart.length % 2 === 0) {
return true;
}
// Allow multiples of 64 chars (32 bytes) for ABI data
if (hexPart.length % 64 === 0) {
return true;
}
// Allow any even length for transaction data and other variable-length hex
return hexPart.length % 2 === 0;
}
}, {
message: 'Must be a valid hex string with appropriate length (0x + even number of hex chars). Expected lengths: addresses (42 chars), hashes (66 chars), or other valid hex formats'
});
}
createEthRpcCall(method) {
return async (chainName, ...params) => {
const result = await this.chainlistClient.executeWithRetry(chainName, method, params, async (rpcUrl) => {
const response = await axios.post(rpcUrl, {
jsonrpc: '2.0',
method,
params,
id: Date.now()
}, {
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
});
if (response.data.error) {
const errorMsg = response.data.error.message || JSON.stringify(response.data.error);
throw new Error(`RPC Error from ${rpcUrl}: ${errorMsg}`);
}
return response.data.result;
});
if (!result.success) {
throw new Error(result.error);
}
return {
result: result.data,
rpc_used: result.usedRpc,
attempted_rpcs: result.attemptedRpcs,
failed_rpcs: result.failedRpcs
};
};
}
createToolFromMethod(method) {
const paramSchemas = {
chain_name: z.string().describe('Name of the blockchain (e.g., ethereum, polygon)')
};
// All eth_* methods take a params array following JSON-RPC convention
// Some methods take no parameters (empty array)
paramSchemas['params'] = z.array(z.unknown()).optional().default([]).describe('RPC method parameters array');
// Create Zod object schema
const zodSchema = z.object(paramSchemas);
// Get required parameter names
// chain_name is always required, params is optional with default empty array
const requiredParams = ['chain_name'];
return {
name: method.name,
description: method.summary || method.description || `Execute ${method.name} on the specified blockchain`,
inputSchema: {
type: 'object',
properties: Object.fromEntries(Object.entries(paramSchemas).map(([key, zodSchema]) => [
key,
{
type: this.getJsonSchemaType(zodSchema),
description: zodSchema.description
}
])),
required: requiredParams
},
zodSchema
};
}
getJsonSchemaType(zodSchema) {
// Handle ZodOptional wrapping
if (zodSchema instanceof z.ZodOptional) {
return this.getJsonSchemaType(zodSchema._def.innerType);
}
// Handle ZodDefault wrapping
if (zodSchema instanceof z.ZodDefault) {
return this.getJsonSchemaType(zodSchema._def.innerType);
}
if (zodSchema instanceof z.ZodString)
return 'string';
if (zodSchema instanceof z.ZodNumber)
return 'number';
if (zodSchema instanceof z.ZodBoolean)
return 'boolean';
if (zodSchema instanceof z.ZodArray)
return 'array';
if (zodSchema instanceof z.ZodObject)
return 'object';
return 'string';
}
async generateEthereumTools() {
const spec = await this.fetchEthereumSpec();
const tools = [];
const handlers = new Map();
// Filter for eth_* methods only
const ethMethods = spec.methods.filter(method => method.name.startsWith('eth_'));
for (const method of ethMethods) {
const tool = this.createToolFromMethod(method);
const handler = this.createEthRpcCall(method.name);
tools.push(tool);
handlers.set(tool.name, handler);
}
return { tools, handlers };
}
async handleEthereumToolCall(toolName, args, handlers) {
const handler = handlers.get(toolName);
if (!handler) {
throw new Error(`No handler found for tool: ${toolName}`);
}
const typedArgs = args;
const { chain_name, params = [] } = typedArgs;
if (!chain_name || typeof chain_name !== 'string') {
throw new Error(`Missing or invalid chain_name parameter. Got: ${typeof chain_name}`);
}
if (!Array.isArray(params)) {
throw new Error(`params must be an array. Got: ${typeof params}`);
}
// Pass the params array directly to the RPC call
// chain_name is used internally to select the RPC endpoint
return await handler(chain_name, ...params);
}
}