Skip to main content
Glama
dashboards.tools.ts15.4 kB
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import axios from 'axios'; import fs from 'fs'; import path from 'path'; // Import the pre-generated dashboards index import { DASHBOARDS_INDEX } from '../scripts/dashboards-index.js'; import { prefixToolName } from './utils/tool-naming.js'; // Define dashboard metadata interface export interface DashboardMetadata { dashboard_name: string; data_last_updated?: string; data_next_update?: string; route?: string; sites?: string[]; required_params?: string[]; optional_params?: string[]; charts?: Record<string, any>; [key: string]: any; } // GitHub raw content base URL for fetching specific dashboards const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/data-gov-my/datagovmy-meta/main/dashboards'; // Local dashboards directory path const dashboardsDir = path.join(process.cwd(), 'dashboards'); // Check if the dashboards directory exists const dashboardsDirExists = fs.existsSync(dashboardsDir); // Cache for detailed dashboard metadata let detailsCache: Record<string, DashboardMetadata> = {}; let lastCacheUpdate: number = 0; const CACHE_TTL = 3600000; // 1 hour in milliseconds // Get all dashboards from the pre-generated index export function getAllDashboards(): DashboardMetadata[] { return DASHBOARDS_INDEX as DashboardMetadata[]; } // Helper function to get dashboard by name async function getDashboardByName(name: string): Promise<DashboardMetadata | null> { // First check if we have it in the index const basicInfo = getAllDashboards().find(d => { // Check if dashboard_name matches or if the filename (without .json) matches return d.dashboard_name === name || (d.route && d.route.replace(/\//g, '_') === name); }); if (!basicInfo) { return null; // Dashboard not found in index } // If we have detailed info cached and it's not expired, return it if (detailsCache[name] && Date.now() - lastCacheUpdate < CACHE_TTL) { return detailsCache[name]; } try { // Always try to fetch from GitHub first to get the latest data try { const response = await axios.get(`${GITHUB_RAW_BASE_URL}/${name}.json`); const detailedData = response.data as DashboardMetadata; // Cache the detailed data detailsCache[name] = detailedData; lastCacheUpdate = Date.now(); console.log(`Successfully fetched ${name} dashboard from GitHub`); return detailedData; } catch (error: any) { console.warn(`Could not fetch ${name} from GitHub, falling back to local file:`, error.message); // If GitHub fetch fails, check if we can fall back to local file if (dashboardsDirExists) { const filePath = path.join(dashboardsDir, `${name}.json`); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf8'); const data = JSON.parse(content) as DashboardMetadata; // Cache the detailed data detailsCache[name] = data; lastCacheUpdate = Date.now(); console.log(`Using local file for ${name} dashboard`); return data; } } else { console.log('Local dashboards directory does not exist, using only GitHub data'); } // If local file doesn't exist either, throw error to be caught by outer catch throw new Error(`Dashboard ${name} not found locally or on GitHub`); } } catch (error) { console.error(`Error getting dashboard ${name}:`, error); // If we can't get detailed data, return the basic info from the index return basicInfo; } } // Helper function to tokenize a query into individual terms function tokenizeQuery(query: string): string[] { // Remove special characters and split by spaces return query.toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) .filter(term => term.length > 0); } // Helper function to normalize terms by removing common prefixes and handling variations function normalizeTerm(term: string): string[] { // Remove hyphens and normalize spacing let normalized = term.replace(/-/g, '').trim(); // Handle common prefixes/variations if (normalized.startsWith('e') && normalized.length > 1) { // e.g., 'epayment' -> also try 'payment' return [normalized, normalized.substring(1)]; } return [normalized]; } // A small set of common synonyms for frequently used terms const COMMON_SYNONYMS: Record<string, string[]> = { 'payment': ['payment', 'pay', 'transaction'], 'electronic': ['electronic', 'digital', 'online', 'cashless'], 'statistics': ['statistics', 'stats', 'data', 'figures', 'numbers'], 'dashboard': ['dashboard', 'visualization', 'chart', 'graph'], 'dataset': ['dataset', 'data set', 'database', 'data'], }; // Helper function to expand search terms for better matching function expandSearchTerms(term: string): string[] { const normalizedTerm = term.toLowerCase().trim(); // Start with the original term let expanded = [normalizedTerm]; // Add normalized variations expanded = expanded.concat(normalizeTerm(normalizedTerm)); // Check for common synonyms for (const [key, synonyms] of Object.entries(COMMON_SYNONYMS)) { if (normalizedTerm === key || synonyms.includes(normalizedTerm)) { expanded = expanded.concat(synonyms); break; } } // Basic stemming for plurals if (normalizedTerm.endsWith('s')) { expanded.push(normalizedTerm.slice(0, -1)); // Remove trailing 's' } else { expanded.push(normalizedTerm + 's'); // Add trailing 's' } // Remove duplicates and return return [...new Set(expanded)]; } // Helper function to search dashboards with improved matching export function searchDashboards(query: string): DashboardMetadata[] { const dashboards = getAllDashboards(); // Tokenize the query const queryTerms = tokenizeQuery(query); const expandedTerms = queryTerms.flatMap(term => expandSearchTerms(term)); // If we have no valid terms after tokenization, fall back to the original query if (expandedTerms.length === 0) { const lowerCaseQuery = query.toLowerCase(); return dashboards.filter(d => d.dashboard_name.toLowerCase().includes(lowerCaseQuery) || (d.route && d.route.toLowerCase().includes(lowerCaseQuery)) ); } // Search using expanded terms return dashboards.filter(d => { const name = d.dashboard_name.toLowerCase(); const route = d.route ? d.route.toLowerCase() : ''; // Check if any of the expanded terms match return expandedTerms.some(term => name.includes(term) || route.includes(term) ); }); } export function registerDashboardTools(server: McpServer) { // List all available dashboards server.tool( prefixToolName('list_dashboards'), 'Lists all available dashboards from the Malaysia Open Data platform', { limit: z.number().min(1).max(100).optional().describe('Number of results to return (1-100)'), offset: z.number().min(0).optional().describe('Number of records to skip for pagination'), }, async ({ limit = 20, offset = 0 }) => { try { const allDashboards = getAllDashboards(); const paginatedDashboards = allDashboards.slice(offset, offset + limit); const total = allDashboards.length; // Create a simplified version of the dashboards for the response const simplifiedDashboards = paginatedDashboards.map(d => ({ dashboard_name: d.dashboard_name, route: d.route, sites: d.sites, data_last_updated: d.data_last_updated, required_params: d.required_params, chart_count: d.charts ? Object.keys(d.charts).length : 0 })); return { content: [ { type: 'text', text: JSON.stringify({ message: 'Dashboards retrieved successfully', total_dashboards: total, showing: `${offset + 1}-${Math.min(offset + limit, total)} of ${total}`, pagination: { limit, offset, next_offset: offset + limit < total ? offset + limit : null, previous_offset: offset > 0 ? Math.max(0, offset - limit) : null, }, dashboards: simplifiedDashboards, timestamp: new Date().toISOString() }, null, 2), }, ], }; } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to retrieve dashboards', message: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString() }, null, 2), }, ], }; } } ); // Search dashboards by query server.tool( prefixToolName('search_dashboards'), '⚠️ CONSIDER USING search_all INSTEAD: This only searches dashboards. For comprehensive results across datasets and dashboards, use search_all tool. ⚠️', { query: z.string().describe('Search query to match against dashboard metadata'), limit: z.number().min(1).max(100).optional().describe('Number of results to return (1-100)'), }, async ({ query, limit = 20 }) => { try { const searchResults = searchDashboards(query); const limitedResults = searchResults.slice(0, limit); // Create a simplified version of the dashboards for the response const simplifiedResults = limitedResults.map(d => ({ dashboard_name: d.dashboard_name, route: d.route, sites: d.sites, data_last_updated: d.data_last_updated, required_params: d.required_params, chart_count: d.charts ? Object.keys(d.charts).length : 0 })); return { content: [ { type: 'text', text: JSON.stringify({ message: 'Search results for dashboards', query, total_matches: searchResults.length, showing: Math.min(limit, searchResults.length), dashboards: simplifiedResults, timestamp: new Date().toISOString() }, null, 2), }, ], }; } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ error: 'Failed to search dashboards', message: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString() }, null, 2), }, ], }; } } ); // Get dashboard details by name server.tool( prefixToolName('get_dashboard_details'), 'Get comprehensive metadata for a dashboard by name', { name: z.string().describe('Name of the dashboard to retrieve metadata for'), }, async ({ name }) => { try { const dashboard = await getDashboardByName(name); if (!dashboard) { // Try to find similar dashboards for suggestion const allDashboards = getAllDashboards(); const similarDashboards = allDashboards .filter(d => d.dashboard_name.includes(name) || name.includes(d.dashboard_name)) .map(d => ({ dashboard_name: d.dashboard_name, route: d.route })) .slice(0, 5); return { content: [ { type: 'text', text: JSON.stringify({ error: `Dashboard '${name}' not found`, suggestions: similarDashboards.length > 0 ? similarDashboards : undefined, timestamp: new Date().toISOString() }, null, 2), }, ], }; } return { content: [ { type: 'text', text: JSON.stringify({ message: `Dashboard '${name}' details retrieved successfully`, dashboard, timestamp: new Date().toISOString() }, null, 2), }, ], }; } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to retrieve dashboard '${name}'`, message: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString() }, null, 2), }, ], }; } } ); // Get charts for a dashboard server.tool( prefixToolName('get_dashboard_charts'), 'Get chart configurations for a specific dashboard', { name: z.string().describe('Name of the dashboard to retrieve charts for'), }, async ({ name }) => { try { const dashboard = await getDashboardByName(name); if (!dashboard) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Dashboard '${name}' not found`, timestamp: new Date().toISOString() }, null, 2), }, ], }; } if (!dashboard.charts) { return { content: [ { type: 'text', text: JSON.stringify({ error: `No charts found for dashboard '${name}'`, timestamp: new Date().toISOString() }, null, 2), }, ], }; } const charts = dashboard.charts; const chartList = Object.entries(charts).map(([key, chart]) => { const chartObj = chart as any; return { chart_id: key, name: chartObj.name, type: chartObj.chart_type, source: chartObj.chart_source, data_as_of: chartObj.data_as_of, api_type: chartObj.api_type, api_params: chartObj.api_params }; }); return { content: [ { type: 'text', text: JSON.stringify({ message: `Charts for dashboard '${name}' retrieved successfully`, dashboard_name: dashboard.dashboard_name, route: dashboard.route, chart_count: chartList.length, charts: chartList, timestamp: new Date().toISOString() }, null, 2), }, ], }; } catch (error) { return { content: [ { type: 'text', text: JSON.stringify({ error: `Failed to retrieve charts for dashboard '${name}'`, message: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString() }, null, 2), }, ], }; } } ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/hithereiamaliff/mcp-datagovmy'

If you have feedback or need assistance with the MCP directory API, please join our Discord server