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
68 changes: 68 additions & 0 deletions dataService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,74 @@ export function getRelationsById(chainId) {
};
}

/**
* BFS graph traversal of chain relations starting from a given chain ID
* @param {number} startChainId - The chain ID to start traversal from
* @param {number} maxDepth - Maximum traversal depth (default: 2)
* @returns {Object|null} Traversal result with nodes and edges, or null if chain not found
*/
export function traverseRelations(startChainId, maxDepth = 2) {
if (!cachedData.indexed) return null;

const startChain = cachedData.indexed.byChainId[startChainId];
if (!startChain) return null;

const visited = new Set();
const queue = [{ chainId: startChainId, depth: 0 }];
const nodes = [];
const edges = [];

while (queue.length > 0) {
const { chainId, depth } = queue.shift();
if (visited.has(chainId)) continue;
visited.add(chainId);

const chain = cachedData.indexed.byChainId[chainId];
if (!chain) continue;

nodes.push({
chainId: chain.chainId,
name: chain.name,
tags: chain.tags || [],
depth
});

if (depth >= maxDepth) continue;

const relations = chain.relations || [];
for (const rel of relations) {
if (rel.chainId === undefined) continue;

// Deduplicate bidirectional edges (A→B and B→A with same kind)
const a = Math.min(chainId, rel.chainId);
const b = Math.max(chainId, rel.chainId);
const isDuplicate = edges.some(e => Math.min(e.from, e.to) === a && Math.max(e.from, e.to) === b && e.kind === rel.kind);
if (!isDuplicate) {
edges.push({
from: chainId,
to: rel.chainId,
kind: rel.kind,
source: rel.source
});
}

if (!visited.has(rel.chainId)) {
queue.push({ chainId: rel.chainId, depth: depth + 1 });
}
Comment on lines +1320 to +1339
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

In traverseRelations, edges are added for every relation encountered during BFS traversal, regardless of whether the target chain has already been visited. Because the graph includes bidirectional relations (e.g., a parent chain has parentOf edges to children, and child chains have l2Of or testnetOf edges back to the parent), the resulting edges array will contain both directions (e.g., A→B and B→A) as well as edges to already-visited nodes. This means totalEdges will be higher than the number of unique relationships, and graph consumers may render duplicate connections. To fix this, track visited pairs (e.g., using a Set of ${from}-${to} strings) or only add an edge when the target node has NOT been visited before adding it to the queue.

Copilot uses AI. Check for mistakes.
}
}

return {
startChainId,
startChainName: startChain.name,
maxDepth,
totalNodes: nodes.length,
totalEdges: edges.length,
nodes,
edges
};
}

/**
* Extract endpoints from a chain (helper function)
*/
Expand Down
65 changes: 63 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +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 } from './dataService.js';
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 {
PORT, HOST, BODY_LIMIT, MAX_PARAM_LENGTH,
Expand Down Expand Up @@ -180,6 +180,28 @@ export async function buildApp(options = {}) {
return result;
});

/**
* BFS graph traversal of chain relations
*/
fastify.get('/relations/:id/graph', async (request, reply) => {
const chainId = parseIntParam(request.params.id);
if (chainId === null) {
return sendError(reply, 400, 'Invalid chain ID');
}

const depth = request.query.depth !== undefined ? parseIntParam(request.query.depth) : 2;
if (depth === null || depth < 1 || depth > 5) {
return sendError(reply, 400, 'Invalid depth. Must be between 1 and 5');
}

const result = traverseRelations(chainId, depth);
if (!result) {
return sendError(reply, 404, 'Chain not found');
}

return result;
});

/**
* Get all endpoints
*/
Expand Down Expand Up @@ -371,17 +393,54 @@ export async function buildApp(options = {}) {
}

const workingCount = chainResults.filter(r => r.status === 'working').length;
const failedCount = chainResults.filter(r => r.status === 'failed').length;

return {
chainId,
chainName: chainResults[0].chainName,
totalEndpoints: chainResults.length,
workingEndpoints: workingCount,
failedEndpoints: failedCount,
lastUpdated: results.lastUpdated,
endpoints: chainResults
};
});

/**
* Get aggregate stats
*/
fastify.get('/stats', async (request, reply) => {
const chains = getAllChains();
const monitorResults = getMonitoringResults();

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 rpcWorking = monitorResults.workingEndpoints;
const rpcFailed = monitorResults.failedEndpoints || 0;
const rpcTested = monitorResults.testedEndpoints;
const rpcHealthPercent = rpcTested > 0 ? Math.round((rpcWorking / rpcTested) * 10000) / 100 : null;

return {
totalChains,
totalMainnets,
totalTestnets,
totalL2s,
totalBeacons,
rpc: {
totalEndpoints: monitorResults.totalEndpoints,
tested: rpcTested,
working: rpcWorking,
failed: rpcFailed,
healthPercent: rpcHealthPercent
},
lastUpdated: monitorResults.lastUpdated
};
});

/**
* Root endpoint with API information
*/
Expand All @@ -407,7 +466,9 @@ export async function buildApp(options = {}) {
'/validate': 'Validate chain data for potential human errors',
'/keywords': 'Get extracted keywords (blockchain names, network names, client names, etc.)',
'/rpc-monitor': 'Get RPC endpoint monitoring results',
'/rpc-monitor/:id': 'Get RPC monitoring results for a specific chain by ID'
'/rpc-monitor/:id': 'Get RPC monitoring results for a specific chain by ID',
'/stats': 'Get aggregate stats (chain counts, RPC health percentage)',
'/relations/:id/graph?depth=N': 'BFS graph traversal of chain relations (default depth: 2)'
},
dataSources: [
DATA_SOURCE_THE_GRAPH,
Expand Down
83 changes: 82 additions & 1 deletion mcp-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getAllEndpoints,
getAllKeywords,
validateChainData,
traverseRelations,
} from './dataService.js';
import { getMonitoringResults, getMonitoringStatus } from './rpcMonitor.js';

Expand Down Expand Up @@ -131,6 +132,32 @@ export function getToolDefinitions() {
properties: {},
},
},
{
name: 'get_stats',
description: 'Get aggregate statistics: total chains, mainnets, testnets, L2s, beacons, and RPC health percentage',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'traverse_relations',
description: 'BFS graph traversal of chain relations from a starting chain. Returns all reachable chains (nodes) and their relationship edges up to a given depth.',
inputSchema: {
type: 'object',
properties: {
chainId: {
type: 'number',
description: 'The chain ID to start traversal from (e.g., 1 for Ethereum)',
},
depth: {
type: 'number',
description: 'Maximum traversal depth (1-5, default: 2)',
},
},
required: ['chainId'],
},
},
{
name: 'get_rpc_monitor_by_id',
description: 'Get RPC endpoint monitoring results for a specific chain by its chain ID',
Expand Down Expand Up @@ -287,6 +314,56 @@ function handleValidateChains() {
return textResponse(validationResults);
}

function handleGetStats() {
const chains = getAllChains();
const monitorResults = getMonitoringResults();

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 rpcTested = monitorResults.testedEndpoints;
const rpcWorking = monitorResults.workingEndpoints;
const rpcFailed = monitorResults.failedEndpoints || 0;
const rpcHealthPercent = rpcTested > 0 ? Math.round((rpcWorking / rpcTested) * 10000) / 100 : null;

return textResponse({
totalChains,
totalMainnets,
totalTestnets,
totalL2s,
totalBeacons,
rpc: {
totalEndpoints: monitorResults.totalEndpoints,
tested: rpcTested,
working: rpcWorking,
failed: rpcFailed,
healthPercent: rpcHealthPercent,
},
lastUpdated: monitorResults.lastUpdated,
});
}

function handleTraverseRelations(args) {
const { chainId, depth } = args;
if (!isValidChainId(chainId)) {
return errorResponse('Invalid chain ID');
}

const maxDepth = depth !== undefined ? depth : 2;
if (typeof maxDepth !== 'number' || maxDepth < 1 || maxDepth > 5) {
return errorResponse('Invalid depth. Must be between 1 and 5');
}

const result = traverseRelations(chainId, maxDepth);
if (!result) {
return errorResponse('Chain not found');
}
return textResponse(result);
}

function getStatusLabel(status, results) {
if (status.isMonitoring) return 'Running';
if (results.testedEndpoints > 0) return 'Completed';
Expand All @@ -304,6 +381,7 @@ function formatRpcMonitorStatus(status, results) {
`- Total endpoints discovered: ${results.totalEndpoints}`,
`- Endpoints tested: ${results.testedEndpoints}`,
`- Working endpoints: ${results.workingEndpoints}`,
`- Failed endpoints: ${results.failedEndpoints ?? 0}`,
'- Use `get_rpc_monitor_by_id` for per-chain endpoint details.',
];

Expand Down Expand Up @@ -355,9 +433,10 @@ function handleGetRpcMonitorById(args) {
];
for (const ep of chainResults) {
const block = ep.blockNumber == null ? '' : ` — block #${ep.blockNumber}`;
const latency = ep.latencyMs != null ? ` [${ep.latencyMs}ms]` : '';
const client = ep.clientVersion && ep.clientVersion !== 'unavailable' ? ` (${ep.clientVersion})` : '';
lines.push(
`- **${ep.status}** ${ep.url}${block}${client}`,
`- **${ep.status}** ${ep.url}${block}${latency}${client}`,
...(ep.error ? [` - Error: ${ep.error}`] : [])
);
}
Expand All @@ -376,6 +455,8 @@ const toolHandlers = {
get_sources: handleGetSources,
get_keywords: handleGetKeywords,
validate_chains: handleValidateChains,
get_stats: handleGetStats,
traverse_relations: handleTraverseRelations,
get_rpc_monitor: handleGetRpcMonitor,
get_rpc_monitor_by_id: handleGetRpcMonitorById,
};
Expand Down
Loading