infura-mcp-server
Version:
Model Context Protocol (MCP) server providing comprehensive read-only Ethereum blockchain access through Infura's infrastructure. Connect Claude Desktop, VS Code, and Cursor to 29 secure Ethereum JSON-RPC tools across 30+ networks including Ethereum, Poly
813 lines (728 loc) ⢠27.6 kB
JavaScript
#!/usr/bin/env node
/**
* @fileoverview Infura MCP Server - Main server entry point
*
* This module implements a Model Context Protocol (MCP) server that provides
* access to Infura blockchain APIs. The server supports two transport modes:
*
* **Transport Modes:**
* - **stdio**: Standard input/output transport for CLI integration and local development.
* Used by MCP clients like Claude Desktop for direct communication.
* - **HTTP**: Streamable HTTP transport for web-based integrations and remote access.
* Supports Server-Sent Events (SSE) for real-time notifications.
*
* **Security Features:**
* - DNS rebinding protection via Host header validation
* - CORS with configurable origin allowlist
* - Rate limiting (100 requests/minute per IP)
* - Security headers (CSP, X-Frame-Options, X-Content-Type-Options, etc.)
* - Request body size limits (100kb) to prevent DoS
* - Session timeout and maximum session limits
* - Server fingerprint prevention (X-Powered-By disabled)
*
* **Usage:**
* - stdio mode: `node mcpServer.js` or `npx infura-mcp-server`
* - HTTP mode: `node mcpServer.js --http` or `npx infura-mcp-server --http`
*
* @module mcpServer
* @author Qbandev
* @license MIT
* @see {@link https://github.com/qbandev/infura-mcp-server} for documentation
*/
import dotenv from "dotenv";
import express from "express";
import rateLimit from "express-rate-limit";
import { randomUUID } from "crypto";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import { ValidationError } from "./lib/validators.js";
import { InfuraApiError, createActionableMessage } from "./lib/errors.js";
import { CHARACTER_LIMIT, truncationMessage } from "./lib/constants.js";
import { discoverTools } from "./lib/tools.js";
import { formatAsMarkdown } from "./lib/formatters.js";
import path from "path";
import { fileURLToPath } from "url";
import { readFileSync } from "fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.resolve(__dirname, ".env") });
const packageJson = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf-8"));
/**
* The server identifier used in MCP protocol handshakes.
* @constant {string}
*/
const SERVER_NAME = "infura-mcp-server";
/**
* The server version, read from package.json for consistency.
* @constant {string}
*/
const SERVER_VERSION = packageJson.version;
/**
* Session timeout in milliseconds. Sessions inactive for longer than this
* duration will be automatically cleaned up. Configurable via SESSION_TIMEOUT_MS
* environment variable.
* @constant {number}
* @default 1800000 (30 minutes)
*/
const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS, 10) || 1800000; // 30 minutes
/**
* Maximum number of concurrent sessions allowed. When this limit is reached,
* new session requests will receive a 503 Service Unavailable response.
* Configurable via MAX_SESSIONS environment variable.
* @constant {number}
* @default 1000
*/
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS, 10) || 1000;
/**
* List of allowed origins for CORS. Requests from origins not in this list
* will not receive CORS headers, effectively blocking cross-origin requests
* from browsers. Configurable via CORS_ALLOWED_ORIGINS environment variable
* (comma-separated list).
* @constant {string[]}
* @default ['http://localhost:3000', 'http://localhost:3001', 'http://127.0.0.1:3000']
*/
const ALLOWED_ORIGINS = process.env.CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) ||
['http://localhost:3000', 'http://localhost:3001', 'http://127.0.0.1:3000'];
/**
* List of allowed hostnames for DNS rebinding protection. Requests with Host
* headers not matching these values will be rejected with a 403 Forbidden.
* Configurable via ALLOWED_HOSTS environment variable (comma-separated list).
* @constant {string[]}
* @default ['localhost', '127.0.0.1']
*/
const ALLOWED_HOSTS = process.env.ALLOWED_HOSTS?.split(',').map(h => h.trim()) ||
['localhost', '127.0.0.1'];
/**
* Structured logging utility for server events.
*
* Outputs JSON-formatted log entries to stdout (info, warn) or stderr (error).
* Info-level messages are only output when DEBUG environment variable is set.
* Warning and error messages are always output.
*
* @param {('info'|'warn'|'error')} level - The log level
* @param {string} message - The log message describing the event
* @param {Object|null} [data=null] - Optional structured data to include in the log entry
* @returns {void}
*
* @example
* log('info', 'Server started', { port: 3001, transport: 'http' });
* log('error', 'Connection failed', { error: error.message });
*/
function log(level, message, data = null) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
level: level.toUpperCase(),
message,
...(data && { data }),
};
if (level === "error") {
console.error(JSON.stringify(logEntry));
} else if (process.env.DEBUG || level === "warn") {
console.log(JSON.stringify(logEntry));
}
}
/**
* Transforms internal tool definitions to MCP-compatible tool schemas.
*
* Converts the tool format used by the Infura tool discovery system to the
* format expected by the MCP protocol. Each tool must have a definition
* with a function property containing name, description, and parameters.
*
* @async
* @param {Array<Object>} tools - Array of tool objects from discoverTools()
* @param {Object} tools[].definition - The tool definition object
* @param {Object} tools[].definition.function - Function metadata
* @param {string} tools[].definition.function.name - The tool name
* @param {string} tools[].definition.function.description - Human-readable description
* @param {Object} tools[].definition.function.parameters - JSON Schema for parameters
* @returns {Promise<Array<Object>>} Array of MCP-formatted tool schemas
* @returns {string} returns[].name - The tool name
* @returns {string} returns[].description - Human-readable description
* @returns {Object} returns[].inputSchema - JSON Schema for input validation
*
* @example
* const mcpTools = await transformTools(tools);
* // Returns: [{ name: 'eth_getBalance', description: '...', inputSchema: {...} }]
*/
async function transformTools(tools) {
return tools
.map((tool) => {
const definitionFunction = tool.definition?.function;
if (!definitionFunction) return;
return {
name: definitionFunction.name,
description: definitionFunction.description,
inputSchema: definitionFunction.parameters,
};
})
.filter(Boolean);
}
/**
* Registers MCP protocol request handlers on the server instance.
*
* Sets up handlers for:
* - ListToolsRequest: Returns available tools and their schemas
* - CallToolRequest: Executes a tool and returns the result
*
* The handler implements:
* - Tool discovery and validation
* - Required parameter validation
* - Error classification and MCP error code mapping
* - Response truncation for large results (CHARACTER_LIMIT)
*
* @async
* @param {Server} server - The MCP Server instance to configure
* @param {Array<Object>} tools - Array of tool objects from discoverTools()
* @returns {Promise<void>}
* @throws {McpError} With ErrorCode.MethodNotFound for unknown tools
* @throws {McpError} With ErrorCode.InvalidParams for missing required parameters
* @throws {McpError} With ErrorCode.InvalidRequest for auth/permission errors
* @throws {McpError} With ErrorCode.InternalError for API or execution errors
*
* @example
* const server = createServer();
* await setupServerHandlers(server, tools);
*/
async function setupServerHandlers(server, tools) {
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: await transformTools(tools),
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const toolName = request.params.name;
const args = request.params.arguments;
log("info", `Tool call received: ${toolName}`, { args });
const tool = tools.find((t) => t.definition.function.name === toolName);
if (!tool) {
log("error", `Unknown tool requested: ${toolName}`);
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
}
const requiredParameters =
tool.definition?.function?.parameters?.required || [];
for (const requiredParameter of requiredParameters) {
if (!(requiredParameter in args)) {
log("error", `Missing required parameter: ${requiredParameter}`, {
toolName,
args,
});
throw new McpError(
ErrorCode.InvalidParams,
`Missing required parameter: ${requiredParameter}`
);
}
}
try {
log("info", `Executing tool: ${toolName}`);
const result = await tool.function(args);
log("info", `Tool execution successful: ${toolName}`);
// Format response based on response_format parameter
const responseFormat = args.response_format || 'json';
let textContent;
if (responseFormat === 'markdown') {
textContent = formatAsMarkdown(result, toolName);
} else {
textContent = JSON.stringify(result, null, 2);
}
// Apply character limit
if (textContent.length > CHARACTER_LIMIT) {
const truncated = textContent.substring(0, CHARACTER_LIMIT);
// Determine item count for truncation message
let itemCount = 1;
if (result && typeof result === "object" && Array.isArray(result.logs)) {
// Handle paginated responses like { logs: [...], pagination: {...} }
itemCount = result.logs.length;
} else if (Array.isArray(result)) {
itemCount = result.length;
}
textContent = truncated + truncationMessage(
itemCount,
CHARACTER_LIMIT
);
}
return {
content: [{ type: "text", text: textContent }],
};
} catch (error) {
log("error", `Tool execution failed: ${toolName}`, {
error: error.message,
stack: error.stack,
args,
});
// Don't re-wrap McpErrors
if (error instanceof McpError) {
throw error;
}
// Handle validation errors
if (error instanceof ValidationError) {
throw new McpError(
ErrorCode.InvalidParams,
`Validation error for '${error.field}': ${error.message}`
);
}
// Handle Infura API errors with proper classification
if (error instanceof InfuraApiError) {
if (error.httpStatus === 401 || error.httpStatus === 403) {
throw new McpError(ErrorCode.InvalidRequest, createActionableMessage(error, { network: args.network }));
}
throw new McpError(ErrorCode.InternalError, createActionableMessage(error, { network: args.network }));
}
// Generic error fallback
throw new McpError(ErrorCode.InternalError, `API error: ${error.message}`);
}
});
}
/**
* Factory function to create a new MCP Server instance.
*
* Creates a server with the configured name, version, and capabilities.
* The server is configured with tools capability enabled.
*
* @returns {Server} A new MCP Server instance ready to be connected to a transport
*
* @example
* const server = createServer();
* await setupServerHandlers(server, tools);
* await server.connect(transport);
*/
function createServer() {
return new Server(
{
name: SERVER_NAME,
version: SERVER_VERSION,
},
{
capabilities: {
tools: {},
},
}
);
}
/**
* Starts the HTTP transport server with full security middleware stack.
*
* Creates an Express application with the following middleware chain:
* 1. Request body size limit (100kb)
* 2. DNS rebinding protection (Host header validation)
* 3. Security headers (CSP, X-Frame-Options, etc.)
* 4. CORS with configurable origin allowlist
* 5. Rate limiting (100 req/min per IP on /mcp)
*
* **Endpoints:**
* - `GET /` - Server info and documentation links
* - `GET /health` - Health check with session count and uptime
* - `GET /mcp` - SSE stream for MCP notifications (requires session)
* - `POST /mcp` - MCP JSON-RPC requests
* - `DELETE /mcp` - Terminate a session
*
* **Session Management:**
* - Sessions are created on first POST request
* - Sessions timeout after SESSION_TIMEOUT_MS of inactivity
* - Maximum concurrent sessions limited by MAX_SESSIONS
* - Expired sessions are cleaned up every 5 minutes
*
* @async
* @param {Array<Object>} tools - Array of tool objects from discoverTools()
* @returns {Promise<import('http').Server>} The HTTP server instance
*
* @example
* const tools = await discoverTools();
* const httpServer = await runStreamableHttpServer(tools);
* // Server is now listening on PORT (default 3001)
*/
async function runStreamableHttpServer(tools) {
const app = express();
// Disable X-Powered-By header to prevent server fingerprinting
app.disable('x-powered-by');
// Request body size limit to prevent DoS attacks
app.use(express.json({ limit: '100kb' }));
/**
* Session storage mapping session IDs to session objects.
* @type {Map<string, {server: Server, transport: StreamableHTTPServerTransport, createdAt: number, lastActivity: number}>}
*/
const sessions = new Map();
/**
* Session cleanup interval - removes expired sessions every 5 minutes.
* Sessions are considered expired if they haven't had activity within
* SESSION_TIMEOUT_MS milliseconds. The interval is unreffed to allow
* the process to exit cleanly.
*/
const sessionCleanupInterval = setInterval(() => {
const now = Date.now();
for (const [sessionId, session] of sessions.entries()) {
if (now - session.createdAt > SESSION_TIMEOUT_MS) {
try {
session.transport.close();
session.server.close();
} catch (error) {
log("error", `Error closing expired session ${sessionId}`, { error: error.message });
}
sessions.delete(sessionId);
log("info", "Expired session cleaned up", { sessionId });
}
}
}, 300000); // Run every 5 minutes
// Ensure cleanup interval doesn't prevent process exit
sessionCleanupInterval.unref();
/**
* Rate limiter middleware for the /mcp endpoint.
* Limits each IP to 100 requests per minute to prevent abuse.
* Uses X-Forwarded-For header when behind a proxy.
*/
const mcpRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute per IP
message: { error: "Too many requests, please try again later" },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Use X-Forwarded-For if behind a proxy, otherwise use IP
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
},
});
/**
* DNS rebinding protection middleware.
* Validates the Host header against ALLOWED_HOSTS to prevent DNS rebinding attacks.
* Rejects requests with missing or invalid Host headers with 403 Forbidden.
*/
app.use((req, res, next) => {
const host = req.headers.host;
if (!host) {
log("warn", "Request missing Host header", { ip: req.ip });
return res.status(403).json({ error: "Forbidden: Missing Host header" });
}
// Extract hostname (remove port if present)
const hostname = host.split(':')[0];
// Check if the hostname is in the allowed list
if (!ALLOWED_HOSTS.includes(hostname)) {
log("warn", "DNS rebinding attempt blocked", { host, ip: req.ip });
return res.status(403).json({ error: "Forbidden: Invalid Host header" });
}
next();
});
/**
* Security headers middleware.
* Sets various security-related HTTP headers:
* - X-Content-Type-Options: nosniff - Prevents MIME type sniffing
* - X-Frame-Options: DENY - Prevents clickjacking
* - Content-Security-Policy: Strict CSP for API server
* - Referrer-Policy: no-referrer - Prevents referrer leakage
* - Permissions-Policy: Disables unnecessary browser features
* - Cache-Control: no-store - Prevents caching of sensitive responses
*/
app.use((req, res, next) => {
// Prevent MIME type sniffing
res.header("X-Content-Type-Options", "nosniff");
// Prevent clickjacking
res.header("X-Frame-Options", "DENY");
// Content Security Policy - API server, no scripts
res.header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'");
// Referrer policy
res.header("Referrer-Policy", "no-referrer");
// Permissions policy - disable unnecessary features
res.header("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
// Prevent caching of sensitive API responses
res.header("Cache-Control", "no-store");
next();
});
/**
* CORS middleware with configurable origin allowlist.
* Only sets Access-Control-Allow-Origin for origins in ALLOWED_ORIGINS.
* Handles preflight OPTIONS requests automatically.
* Sets Vary: Origin header for proper cache behavior.
*/
app.use((req, res, next) => {
const origin = req.headers.origin;
// Check if the request origin is in the allowed list
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.header("Access-Control-Allow-Origin", origin);
res.header("Access-Control-Allow-Credentials", "true");
}
// For requests without origin (e.g., same-origin, curl, etc.) - allow if no origin header
// This is safe because the Host header validation already protects against DNS rebinding
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Accept, Mcp-Session-Id, Last-Event-ID"
);
res.header("Access-Control-Expose-Headers", "Mcp-Session-Id");
res.header("Vary", "Origin");
if (req.method === "OPTIONS") {
return res.sendStatus(200);
}
next();
});
/**
* Main MCP endpoint handler.
* Supports GET (SSE stream), POST (JSON-RPC), and DELETE (session termination).
* Rate limited to 100 requests per minute per IP.
*/
app.all("/mcp", mcpRateLimiter, async (req, res) => {
const sessionId = req.headers["mcp-session-id"];
if (req.method === "GET") {
log("info", "GET /mcp - Opening stream for notifications", { sessionId });
if (!sessionId || !sessions.has(sessionId)) {
return res.status(400).json({ error: "Invalid or missing session ID" });
}
// Update session activity timestamp
const session = sessions.get(sessionId);
session.lastActivity = Date.now();
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Flush headers to establish event stream connection immediately
res.flushHeaders();
res.write(": connected\n\n");
const keepAlive = setInterval(() => {
// Check if response is still writable before sending keep-alive
if (res.writableEnded || res.destroyed) {
clearInterval(keepAlive);
return;
}
try {
res.write(": keep-alive\n\n");
} catch (error) {
clearInterval(keepAlive);
log("error", "Error writing keep-alive", { sessionId, error: error.message });
}
}, 30000);
// Single close handler for cleanup and logging
req.on("close", () => {
clearInterval(keepAlive);
log("info", "Stream closed", { sessionId });
});
return;
} else if (req.method === "POST") {
log("info", "POST /mcp - Received request", { sessionId, body: req.body });
let transport;
let server;
if (sessionId && sessions.has(sessionId)) {
const session = sessions.get(sessionId);
transport = session.transport;
server = session.server;
// Update session activity timestamp
session.lastActivity = Date.now();
} else {
// Check session limit before creating new session
if (sessions.size >= MAX_SESSIONS) {
log("warn", "Max sessions reached, rejecting new session", {
currentSessions: sessions.size,
maxSessions: MAX_SESSIONS,
ip: req.ip,
});
return res.status(503).json({
error: "Service temporarily unavailable: maximum sessions reached. Please try again later.",
});
}
server = createServer();
await setupServerHandlers(server, tools);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
log("info", "New session initialized", { sessionId: newSessionId });
sessions.set(newSessionId, {
server,
transport,
createdAt: Date.now(),
lastActivity: Date.now(),
});
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && sessions.has(sid)) {
sessions.delete(sid);
log("info", "Session closed and cleaned up", { sessionId: sid });
}
};
await server.connect(transport);
}
try {
await transport.handleRequest(req, res, req.body);
} catch (error) {
log("error", "Error handling request", { error: error.message, sessionId });
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
}
} else if (req.method === "DELETE") {
if (sessionId && sessions.has(sessionId)) {
const session = sessions.get(sessionId);
try {
await session.transport.close();
await session.server.close();
} catch (error) {
log("error", "Error closing session", { error: error.message, sessionId });
}
sessions.delete(sessionId);
log("info", "Session terminated", { sessionId });
res.status(200).json({ message: "Session terminated" });
} else {
res.status(404).json({ error: "Session not found" });
}
} else {
// Return 405 for unsupported HTTP methods
res.set("Allow", "GET, POST, DELETE, OPTIONS");
res.status(405).json({ error: "Method Not Allowed" });
}
});
/**
* Health check endpoint.
* Returns server status, version, transport type, active session count, and uptime.
*/
app.get("/health", (req, res) => {
res.json({
status: "ok",
server: SERVER_NAME,
version: SERVER_VERSION,
transport: "streamable-http",
activeSessions: sessions.size,
uptime: process.uptime(),
});
});
/**
* Root endpoint.
* Returns server information and documentation links.
*/
app.get("/", (req, res) => {
res.json({
name: SERVER_NAME,
version: SERVER_VERSION,
description: "Infura MCP Server - Ethereum blockchain access via MCP",
endpoints: {
mcp: "/mcp",
health: "/health",
},
documentation: "https://github.com/qbandev/infura-mcp-server",
});
});
const port = process.env.PORT || 3001;
const httpServer = app.listen(port, () => {
log("info", `Streamable HTTP server running on port ${port}`);
console.log(`\nš Infura MCP Server v${SERVER_VERSION}`);
console.log(` Streamable HTTP: http://localhost:${port}/mcp`);
console.log(` Health: http://localhost:${port}/health\n`);
});
httpServer.on("error", (error) => {
log("error", "HTTP server error", { error: error.message });
});
/**
* Graceful shutdown handler.
* Stops accepting new connections, closes all active MCP sessions,
* and exits with appropriate status code.
* @async
* @returns {Promise<void>}
*/
const shutdown = async () => {
log("info", "Shutting down gracefully...");
let hasErrors = false;
// Stop accepting new connections
await new Promise((resolve) => httpServer.close(resolve));
log("info", "HTTP server closed, no new connections accepted");
// Close all MCP sessions
for (const [sessionId, session] of sessions.entries()) {
try {
await session.transport.close();
await session.server.close();
} catch (error) {
hasErrors = true;
log("error", `Error closing session ${sessionId}`, { error: error.message });
}
}
log("info", "All sessions closed", { hasErrors });
process.exit(hasErrors ? 1 : 0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
return httpServer;
}
/**
* Starts the stdio transport server for CLI integration.
*
* Creates an MCP server that communicates over standard input/output streams.
* This mode is used by MCP clients like Claude Desktop for direct process
* communication without network overhead.
*
* Features:
* - Direct stdin/stdout communication
* - Graceful shutdown on SIGINT
* - Error logging to stderr
*
* @async
* @param {Array<Object>} tools - Array of tool objects from discoverTools()
* @returns {Promise<void>} Resolves when the server is connected and ready
*
* @example
* const tools = await discoverTools();
* await runStdioServer(tools);
* // Server is now listening on stdin/stdout
*/
async function runStdioServer(tools) {
const server = createServer();
server.onerror = (error) => {
log("error", "Stdio server error", { error: error.message, stack: error.stack });
};
await setupServerHandlers(server, tools);
process.on("SIGINT", async () => {
log("info", "Received SIGINT, closing server...");
await server.close();
process.exit(0);
});
const transport = new StdioServerTransport();
await server.connect(transport);
log("info", "Connected to stdio transport");
}
/**
* Main entry point for the MCP server.
*
* Parses command-line arguments to determine transport mode:
* - `--http` flag: Starts HTTP transport server
* - No flag (default): Starts stdio transport server
*
* Initializes the tool discovery system and starts the appropriate server.
* Exits with code 1 on startup failure.
*
* @async
* @returns {Promise<void>}
* @throws {Error} Logs error and exits process on failure
*
* @example
* // Start in stdio mode (default)
* // $ node mcpServer.js
*
* // Start in HTTP mode
* // $ node mcpServer.js --http
*/
async function run() {
const args = process.argv.slice(2);
const isHTTP = args.includes("--http");
log("info", `Starting MCP server in ${isHTTP ? "HTTP" : "stdio"} mode`);
try {
const tools = await discoverTools();
log("info", `Loaded ${tools.length} tools successfully`);
if (isHTTP) {
await runStreamableHttpServer(tools);
} else {
await runStdioServer(tools);
}
} catch (error) {
log("error", "Failed to start server", {
error: error.message,
stack: error.stack,
});
process.exit(1);
}
}
run().catch((error) => {
log("error", "Unhandled error in main", {
error: error.message,
stack: error.stack,
});
process.exit(1);
});