From d6b5aa364a7555e1f25b84642b598ba3bc5ca461 Mon Sep 17 00:00:00 2001 From: Johnaverse Date: Sat, 7 Mar 2026 14:32:17 -0500 Subject: [PATCH 1/5] Release v1.1.1 --- CHANGELOG.md | 6 +++ README.md | 7 +-- dataService.js | 47 +++++++++++++++++- index.js | 32 ++++++++---- mcp-server-http.js | 4 +- mcp-server.js | 4 +- mcp-tools.js | 14 +++--- package-lock.json | 4 +- package.json | 2 +- public/app.js | 42 ++++++++++------ tests/integration/api.test.js | 78 +++++++++++++++++------------- tests/unit/dataService.test.js | 23 +++++++++ tests/unit/index.test.js | 21 +++++--- tests/unit/mcp-server-http.test.js | 11 +++-- tests/unit/mcp-server.test.js | 13 +++++ tests/unit/mcp-tools.test.js | 27 +++++------ 16 files changed, 232 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f79c8fd..83cdbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## v1.1.1 — 2026-03-07 +- Fixed: unified RPC health reporting so the API and MCP servers use the same data source. +- Fixed: prevented empty-source reloads from wiping a healthy cached dataset. +- Fixed: tightened numeric route parsing to reject partially numeric IDs and graph depth values. +- Fixed: reduced frontend cognitive complexity in `public/app.js` for SonarQube compliance. # Changelog All notable changes to this project will be documented in this file. @@ -57,3 +62,4 @@ Full Changelog: v1.0.0...v1.0.9 --- This changelog was drafted from repository structure and test files; run `git log --pretty=oneline v1.0.9..v1.0.11` to produce a commit-based changelog if you prefer exact commit messages. + diff --git a/README.md b/README.md index 04579f4..9167e6f 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Pre-built Docker images are automatically published to GitHub Container Registry docker pull ghcr.io/johnaverse/chains-api:latest # Or pull a specific version -docker pull ghcr.io/johnaverse/chains-api:v1.0.0 +docker pull ghcr.io/johnaverse/chains-api:v1.1.1 ``` #### Build Docker image locally @@ -256,7 +256,7 @@ The HTTP server will start on `http://0.0.0.0:3001` by default (configurable via curl -X POST http://localhost:3001/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"my-client","version":"1.0.0"}}}' + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"my-client","version":"1.1.1"}}}' # Extract session ID from the mcp-session-id header, then call a tool: curl -X POST http://localhost:3001/mcp \ @@ -355,7 +355,7 @@ Get API information and available endpoints. ```json { "name": "Chains API", - "version": "1.0.0", + "version": "1.1.1", "description": "API query service for blockchain chain data from multiple sources", "endpoints": { ... }, "dataSources": [ ... ] @@ -865,3 +865,4 @@ If you have questions or encounter issues, please [open an issue](https://github ## License MIT + diff --git a/dataService.js b/dataService.js index df3408e..0457f43 100644 --- a/dataService.js +++ b/dataService.js @@ -223,7 +223,7 @@ async function refreshDataWithGuard(options = {}) { const { data, loadedSourceCount } = await fetchAndBuildData(); if (requireAtLeastOneSource && loadedSourceCount === 0) { - throw new Error('All data sources failed during background refresh'); + throw new Error('All data sources failed during data refresh'); } applyDataToCache(data); @@ -851,7 +851,7 @@ export function indexData(theGraph, chainlist, chains, slip44) { * Load and cache all data sources */ export async function loadData() { - return refreshDataWithGuard(); + return refreshDataWithGuard({ requireAtLeastOneSource: true }); } /** @@ -913,6 +913,46 @@ export function getCachedData() { return cachedData; } +function flattenRpcHealthResults() { + return Object.entries(cachedData.rpcHealth || {}).flatMap(([chainId, results]) => { + const numericChainId = Number.parseInt(chainId, 10); + const chainName = cachedData.indexed?.byChainId?.[numericChainId]?.name ?? `Chain ${chainId}`; + + return (results || []).map((result) => ({ + chainId: numericChainId, + chainName, + url: result.url, + status: result.ok ? 'working' : 'failed', + clientVersion: result.clientVersion ?? null, + blockNumber: result.blockHeight ?? null, + latencyMs: result.latencyMs ?? null, + error: result.error ?? null + })); + }); +} + +export function getRpcMonitoringResults() { + const results = flattenRpcHealthResults(); + const workingEndpoints = results.filter(result => result.status === 'working').length; + const failedEndpoints = results.length - workingEndpoints; + + return { + lastUpdated: cachedData.lastRpcCheck, + totalEndpoints: results.length, + testedEndpoints: results.length, + workingEndpoints, + failedEndpoints, + results + }; +} + +export function getRpcMonitoringStatus() { + return { + isMonitoring: rpcCheckInProgress, + lastUpdated: cachedData.lastRpcCheck + }; +} + /** * Search chains by various criteria */ @@ -1804,3 +1844,6 @@ export function validateChainData() { allErrors: errors }; } + + + diff --git a/index.js b/index.js index d5d449a..616de69 100644 --- a/index.js +++ b/index.js @@ -4,8 +4,7 @@ import rateLimit from '@fastify/rate-limit'; import helmet from '@fastify/helmet'; import { readFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; -import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations } from './dataService.js'; -import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck } from './rpcMonitor.js'; +import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations, getRpcMonitoringResults, getRpcMonitoringStatus, startRpcHealthCheck } from './dataService.js'; import { PORT, HOST, BODY_LIMIT, MAX_PARAM_LENGTH, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, @@ -367,8 +366,8 @@ export async function buildApp(options = {}) { * Get RPC monitoring results */ fastify.get('/rpc-monitor', async (request, reply) => { - const results = getMonitoringResults(); - const status = getMonitoringStatus(); + const results = getRpcMonitoringResults(); + const status = getRpcMonitoringStatus(); return { ...status, @@ -385,7 +384,7 @@ export async function buildApp(options = {}) { return sendError(reply, 400, 'Invalid chain ID'); } - const results = getMonitoringResults(); + const results = getRpcMonitoringResults(); const chainResults = results.results.filter(r => r.chainId === chainId); if (chainResults.length === 0) { @@ -411,7 +410,7 @@ export async function buildApp(options = {}) { */ fastify.get('/stats', async (request, reply) => { const chains = getAllChains(); - const monitorResults = getMonitoringResults(); + const monitorResults = getRpcMonitoringResults(); const totalChains = chains.length; const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet')).length; @@ -447,7 +446,7 @@ export async function buildApp(options = {}) { fastify.get('/', async (request, reply) => { return { name: 'Chains API', - version: '1.0.0', + version: '1.1.1', description: 'API query service for blockchain chain data from multiple sources', endpoints: { '/health': 'Health check and data status', @@ -490,8 +489,21 @@ export async function buildApp(options = {}) { * @param {string} paramName - Name of the parameter for error message * @returns {number|null} Parsed integer or null if invalid */ -function parseIntParam(param, paramName = 'ID') { - const parsed = Number.parseInt(param, 10); +function parseIntParam(param) { + if (typeof param === 'number') { + return Number.isInteger(param) ? param : null; + } + + if (typeof param !== 'string') { + return null; + } + + const normalized = param.trim(); + if (!/^-?\d+$/.test(normalized)) { + return null; + } + + const parsed = Number.parseInt(normalized, 10); return Number.isNaN(parsed) ? null : parsed; } @@ -528,3 +540,5 @@ if (isMainModule) { start(); } + + diff --git a/mcp-server-http.js b/mcp-server-http.js index 5e091f7..3c07961 100755 --- a/mcp-server-http.js +++ b/mcp-server-http.js @@ -10,8 +10,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; import { createRequire } from 'node:module'; -import { initializeDataOnStartup, getCachedData } from './dataService.js'; -import { startRpcHealthCheck } from './rpcMonitor.js'; +import { initializeDataOnStartup, getCachedData, startRpcHealthCheck } from './dataService.js'; import { getToolDefinitions, handleToolCall } from './mcp-tools.js'; const require = createRequire(import.meta.url); @@ -222,3 +221,4 @@ process.on('SIGINT', async () => { console.log('Server shutdown complete'); process.exit(0); }); + diff --git a/mcp-server.js b/mcp-server.js index 496a9b9..2f46bee 100755 --- a/mcp-server.js +++ b/mcp-server.js @@ -7,8 +7,7 @@ import { ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { createRequire } from 'node:module'; -import { initializeDataOnStartup } from './dataService.js'; -import { startRpcHealthCheck } from './rpcMonitor.js'; +import { initializeDataOnStartup, startRpcHealthCheck } from './dataService.js'; import { getToolDefinitions, handleToolCall } from './mcp-tools.js'; const require = createRequire(import.meta.url); @@ -59,3 +58,4 @@ try { console.error('Server error:', error); process.exit(1); } + diff --git a/mcp-tools.js b/mcp-tools.js index 37e6c51..8415b2a 100644 --- a/mcp-tools.js +++ b/mcp-tools.js @@ -10,8 +10,9 @@ import { getAllKeywords, validateChainData, traverseRelations, + getRpcMonitoringResults, + getRpcMonitoringStatus, } from './dataService.js'; -import { getMonitoringResults, getMonitoringStatus } from './rpcMonitor.js'; /** * Get the list of MCP tool definitions (schemas) @@ -316,7 +317,7 @@ function handleValidateChains() { function handleGetStats() { const chains = getAllChains(); - const monitorResults = getMonitoringResults(); + const monitorResults = getRpcMonitoringResults(); const totalChains = chains.length; const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet')).length; @@ -399,8 +400,8 @@ function formatRpcMonitorStatus(status, results) { } function handleGetRpcMonitor() { - const results = getMonitoringResults(); - const status = getMonitoringStatus(); + const results = getRpcMonitoringResults(); + const status = getRpcMonitoringStatus(); return { content: [{ type: 'text', text: formatRpcMonitorStatus(status, results) }] }; } @@ -410,8 +411,8 @@ function handleGetRpcMonitorById(args) { return errorResponse('Invalid chain ID'); } - const results = getMonitoringResults(); - const status = getMonitoringStatus(); + const results = getRpcMonitoringResults(); + const status = getRpcMonitoringStatus(); const chainResults = results.results.filter((r) => r.chainId === chainId); if (chainResults.length === 0) { @@ -478,3 +479,4 @@ export async function handleToolCall(name, args) { return errorResponse('Internal error', error.message); } } + diff --git a/package-lock.json b/package-lock.json index 382c6ee..3cea51c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chains-api", - "version": "1.0.12", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chains-api", - "version": "1.0.12", + "version": "1.1.1", "license": "ISC", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/package.json b/package.json index 822190e..e523c9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chains-api", - "version": "1.1.0", + "version": "1.1.1", "description": "API query service for blockchain chain data from multiple sources", "main": "index.js", "type": "module", diff --git a/public/app.js b/public/app.js index c691fe0..7b7ce3d 100644 --- a/public/app.js +++ b/public/app.js @@ -600,44 +600,58 @@ function showWebsite(data) { } } -function showNodeDetails(node) { - const panel = document.getElementById('detailsPanel'); - const data = node.data; - +function showNodeHeader(node, data) { const iconElem = document.getElementById('chainIcon'); iconElem.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?'; iconElem.style.background = `linear-gradient(135deg, ${node.color}, ${node.color}33)`; document.getElementById('chainName').textContent = node.name || 'Unknown Chain'; document.getElementById('chainIdBadge').textContent = `ID: ${data.chainId}`; - - showStatusBadge(data); - showTagsBadge(data); - document.getElementById('chainCurrency').textContent = data.nativeCurrency ? `${data.nativeCurrency.name} (${data.nativeCurrency.symbol})` : 'None'; + showStatusBadge(data); + showTagsBadge(data); +} + +function showNodeParents(node) { const { row: rowL1, elem: l1Elem } = showParentRow('rowL1Parent', 'chainL1Parent', node.l2Parent); showParentRow('rowMainnet', 'chainMainnet', node.mainnetParent); - if (!node.l2Parent && !node.mainnetParent) { - rowL1.style.display = 'flex'; - l1Elem.textContent = 'None'; + if (node.l2Parent || node.mainnetParent) { + return; } - showChildrenSection('chainL2Children', 'labelL2Children', node.l2Children, 'L2 / L3'); + rowL1.style.display = 'flex'; + l1Elem.textContent = 'None'; +} +function showNodeTestnets(node) { const rowTestnetChildren = document.getElementById('rowTestnetChildren'); const isTestnet = node.data.tags?.includes('Testnet'); + rowTestnetChildren.style.display = isTestnet ? 'none' : 'flex'; - if (!isTestnet) { - showChildrenSection('chainTestnetChildren', 'labelTestnetChildren', node.testnetChildren, 'Testnets'); + if (isTestnet) { + return; } + showChildrenSection('chainTestnetChildren', 'labelTestnetChildren', node.testnetChildren, 'Testnets'); +} + +function showNodeDetails(node) { + const panel = document.getElementById('detailsPanel'); + const data = node.data; + + showNodeHeader(node, data); + showNodeParents(node); + showChildrenSection('chainL2Children', 'labelL2Children', node.l2Children, 'L2 / L3'); + showNodeTestnets(node); showRpcEndpoints(data); showExplorers(data); showWebsite(data); panel.classList.remove('hidden'); } + + diff --git a/tests/integration/api.test.js b/tests/integration/api.test.js index 9e2ecd9..0b9d7db 100644 --- a/tests/integration/api.test.js +++ b/tests/integration/api.test.js @@ -1,7 +1,6 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { buildApp } from '../../index.js'; import * as dataService from '../../dataService.js'; -import * as rpcMonitor from '../../rpcMonitor.js'; import * as fsPromises from 'node:fs/promises'; vi.mock('node:fs/promises', () => ({ @@ -209,6 +208,29 @@ vi.mock('../../dataService.js', async () => { } ] })), + getRpcMonitoringResults: vi.fn(() => ({ + lastUpdated: new Date().toISOString(), + totalEndpoints: 100, + testedEndpoints: 50, + workingEndpoints: 30, + failedEndpoints: 20, + results: [ + { + chainId: 1, + chainName: 'Ethereum Mainnet', + url: 'https://eth.llamarpc.com', + status: 'working', + blockNumber: 12345678, + latencyMs: 150, + error: null + } + ] + })), + getRpcMonitoringStatus: vi.fn(() => ({ + isMonitoring: false, + lastUpdated: new Date().toISOString() + })), + startRpcHealthCheck: vi.fn(), getAllKeywords: vi.fn(() => ({ totalKeywords: 13, keywords: { @@ -226,30 +248,6 @@ vi.mock('../../dataService.js', async () => { }; }); -vi.mock('../../rpcMonitor.js', () => ({ - getMonitoringResults: vi.fn(() => ({ - lastUpdated: new Date().toISOString(), - totalEndpoints: 100, - testedEndpoints: 50, - workingEndpoints: 30, - results: [ - { - chainId: 1, - chainName: 'Ethereum Mainnet', - endpoint: 'https://eth.llamarpc.com', - status: 'working', - blockNumber: 12345678, - responseTime: 150 - } - ] - })), - getMonitoringStatus: vi.fn(() => ({ - isMonitoring: false, - lastUpdated: new Date().toISOString() - })), - startRpcHealthCheck: vi.fn() -})); - describe('API Endpoints', () => { let app; @@ -265,7 +263,7 @@ describe('API Endpoints', () => { describe('Startup Initialization', () => { it('should warm-start using initializeDataOnStartup and serve endpoints immediately', async () => { vi.mocked(dataService.initializeDataOnStartup).mockClear(); - vi.mocked(rpcMonitor.startRpcHealthCheck).mockClear(); + vi.mocked(dataService.startRpcHealthCheck).mockClear(); const startupApp = await buildApp({ logger: false, loadDataOnStartup: true }); const response = await startupApp.inject({ @@ -279,7 +277,7 @@ describe('API Endpoints', () => { expect(payload).toHaveProperty('dataLoaded'); expect(payload).toHaveProperty('totalChains'); expect(vi.mocked(dataService.initializeDataOnStartup)).toHaveBeenCalledTimes(1); - expect(vi.mocked(rpcMonitor.startRpcHealthCheck)).toHaveBeenCalled(); + expect(vi.mocked(dataService.startRpcHealthCheck)).toHaveBeenCalled(); await startupApp.close(); }); @@ -295,7 +293,7 @@ describe('API Endpoints', () => { expect(response.statusCode).toBe(200); const data = JSON.parse(response.payload); expect(data).toHaveProperty('name', 'Chains API'); - expect(data).toHaveProperty('version', '1.0.0'); + expect(data).toHaveProperty('version', '1.1.1'); expect(data).toHaveProperty('description'); expect(data).toHaveProperty('endpoints'); expect(data).toHaveProperty('dataSources'); @@ -412,17 +410,15 @@ describe('API Endpoints', () => { expect(data).toHaveProperty('error', 'Invalid chain ID'); }); - it('should handle decimal chain ID by parsing as integer', async () => { - // Note: Fastify's URL parsing converts '1.5' to '1' before our handler sees it + it('should reject partially numeric chain IDs', async () => { const response = await app.inject({ method: 'GET', - url: '/chains/1.5' + url: '/chains/1abc' }); - // The request is handled as chain ID 1 - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(400); const data = JSON.parse(response.payload); - expect(data).toHaveProperty('chainId', 1); + expect(data).toHaveProperty('error', 'Invalid chain ID'); }); }); @@ -528,6 +524,17 @@ describe('API Endpoints', () => { const data = JSON.parse(response.payload); expect(data).toHaveProperty('error', 'Invalid chain ID'); }); + + it('should reject partially numeric graph depth values', async () => { + const response = await app.inject({ + method: 'GET', + url: '/relations/1/graph?depth=2xyz' + }); + + expect(response.statusCode).toBe(400); + const data = JSON.parse(response.payload); + expect(data).toHaveProperty('error', 'Invalid depth. Must be between 1 and 5'); + }); }); describe('GET /endpoints', () => { @@ -973,3 +980,6 @@ describe('API Endpoints', () => { }); }); }); + + + diff --git a/tests/unit/dataService.test.js b/tests/unit/dataService.test.js index 0b63a2f..4737124 100644 --- a/tests/unit/dataService.test.js +++ b/tests/unit/dataService.test.js @@ -2240,6 +2240,27 @@ describe('initializeDataOnStartup with disk cache', () => { expect(cache.indexed.byChainId[1].name).toBe('Stale Chain'); }); + it('preserves cached data when a manual reload loses every source', async () => { + const { mod, fsMock } = await importWithDiskCacheEnabled(); + fsMock.readFile.mockResolvedValueOnce(JSON.stringify(buildSnapshot(1, 'Stale Chain'))); + + global.fetch + .mockResolvedValueOnce({ ok: true, json: async () => ({ networks: [] }) }) + .mockResolvedValueOnce({ ok: true, json: async () => [{ chainId: 25, name: 'Fresh Chain' }] }) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) + .mockResolvedValueOnce({ ok: true, text: async () => '' }); + + await mod.initializeDataOnStartup(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mod.getCachedData().indexed.byChainId[25].name).toBe('Fresh Chain'); + + global.fetch.mockRejectedValue(new Error('network down')); + + await expect(mod.loadData()).rejects.toThrow('All data sources failed during data refresh'); + expect(mod.getCachedData().indexed.byChainId[25].name).toBe('Fresh Chain'); + }); + it('deduplicates concurrent startup initialization and refresh operations', async () => { const { mod, fsMock } = await importWithDiskCacheEnabled(); fsMock.readFile.mockResolvedValueOnce(JSON.stringify(buildSnapshot(1, 'Stale Chain'))); @@ -2743,3 +2764,5 @@ describe('traverseRelations', () => { } }); }); + + diff --git a/tests/unit/index.test.js b/tests/unit/index.test.js index 990faaa..7ecfcd4 100644 --- a/tests/unit/index.test.js +++ b/tests/unit/index.test.js @@ -50,22 +50,26 @@ vi.mock('../../dataService.js', async () => { getEndpointsById: vi.fn(() => null), getAllEndpoints: vi.fn(() => []), getAllKeywords: vi.fn(() => ({})), + getRpcMonitoringResults: vi.fn(() => ({ + lastUpdated: null, + totalEndpoints: 0, + testedEndpoints: 0, + workingEndpoints: 0, + failedEndpoints: 0, + results: [] + })), + getRpcMonitoringStatus: vi.fn(() => ({ isMonitoring: false, lastUpdated: null })), + startRpcHealthCheck: vi.fn(), validateChainData: vi.fn(() => []) }; }); -vi.mock('../../rpcMonitor.js', () => ({ - getMonitoringResults: vi.fn(() => ({ results: [], lastUpdated: null })), - getMonitoringStatus: vi.fn(() => ({ isMonitoring: false, lastUpdated: null })), - startRpcHealthCheck: vi.fn() -})); - vi.mock('node:fs/promises', () => ({ readFile: vi.fn().mockRejectedValue(new Error('ENOENT')) })); import { buildApp } from '../../index.js'; -import { startRpcHealthCheck } from '../../rpcMonitor.js'; +import * as dataService from '../../dataService.js'; describe('index.js - CORS origin split/map callback', () => { beforeEach(() => { @@ -95,8 +99,9 @@ describe('index.js - onBackgroundRefreshSuccess callback', () => { // Invoke it to exercise the callback capturedCallback(); - expect(startRpcHealthCheck).toHaveBeenCalled(); + expect(dataService.startRpcHealthCheck).toHaveBeenCalled(); await app.close(); }); }); + diff --git a/tests/unit/mcp-server-http.test.js b/tests/unit/mcp-server-http.test.js index ef102e5..b931954 100644 --- a/tests/unit/mcp-server-http.test.js +++ b/tests/unit/mcp-server-http.test.js @@ -70,7 +70,7 @@ describe('MCP HTTP Server Handler Logic', () => { const infoData = { name: 'Chains API - MCP HTTP Server', - version: '1.0.0', + version: '1.1.1', description: 'HTTP-based MCP server for blockchain chain data', endpoints: { '/mcp': 'MCP protocol endpoint (POST for requests, DELETE for session termination)', @@ -81,7 +81,7 @@ describe('MCP HTTP Server Handler Logic', () => { }; expect(infoData.name).toBe('Chains API - MCP HTTP Server'); - expect(infoData.version).toBe('1.0.0'); + expect(infoData.version).toBe('1.1.1'); expect(infoData.endpoints).toHaveProperty('/mcp'); expect(infoData.endpoints).toHaveProperty('/health'); }); @@ -124,7 +124,7 @@ describe('MCP HTTP Server Handler Logic', () => { capabilities: {}, clientInfo: { name: 'test-client', - version: '1.0.0', + version: '1.1.1', }, }, }; @@ -355,7 +355,7 @@ describe('MCP HTTP Server Handler Logic', () => { it('should create server with correct metadata', () => { const serverConfig = { name: 'chains-api', - version: '1.0.0', + version: '1.1.1', }; const capabilities = { @@ -363,8 +363,9 @@ describe('MCP HTTP Server Handler Logic', () => { }; expect(serverConfig.name).toBe('chains-api'); - expect(serverConfig.version).toBe('1.0.0'); + expect(serverConfig.version).toBe('1.1.1'); expect(capabilities).toHaveProperty('tools'); }); }); }); + diff --git a/tests/unit/mcp-server.test.js b/tests/unit/mcp-server.test.js index 671d253..d3187eb 100644 --- a/tests/unit/mcp-server.test.js +++ b/tests/unit/mcp-server.test.js @@ -36,6 +36,18 @@ vi.mock('../../dataService.js', () => ({ generic: [], }, })), + getRpcMonitoringResults: vi.fn(() => ({ + lastUpdated: null, + totalEndpoints: 0, + testedEndpoints: 0, + workingEndpoints: 0, + failedEndpoints: 0, + results: [], + })), + getRpcMonitoringStatus: vi.fn(() => ({ + isMonitoring: false, + lastUpdated: null, + })), startRpcHealthCheck: vi.fn(), validateChainData: vi.fn(() => ({ totalErrors: 0, errorsByRule: {}, summary: {}, allErrors: [] })), })); @@ -300,3 +312,4 @@ describe('MCP Server Tool Handlers', () => { }); }); }); + diff --git a/tests/unit/mcp-tools.test.js b/tests/unit/mcp-tools.test.js index cb36d67..2f20bb2 100644 --- a/tests/unit/mcp-tools.test.js +++ b/tests/unit/mcp-tools.test.js @@ -36,25 +36,21 @@ vi.mock('../../dataService.js', () => ({ })), validateChainData: vi.fn(() => ({ totalErrors: 0, errorsByRule: {}, summary: {}, allErrors: [] })), traverseRelations: vi.fn(() => null), -})); - -// Mock rpcMonitor before importing -vi.mock('../../rpcMonitor.js', () => ({ - getMonitoringResults: vi.fn(() => ({ + getRpcMonitoringResults: vi.fn(() => ({ lastUpdated: '2024-01-01T00:00:00.000Z', totalEndpoints: 0, testedEndpoints: 0, workingEndpoints: 0, + failedEndpoints: 0, results: [], })), - getMonitoringStatus: vi.fn(() => ({ + getRpcMonitoringStatus: vi.fn(() => ({ isMonitoring: false, lastUpdated: null, })), })); import * as dataService from '../../dataService.js'; -import * as rpcMonitor from '../../rpcMonitor.js'; import { getToolDefinitions, handleToolCall } from '../../mcp-tools.js'; describe('MCP Tools - Shared Module', () => { @@ -95,14 +91,14 @@ describe('MCP Tools - Shared Module', () => { vi.mocked(dataService.validateChainData).mockReturnValue({ totalErrors: 0, errorsByRule: {}, summary: {}, allErrors: [], }); - vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringResults).mockReturnValue({ lastUpdated: '2024-01-01T00:00:00.000Z', totalEndpoints: 0, testedEndpoints: 0, workingEndpoints: 0, results: [], }); - vi.mocked(rpcMonitor.getMonitoringStatus).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringStatus).mockReturnValue({ isMonitoring: false, lastUpdated: null, }); @@ -497,7 +493,7 @@ describe('MCP Tools - Shared Module', () => { describe('handleToolCall - get_rpc_monitor', () => { it('should return combined monitoring results and status', async () => { - vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringResults).mockReturnValue({ lastUpdated: '2024-01-01T00:00:00.000Z', totalEndpoints: 100, testedEndpoints: 50, @@ -507,7 +503,7 @@ describe('MCP Tools - Shared Module', () => { { chainId: 137, chainName: 'Polygon', url: 'https://polygon.rpc', status: 'working' }, ], }); - vi.mocked(rpcMonitor.getMonitoringStatus).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringStatus).mockReturnValue({ isMonitoring: true, lastUpdated: '2024-01-01T00:00:00.000Z', }); @@ -526,7 +522,7 @@ describe('MCP Tools - Shared Module', () => { describe('handleToolCall - get_rpc_monitor_by_id', () => { it('should return monitoring results for specific chain', async () => { - vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringResults).mockReturnValue({ lastUpdated: '2024-01-01T00:00:00.000Z', totalEndpoints: 10, testedEndpoints: 5, @@ -561,7 +557,7 @@ describe('MCP Tools - Shared Module', () => { }); it('should return error when no results for chain', async () => { - vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringResults).mockReturnValue({ lastUpdated: null, totalEndpoints: 0, testedEndpoints: 0, workingEndpoints: 0, results: [], }); @@ -580,7 +576,7 @@ describe('MCP Tools - Shared Module', () => { { chainId: 137, name: 'Polygon', tags: ['L2'] }, { chainId: 100, name: 'Gnosis Beacon', tags: ['Beacon'] }, ]); - vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringResults).mockReturnValue({ lastUpdated: '2024-01-01T00:00:00.000Z', totalEndpoints: 100, testedEndpoints: 50, @@ -604,7 +600,7 @@ describe('MCP Tools - Shared Module', () => { it('should return null healthPercent when no endpoints tested', async () => { vi.mocked(dataService.getAllChains).mockReturnValue([]); - vi.mocked(rpcMonitor.getMonitoringResults).mockReturnValue({ + vi.mocked(dataService.getRpcMonitoringResults).mockReturnValue({ lastUpdated: null, totalEndpoints: 0, testedEndpoints: 0, @@ -723,3 +719,4 @@ describe('MCP Tools - Shared Module', () => { }); }); }); + From 7e0cebd0b63f3d32a50a62f62e655f7247fee284 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:38:12 +0000 Subject: [PATCH 2/5] Initial plan From 682b73f728fdf2683c3a9a6c846096db6e45790c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:42:10 +0000 Subject: [PATCH 3/5] Initial plan From 5831f55dee0199a21c24419d618c1ecf7230d3b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:44:11 +0000 Subject: [PATCH 4/5] Address PR review feedback: fix JSDoc, CHANGELOG encoding/structure, and Array.isArray guard Co-authored-by: Johnaverse <110527930+Johnaverse@users.noreply.github.com> --- CHANGELOG.md | 9 +++++---- dataService.js | 2 +- index.js | 1 - 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83cdbb3..d021efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ -## v1.1.1 — 2026-03-07 +# Changelog + +All notable changes to this project will be documented in this file. + +## v1.1.1 — 2026-03-07 - Fixed: unified RPC health reporting so the API and MCP servers use the same data source. - Fixed: prevented empty-source reloads from wiping a healthy cached dataset. - Fixed: tightened numeric route parsing to reject partially numeric IDs and graph depth values. - Fixed: reduced frontend cognitive complexity in `public/app.js` for SonarQube compliance. -# Changelog - -All notable changes to this project will be documented in this file. ## v1.0.11 — 2026-02-22 - Added: improved error handling in `rpcUtil.js` to surface remote errors more clearly. diff --git a/dataService.js b/dataService.js index 0457f43..ab19eac 100644 --- a/dataService.js +++ b/dataService.js @@ -918,7 +918,7 @@ function flattenRpcHealthResults() { const numericChainId = Number.parseInt(chainId, 10); const chainName = cachedData.indexed?.byChainId?.[numericChainId]?.name ?? `Chain ${chainId}`; - return (results || []).map((result) => ({ + return (Array.isArray(results) ? results : []).map((result) => ({ chainId: numericChainId, chainName, url: result.url, diff --git a/index.js b/index.js index 616de69..bb80699 100644 --- a/index.js +++ b/index.js @@ -486,7 +486,6 @@ export async function buildApp(options = {}) { /** * Parse and validate an integer parameter * @param {string} param - Parameter value to parse - * @param {string} paramName - Name of the parameter for error message * @returns {number|null} Parsed integer or null if invalid */ function parseIntParam(param) { From 0e2f798762b78c4426eba5051edb3522969c1e09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:53:58 +0000 Subject: [PATCH 5/5] Resolve merge conflicts: combine main's fixes with v1.1.1 changes Co-authored-by: Johnaverse <110527930+Johnaverse@users.noreply.github.com> --- dataService.js | 27 +++++ index.js | 51 +++++----- mcp-tools.js | 7 +- package-lock.json | 180 +++++++++++++++++++++++++++++++-- package.json | 4 + public/app.js | 149 +++++++++++++++------------ tests/unit/dataService.test.js | 79 ++++++++++++++- tests/unit/mcp-tools.test.js | 2 +- 8 files changed, 400 insertions(+), 99 deletions(-) diff --git a/dataService.js b/dataService.js index 0457f43..0edd64c 100644 --- a/dataService.js +++ b/dataService.js @@ -1072,6 +1072,33 @@ export function getAllChains() { return cachedData.indexed.all.map(transformChain); } +/** + * Count chains by tag categories + * @param {Array} chains - Array of chain objects + * @returns {{ totalChains: number, totalMainnets: number, totalTestnets: number, totalL2s: number, totalBeacons: number }} + */ +export function countChainsByTag(chains) { + const totalChains = chains.length; + let totalTestnets = 0; + let totalL2s = 0; + let totalBeacons = 0; + let totalMainnets = 0; + + for (const chain of chains) { + const tags = chain.tags || []; + const isTestnet = tags.includes('Testnet'); + const isL2 = tags.includes('L2'); + const isBeacon = tags.includes('Beacon'); + + if (isTestnet) totalTestnets += 1; + if (isL2) totalL2s += 1; + if (isBeacon) totalBeacons += 1; + if (!isTestnet && !isL2 && !isBeacon) totalMainnets += 1; + } + + return { totalChains, totalMainnets, totalTestnets, totalL2s, totalBeacons }; +} + /** * Add value to a keyword set if it is a non-empty string */ diff --git a/index.js b/index.js index 616de69..fd3c1da 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,12 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import rateLimit from '@fastify/rate-limit'; import helmet from '@fastify/helmet'; +import fastifyStatic from '@fastify/static'; import { readFile } from 'node:fs/promises'; -import { basename, resolve } from 'node:path'; -import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations, getRpcMonitoringResults, getRpcMonitoringStatus, startRpcHealthCheck } from './dataService.js'; +import { basename, resolve, dirname, join } from 'node:path'; +import { fileURLToPath as toFilePath } from 'node:url'; +import pkg from './package.json' with { type: 'json' }; +import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations, countChainsByTag, getRpcMonitoringResults, getRpcMonitoringStatus, startRpcHealthCheck } from './dataService.js'; import { PORT, HOST, BODY_LIMIT, MAX_PARAM_LENGTH, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, @@ -50,11 +53,22 @@ export async function buildApp(options = {}) { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], - styleSrc: ["'self'"] + styleSrc: ["'self'"], + fontSrc: ["'self'"], + connectSrc: ["'self'"], + imgSrc: ["'self'", "data:"] } } }); + // Serve public/ directory for the 3D visualization UI + const __dir = dirname(toFilePath(import.meta.url)); + await fastify.register(fastifyStatic, { + root: join(__dir, 'public'), + prefix: '/ui/', + decorateReply: false + }); + // Security: Rate limiting await fastify.register(rateLimit, { max: RATE_LIMIT_MAX, @@ -74,7 +88,7 @@ export async function buildApp(options = {}) { /** * Health check endpoint */ - fastify.get('/health', async (request, reply) => { + fastify.get('/health', async () => { const cachedData = getCachedData(); return { status: 'ok', @@ -156,7 +170,7 @@ export async function buildApp(options = {}) { /** * Get all chain relations */ - fastify.get('/relations', async (request, reply) => { + fastify.get('/relations', async () => { const relations = getAllRelations(); return relations; @@ -204,7 +218,7 @@ export async function buildApp(options = {}) { /** * Get all endpoints */ - fastify.get('/endpoints', async (request, reply) => { + fastify.get('/endpoints', async () => { const endpoints = getAllEndpoints(); return { @@ -233,7 +247,7 @@ export async function buildApp(options = {}) { /** * Get raw data sources */ - fastify.get('/sources', async (request, reply) => { + fastify.get('/sources', async () => { const cachedData = getCachedData(); return { lastUpdated: cachedData.lastUpdated, @@ -280,7 +294,7 @@ export async function buildApp(options = {}) { /** * Get SLIP-0044 coin types as JSON */ - fastify.get('/slip44', async (request, reply) => { + fastify.get('/slip44', async (_request, reply) => { const cachedData = getCachedData(); if (!cachedData.slip44) { @@ -365,7 +379,7 @@ export async function buildApp(options = {}) { /** * Get RPC monitoring results */ - fastify.get('/rpc-monitor', async (request, reply) => { + fastify.get('/rpc-monitor', async () => { const results = getRpcMonitoringResults(); const status = getRpcMonitoringStatus(); @@ -408,15 +422,11 @@ export async function buildApp(options = {}) { /** * Get aggregate stats */ - fastify.get('/stats', async (request, reply) => { + fastify.get('/stats', async () => { const chains = getAllChains(); const monitorResults = getRpcMonitoringResults(); - const totalChains = chains.length; - const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet')).length; - const totalTestnets = chains.filter(c => c.tags?.includes('Testnet')).length; - const totalL2s = chains.filter(c => c.tags?.includes('L2')).length; - const totalBeacons = chains.filter(c => c.tags?.includes('Beacon')).length; + const { totalChains, totalMainnets, totalTestnets, totalL2s, totalBeacons } = countChainsByTag(chains); const rpcWorking = monitorResults.workingEndpoints; const rpcFailed = monitorResults.failedEndpoints || 0; @@ -446,7 +456,7 @@ export async function buildApp(options = {}) { fastify.get('/', async (request, reply) => { return { name: 'Chains API', - version: '1.1.1', + version: pkg.version, description: 'API query service for blockchain chain data from multiple sources', endpoints: { '/health': 'Health check and data status', @@ -486,7 +496,6 @@ export async function buildApp(options = {}) { /** * Parse and validate an integer parameter * @param {string} param - Parameter value to parse - * @param {string} paramName - Name of the parameter for error message * @returns {number|null} Parsed integer or null if invalid */ function parseIntParam(param) { @@ -519,12 +528,10 @@ function sendError(reply, code, message) { // Only run the server if this file is executed directly (CLI mode) // This allows the file to be imported for testing without starting the server -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); +const __filename = toFilePath(import.meta.url); // Check if this file is being run directly -const isMainModule = process.argv[1] === __filename || process.argv[1] === fileURLToPath(import.meta.url); +const isMainModule = process.argv[1] === __filename; if (isMainModule) { const start = async () => { @@ -540,5 +547,3 @@ if (isMainModule) { start(); } - - diff --git a/mcp-tools.js b/mcp-tools.js index 8415b2a..2e3cd11 100644 --- a/mcp-tools.js +++ b/mcp-tools.js @@ -10,6 +10,7 @@ import { getAllKeywords, validateChainData, traverseRelations, + countChainsByTag, getRpcMonitoringResults, getRpcMonitoringStatus, } from './dataService.js'; @@ -319,11 +320,7 @@ function handleGetStats() { const chains = getAllChains(); const monitorResults = getRpcMonitoringResults(); - const totalChains = chains.length; - const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet')).length; - const totalTestnets = chains.filter(c => c.tags?.includes('Testnet')).length; - const totalL2s = chains.filter(c => c.tags?.includes('L2')).length; - const totalBeacons = chains.filter(c => c.tags?.includes('Beacon')).length; + const { totalChains, totalMainnets, totalTestnets, totalL2s, totalBeacons } = countChainsByTag(chains); const rpcTested = monitorResults.testedEndpoints; const rpcWorking = monitorResults.workingEndpoints; diff --git a/package-lock.json b/package-lock.json index 3cea51c..80c1520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@fastify/rate-limit": "^10.3.0", + "@fastify/static": "^9.0.0", "@modelcontextprotocol/sdk": "^1.26.0", "express": "^5.2.1", "fastify": "^5.8.1", @@ -26,6 +27,9 @@ "@vitest/coverage-v8": "^4.0.18", "fast-check": "^4.5.3", "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20" } }, "node_modules/@babel/helper-string-parser": { @@ -553,6 +557,22 @@ "vitest": "^1 || ^2 || ^3 || ^4" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -725,6 +745,53 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz", + "integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.10", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", @@ -1446,6 +1513,15 @@ "fastq": "^1.17.1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -1470,6 +1546,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1836,12 +1924,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -2136,6 +2224,23 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2263,9 +2368,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -2415,6 +2520,15 @@ ], "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2483,6 +2597,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2508,6 +2634,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2622,6 +2772,22 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", diff --git a/package.json b/package.json index e523c9f..c3b4cbd 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@fastify/cors": "^11.2.0", "@fastify/helmet": "^13.0.2", "@fastify/rate-limit": "^10.3.0", + "@fastify/static": "^9.0.0", "@modelcontextprotocol/sdk": "^1.26.0", "express": "^5.2.1", "fastify": "^5.8.1", @@ -42,5 +43,8 @@ "@vitest/coverage-v8": "^4.0.18", "fast-check": "^4.5.3", "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20" } } diff --git a/public/app.js b/public/app.js index 7b7ce3d..09b6cf6 100644 --- a/public/app.js +++ b/public/app.js @@ -223,9 +223,12 @@ function initUI() { } // Close Details Panel - document.getElementById('closeDetails').addEventListener('click', () => { - document.getElementById('detailsPanel').classList.add('hidden'); - }); + const closeBtn = document.getElementById('closeDetails'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + document.getElementById('detailsPanel')?.classList.add('hidden'); + }); + } } async function fetchExportData() { @@ -234,7 +237,7 @@ async function fetchExportData() { res = await fetch('/export'); if (!res.ok) throw new Error('Local export unavailable'); } catch { - res = await fetch('https://raw.githubusercontent.com/Johnaverse/chains-api/refs/heads/main/public/export.json'); + res = await fetch('export.json'); } return res.json(); } @@ -473,6 +476,7 @@ function focusNode(node) { function showParentRow(rowId, elemId, parentNode) { const row = document.getElementById(rowId); const elem = document.getElementById(elemId); + if (!row || !elem) return { row, elem }; if (parentNode) { row.style.display = 'flex'; const a = document.createElement('a'); @@ -501,6 +505,7 @@ function populateChildLinks(container, children) { function showChildrenSection(containerId, labelId, children, label) { const container = document.getElementById(containerId); const labelElem = document.getElementById(labelId); + if (!container || !labelElem) return; container.textContent = ''; if (children && children.length > 0) { labelElem.textContent = `${label} (${children.length})`; @@ -513,6 +518,7 @@ function showChildrenSection(containerId, labelId, children, label) { function showRpcEndpoints(data) { const rpcContainer = document.getElementById('chainRPCs'); + if (!rpcContainer) return; rpcContainer.textContent = ''; if (!data.rpc || data.rpc.length === 0) { rpcContainer.textContent = 'None available'; @@ -536,6 +542,7 @@ function showRpcEndpoints(data) { function showExplorers(data) { const expContainer = document.getElementById('chainExplorers'); + if (!expContainer) return; expContainer.textContent = ''; if (data.explorers && data.explorers.length > 0) { for (const e of data.explorers) { @@ -560,58 +567,37 @@ function getStatusClass(status) { return ''; } -function showStatusBadge(data) { - const statusBadge = document.getElementById('chainStatusBadge'); - if (data.status) { - statusBadge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1); - statusBadge.className = `badge tag-badge ${getStatusClass(data.status)}`; - statusBadge.style.display = 'inline-block'; - } else { - statusBadge.style.display = 'none'; +function showNodeHeader(node, data) { + const iconElem = document.getElementById('chainIcon'); + if (iconElem) { + iconElem.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?'; + iconElem.style.background = `linear-gradient(135deg, ${node.color}, ${node.color}33)`; } -} -function showTagsBadge(data) { - const tagsElem = document.getElementById('chainTags'); - if (data.tags?.length > 0) { - tagsElem.textContent = data.tags.join(', '); - tagsElem.style.display = 'inline-block'; - } else { - tagsElem.style.display = 'none'; - } -} + const nameElem = document.getElementById('chainName'); + if (nameElem) nameElem.textContent = node.name || 'Unknown Chain'; + const idBadge = document.getElementById('chainIdBadge'); + if (idBadge) idBadge.textContent = `ID: ${data.chainId}`; -function showWebsite(data) { - const webElem = document.getElementById('chainWebsite'); - if (!data.infoURL) { - webElem.textContent = 'None available'; - return; - } - try { - const a = document.createElement('a'); - a.href = data.infoURL; - a.target = "_blank"; - a.rel = "noopener"; - a.textContent = new URL(data.infoURL).hostname; - webElem.textContent = ''; - webElem.appendChild(a); - } catch { - webElem.textContent = data.infoURL; + const curElem = document.getElementById('chainCurrency'); + if (curElem) { + curElem.textContent = data.nativeCurrency + ? `${data.nativeCurrency.name} (${data.nativeCurrency.symbol})` + : 'None'; } -} - -function showNodeHeader(node, data) { - const iconElem = document.getElementById('chainIcon'); - iconElem.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?'; - iconElem.style.background = `linear-gradient(135deg, ${node.color}, ${node.color}33)`; - document.getElementById('chainName').textContent = node.name || 'Unknown Chain'; - document.getElementById('chainIdBadge').textContent = `ID: ${data.chainId}`; - document.getElementById('chainCurrency').textContent = data.nativeCurrency - ? `${data.nativeCurrency.name} (${data.nativeCurrency.symbol})` - : 'None'; + // Status badge + const statusBadge = document.getElementById('chainStatusBadge'); + if (statusBadge) { + if (data.status) { + statusBadge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1); + statusBadge.className = `badge tag-badge ${getStatusClass(data.status)}`; + statusBadge.style.display = 'inline-block'; + } else { + statusBadge.style.display = 'none'; + } + } - showStatusBadge(data); showTagsBadge(data); } @@ -619,28 +605,27 @@ function showNodeParents(node) { const { row: rowL1, elem: l1Elem } = showParentRow('rowL1Parent', 'chainL1Parent', node.l2Parent); showParentRow('rowMainnet', 'chainMainnet', node.mainnetParent); - if (node.l2Parent || node.mainnetParent) { - return; + if (!node.l2Parent && !node.mainnetParent && rowL1 && l1Elem) { + rowL1.style.display = 'flex'; + l1Elem.textContent = 'None'; } - - rowL1.style.display = 'flex'; - l1Elem.textContent = 'None'; } function showNodeTestnets(node) { const rowTestnetChildren = document.getElementById('rowTestnetChildren'); - const isTestnet = node.data.tags?.includes('Testnet'); - - rowTestnetChildren.style.display = isTestnet ? 'none' : 'flex'; - if (isTestnet) { - return; + if (rowTestnetChildren) { + if (node.data.tags?.includes('Testnet')) { + rowTestnetChildren.style.display = 'none'; + } else { + rowTestnetChildren.style.display = 'flex'; + showChildrenSection('chainTestnetChildren', 'labelTestnetChildren', node.testnetChildren, 'Testnets'); + } } - - showChildrenSection('chainTestnetChildren', 'labelTestnetChildren', node.testnetChildren, 'Testnets'); } function showNodeDetails(node) { const panel = document.getElementById('detailsPanel'); + if (!panel) return; const data = node.data; showNodeHeader(node, data); @@ -654,4 +639,44 @@ function showNodeDetails(node) { panel.classList.remove('hidden'); } +function showTagsBadge(data) { + const tagsElem = document.getElementById('chainTags'); + if (tagsElem) { + if (data.tags?.length > 0) { + tagsElem.textContent = data.tags.join(', '); + tagsElem.style.display = 'inline-block'; + } else { + tagsElem.style.display = 'none'; + } + } +} + +function showWebsite(data) { + const webElem = document.getElementById('chainWebsite'); + if (!webElem) return; + if (!data.infoURL) { + webElem.textContent = 'None available'; + return; + } + try { + const url = new URL(data.infoURL); + const protocol = url.protocol.toLowerCase(); + + if (protocol === 'http:' || protocol === 'https:') { + const a = document.createElement('a'); + a.href = url.toString(); + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = url.hostname; + webElem.textContent = ''; + webElem.appendChild(a); + } else { + // Unsafe or unsupported protocol: show as plain text without a link + webElem.textContent = data.infoURL; + } + } catch { + // Invalid URL: show as plain text without a link + webElem.textContent = data.infoURL; + } +} diff --git a/tests/unit/dataService.test.js b/tests/unit/dataService.test.js index 4737124..0395522 100644 --- a/tests/unit/dataService.test.js +++ b/tests/unit/dataService.test.js @@ -433,7 +433,8 @@ import { runRpcHealthCheck, startRpcHealthCheck, validateChainData, - traverseRelations + traverseRelations, + countChainsByTag } from '../../dataService.js'; describe('fetchData', () => { @@ -2765,4 +2766,80 @@ describe('traverseRelations', () => { }); }); +describe('countChainsByTag', () => { + it('should return all zeros for an empty array', () => { + const result = countChainsByTag([]); + expect(result).toEqual({ totalChains: 0, totalMainnets: 0, totalTestnets: 0, totalL2s: 0, totalBeacons: 0 }); + }); + + it('should count chains with no tags as mainnets', () => { + const chains = [{ chainId: 1, name: 'Ethereum' }, { chainId: 56, name: 'BSC' }]; + const result = countChainsByTag(chains); + expect(result.totalChains).toBe(2); + expect(result.totalMainnets).toBe(2); + expect(result.totalTestnets).toBe(0); + expect(result.totalL2s).toBe(0); + expect(result.totalBeacons).toBe(0); + }); + + it('should count Testnet-tagged chains correctly', () => { + const chains = [ + { chainId: 1, name: 'Ethereum', tags: [] }, + { chainId: 5, name: 'Goerli', tags: ['Testnet'] }, + { chainId: 11155111, name: 'Sepolia', tags: ['Testnet'] } + ]; + const result = countChainsByTag(chains); + expect(result.totalChains).toBe(3); + expect(result.totalTestnets).toBe(2); + expect(result.totalMainnets).toBe(1); + }); + + it('should count L2-tagged chains correctly and exclude them from mainnets', () => { + const chains = [ + { chainId: 1, name: 'Ethereum', tags: [] }, + { chainId: 10, name: 'Optimism', tags: ['L2'] }, + { chainId: 42161, name: 'Arbitrum One', tags: ['L2'] } + ]; + const result = countChainsByTag(chains); + expect(result.totalL2s).toBe(2); + expect(result.totalMainnets).toBe(1); + }); + it('should count Beacon-tagged chains correctly and exclude them from mainnets', () => { + const chains = [ + { chainId: 1, name: 'Ethereum', tags: [] }, + { chainId: 9999, name: 'Beacon Chain', tags: ['Beacon'] } + ]; + const result = countChainsByTag(chains); + expect(result.totalBeacons).toBe(1); + expect(result.totalMainnets).toBe(1); + }); + + it('should handle chains with mixed tags (Testnet + L2)', () => { + const chains = [ + { chainId: 1, name: 'Ethereum', tags: [] }, + { chainId: 420, name: 'Optimism Goerli', tags: ['Testnet', 'L2'] } + ]; + const result = countChainsByTag(chains); + expect(result.totalChains).toBe(2); + expect(result.totalTestnets).toBe(1); + expect(result.totalL2s).toBe(1); + expect(result.totalMainnets).toBe(1); + }); + + it('should correctly total all categories in a mixed array', () => { + const chains = [ + { chainId: 1, name: 'Ethereum', tags: [] }, + { chainId: 5, name: 'Goerli', tags: ['Testnet'] }, + { chainId: 10, name: 'Optimism', tags: ['L2'] }, + { chainId: 9999, name: 'Beacon', tags: ['Beacon'] }, + { chainId: 420, name: 'OP Goerli', tags: ['Testnet', 'L2'] } + ]; + const result = countChainsByTag(chains); + expect(result.totalChains).toBe(5); + expect(result.totalMainnets).toBe(1); + expect(result.totalTestnets).toBe(2); + expect(result.totalL2s).toBe(2); + expect(result.totalBeacons).toBe(1); + }); +}); diff --git a/tests/unit/mcp-tools.test.js b/tests/unit/mcp-tools.test.js index 2f20bb2..c804caa 100644 --- a/tests/unit/mcp-tools.test.js +++ b/tests/unit/mcp-tools.test.js @@ -592,7 +592,7 @@ describe('MCP Tools - Shared Module', () => { expect(data.totalTestnets).toBe(1); expect(data.totalL2s).toBe(1); expect(data.totalBeacons).toBe(1); - expect(data.totalMainnets).toBe(3); + expect(data.totalMainnets).toBe(1); expect(data.rpc.working).toBe(40); expect(data.rpc.failed).toBe(10); expect(data.rpc.healthPercent).toBe(80);