Skip to main content
Glama
index.ts23 kB
#!/usr/bin/env node /** * Metabase MCP Server * Implements interaction with Metabase API, providing the following functions: * - Get dashboard list * - Get questions list * - Get database list * - Execute question queries * - Get dashboard details */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListResourcesRequestSchema, ReadResourceRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; // Custom error enum enum ErrorCode { InternalError = "internal_error", InvalidRequest = "invalid_request", InvalidParams = "invalid_params", MethodNotFound = "method_not_found" } // Custom error class class McpError extends Error { code: ErrorCode; constructor(code: ErrorCode, message: string) { super(message); this.code = code; this.name = "McpError"; } } // API error type definition interface ApiError { status?: number; message?: string; data?: { message?: string }; } // Get Metabase configuration from environment variables const METABASE_URL = process.env.METABASE_URL; const METABASE_USER_EMAIL = process.env.METABASE_USER_EMAIL; const METABASE_PASSWORD = process.env.METABASE_PASSWORD; const METABASE_API_KEY = process.env.METABASE_API_KEY; if (!METABASE_URL || (!METABASE_API_KEY && (!METABASE_USER_EMAIL || !METABASE_PASSWORD))) { throw new Error("METABASE_URL is required, and either METABASE_API_KEY or both METABASE_USER_EMAIL and METABASE_PASSWORD must be provided"); } // Create custom Schema object using z.object const ListResourceTemplatesRequestSchema = z.object({ method: z.literal("resources/list_templates") }); const ListToolsRequestSchema = z.object({ method: z.literal("tools/list") }); // Logger level enum enum LogLevel { DEBUG = 'debug', INFO = 'info', WARN = 'warn', ERROR = 'error', FATAL = 'fatal' } // Authentication method enum enum AuthMethod { SESSION = 'session', API_KEY = 'api_key' } class MetabaseServer { private server: Server; private baseUrl: string; private sessionToken: string | null = null; private apiKey: string | null = null; private authMethod: AuthMethod = METABASE_API_KEY ? AuthMethod.API_KEY : AuthMethod.SESSION; private headers: Record<string, string> = { "Content-Type": "application/json", }; constructor() { this.server = new Server( { name: "metabase-mcp-server", version: "0.1.0", }, { capabilities: { resources: {}, tools: {}, }, } ); this.baseUrl = METABASE_URL!; if (METABASE_API_KEY) { this.apiKey = METABASE_API_KEY; this.logInfo('Using API Key authentication method'); } else { this.logInfo('Using Session Token authentication method'); } this.setupResourceHandlers(); this.setupToolHandlers(); // Enhanced error handling with logging this.server.onerror = (error: Error) => { this.logError('Unexpected server error occurred', error); }; process.on('SIGINT', async () => { this.logInfo('Gracefully shutting down server'); await this.server.close(); process.exit(0); }); } // Enhanced logging utilities private log(level: LogLevel, message: string, data?: unknown, error?: Error) { const timestamp = new Date().toISOString(); const logMessage: Record<string, unknown> = { timestamp, level, message }; if (data !== undefined) { logMessage.data = data; } if (error) { logMessage.error = error.message || 'Unknown error'; logMessage.stack = error.stack; } // Output structured log for machine processing console.error(JSON.stringify(logMessage)); // Output human-readable format try { let logPrefix = level.toUpperCase(); if (error) { console.error(`[${timestamp}] ${logPrefix}: ${message} - ${error.message || 'Unknown error'}`); } else { console.error(`[${timestamp}] ${logPrefix}: ${message}`); } } catch (e) { // Ignore if console is not available } } private logDebug(message: string, data?: unknown) { this.log(LogLevel.DEBUG, message, data); } private logInfo(message: string, data?: unknown) { this.log(LogLevel.INFO, message, data); } private logWarn(message: string, data?: unknown, error?: Error) { this.log(LogLevel.WARN, message, data, error); } private logError(message: string, error: unknown) { const errorObj = error instanceof Error ? error : new Error(String(error)); this.log(LogLevel.ERROR, message, undefined, errorObj); } private logFatal(message: string, error: unknown) { const errorObj = error instanceof Error ? error : new Error(String(error)); this.log(LogLevel.FATAL, message, undefined, errorObj); } /** * HTTP request utility method */ private async request<T>(path: string, options: RequestInit = {}): Promise<T> { const url = new URL(path, this.baseUrl); const headers = { ...this.headers }; // Add appropriate authentication headers based on the method if (this.authMethod === AuthMethod.API_KEY && this.apiKey) { // Use X-API-KEY header as specified in the Metabase documentation headers['X-API-KEY'] = this.apiKey; } else if (this.authMethod === AuthMethod.SESSION && this.sessionToken) { headers['X-Metabase-Session'] = this.sessionToken; } this.logDebug(`Making request to ${url.toString()}`); this.logDebug(`Using headers: ${JSON.stringify(headers)}`); const response = await fetch(url.toString(), { ...options, headers }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorMessage = `API request failed with status ${response.status}: ${response.statusText}`; this.logWarn(errorMessage, errorData); throw { status: response.status, message: response.statusText, data: errorData }; } this.logDebug(`Received successful response from ${path}`); return response.json() as Promise<T>; } /** * Get Metabase session token (only needed for session auth method) */ private async getSessionToken(): Promise<string> { // If using API Key authentication, return the API key directly if (this.authMethod === AuthMethod.API_KEY && this.apiKey) { this.logInfo('Using API Key authentication', { keyLength: this.apiKey.length, keyFormat: this.apiKey.includes('mb_') ? 'starts with mb_' : 'other format' }); return this.apiKey; } // For session auth, continue with existing logic if (this.sessionToken) { return this.sessionToken; } this.logInfo('Initiating authentication with Metabase'); try { const response = await this.request<{ id: string }>('/api/session', { method: 'POST', body: JSON.stringify({ username: METABASE_USER_EMAIL, password: METABASE_PASSWORD, }), }); this.sessionToken = response.id; this.logInfo('Successfully authenticated with Metabase'); return this.sessionToken; } catch (error) { this.logError('Authentication with Metabase failed', error); throw new McpError( ErrorCode.InternalError, 'Failed to authenticate with Metabase' ); } } /** * Set up resource handlers */ private setupResourceHandlers() { this.server.setRequestHandler(ListResourcesRequestSchema, async (_request) => { this.logInfo('Processing request to list resources', { requestId: this.generateRequestId() }); await this.getSessionToken(); try { // Get dashboard list this.logDebug('Fetching dashboards from Metabase'); const dashboardsResponse = await this.request<any[]>('/api/dashboard'); const resourceCount = dashboardsResponse.length; this.logInfo(`Successfully retrieved ${resourceCount} dashboards from Metabase`); // Return dashboards as resources return { resources: dashboardsResponse.map((dashboard: any) => ({ uri: `metabase://dashboard/${dashboard.id}`, mimeType: "application/json", name: dashboard.name, description: `Metabase dashboard: ${dashboard.name}` })) }; } catch (error) { this.logError('Failed to retrieve dashboards from Metabase', error); throw new McpError( ErrorCode.InternalError, 'Failed to retrieve Metabase resources' ); } }); // Resource templates this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { this.logInfo('Processing request to list resource templates'); return { resourceTemplates: [ { uriTemplate: 'metabase://dashboard/{id}', name: 'Dashboard by ID', mimeType: 'application/json', description: 'Get a Metabase dashboard by its ID', }, { uriTemplate: 'metabase://card/{id}', name: 'Card by ID', mimeType: 'application/json', description: 'Get a Metabase question/card by its ID', }, { uriTemplate: 'metabase://database/{id}', name: 'Database by ID', mimeType: 'application/json', description: 'Get a Metabase database by its ID', }, ], }; }); // Read resource this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const requestId = this.generateRequestId(); this.logInfo('Processing request to read resource', { requestId, uri: request.params?.uri }); await this.getSessionToken(); const uri = request.params?.uri; if (!uri) { this.logWarn('Missing URI parameter in resource request', { requestId }); throw new McpError( ErrorCode.InvalidParams, "URI parameter is required" ); } let match; try { // Handle dashboard resource if ((match = uri.match(/^metabase:\/\/dashboard\/(\d+)$/))) { const dashboardId = match[1]; this.logDebug(`Fetching dashboard with ID: ${dashboardId}`); const response = await this.request<any>(`/api/dashboard/${dashboardId}`); this.logInfo(`Successfully retrieved dashboard: ${response.name || dashboardId}`); return { contents: [{ uri: request.params?.uri, mimeType: "application/json", text: JSON.stringify(response, null, 2) }] }; } // Handle question/card resource else if ((match = uri.match(/^metabase:\/\/card\/(\d+)$/))) { const cardId = match[1]; this.logDebug(`Fetching card/question with ID: ${cardId}`); const response = await this.request<any>(`/api/card/${cardId}`); this.logInfo(`Successfully retrieved card: ${response.name || cardId}`); return { contents: [{ uri: request.params?.uri, mimeType: "application/json", text: JSON.stringify(response, null, 2) }] }; } // Handle database resource else if ((match = uri.match(/^metabase:\/\/database\/(\d+)$/))) { const databaseId = match[1]; this.logDebug(`Fetching database with ID: ${databaseId}`); const response = await this.request<any>(`/api/database/${databaseId}`); this.logInfo(`Successfully retrieved database: ${response.name || databaseId}`); return { contents: [{ uri: request.params?.uri, mimeType: "application/json", text: JSON.stringify(response, null, 2) }] }; } else { this.logWarn(`Invalid URI format: ${uri}`, { requestId }); throw new McpError( ErrorCode.InvalidRequest, `Invalid URI format: ${uri}` ); } } catch (error) { const apiError = error as ApiError; const errorMessage = apiError.data?.message || apiError.message || 'Unknown error'; this.logError(`Failed to fetch Metabase resource: ${errorMessage}`, error); throw new McpError( ErrorCode.InternalError, `Metabase API error: ${errorMessage}` ); } }); } /** * Generate a unique request ID */ private generateRequestId(): string { return Math.random().toString(36).substring(2, 15); } /** * Set up tool handlers */ private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { this.logInfo('Processing request to list available tools'); return { tools: [ { name: "list_dashboards", description: "List all dashboards in Metabase", inputSchema: { type: "object", properties: {} } }, { name: "list_cards", description: "List all questions/cards in Metabase", inputSchema: { type: "object", properties: {} } }, { name: "list_databases", description: "List all databases in Metabase", inputSchema: { type: "object", properties: {} } }, { name: "execute_card", description: "Execute a Metabase question/card and get results", inputSchema: { type: "object", properties: { card_id: { type: "number", description: "ID of the card/question to execute" }, parameters: { type: "object", description: "Optional parameters for the query" } }, required: ["card_id"] } }, { name: "get_dashboard_cards", description: "Get all cards in a dashboard", inputSchema: { type: "object", properties: { dashboard_id: { type: "number", description: "ID of the dashboard" } }, required: ["dashboard_id"] } }, { name: "execute_query", description: "Execute a SQL query against a Metabase database", inputSchema: { type: "object", properties: { database_id: { type: "number", description: "ID of the database to query" }, query: { type: "string", description: "SQL query to execute" }, native_parameters: { type: "array", description: "Optional parameters for the query", items: { type: "object" } } }, required: ["database_id", "query"] } } ] }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const toolName = request.params?.name || 'unknown'; const requestId = this.generateRequestId(); this.logInfo(`Processing tool execution request: ${toolName}`, { requestId, toolName, arguments: request.params?.arguments }); await this.getSessionToken(); try { switch (request.params?.name) { case "list_dashboards": { this.logDebug('Fetching all dashboards from Metabase'); const response = await this.request<any[]>('/api/dashboard'); this.logInfo(`Successfully retrieved ${response.length} dashboards`); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } case "list_cards": { this.logDebug('Fetching all cards/questions from Metabase'); const response = await this.request<any[]>('/api/card'); this.logInfo(`Successfully retrieved ${response.length} cards/questions`); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } case "list_databases": { this.logDebug('Fetching all databases from Metabase'); const response = await this.request<any[]>('/api/database'); this.logInfo(`Successfully retrieved ${response.length} databases`); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } case "execute_card": { const cardId = request.params?.arguments?.card_id; if (!cardId) { this.logWarn('Missing card_id parameter in execute_card request', { requestId }); throw new McpError( ErrorCode.InvalidParams, "Card ID parameter is required" ); } this.logDebug(`Executing card with ID: ${cardId}`); const parameters = request.params?.arguments?.parameters || {}; const response = await this.request<any>(`/api/card/${cardId}/query`, { method: 'POST', body: JSON.stringify({ parameters }) }); this.logInfo(`Successfully executed card: ${cardId}`); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } case "get_dashboard_cards": { const dashboardId = request.params?.arguments?.dashboard_id; if (!dashboardId) { this.logWarn('Missing dashboard_id parameter in get_dashboard_cards request', { requestId }); throw new McpError( ErrorCode.InvalidParams, "Dashboard ID parameter is required" ); } this.logDebug(`Fetching cards for dashboard with ID: ${dashboardId}`); const response = await this.request<any>(`/api/dashboard/${dashboardId}`); const cardCount = response.cards?.length || 0; this.logInfo(`Successfully retrieved ${cardCount} cards from dashboard: ${dashboardId}`); return { content: [{ type: "text", text: JSON.stringify(response.cards, null, 2) }] }; } case "execute_query": { const databaseId = request.params?.arguments?.database_id; const query = request.params?.arguments?.query; const nativeParameters = request.params?.arguments?.native_parameters || []; if (!databaseId) { this.logWarn('Missing database_id parameter in execute_query request', { requestId }); throw new McpError( ErrorCode.InvalidParams, "Database ID parameter is required" ); } if (!query) { this.logWarn('Missing query parameter in execute_query request', { requestId }); throw new McpError( ErrorCode.InvalidParams, "SQL query parameter is required" ); } this.logDebug(`Executing SQL query against database ID: ${databaseId}`); // Build query request body const queryData = { type: "native", native: { query: query, template_tags: {} }, parameters: nativeParameters, database: databaseId }; const response = await this.request<any>('/api/dataset', { method: 'POST', body: JSON.stringify(queryData) }); this.logInfo(`Successfully executed SQL query against database: ${databaseId}`); return { content: [{ type: "text", text: JSON.stringify(response, null, 2) }] }; } default: this.logWarn(`Received request for unknown tool: ${request.params?.name}`, { requestId }); return { content: [ { type: "text", text: `Unknown tool: ${request.params?.name}` } ], isError: true }; } } catch (error) { const apiError = error as ApiError; const errorMessage = apiError.data?.message || apiError.message || 'Unknown error'; this.logError(`Tool execution failed: ${errorMessage}`, error); return { content: [{ type: "text", text: `Metabase API error: ${errorMessage}` }], isError: true }; } }); } async run() { try { this.logInfo('Starting Metabase MCP server'); const transport = new StdioServerTransport(); await this.server.connect(transport); this.logInfo('Metabase MCP server successfully connected and running on stdio transport'); } catch (error) { this.logFatal('Failed to start Metabase MCP server', error); throw error; } } } // Add global error handlers process.on('uncaughtException', (error: Error) => { console.error(JSON.stringify({ timestamp: new Date().toISOString(), level: 'fatal', message: 'Uncaught exception detected', error: error.message, stack: error.stack })); process.exit(1); }); process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => { const errorMessage = reason instanceof Error ? reason.message : String(reason); console.error(JSON.stringify({ timestamp: new Date().toISOString(), level: 'fatal', message: 'Unhandled promise rejection detected', error: errorMessage })); }); const server = new MetabaseServer(); server.run().catch(error => { console.error(JSON.stringify({ timestamp: new Date().toISOString(), level: 'fatal', message: 'Fatal error during server startup', error: error instanceof Error ? error.message : String(error) })); process.exit(1); });

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/hyeongjun-dev/metabase-mcp-server'

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