Skip to main content
Glama
traghav
by traghav
index.ts44.4 kB
#!/usr/bin/env node // Add AbortController polyfill for older Node.js versions import AbortController from 'abort-controller'; global.AbortController = global.AbortController || AbortController; /** * Metabase MCP Server * Implements interaction with Metabase API, providing: * - Dashboard listing * - Question listing * - Database listing * - Query execution * - Dashboard details */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListResourcesRequestSchema, ReadResourceRequestSchema, CallToolRequestSchema, ListResourcesResult, ReadResourceResult, Resource } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import axios, { AxiosInstance } from "axios"; import { MetabaseDatabaseResource, MetabaseCollectionResource, MetabaseTableResource, MetabaseCardResource, MetabaseDashboardResource } from "./metabase-types.js"; // Custom error enumeration enum ErrorCode { InternalError = "internal_error", InvalidRequest = "invalid_request", InvalidParams = "invalid_params", MethodNotFound = "method_not_found", NotFound = "not_found", PermissionDenied = "permission_denied" } // Custom error class class McpError extends Error { code: ErrorCode; constructor(code: ErrorCode, message: string) { super(message); this.code = code; this.name = "McpError"; } } // Get Metabase configuration from environment variables const METABASE_URL = process.env.METABASE_URL; const METABASE_USERNAME = process.env.METABASE_USERNAME; const METABASE_PASSWORD = process.env.METABASE_PASSWORD; if (!METABASE_URL || !METABASE_USERNAME || !METABASE_PASSWORD) { throw new Error("METABASE_URL, METABASE_USERNAME, and METABASE_PASSWORD environment variables are required"); } // Create custom Schema objects using z.object const ListResourceTemplatesRequestSchema = z.object({ method: z.literal("resources/list_templates") }); const ListToolsRequestSchema = z.object({ method: z.literal("tools/list") }); class MetabaseServer { private server: Server; private axiosInstance: AxiosInstance; private sessionToken: string | null = null; constructor() { this.server = new Server( { name: "metabase-server", version: "0.1.0", }, { capabilities: { resources: {}, tools: {}, }, } ); this.axiosInstance = axios.create({ baseURL: METABASE_URL, headers: { "Content-Type": "application/json", }, }); this.setupResourceHandlers(); this.setupToolHandlers(); // Enhanced error handling with logging this.server.onerror = (error: Error) => { this.logError('Server Error', error); }; process.on('SIGINT', async () => { this.logInfo('Shutting down server...'); await this.server.close(); process.exit(0); }); } // Add logging utilities private logInfo(message: string, data?: unknown) { const logMessage = { timestamp: new Date().toISOString(), level: 'info', message, data }; console.error(JSON.stringify(logMessage)); // MCP SDK changed, can't directly access session try { // Use current session if available console.error(`INFO: ${message}`); } catch (e) { // Ignore if session not available } } private logError(message: string, error: unknown) { const errorObj = error as Error; const apiError = error as { response?: { data?: { message?: string } }, message?: string }; const logMessage = { timestamp: new Date().toISOString(), level: 'error', message, error: errorObj.message || 'Unknown error', stack: errorObj.stack }; console.error(JSON.stringify(logMessage)); // MCP SDK changed, can't directly access session try { console.error(`ERROR: ${message} - ${errorObj.message || 'Unknown error'}`); } catch (e) { // Ignore if session not available } } /** * Get Metabase session token */ private async getSessionToken(): Promise<string> { if (this.sessionToken) { return this.sessionToken; } this.logInfo('Authenticating with Metabase...'); try { const response = await this.axiosInstance.post('/api/session', { username: METABASE_USERNAME, password: METABASE_PASSWORD, }); this.sessionToken = response.data.id; // Set default request header this.axiosInstance.defaults.headers.common['X-Metabase-Session'] = this.sessionToken; this.logInfo('Successfully authenticated with Metabase'); return this.sessionToken as string; } catch (error) { this.logError('Authentication failed', error); throw new McpError( ErrorCode.InternalError, 'Failed to authenticate with Metabase' ); } } /** * Setup resource handlers */ private setupResourceHandlers() { this.server.setRequestHandler(ListResourcesRequestSchema, async (request): Promise<ListResourcesResult> => { this.logInfo('Listing resources...', { requestStructure: JSON.stringify(request) }); await this.getSessionToken(); const resources: Resource[] = []; try { const requestUri = request.params?.uri; if (!requestUri || requestUri === "metabase://") { // Top-level listing: Databases and Root Collections this.logInfo('Fetching top-level resources: databases and root collections'); // Fetch databases const databasesResponse = await this.axiosInstance.get<{ data: MetabaseDatabaseResource[] }>('/api/database'); databasesResponse.data.data.forEach(db => { resources.push({ uri: `metabase://database/${db.id}`, name: db.name, mimeType: "application/json" }); }); this.logInfo(`Fetched ${databasesResponse.data.data.length} databases`); // Fetch collections const collectionsResponse = await this.axiosInstance.get<MetabaseCollectionResource[]>('/api/collection'); collectionsResponse.data.forEach(coll => { // Assuming top-level collections are those without a parent_id // or those explicitly marked (e.g. personal collections might appear at root) // The API for /api/collection might return all, so filter for actual root/top-level ones. // A common pattern is `parent_id: null` for true top-level user-created collections. // "Root" collection itself often has a special ID or status. if (coll.parent_id === null || coll.id === 'root' || (coll.personal_owner_id != null && coll.parent_id == undefined )) { resources.push({ uri: `metabase://collection/${coll.id}`, name: coll.name, mimeType: "application/json" }); } }); this.logInfo(`Fetched ${collectionsResponse.data.length} collections, filtered for top-level`); } else if (typeof requestUri === 'string' && requestUri.startsWith("metabase://database/")) { const dbIdMatch = requestUri.match(/^metabase:\/\/database\/(\d+)(\/schema\/([^/]+))?$/); if (dbIdMatch) { const dbId = dbIdMatch[1]; const schemaName = dbIdMatch[3]; if (schemaName) { // Listing tables within a specific schema this.logInfo(`Fetching tables for database ${dbId}, schema ${schemaName}`); const dbMetadataResponse = await this.axiosInstance.get<{ tables: MetabaseTableResource[] }>(`/api/database/${dbId}/metadata`); dbMetadataResponse.data.tables.forEach(table => { if (table.schema === schemaName) { resources.push({ uri: `metabase://database/${dbId}/table/${table.id}`, name: table.display_name || table.name, mimeType: "application/json" }); } }); this.logInfo(`Fetched ${resources.length} tables in schema ${schemaName} for database ${dbId}`); } else { // Listing schemas and tables for a database this.logInfo(`Fetching schemas and tables for database ${dbId}`); // Fetch Schemas // Metabase API for schemas of a specific DB: GET /api/database/:id/schemas // This returns a list of schema names (strings) const schemasResponse = await this.axiosInstance.get<string[]>(`/api/database/${dbId}/schemas`); schemasResponse.data.forEach(schema => { // Only add schema if it's not empty or "public" if other schemas exist (to avoid clutter if public is the only one) // This logic might need refinement based on how Metabase handles schemas (e.g. always show public, or hide if it's the default and only one) if (schema) { // Filter out empty schema names if any resources.push({ uri: `metabase://database/${dbId}/schema/${encodeURIComponent(schema)}`, name: schema, mimeType: "application/json" }); } }); this.logInfo(`Fetched ${schemasResponse.data.length} schemas for database ${dbId}`); // Fetch Tables (those not in a specific schema, or all if schemas are not strongly enforced at top level) // The `/api/database/:id/metadata` endpoint includes tables with their schema info. // We can list tables that belong to the "default" schema (often null or "public") // or decide if we want to mix tables and schemas at this level. // For now, let's list tables that are commonly directly under the DB (e.g. in "public" schema if it's the default) // More specific table listings will happen under schema URI. const dbMetadataResponse = await this.axiosInstance.get<{ tables: MetabaseTableResource[] }>(`/api/database/${dbId}/metadata`); dbMetadataResponse.data.tables.forEach(table => { // Heuristic: if schemas are present, only show tables from a "default" or "public" schema at this level. // If no schemas or only one "public" schema, show all tables. // This avoids duplicating tables shown under specific schema URIs if we also list them here. // A simple approach: if no schemas were found, or only one schema (e.g. "public"), list all tables. // Otherwise, expect users to navigate into a schema to see its tables. // Let's list tables that don't have a schema or are in the "public" schema for now. // This might need adjustment based on typical Metabase usage. if (!table.schema || table.schema === "public" || schemasResponse.data.length === 0 || (schemasResponse.data.length === 1 && schemasResponse.data[0] === "public")) { resources.push({ uri: `metabase://database/${dbId}/table/${table.id}`, name: table.display_name || table.name, mimeType: "application/json" }); } }); this.logInfo(`Fetched tables (potentially filtered) for database ${dbId}`); } } else { this.logError('Invalid database URI format', { uri: requestUri }); // Potentially throw error or return empty if format is unexpected } } else if ((requestUri as string).startsWith("metabase://collection/")) { const collIdMatch = (requestUri as string).match(/^metabase:\/\/collection\/([^/]+)$/); if (collIdMatch) { const collectionIdOrRoot = collIdMatch[1]; this.logInfo(`Fetching items for collection ${collectionIdOrRoot}`); // The /api/collection/:id/items endpoint returns a list of items. // Each item has a 'model' field (e.g., 'card', 'dashboard', 'collection'). const itemsResponse = await this.axiosInstance.get<{ data: any[] }>(`/api/collection/${collectionIdOrRoot}/items`); itemsResponse.data.data.forEach(item => { let resource: Resource | null = null; switch (item.model) { case 'card': resource = { uri: `metabase://card/${item.id}`, name: item.name || item.display_name || `Card ${item.id}`, mimeType: "application/json" }; break; case 'dashboard': resource = { uri: `metabase://dashboard/${item.id}`, name: item.name || item.display_name || `Dashboard ${item.id}`, mimeType: "application/json" }; break; case 'collection': resource = { uri: `metabase://collection/${item.id}`, name: item.name || `Collection ${item.id}`, mimeType: "application/json" }; break; // Other model types like 'dataset' (for models) could be handled here if needed. default: this.logInfo(`Unhandled item type in collection ${collectionIdOrRoot}: ${item.model} with id ${item.id}`); } if (resource) { resources.push(resource); } }); this.logInfo(`Fetched ${resources.length} items for collection ${collectionIdOrRoot}`); } else { this.logError('Invalid collection URI format', { uri: requestUri }); } } else { this.logError('Invalid collection URI format', { uri: requestUri }); } return { resources }; } catch (error) { this.logError('Failed to list resources', error); if (axios.isAxiosError(error)) { throw new McpError( ErrorCode.InternalError, `Metabase API error while listing resources: ${error.response?.data?.message || error.message}` ); } throw new McpError( ErrorCode.InternalError, 'Failed to list Metabase resources' ); } }); // Resource templates this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { 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', }, { uriTemplate: "metabase://database/{database_id}/table/{table_id}", name: "Metabase Table by ID", description: "Access a specific Metabase table by its database and table ID.", mimeType: 'application/json' }, { uriTemplate: "metabase://collection/{collection_id}", name: "Metabase Collection by ID", description: "Access a specific Metabase collection by its ID.", mimeType: 'application/json' } ], }; }); // Read resource this.server.setRequestHandler(ReadResourceRequestSchema, async (request): Promise<ReadResourceResult> => { this.logInfo('Reading resource...', { requestStructure: JSON.stringify(request) }); await this.getSessionToken(); const uri = request.params?.uri; if (!uri) { throw new McpError(ErrorCode.InvalidParams, "URI is required for reading a resource."); } let match; try { // Handle dashboard resource if ((match = uri.match(/^metabase:\/\/dashboard\/(\d+)$/))) { const dashboardId = match[1]; this.logInfo(`Reading dashboard ${dashboardId}`); const response = await this.axiosInstance.get<MetabaseDashboardResource>(`/api/dashboard/${dashboardId}`); return { contents: [{ uri: uri, mimeType: "application/json", text: JSON.stringify(response.data, null, 2) }] }; } // Handle question/card resource else if ((match = uri.match(/^metabase:\/\/card\/(\d+)$/))) { const cardId = match[1]; this.logInfo(`Reading card ${cardId}`); const response = await this.axiosInstance.get<MetabaseCardResource>(`/api/card/${cardId}`); return { contents: [{ uri: uri, mimeType: "application/json", text: JSON.stringify(response.data, null, 2) }] }; } // Handle database resource else if ((match = uri.match(/^metabase:\/\/database\/(\d+)$/))) { const databaseId = match[1]; this.logInfo(`Reading database ${databaseId}`); const dbDetailsResponse = await this.axiosInstance.get<MetabaseDatabaseResource>(`/api/database/${databaseId}`); const dbMetadataResponse = await this.axiosInstance.get<{ tables: MetabaseTableResource[] }>(`/api/database/${databaseId}/metadata`); const databaseResource: MetabaseDatabaseResource = { ...dbDetailsResponse.data, tables: dbMetadataResponse.data.tables.map(table => ({ ...table, uri: `metabase://database/${databaseId}/table/${table.id}`, // Add URI to table // Fields might be stubs here, or need another call if full field detail is required at this level })), }; return { contents: [{ uri: uri, mimeType: "application/json", text: JSON.stringify(databaseResource, null, 2) }] }; } // Handle Collection Resource else if ((match = uri.match(/^metabase:\/\/collection\/([^/]+)$/))) { const collectionIdOrRoot = match[1]; this.logInfo(`Reading collection ${collectionIdOrRoot}`); const response = await this.axiosInstance.get<MetabaseCollectionResource>(`/api/collection/${collectionIdOrRoot}`); return { contents: [{ uri: uri, mimeType: "application/json", text: JSON.stringify(response.data, null, 2) }] }; } // Handle Table Resource else if ((match = uri.match(/^metabase:\/\/database\/(\d+)\/table\/(card__\d+|\d+)$/))) { const dbId = match[1]; // Though not always directly used in /api/table/:id calls, good for context const tableId = match[2]; this.logInfo(`Reading table ${tableId} from database ${dbId}`); if (tableId.startsWith("card__")) { // Model based on a card. The ID for the API is just the numeric part. this.logInfo(`Reading model (dataset) ${tableId}`); const cardResponse = await this.axiosInstance.get<MetabaseCardResource>(`/api/card/${tableId.substring("card__".length)}`); const cardData = cardResponse.data; if (!cardData.result_metadata) { throw new McpError(ErrorCode.NotFound, `Result metadata not found for model ${tableId}`); } const tableResource: MetabaseTableResource = { uri: uri, id: tableId, // Keep the card__<ID> format for the resource ID name: cardData.name, display_name: cardData.name, db_id: parseInt(dbId), // This might be tricky as models don't belong to a DB in the same way tables do. fields: cardData.result_metadata, // Use the result_metadata from the card description: cardData.description, entity_type: 'model', // Custom field to denote it's a model }; return { contents: [{ uri: uri, mimeType: "application/json", text: JSON.stringify(tableResource, null, 2) }] }; } else { // Regular table const tableMetadataUrl = `/api/table/${tableId}/query_metadata`; const response = await this.axiosInstance.get<MetabaseTableResource>(tableMetadataUrl); const tableResource: MetabaseTableResource = { ...response.data, uri: uri, // Ensure URI is part of the resource db_id: parseInt(dbId), // Ensure db_id is set from URI }; return { contents: [{ uri: uri, mimeType: "application/json", text: JSON.stringify(tableResource, null, 2) }] }; } } // Handle Schema Resource else if ((match = uri.match(/^metabase:\/\/database\/(\d+)\/schema\/([^/]+)$/))) { const dbId = match[1]; const schemaName = decodeURIComponent(match[2]); this.logInfo(`Reading schema "${schemaName}" from database ${dbId}`); const dbMetadataResponse = await this.axiosInstance.get<{ tables: MetabaseTableResource[] }>(`/api/database/${dbId}/metadata`); const tablesInSchema = dbMetadataResponse.data.tables .filter(table => table.schema === schemaName) .map(table => ({ ...table, uri: `metabase://database/${dbId}/table/${table.id}`, // Add URI to table stub })); const schemaResource = { // Define a simple structure for schema resource uri: uri, name: schemaName, db_id: parseInt(dbId), tables: tablesInSchema, }; return { contents: [{ uri: uri, mimeType: "application/json", text: JSON.stringify(schemaResource, null, 2) }] }; } else { this.logError('Invalid or unsupported URI format', { uri }); throw new McpError( ErrorCode.InvalidRequest, `Invalid or unsupported URI format: ${uri}` ); } } catch (error) { this.logError('Failed to read resource', { uri, error }); if (axios.isAxiosError(error)) { if (error.response?.status === 404) { throw new McpError(ErrorCode.NotFound, `Resource not found at URI: ${uri}`); } throw new McpError( ErrorCode.InternalError, `Metabase API error while reading resource ${uri}: ${error.response?.data?.message || error.message}` ); } throw error; // Rethrow other errors (e.g., McpError) } }); } /** * Setup tool handlers */ private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "list_dashboards", description: "List Metabase dashboards. Can be filtered.", inputSchema: { type: "object", properties: { collection_id: { type: "number", description: "Filter by collection ID" }, archived: { type: "boolean", description: "Filter by archived status (e.g. true to list archived dashboards)" } } } }, { name: "list_cards", description: "List Metabase cards (questions/models). Can be filtered.", inputSchema: { type: "object", properties: { collection_id: { type: "number", description: "Filter by collection ID" }, archived: { type: "boolean", description: "Filter by archived status" }, f: { type: "string", description: "Filter type (e.g., 'archived', 'mine', 'popular', 'table_id')" } } } }, { name: "list_databases", description: "List Metabase databases. Can be filtered.", inputSchema: { type: "object", properties: { include_tables: { type: "boolean", description: "Include tables for each database" }, include_cards: { type: "boolean", description: "Include cards (questions/models) related to each database" } } } }, { 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: "array", description: "Parameters for the card query", items: { type: "object", properties: { type: { type: "string", description: "Parameter type (e.g., 'category', 'date/single')" }, target: { type: "array", description: "Target field/dimension for the parameter" }, value: { "type": ["string", "number", "array", "boolean"], "description": "Value of the parameter" }, id: { type: "string", description: "Parameter ID (optional, sometimes needed)" }, slug: { type: "string", description: "Parameter slug (optional, sometimes needed)" } }, required: ["type", "target", "value"] } } }, required: ["card_id"] } }, { name: "get_dashboard_details", description: "Get full details for a specific dashboard, including its dashcards and parameters.", 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 specified database.", inputSchema: { type: "object", properties: { database_id: { type: "number", description: "ID of the database to query" }, query: { type: "string", description: "The SQL query string to execute." }, template_tags: { type: "object", description: "An object of template tags to substitute in the native SQL query (e.g., {\"tag_name\": \"value\"}).", additionalProperties: true } }, required: ["database_id", "query"] } }, { name: "create_card", description: "Create a new Metabase question (card).", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name of the card" }, dataset_query: { type: "object", description: "The query for the card (e.g., MBQL or native query)" }, display: { type: "string", description: "Display type (e.g., 'table', 'line', 'bar')" }, visualization_settings: { type: "object", description: "Settings for the visualization" }, collection_id: { type: "number", description: "Optional ID of the collection to save the card in" }, description: { type: "string", description: "Optional description for the card" } }, required: ["name", "dataset_query", "display", "visualization_settings"] } }, { name: "update_card", description: "Update an existing Metabase question (card).", inputSchema: { type: "object", properties: { card_id: { type: "number", description: "ID of the card to update" }, name: { type: "string", description: "New name for the card" }, dataset_query: { type: "object", description: "New query for the card" }, display: { type: "string", description: "New display type" }, visualization_settings: { type: "object", description: "New visualization settings" }, collection_id: { type: "number", description: "New collection ID" }, description: { type: "string", description: "New description" }, archived: { type: "boolean", description: "Set to true to archive the card" } }, required: ["card_id"] } }, { name: "delete_card", description: "Delete a Metabase question (card).", inputSchema: { type: "object", properties: { card_id: { type: "number", description: "ID of the card to delete" }, hard_delete: { type: "boolean", description: "Set to true for hard delete, false (default) for archive", default: false } }, required: ["card_id"] } }, { name: "create_dashboard", description: "Create a new Metabase dashboard.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name of the dashboard" }, description: { type: "string", description: "Optional description for the dashboard" }, parameters: { type: "array", description: "Optional parameters for the dashboard", items: { type: "object" } }, collection_id: { type: "number", description: "Optional ID of the collection to save the dashboard in" } }, required: ["name"] } }, { name: "update_dashboard", description: "Update an existing Metabase dashboard.", inputSchema: { type: "object", properties: { dashboard_id: { type: "number", description: "ID of the dashboard to update" }, name: { type: "string", description: "New name for the dashboard" }, description: { type: "string", description: "New description for the dashboard" }, parameters: { type: "array", description: "New parameters for the dashboard", items: { type: "object" } }, collection_id: { type: "number", description: "New collection ID" }, archived: { type: "boolean", description: "Set to true to archive the dashboard" } }, required: ["dashboard_id"] } }, { name: "delete_dashboard", description: "Delete a Metabase dashboard.", inputSchema: { type: "object", properties: { dashboard_id: { type: "number", description: "ID of the dashboard to delete" }, hard_delete: { type: "boolean", description: "Set to true for hard delete, false (default) for archive", default: false } }, required: ["dashboard_id"] } } ] }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { this.logInfo('Calling tool...', { requestStructure: JSON.stringify(request) }); await this.getSessionToken(); try { const toolName = request.params?.name; const args = request.params?.arguments || {}; switch (toolName) { case "list_dashboards": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { collection_id, archived } = args; const queryParams: Record<string, any> = {}; if (collection_id !== undefined) queryParams.collection_id = collection_id; if (archived !== undefined) queryParams.archived = archived; const response = await this.axiosInstance.get<MetabaseDashboardResource[]>('/api/dashboard', { params: queryParams }); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "list_cards": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { collection_id, archived, f } = args; const queryParams: Record<string, any> = {}; if (collection_id !== undefined) queryParams.collection_id = collection_id; if (archived !== undefined) queryParams.archived = archived; if (f !== undefined) queryParams.f = f; const response = await this.axiosInstance.get<MetabaseCardResource[]>('/api/card', { params: queryParams }); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "list_databases": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { include_tables, include_cards } = args; const queryParams: Record<string, any> = {}; if (include_tables !== undefined) queryParams.include_tables = include_tables; if (include_cards !== undefined) queryParams.include_cards = include_cards; const response = await this.axiosInstance.get<{data: MetabaseDatabaseResource[]}>('/api/database', { params: queryParams }); return { content: [{ type: "text", text: JSON.stringify(response.data.data, null, 2) }] }; } case "execute_card": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { card_id, parameters } = args; if (!card_id) { throw new McpError(ErrorCode.InvalidParams, "Card ID is required for execute_card"); } const response = await this.axiosInstance.post(`/api/card/${card_id}/query`, { parameters: parameters || [] }); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "get_dashboard_details": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { dashboard_id } = args; if (!dashboard_id) { throw new McpError(ErrorCode.InvalidParams, "Dashboard ID is required for get_dashboard_details"); } const response = await this.axiosInstance.get<MetabaseDashboardResource>(`/api/dashboard/${dashboard_id}`); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "execute_query": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { database_id, query, template_tags } = args; if (!database_id) { throw new McpError(ErrorCode.InvalidParams, "Database ID is required for execute_query"); } if (!query) { throw new McpError(ErrorCode.InvalidParams, "Query string is required for execute_query"); } const payload = { database: database_id, type: "native", native: { query: query, template_tags: template_tags || {}, }, }; const response = await this.axiosInstance.post('/api/dataset', payload); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "create_card": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { name, dataset_query, display, visualization_settings, collection_id, description } = args; if (!name || !dataset_query || !display || !visualization_settings) { throw new McpError( ErrorCode.InvalidParams, "Missing required fields for create_card: name, dataset_query, display, visualization_settings" ); } const createCardBody: any = { name, dataset_query, display, visualization_settings }; if (collection_id !== undefined) createCardBody.collection_id = collection_id; if (description !== undefined) createCardBody.description = description; const response = await this.axiosInstance.post<MetabaseCardResource>('/api/card', createCardBody); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "update_card": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { card_id, ...updateFields } = args; if (!card_id) { throw new McpError(ErrorCode.InvalidParams, "Card ID is required for update_card"); } if (Object.keys(updateFields).length === 0) { throw new McpError(ErrorCode.InvalidParams, "No fields provided for update_card"); } const response = await this.axiosInstance.put<MetabaseCardResource>(`/api/card/${card_id}`, updateFields); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "delete_card": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { card_id, hard_delete = false } = args; if (!card_id) { throw new McpError(ErrorCode.InvalidParams, "Card ID is required for delete_card"); } if (hard_delete) { await this.axiosInstance.delete(`/api/card/${card_id}`); return { content: [{ type: "text", text: `Card ${card_id} permanently deleted.` }] }; } else { const response = await this.axiosInstance.put<MetabaseCardResource>(`/api/card/${card_id}`, { archived: true }); return { content: [{ type: "text", text: response.data ? `Card ${card_id} archived. Details: ${JSON.stringify(response.data, null, 2)}` : `Card ${card_id} archived.` }] }; } } case "create_dashboard": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { name, description, parameters, collection_id } = args; if (!name) { throw new McpError(ErrorCode.InvalidParams, "Name is required for create_dashboard"); } const createDashboardBody: any = { name }; if (description !== undefined) createDashboardBody.description = description; if (parameters !== undefined) createDashboardBody.parameters = parameters; if (collection_id !== undefined) createDashboardBody.collection_id = collection_id; const response = await this.axiosInstance.post<MetabaseDashboardResource>('/api/dashboard', createDashboardBody); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "update_dashboard": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { dashboard_id, ...updateFields } = args; if (!dashboard_id) { throw new McpError(ErrorCode.InvalidParams, "Dashboard ID is required for update_dashboard"); } if (Object.keys(updateFields).length === 0) { throw new McpError(ErrorCode.InvalidParams, "No fields provided for update_dashboard"); } const response = await this.axiosInstance.put<MetabaseDashboardResource>(`/api/dashboard/${dashboard_id}`, updateFields); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "delete_dashboard": { this.logInfo(`Executing tool: ${toolName}`, { args }); const { dashboard_id, hard_delete = false } = args; if (!dashboard_id) { throw new McpError(ErrorCode.InvalidParams, "Dashboard ID is required for delete_dashboard"); } if (hard_delete) { await this.axiosInstance.delete(`/api/dashboard/${dashboard_id}`); return { content: [{ type: "text", text: `Dashboard ${dashboard_id} permanently deleted.` }] }; } else { const response = await this.axiosInstance.put<MetabaseDashboardResource>(`/api/dashboard/${dashboard_id}`, { archived: true }); return { content: [{ type: "text", text: response.data ? `Dashboard ${dashboard_id} archived. Details: ${JSON.stringify(response.data, null, 2)}` : `Dashboard ${dashboard_id} archived.` }] }; } } default: return { content: [ { type: "text", text: `Unknown tool: ${request.params?.name}` } ], isError: true }; } } catch (error) { if (axios.isAxiosError(error)) { return { content: [{ type: "text", text: `Metabase API error: ${error.response?.data?.message || error.message}` }], isError: true }; } throw error; } }); } async run() { try { this.logInfo('Starting Metabase MCP server...'); const transport = new StdioServerTransport(); await this.server.connect(transport); this.logInfo('Metabase MCP server running on stdio'); } catch (error) { this.logError('Failed to start 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', 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 Rejection', error: errorMessage })); }); const server = new MetabaseServer(); server.run().catch(console.error);

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/traghav/metabase-server'

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