Skip to main content
Glama
by MikelA92
MetabaseClient.js•18.7 kB
/** * Metabase API Client * Comprehensive client for interacting with Metabase API * Version 2.0 - Refactored with shared utilities and error handling */ import { ApiError, ValidationError } from '../shared/errors/MetabaseError.js'; import { Validators } from '../shared/utils/validators.js'; import { DashboardUrlDecoder } from '../shared/utils/urlDecoder.js'; import { logger } from '../shared/utils/logger.js'; export class MetabaseClient { constructor(metabaseUrl, apiKey, timeout = 30000) { this.metabaseUrl = metabaseUrl.replace(/\/$/, ''); // Remove trailing slash this.apiKey = apiKey; this.timeout = timeout; this.defaultHeaders = { 'Content-Type': 'application/json', 'X-API-Key': this.apiKey, }; this.logger = logger.child('MetabaseClient'); } /** * Make an authenticated API request to Metabase * @param {string} endpoint - API endpoint (e.g., '/api/card/123') * @param {Object} options - Fetch options * @returns {Promise<Object>} JSON response */ async makeRequest(endpoint, options = {}) { const url = `${this.metabaseUrl}${endpoint}`; const requestOptions = { headers: { ...this.defaultHeaders }, ...options, }; this.logger.debug('Making API request', { endpoint, method: requestOptions.method || 'GET' }); try { const response = await this.fetchWithTimeout(url, requestOptions, this.timeout); if (!response.ok) { const errorText = await response.text(); throw new ApiError( `API request failed: ${response.status} ${response.statusText}`, response.status, endpoint, errorText ); } const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { const text = await response.text(); throw new ApiError( 'Non-JSON response received', response.status, endpoint, text.substring(0, 500) ); } return await response.json(); } catch (error) { if (error instanceof ApiError) { throw error; } this.logger.error('Unexpected error during API request', error, { endpoint }); throw new ApiError( `Unexpected error: ${error.message}`, 0, endpoint, error.message ); } } /** * Fetch with timeout support */ async fetchWithTimeout(url, options = {}, timeout = 30000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...options, signal: controller.signal, }); clearTimeout(timeoutId); return response; } catch (error) { clearTimeout(timeoutId); if (error.name === 'AbortError') { throw new Error(`Request timeout after ${timeout}ms`); } throw error; } } // ======================================== // CARD METHODS // ======================================== /** * Get a specific card/question by ID */ async getCard(cardId) { Validators.validateCardId(cardId); const card = await this.makeRequest(`/api/card/${cardId}`); return { id: card.id, name: card.name, description: card.description, sqlQuery: card.dataset_query?.native?.query || null, databaseId: card.dataset_query?.database, queryType: card.dataset_query?.type, createdAt: card.created_at, updatedAt: card.updated_at, rawData: card, }; } /** * List cards with optional filtering */ async listCards(filter = 'all', modelId = null) { const params = new URLSearchParams({ f: filter }); if (modelId) { params.append('model_id', modelId); } const response = await this.makeRequest(`/api/card/?${params}`); const cards = Array.isArray(response) ? response : response.data || []; return cards.map(card => ({ id: card.id, name: card.name, description: card.description, databaseId: card.dataset_query?.database, queryType: card.dataset_query?.type, createdAt: card.created_at, updatedAt: card.updated_at, })); } /** * Execute a card query and return results */ async executeCardQuery(cardId, parameters = {}) { Validators.validateCardId(cardId); const queryParams = new URLSearchParams(); Object.entries(parameters).forEach(([key, value]) => { queryParams.append(key, value); }); const endpoint = `/api/card/${cardId}/query${queryParams.toString() ? `?${queryParams}` : ''}`; return await this.makeRequest(endpoint, { method: 'POST' }); } /** * Extract SQL query from a card (convenience method) */ async getCardSQL(cardId) { const card = await this.getCard(cardId); return card.sqlQuery; } // ======================================== // DASHBOARD METHODS // ======================================== /** * Get a dashboard by ID */ async getDashboard(dashboardId) { Validators.validateDashboardId(dashboardId); const dashboard = await this.makeRequest(`/api/dashboard/${dashboardId}`); return { id: dashboard.id, name: dashboard.name, description: dashboard.description, cards: dashboard.dashcards?.map(dc => ({ cardId: dc.card_id, cardName: dc.card?.name, row: dc.row, col: dc.col, sizeX: dc.size_x, sizeY: dc.size_y, })) || [], createdAt: dashboard.created_at, updatedAt: dashboard.updated_at, rawData: dashboard, }; } /** * List all dashboards */ async listDashboards() { const dashboards = await this.makeRequest('/api/dashboard'); return dashboards.map(d => ({ id: d.id, name: d.name, description: d.description, createdAt: d.created_at, updatedAt: d.updated_at, })); } // ======================================== // DATABASE METHODS // ======================================== /** * Get database information by ID */ async getDatabase(databaseId) { Validators.validateDatabaseId(databaseId); const database = await this.makeRequest(`/api/database/${databaseId}`); return { id: database.id, name: database.name, engine: database.engine, description: database.description, createdAt: database.created_at, updatedAt: database.updated_at, rawData: database, }; } /** * List all available databases */ async listDatabases() { const response = await this.makeRequest('/api/database/'); const databases = Array.isArray(response) ? response : response.data || []; return databases.map(db => ({ id: db.id, name: db.name, engine: db.engine, description: db.description, createdAt: db.created_at, updatedAt: db.updated_at, })); } /** * Get complete metadata for a database including all tables and fields */ async getDatabaseMetadata(databaseId) { Validators.validateDatabaseId(databaseId); const metadata = await this.makeRequest(`/api/database/${databaseId}/metadata`); return { id: metadata.id, name: metadata.name, engine: metadata.engine, tables: metadata.tables?.map(t => ({ id: t.id, name: t.name, schema: t.schema, fields: t.fields?.map(f => ({ id: f.id, name: f.name, type: f.base_type, })) || [], })) || [], rawData: metadata, }; } /** * List all tables in a database */ async listDatabaseTables(databaseId) { Validators.validateDatabaseId(databaseId); // Use metadata endpoint as /tables endpoint doesn't exist const metadata = await this.makeRequest(`/api/database/${databaseId}/metadata`); return (metadata.tables || []).map(t => ({ id: t.id, name: t.name, schema: t.schema, description: t.description, })); } /** * Get detailed metadata for a specific table */ async getTableMetadata(tableId) { Validators.validateTableId(tableId); const table = await this.makeRequest(`/api/table/${tableId}/query_metadata`); return { id: table.id, name: table.name, schema: table.schema, database: table.db, fields: table.fields?.map(f => ({ id: f.id, name: f.name, displayName: f.display_name, type: f.base_type, semanticType: f.semantic_type, description: f.description, })) || [], rawData: table, }; } // ======================================== // COLLECTION METHODS // ======================================== /** * List all collections */ async listCollections(namespace = null) { const params = namespace ? new URLSearchParams({ namespace }) : ''; const collections = await this.makeRequest(`/api/collection/${params ? '?' + params : ''}`); return collections.map(c => ({ id: c.id, name: c.name, description: c.description, slug: c.slug, })); } /** * Get all items in a collection */ async getCollectionItems(collectionId, models = null) { Validators.validateCollectionId(collectionId); const params = models && models.length > 0 ? new URLSearchParams({ models: models.join(',') }) : ''; const items = await this.makeRequest(`/api/collection/${collectionId}/items${params ? '?' + params : ''}`); return { data: items.data?.map(item => ({ id: item.id, name: item.name, description: item.description, model: item.model, })) || [], total: items.total || 0, }; } // ======================================== // QUERY EXECUTION METHODS // ======================================== /** * Execute an ad-hoc native SQL query */ async executeNativeQuery(databaseId, query) { Validators.validateDatabaseId(databaseId); Validators.validateQuery(query); const body = { database: databaseId, type: 'native', native: { query: query, }, }; return await this.makeRequest('/api/dataset', { method: 'POST', body: JSON.stringify(body), }); } // ======================================== // FIELD METHODS // ======================================== /** * Get detailed information about a field */ async getField(fieldId) { Validators.validateFieldId(fieldId); const field = await this.makeRequest(`/api/field/${fieldId}`); return { id: field.id, name: field.name, displayName: field.display_name, type: field.base_type, semanticType: field.semantic_type, description: field.description, table: field.table, rawData: field, }; } /** * Get distinct values for a field */ async getFieldValues(fieldId) { Validators.validateFieldId(fieldId); const values = await this.makeRequest(`/api/field/${fieldId}/values`); return values.values || []; } // ======================================== // SEGMENTS & METRICS METHODS // ======================================== /** * List all segments (saved filters) */ async listSegments() { const segments = await this.makeRequest('/api/segment'); return segments.map(s => ({ id: s.id, name: s.name, description: s.description, tableId: s.table_id, table: s.table, })); } /** * List all metrics (saved aggregations) */ async listMetrics() { try { const metrics = await this.makeRequest('/api/metric'); return metrics.map(m => ({ id: m.id, name: m.name, description: m.description, tableId: m.table_id, table: m.table, })); } catch (error) { // Metrics endpoint may not exist in all Metabase versions if (error.statusCode === 404) { return []; } throw error; } } // ======================================== // ACTIVITY & USER METHODS // ======================================== /** * Get recent activity feed */ async getActivity(limit = 20) { Validators.validateLimit(limit, 1, 100); try { const activity = await this.makeRequest(`/api/activity?limit=${limit}`); return activity.map(a => ({ id: a.id, topic: a.topic, timestamp: a.timestamp, user: a.user, details: a.details, })); } catch (error) { // Activity endpoint may not exist in all Metabase versions if (error.statusCode === 404) { return []; } throw error; } } /** * Get current user information */ async getCurrentUser() { const user = await this.makeRequest('/api/user/current'); return { id: user.id, email: user.email, firstName: user.first_name, lastName: user.last_name, commonName: user.common_name, isAdmin: user.is_superuser, rawData: user, }; } /** * List all users (requires admin permissions) */ async listUsers() { const users = await this.makeRequest('/api/user/'); return users.map(u => ({ id: u.id, email: u.email, firstName: u.first_name, lastName: u.last_name, commonName: u.common_name, isAdmin: u.is_superuser, })); } // ======================================== // DASHBOARD URL & PARAMETER METHODS // ======================================== /** * Get card with parameters from dashboard URL */ async getCardWithParameters(url) { Validators.validateUrl(url); const decoded = DashboardUrlDecoder.decode(url); const baseCard = await this.getCard(decoded.originalCardId); return { ...baseCard, extractedParameters: decoded.parameters, datasetQuery: decoded.datasetQuery, display: decoded.display, visualizationSettings: decoded.visualizationSettings, decodedData: decoded, }; } /** * Execute a query builder card with specific parameters */ async executeQueryBuilderCard(cardId, parameters) { Validators.validateCardId(cardId); // First, get the base card to understand its structure const baseCard = await this.makeRequest(`/api/card/${cardId}`); if (baseCard.dataset_query.type !== 'query') { throw new ValidationError( 'Card is not a query-builder card', 'cardId', cardId ); } // Create a modified dataset query with the provided parameters const modifiedQuery = { ...baseCard.dataset_query, query: { ...baseCard.dataset_query.query, ...parameters, }, }; // Execute the query using the dataset endpoint const body = { database: baseCard.dataset_query.database, type: 'query', query: modifiedQuery.query, }; return await this.makeRequest('/api/dataset', { method: 'POST', body: JSON.stringify(body), }); } /** * Get generated SQL for a query builder card with parameters */ async getGeneratedSQL(cardId, parameters) { Validators.validateCardId(cardId); // Get the base card const baseCard = await this.makeRequest(`/api/card/${cardId}`); if (baseCard.dataset_query.type !== 'query') { throw new ValidationError( 'Card is not a query-builder card', 'cardId', cardId ); } // Create a modified dataset query with the provided parameters const modifiedQuery = { ...baseCard.dataset_query, query: { ...baseCard.dataset_query.query, ...parameters, }, }; // Use the query endpoint to get the generated SQL const body = { database: baseCard.dataset_query.database, type: 'query', query: modifiedQuery.query, }; // Try to get the SQL by using the query endpoint const results = await this.makeRequest('/api/dataset', { method: 'POST', body: JSON.stringify(body), }); // Extract SQL if available const sql = results.query || results.native?.query || 'SQL not available in response'; return { cardId: cardId, cardName: baseCard.name, parameters: parameters, generatedSQL: sql, fullResults: results, }; } // ======================================== // CONVENIENCE METHODS // ======================================== /** * Search for cards by name or description */ async searchCards(searchTerm) { const allCards = await this.listCards('all'); const searchLower = searchTerm.toLowerCase(); return allCards.filter(card => card.name.toLowerCase().includes(searchLower) || (card.description && card.description.toLowerCase().includes(searchLower)) ); } /** * Search for dashboards by name or description */ async searchDashboards(searchTerm) { const allDashboards = await this.listDashboards(); const searchLower = searchTerm.toLowerCase(); return allDashboards.filter(dashboard => dashboard.name.toLowerCase().includes(searchLower) || (dashboard.description && dashboard.description.toLowerCase().includes(searchLower)) ); } /** * Get cards by database ID */ async getCardsByDatabase(databaseId) { const allCards = await this.listCards('all'); return allCards.filter(card => card.databaseId === databaseId); } /** * Test the API connection */ async testConnection() { try { await this.listDatabases(); return true; } catch (error) { this.logger.error('Metabase connection test failed', error); return false; } } } // Export for use in other modules export default MetabaseClient; // Factory functions export function createMetabaseClient(metabaseUrl, apiKey, timeout) { return new MetabaseClient(metabaseUrl, apiKey, timeout); } // Default configuration (can be overridden) export const DEFAULT_CONFIG = { metabaseUrl: process.env.METABASE_URL || 'https://data-metabase.swile.co', }; // Convenience function to create client with default config // Reads API key from environment variables for security export function createDefaultClient() { const apiKey = process.env.METABASE_API_KEY; if (!apiKey) { throw new Error('METABASE_API_KEY environment variable is required. Please set it in your environment or mcp.json configuration.'); } return new MetabaseClient(DEFAULT_CONFIG.metabaseUrl, apiKey); }

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/MikelA92/metabase-mcp-mab'

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