Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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.

## v1.0.11 — 2026-02-22
- Added: improved error handling in `rpcUtil.js` to surface remote errors more clearly.
- Added: new integration checks in `tests/integration` to validate API responses.
Expand Down Expand Up @@ -57,3 +63,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.

7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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": [ ... ]
Expand Down Expand Up @@ -865,3 +865,4 @@ If you have questions or encounter issues, please [open an issue](https://github
## License

MIT

51 changes: 47 additions & 4 deletions dataService.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@
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');

Check failure on line 226 in dataService.js

View workflow job for this annotation

GitHub Actions / Test and analyze

tests/unit/dataService.test.js > loadData > should handle all sources failing

Error: All data sources failed during data refresh ❯ dataService.js:226:13 ❯ refreshDataWithGuard dataService.js:240:12 ❯ tests/unit/dataService.test.js:1229:20
}

applyDataToCache(data);
Expand Down Expand Up @@ -851,7 +851,7 @@
* Load and cache all data sources
*/
export async function loadData() {
return refreshDataWithGuard();
return refreshDataWithGuard({ requireAtLeastOneSource: true });
}

/**
Expand Down Expand Up @@ -913,6 +913,46 @@
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 (Array.isArray(results) ? 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,
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenRpcHealthResults() populates latencyMs from result.latencyMs, but the RPC health check results currently stored in cachedData.rpcHealth (from checkRpcEndpoint() / runRpcHealthCheck()) don’t set a latencyMs field, so this will always be null. Either measure and persist endpoint latency in the health-check results, or omit latencyMs from the normalized output to avoid advertising a field that can’t be produced.

Suggested change
latencyMs: result.latencyMs ?? null,

Copilot uses AI. Check for mistakes.
error: result.error ?? null
}));
});
Comment on lines +916 to +931
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flattenRpcHealthResults() assumes each cachedData.rpcHealth[chainId] value is an array and calls .map() on it. Because snapshot validation doesn’t validate the rpcHealth shape, a malformed/older on-disk snapshot (or any unexpected in-memory value) could cause a runtime crash here. Consider guarding with Array.isArray(results) (treat non-arrays as empty) before mapping.

Copilot uses AI. Check for mistakes.
}

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
*/
Expand Down Expand Up @@ -1033,9 +1073,9 @@
}

/**
* Count chains grouped by tag category
* Count chains by tag categories
* @param {Array} chains - Array of chain objects
* @returns {Object} Counts for each category
* @returns {{ totalChains: number, totalMainnets: number, totalTestnets: number, totalL2s: number, totalBeacons: number }}
*/
export function countChainsByTag(chains) {
const totalChains = chains.length;
Expand Down Expand Up @@ -1839,3 +1879,6 @@
allErrors: errors
};
}



26 changes: 19 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { readFile } from 'node:fs/promises';
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 } from './dataService.js';
import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck } from './rpcMonitor.js';
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,
Expand Down Expand Up @@ -381,8 +380,8 @@ export async function buildApp(options = {}) {
* Get RPC monitoring results
*/
fastify.get('/rpc-monitor', async () => {
const results = getMonitoringResults();
const status = getMonitoringStatus();
const results = getRpcMonitoringResults();
const status = getRpcMonitoringStatus();

return {
...status,
Expand All @@ -399,7 +398,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) {
Expand All @@ -425,7 +424,7 @@ export async function buildApp(options = {}) {
*/
fastify.get('/stats', async () => {
const chains = getAllChains();
const monitorResults = getMonitoringResults();
const monitorResults = getRpcMonitoringResults();

const { totalChains, totalMainnets, totalTestnets, totalL2s, totalBeacons } = countChainsByTag(chains);

Expand Down Expand Up @@ -500,7 +499,20 @@ export async function buildApp(options = {}) {
* @returns {number|null} Parsed integer or null if invalid
*/
function parseIntParam(param) {
const parsed = Number.parseInt(param, 10);
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;
}

Expand Down
4 changes: 2 additions & 2 deletions mcp-server-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -222,3 +221,4 @@ process.on('SIGINT', async () => {
console.log('Server shutdown complete');
process.exit(0);
});

4 changes: 2 additions & 2 deletions mcp-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -59,3 +58,4 @@ try {
console.error('Server error:', error);
process.exit(1);
}

21 changes: 10 additions & 11 deletions mcp-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import {
getAllKeywords,
validateChainData,
traverseRelations,
countChainsByTag,
getRpcMonitoringResults,
getRpcMonitoringStatus,
} from './dataService.js';
import { getMonitoringResults, getMonitoringStatus } from './rpcMonitor.js';

/**
* Get the list of MCP tool definitions (schemas)
Expand Down Expand Up @@ -316,13 +318,9 @@ 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') && !c.tags?.includes('L2') && !c.tags?.includes('Beacon')).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;
Expand Down Expand Up @@ -399,8 +397,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) }] };
}

Expand All @@ -410,8 +408,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) {
Expand Down Expand Up @@ -478,3 +476,4 @@ export async function handleToolCall(name, args) {
return errorResponse('Internal error', error.message);
}
}

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -46,5 +46,8 @@
"@vitest/coverage-v8": "^4.0.18",
"fast-check": "^4.5.3",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20"
}
}
37 changes: 23 additions & 14 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -567,11 +567,7 @@ function getStatusClass(status) {
return '';
}

function showNodeDetails(node) {
const panel = document.getElementById('detailsPanel');
if (!panel) return;
const data = node.data;

function showNodeHeader(node, data) {
const iconElem = document.getElementById('chainIcon');
if (iconElem) {
iconElem.textContent = node.name ? node.name.charAt(0).toUpperCase() : '?';
Expand All @@ -583,6 +579,13 @@ function showNodeDetails(node) {
const idBadge = document.getElementById('chainIdBadge');
if (idBadge) idBadge.textContent = `ID: ${data.chainId}`;

const curElem = document.getElementById('chainCurrency');
if (curElem) {
curElem.textContent = data.nativeCurrency
? `${data.nativeCurrency.name} (${data.nativeCurrency.symbol})`
: 'None';
}

// Status badge
const statusBadge = document.getElementById('chainStatusBadge');
if (statusBadge) {
Expand All @@ -596,24 +599,19 @@ function showNodeDetails(node) {
}

showTagsBadge(data);
}

const curElem = document.getElementById('chainCurrency');
if (curElem) {
curElem.textContent = data.nativeCurrency
? `${data.nativeCurrency.name} (${data.nativeCurrency.symbol})`
: 'None';
}

function showNodeParents(node) {
const { row: rowL1, elem: l1Elem } = showParentRow('rowL1Parent', 'chainL1Parent', node.l2Parent);
showParentRow('rowMainnet', 'chainMainnet', node.mainnetParent);

if (!node.l2Parent && !node.mainnetParent && rowL1 && l1Elem) {
rowL1.style.display = 'flex';
l1Elem.textContent = 'None';
}
}

showChildrenSection('chainL2Children', 'labelL2Children', node.l2Children, 'L2 / L3');

function showNodeTestnets(node) {
const rowTestnetChildren = document.getElementById('rowTestnetChildren');
if (rowTestnetChildren) {
if (node.data.tags?.includes('Testnet')) {
Expand All @@ -623,7 +621,17 @@ function showNodeDetails(node) {
showChildrenSection('chainTestnetChildren', 'labelTestnetChildren', node.testnetChildren, 'Testnets');
}
}
}

function showNodeDetails(node) {
const panel = document.getElementById('detailsPanel');
if (!panel) return;
const data = node.data;

showNodeHeader(node, data);
showNodeParents(node);
showChildrenSection('chainL2Children', 'labelL2Children', node.l2Children, 'L2 / L3');
showNodeTestnets(node);
showRpcEndpoints(data);
showExplorers(data);
showWebsite(data);
Expand Down Expand Up @@ -671,3 +679,4 @@ function showWebsite(data) {
webElem.textContent = data.infoURL;
}
}

Loading
Loading