Skip to content

Commit 4f9e166

Browse files
authored
Merge pull request #28 from Johnaverse/feature/rpc-monitoring-and-ui-optimization
Feature/rpc monitoring and UI optimization
2 parents 18c8b4f + d6803f5 commit 4f9e166

11 files changed

Lines changed: 1344 additions & 394 deletions

File tree

dataService.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,6 +1283,74 @@ export function getRelationsById(chainId) {
12831283
};
12841284
}
12851285

1286+
/**
1287+
* BFS graph traversal of chain relations starting from a given chain ID
1288+
* @param {number} startChainId - The chain ID to start traversal from
1289+
* @param {number} maxDepth - Maximum traversal depth (default: 2)
1290+
* @returns {Object|null} Traversal result with nodes and edges, or null if chain not found
1291+
*/
1292+
export function traverseRelations(startChainId, maxDepth = 2) {
1293+
if (!cachedData.indexed) return null;
1294+
1295+
const startChain = cachedData.indexed.byChainId[startChainId];
1296+
if (!startChain) return null;
1297+
1298+
const visited = new Set();
1299+
const queue = [{ chainId: startChainId, depth: 0 }];
1300+
const nodes = [];
1301+
const edges = [];
1302+
1303+
while (queue.length > 0) {
1304+
const { chainId, depth } = queue.shift();
1305+
if (visited.has(chainId)) continue;
1306+
visited.add(chainId);
1307+
1308+
const chain = cachedData.indexed.byChainId[chainId];
1309+
if (!chain) continue;
1310+
1311+
nodes.push({
1312+
chainId: chain.chainId,
1313+
name: chain.name,
1314+
tags: chain.tags || [],
1315+
depth
1316+
});
1317+
1318+
if (depth >= maxDepth) continue;
1319+
1320+
const relations = chain.relations || [];
1321+
for (const rel of relations) {
1322+
if (rel.chainId === undefined) continue;
1323+
1324+
// Deduplicate bidirectional edges (A→B and B→A with same kind)
1325+
const a = Math.min(chainId, rel.chainId);
1326+
const b = Math.max(chainId, rel.chainId);
1327+
const isDuplicate = edges.some(e => Math.min(e.from, e.to) === a && Math.max(e.from, e.to) === b && e.kind === rel.kind);
1328+
if (!isDuplicate) {
1329+
edges.push({
1330+
from: chainId,
1331+
to: rel.chainId,
1332+
kind: rel.kind,
1333+
source: rel.source
1334+
});
1335+
}
1336+
1337+
if (!visited.has(rel.chainId)) {
1338+
queue.push({ chainId: rel.chainId, depth: depth + 1 });
1339+
}
1340+
}
1341+
}
1342+
1343+
return {
1344+
startChainId,
1345+
startChainName: startChain.name,
1346+
maxDepth,
1347+
totalNodes: nodes.length,
1348+
totalEdges: edges.length,
1349+
nodes,
1350+
edges
1351+
};
1352+
}
1353+
12861354
/**
12871355
* Extract endpoints from a chain (helper function)
12881356
*/

index.js

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import rateLimit from '@fastify/rate-limit';
44
import helmet from '@fastify/helmet';
55
import { readFile } from 'node:fs/promises';
66
import { basename, resolve } from 'node:path';
7-
import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData } from './dataService.js';
7+
import { loadData, initializeDataOnStartup, getCachedData, searchChains, getChainById, getAllChains, getAllRelations, getRelationsById, getEndpointsById, getAllEndpoints, getAllKeywords, validateChainData, traverseRelations } from './dataService.js';
88
import { getMonitoringResults, getMonitoringStatus, startRpcHealthCheck } from './rpcMonitor.js';
99
import {
1010
PORT, HOST, BODY_LIMIT, MAX_PARAM_LENGTH,
@@ -180,6 +180,28 @@ export async function buildApp(options = {}) {
180180
return result;
181181
});
182182

183+
/**
184+
* BFS graph traversal of chain relations
185+
*/
186+
fastify.get('/relations/:id/graph', async (request, reply) => {
187+
const chainId = parseIntParam(request.params.id);
188+
if (chainId === null) {
189+
return sendError(reply, 400, 'Invalid chain ID');
190+
}
191+
192+
const depth = request.query.depth !== undefined ? parseIntParam(request.query.depth) : 2;
193+
if (depth === null || depth < 1 || depth > 5) {
194+
return sendError(reply, 400, 'Invalid depth. Must be between 1 and 5');
195+
}
196+
197+
const result = traverseRelations(chainId, depth);
198+
if (!result) {
199+
return sendError(reply, 404, 'Chain not found');
200+
}
201+
202+
return result;
203+
});
204+
183205
/**
184206
* Get all endpoints
185207
*/
@@ -371,17 +393,54 @@ export async function buildApp(options = {}) {
371393
}
372394

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

375398
return {
376399
chainId,
377400
chainName: chainResults[0].chainName,
378401
totalEndpoints: chainResults.length,
379402
workingEndpoints: workingCount,
403+
failedEndpoints: failedCount,
380404
lastUpdated: results.lastUpdated,
381405
endpoints: chainResults
382406
};
383407
});
384408

409+
/**
410+
* Get aggregate stats
411+
*/
412+
fastify.get('/stats', async (request, reply) => {
413+
const chains = getAllChains();
414+
const monitorResults = getMonitoringResults();
415+
416+
const totalChains = chains.length;
417+
const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet') && !c.tags?.includes('L2') && !c.tags?.includes('Beacon')).length;
418+
const totalTestnets = chains.filter(c => c.tags?.includes('Testnet')).length;
419+
const totalL2s = chains.filter(c => c.tags?.includes('L2')).length;
420+
const totalBeacons = chains.filter(c => c.tags?.includes('Beacon')).length;
421+
422+
const rpcWorking = monitorResults.workingEndpoints;
423+
const rpcFailed = monitorResults.failedEndpoints || 0;
424+
const rpcTested = monitorResults.testedEndpoints;
425+
const rpcHealthPercent = rpcTested > 0 ? Math.round((rpcWorking / rpcTested) * 10000) / 100 : null;
426+
427+
return {
428+
totalChains,
429+
totalMainnets,
430+
totalTestnets,
431+
totalL2s,
432+
totalBeacons,
433+
rpc: {
434+
totalEndpoints: monitorResults.totalEndpoints,
435+
tested: rpcTested,
436+
working: rpcWorking,
437+
failed: rpcFailed,
438+
healthPercent: rpcHealthPercent
439+
},
440+
lastUpdated: monitorResults.lastUpdated
441+
};
442+
});
443+
385444
/**
386445
* Root endpoint with API information
387446
*/
@@ -407,7 +466,9 @@ export async function buildApp(options = {}) {
407466
'/validate': 'Validate chain data for potential human errors',
408467
'/keywords': 'Get extracted keywords (blockchain names, network names, client names, etc.)',
409468
'/rpc-monitor': 'Get RPC endpoint monitoring results',
410-
'/rpc-monitor/:id': 'Get RPC monitoring results for a specific chain by ID'
469+
'/rpc-monitor/:id': 'Get RPC monitoring results for a specific chain by ID',
470+
'/stats': 'Get aggregate stats (chain counts, RPC health percentage)',
471+
'/relations/:id/graph?depth=N': 'BFS graph traversal of chain relations (default depth: 2)'
411472
},
412473
dataSources: [
413474
DATA_SOURCE_THE_GRAPH,

mcp-tools.js

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getAllEndpoints,
1010
getAllKeywords,
1111
validateChainData,
12+
traverseRelations,
1213
} from './dataService.js';
1314
import { getMonitoringResults, getMonitoringStatus } from './rpcMonitor.js';
1415

@@ -131,6 +132,32 @@ export function getToolDefinitions() {
131132
properties: {},
132133
},
133134
},
135+
{
136+
name: 'get_stats',
137+
description: 'Get aggregate statistics: total chains, mainnets, testnets, L2s, beacons, and RPC health percentage',
138+
inputSchema: {
139+
type: 'object',
140+
properties: {},
141+
},
142+
},
143+
{
144+
name: 'traverse_relations',
145+
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.',
146+
inputSchema: {
147+
type: 'object',
148+
properties: {
149+
chainId: {
150+
type: 'number',
151+
description: 'The chain ID to start traversal from (e.g., 1 for Ethereum)',
152+
},
153+
depth: {
154+
type: 'number',
155+
description: 'Maximum traversal depth (1-5, default: 2)',
156+
},
157+
},
158+
required: ['chainId'],
159+
},
160+
},
134161
{
135162
name: 'get_rpc_monitor_by_id',
136163
description: 'Get RPC endpoint monitoring results for a specific chain by its chain ID',
@@ -287,6 +314,56 @@ function handleValidateChains() {
287314
return textResponse(validationResults);
288315
}
289316

317+
function handleGetStats() {
318+
const chains = getAllChains();
319+
const monitorResults = getMonitoringResults();
320+
321+
const totalChains = chains.length;
322+
const totalMainnets = chains.filter(c => !c.tags?.includes('Testnet') && !c.tags?.includes('L2') && !c.tags?.includes('Beacon')).length;
323+
const totalTestnets = chains.filter(c => c.tags?.includes('Testnet')).length;
324+
const totalL2s = chains.filter(c => c.tags?.includes('L2')).length;
325+
const totalBeacons = chains.filter(c => c.tags?.includes('Beacon')).length;
326+
327+
const rpcTested = monitorResults.testedEndpoints;
328+
const rpcWorking = monitorResults.workingEndpoints;
329+
const rpcFailed = monitorResults.failedEndpoints || 0;
330+
const rpcHealthPercent = rpcTested > 0 ? Math.round((rpcWorking / rpcTested) * 10000) / 100 : null;
331+
332+
return textResponse({
333+
totalChains,
334+
totalMainnets,
335+
totalTestnets,
336+
totalL2s,
337+
totalBeacons,
338+
rpc: {
339+
totalEndpoints: monitorResults.totalEndpoints,
340+
tested: rpcTested,
341+
working: rpcWorking,
342+
failed: rpcFailed,
343+
healthPercent: rpcHealthPercent,
344+
},
345+
lastUpdated: monitorResults.lastUpdated,
346+
});
347+
}
348+
349+
function handleTraverseRelations(args) {
350+
const { chainId, depth } = args;
351+
if (!isValidChainId(chainId)) {
352+
return errorResponse('Invalid chain ID');
353+
}
354+
355+
const maxDepth = depth !== undefined ? depth : 2;
356+
if (typeof maxDepth !== 'number' || maxDepth < 1 || maxDepth > 5) {
357+
return errorResponse('Invalid depth. Must be between 1 and 5');
358+
}
359+
360+
const result = traverseRelations(chainId, maxDepth);
361+
if (!result) {
362+
return errorResponse('Chain not found');
363+
}
364+
return textResponse(result);
365+
}
366+
290367
function getStatusLabel(status, results) {
291368
if (status.isMonitoring) return 'Running';
292369
if (results.testedEndpoints > 0) return 'Completed';
@@ -304,6 +381,7 @@ function formatRpcMonitorStatus(status, results) {
304381
`- Total endpoints discovered: ${results.totalEndpoints}`,
305382
`- Endpoints tested: ${results.testedEndpoints}`,
306383
`- Working endpoints: ${results.workingEndpoints}`,
384+
`- Failed endpoints: ${results.failedEndpoints ?? 0}`,
307385
'- Use `get_rpc_monitor_by_id` for per-chain endpoint details.',
308386
];
309387

@@ -355,9 +433,10 @@ function handleGetRpcMonitorById(args) {
355433
];
356434
for (const ep of chainResults) {
357435
const block = ep.blockNumber == null ? '' : ` — block #${ep.blockNumber}`;
436+
const latency = ep.latencyMs != null ? ` [${ep.latencyMs}ms]` : '';
358437
const client = ep.clientVersion && ep.clientVersion !== 'unavailable' ? ` (${ep.clientVersion})` : '';
359438
lines.push(
360-
`- **${ep.status}** ${ep.url}${block}${client}`,
439+
`- **${ep.status}** ${ep.url}${block}${latency}${client}`,
361440
...(ep.error ? [` - Error: ${ep.error}`] : [])
362441
);
363442
}
@@ -376,6 +455,8 @@ const toolHandlers = {
376455
get_sources: handleGetSources,
377456
get_keywords: handleGetKeywords,
378457
validate_chains: handleValidateChains,
458+
get_stats: handleGetStats,
459+
traverse_relations: handleTraverseRelations,
379460
get_rpc_monitor: handleGetRpcMonitor,
380461
get_rpc_monitor_by_id: handleGetRpcMonitorById,
381462
};

0 commit comments

Comments
 (0)