# Directory Structure ``` ├── .gitignore ├── Dockerfile ├── package-lock.json ├── package.json ├── README.md ├── smithery.yaml ├── src │ └── index.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` node_modules/ build/ *.log .env* /dist ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # metabase-server MCP Server [](https://smithery.ai/server/@imlewc/metabase-server) A Model Context Protocol server for Metabase integration. This is a TypeScript-based MCP server that implements integration with Metabase API. It allows AI assistants to interact with Metabase, providing access to: - Dashboards, questions/cards, and databases as resources - Tools for listing and executing Metabase queries - Ability to view and interact with Metabase data ## Features ### Resources - List and access Metabase resources via `metabase://` URIs - Access dashboards, cards/questions, and databases - JSON content type for structured data access ### Tools - `list_dashboards` - List all dashboards in Metabase - `list_cards` - List all questions/cards in Metabase - `list_databases` - List all databases in Metabase - `execute_card` - Execute a Metabase question/card and get results - `get_dashboard_cards` - Get all cards in a dashboard - `execute_query` - Execute a SQL query against a Metabase database ## Configuration Before running the server, you need to set environment variables for authentication. The server supports two methods: 1. **API Key (Preferred):** * `METABASE_URL`: The URL of your Metabase instance (e.g., `https://your-metabase-instance.com`). * `METABASE_API_KEY`: Your Metabase API key. 2. **Username/Password (Fallback):** * `METABASE_URL`: The URL of your Metabase instance. * `METABASE_USERNAME`: Your Metabase username. * `METABASE_PASSWORD`: Your Metabase password. The server will first check for `METABASE_API_KEY`. If it's set, API key authentication will be used. If `METABASE_API_KEY` is not set, the server will fall back to using `METABASE_USERNAME` and `METABASE_PASSWORD`. You must provide credentials for at least one of these methods. **Example setup:** Using API Key: ```bash # Required environment variables export METABASE_URL=https://your-metabase-instance.com export METABASE_API_KEY=your_metabase_api_key ``` Or, using Username/Password: ```bash # Required environment variables export METABASE_URL=https://your-metabase-instance.com export METABASE_USERNAME=your_username export METABASE_PASSWORD=your_password ``` You can set these environment variables in your shell profile or use a `.env` file with a package like `dotenv`. ## Development Install dependencies: ```bash npm install ``` Build the server: ```bash npm run build ``` For development with auto-rebuild: ```bash npm run watch ``` ## Installation ```bash # Oneliner, suitable for CI environment git clone https://github.com/imlewc/metabase-server.git && cd metabase-server && npm i && npm run build && npm link ``` To use with Claude Desktop, add the server config: On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json` On Windows: `%APPDATA%/Claude/claude_desktop_config.json` ```json { "mcpServers": { "metabase-server": { "command": "metabase-server", "env": { "METABASE_URL": "https://your-metabase-instance.com", // Use API Key (preferred) "METABASE_API_KEY": "your_metabase_api_key" // Or Username/Password (if API Key is not set) // "METABASE_USERNAME": "your_username", // "METABASE_PASSWORD": "your_password" } } } } ``` Note: You can also set these environment variables in your system instead of in the config file if you prefer. ### Installing via Smithery To install metabase-server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@imlewc/metabase-server): ```bash npx -y @smithery/cli install @imlewc/metabase-server --client claude ``` ### Debugging Since MCP servers communicate over stdio, debugging can be challenging. We recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which is available as a package script: ```bash npm run inspector ``` The Inspector will provide a URL to access debugging tools in your browser. ## Testing After configuring the environment variables as described in the "Configuration" section, you can manually test the server's authentication. The MCP Inspector (`npm run inspector`) is a useful tool for sending requests to the server. ### 1. Testing with API Key Authentication 1. Set the `METABASE_URL` and `METABASE_API_KEY` environment variables with your Metabase instance URL and a valid API key. 2. Ensure `METABASE_USERNAME` and `METABASE_PASSWORD` are unset or leave them, as the API key should take precedence. 3. Start the server: `npm run build && node build/index.js` (or use your chosen method for running the server, like via Claude Desktop config). 4. Check the server logs. You should see a message indicating that it's using API key authentication (e.g., "Using Metabase API Key for authentication."). 5. Using an MCP client or the MCP Inspector, try calling a tool, for example, `tools/call` with `{"name": "list_dashboards"}`. 6. Verify that the tool call is successful and you receive the expected data. ### 2. Testing with Username/Password Authentication (Fallback) 1. Ensure the `METABASE_API_KEY` environment variable is unset. 2. Set `METABASE_URL`, `METABASE_USERNAME`, and `METABASE_PASSWORD` with valid credentials for your Metabase instance. 3. Start the server. 4. Check the server logs. You should see a message indicating that it's using username/password authentication (e.g., "Using Metabase username/password for authentication." followed by "Authenticating with Metabase using username/password..."). 5. Using an MCP client or the MCP Inspector, try calling the `list_dashboards` tool. 6. Verify that the tool call is successful. ### 3. Testing Authentication Failures * **Invalid API Key:** 1. Set `METABASE_URL` and an invalid `METABASE_API_KEY`. Ensure `METABASE_USERNAME` and `METABASE_PASSWORD` variables are unset. 2. Start the server. 3. Attempt to call a tool (e.g., `list_dashboards`). The tool call should fail, and the server logs might indicate an authentication error from Metabase (e.g., "Metabase API error: Invalid X-API-Key"). * **Invalid Username/Password:** 1. Ensure `METABASE_API_KEY` is unset. Set `METABASE_URL` and invalid `METABASE_USERNAME`/`METABASE_PASSWORD`. 2. Start the server. 3. Attempt to call a tool. The tool call should fail due to failed session authentication. The server logs might show "Authentication failed" or "Failed to authenticate with Metabase". * **Missing Credentials:** 1. Unset `METABASE_API_KEY`, `METABASE_USERNAME`, and `METABASE_PASSWORD`. Set only `METABASE_URL`. 2. Attempt to start the server. 3. The server should fail to start and log an error message stating that authentication credentials (either API key or username/password) are required (e.g., "Either (METABASE_URL and METABASE_API_KEY) or (METABASE_URL, METABASE_USERNAME, and METABASE_PASSWORD) environment variables are required"). ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile FROM node:lts-alpine # Create app directory WORKDIR /usr/src/app # Copy package files for dependency installation COPY package*.json ./ # Install dependencies RUN npm install --ignore-scripts # Copy the rest of the project files COPY . . # Build the project RUN npm run build # Expose any ports if needed (optional) # Set environment variables from Docker if desired (they can also be set externally) # ENV METABASE_URL=https://your-metabase-instance.com \ # METABASE_USERNAME=your_username \ # METABASE_PASSWORD=your_password # Use the node binary to run the built server CMD ["node", "build/index.js"] ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "metabase-server", "version": "0.1.0", "description": "A Model Context Protocol server", "private": true, "type": "module", "bin": { "metabase-server": "./build/index.js" }, "files": [ "build" ], "scripts": { "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\" && mkdir -p dist && cp build/index.js dist/index.js", "prepare": "npm run build", "watch": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^0.6.1", "abort-controller": "^3.0.0", "axios": "^1.8.2" }, "devDependencies": { "@types/axios": "^0.14.4", "@types/node": "^20.17.22", "typescript": "^5.3.3" } } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. type: object required: - metabaseUrl - metabaseUsername - metabasePassword properties: metabaseUrl: type: string description: URL of the Metabase instance (e.g. https://your-metabase-instance.com) metabaseUsername: type: string description: Username for authenticating with Metabase metabasePassword: type: string description: Password for authenticating with Metabase commandFunction: # A JS function that produces the CLI command based on the given config to start the MCP on stdio. |- (config) => ({ command: 'node', args: ['build/index.js'], env: { METABASE_URL: config.metabaseUrl, METABASE_USERNAME: config.metabaseUsername, METABASE_PASSWORD: config.metabasePassword } }) exampleConfig: metabaseUrl: https://example-metabase.com metabaseUsername: example_user metabasePassword: example_password ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node // 为老版本 Node.js 添加 AbortController polyfill import AbortController from 'abort-controller'; global.AbortController = global.AbortController || AbortController; /** * Metabase MCP 服务器 * 实现与 Metabase API 的交互,提供以下功能: * - 获取仪表板列表 * - 获取问题列表 * - 获取数据库列表 * - 执行问题查询 * - 获取仪表板详情 */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ListResourcesRequestSchema, ReadResourceRequestSchema, CallToolRequestSchema, ListResourcesResult, ReadResourceResult, ResourceSchema, ToolSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import axios, { AxiosInstance } from "axios"; // 自定义错误枚举 enum ErrorCode { InternalError = "internal_error", InvalidRequest = "invalid_request", InvalidParams = "invalid_params", MethodNotFound = "method_not_found" } // 自定义错误类 class McpError extends Error { code: ErrorCode; constructor(code: ErrorCode, message: string) { super(message); this.code = code; this.name = "McpError"; } } // 从环境变量获取 Metabase 配置 const METABASE_URL = process.env.METABASE_URL; const METABASE_USERNAME = process.env.METABASE_USERNAME; const METABASE_PASSWORD = process.env.METABASE_PASSWORD; const METABASE_API_KEY = process.env.METABASE_API_KEY; if (!METABASE_URL || (!METABASE_API_KEY && (!METABASE_USERNAME || !METABASE_PASSWORD))) { throw new Error( "Either (METABASE_URL and METABASE_API_KEY) or (METABASE_URL, METABASE_USERNAME, and METABASE_PASSWORD) environment variables are required" ); } // 创建自定义 Schema 对象,使用 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", }, }); if (METABASE_API_KEY) { this.logInfo('Using Metabase API Key for authentication.'); this.axiosInstance.defaults.headers.common['X-API-Key'] = METABASE_API_KEY; this.sessionToken = "api_key_used"; // Indicate API key is in use } else if (METABASE_USERNAME && METABASE_PASSWORD) { this.logInfo('Using Metabase username/password for authentication.'); // Existing session token logic will apply } else { // This case should ideally be caught by the initial environment variable check // but as a safeguard: this.logError('Metabase authentication credentials not configured properly.', {}); throw new Error("Metabase authentication credentials not provided or incomplete."); } 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 } } /** * 获取 Metabase 会话令牌 */ private async getSessionToken(): Promise<string> { if (this.sessionToken) { // Handles both API key ("api_key_used") and actual session tokens return this.sessionToken; } // This part should only be reached if using username/password and sessionToken is null this.logInfo('Authenticating with Metabase using username/password...'); try { const response = await this.axiosInstance.post('/api/session', { username: METABASE_USERNAME, password: METABASE_PASSWORD, }); this.sessionToken = response.data.id; // 设置默认请求头 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' ); } } /** * 设置资源处理程序 */ private setupResourceHandlers() { this.server.setRequestHandler(ListResourcesRequestSchema, async (request) => { this.logInfo('Listing resources...', { requestStructure: JSON.stringify(request) }); if (!METABASE_API_KEY) { await this.getSessionToken(); } try { // 获取仪表板列表 const dashboardsResponse = await this.axiosInstance.get('/api/dashboard'); this.logInfo('Successfully listed resources', { count: dashboardsResponse.data.length }); // 将仪表板作为资源返回 return { resources: dashboardsResponse.data.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 list resources', error); throw new McpError( ErrorCode.InternalError, 'Failed to list Metabase resources' ); } }); // 资源模板 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', }, ], }; }); // 读取资源 this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { this.logInfo('Reading resource...', { requestStructure: JSON.stringify(request) }); if (!METABASE_API_KEY) { await this.getSessionToken(); } const uri = request.params?.uri; let match; try { // 处理仪表板资源 if ((match = uri.match(/^metabase:\/\/dashboard\/(\d+)$/))) { const dashboardId = match[1]; const response = await this.axiosInstance.get(`/api/dashboard/${dashboardId}`); return { contents: [{ uri: request.params?.uri, mimeType: "application/json", text: JSON.stringify(response.data, null, 2) }] }; } // 处理问题/卡片资源 else if ((match = uri.match(/^metabase:\/\/card\/(\d+)$/))) { const cardId = match[1]; const response = await this.axiosInstance.get(`/api/card/${cardId}`); return { contents: [{ uri: request.params?.uri, mimeType: "application/json", text: JSON.stringify(response.data, null, 2) }] }; } // 处理数据库资源 else if ((match = uri.match(/^metabase:\/\/database\/(\d+)$/))) { const databaseId = match[1]; const response = await this.axiosInstance.get(`/api/database/${databaseId}`); return { contents: [{ uri: request.params?.uri, mimeType: "application/json", text: JSON.stringify(response.data, null, 2) }] }; } else { throw new McpError( ErrorCode.InvalidRequest, `Invalid URI format: ${uri}` ); } } catch (error) { if (axios.isAxiosError(error)) { throw new McpError( ErrorCode.InternalError, `Metabase API error: ${error.response?.data?.message || error.message}` ); } throw error; } }); } /** * 设置工具处理程序 */ private setupToolHandlers() { // No session token needed for listing tools, as it's static data this.server.setRequestHandler(ListToolsRequestSchema, async () => { 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: { f: { type: "string", description: "Optional filter function, possible values: archived, table, database, using_model, bookmarked, using_segment, all, mine" } } } }, { 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"] } }, { 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) }); if (!METABASE_API_KEY) { await this.getSessionToken(); } try { switch (request.params?.name) { case "list_dashboards": { const response = await this.axiosInstance.get('/api/dashboard'); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "list_cards": { const f = request.params?.arguments?.f || "all"; const response = await this.axiosInstance.get(`/api/card?f=${f}`); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "list_databases": { const response = await this.axiosInstance.get('/api/database'); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "execute_card": { const cardId = request.params?.arguments?.card_id; if (!cardId) { throw new McpError( ErrorCode.InvalidParams, "Card ID is required" ); } const parameters = request.params?.arguments?.parameters || {}; const response = await this.axiosInstance.post(`/api/card/${cardId}/query`, { parameters }); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "get_dashboard_cards": { const dashboardId = request.params?.arguments?.dashboard_id; if (!dashboardId) { throw new McpError( ErrorCode.InvalidParams, "Dashboard ID is required" ); } const response = await this.axiosInstance.get(`/api/dashboard/${dashboardId}`); return { content: [{ type: "text", text: JSON.stringify(response.data.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) { throw new McpError( ErrorCode.InvalidParams, "Database ID is required" ); } if (!query) { throw new McpError( ErrorCode.InvalidParams, "SQL query is required" ); } // 构建查询请求体 const queryData = { type: "native", native: { query: query, template_tags: {} }, parameters: nativeParameters, database: databaseId }; const response = await this.axiosInstance.post('/api/dataset', queryData); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "create_card": { const { name, dataset_query, display, visualization_settings, collection_id, description } = request.params?.arguments || {}; 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('/api/card', createCardBody); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "update_card": { const { card_id, ...updateFields } = request.params?.arguments || {}; 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(`/api/card/${card_id}`, updateFields); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "delete_card": { const { card_id, hard_delete = false } = request.params?.arguments || {}; 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 { // Soft delete (archive) const response = await this.axiosInstance.put(`/api/card/${card_id}`, { archived: true }); return { content: [{ type: "text", // Metabase might return the updated card object or just a success status. // If response.data is available and meaningful, include it. Otherwise, a generic success message. text: response.data ? `Card ${card_id} archived. Details: ${JSON.stringify(response.data, null, 2)}` : `Card ${card_id} archived.` }] }; } } case "create_dashboard": { const { name, description, parameters, collection_id } = request.params?.arguments || {}; if (!name) { throw new McpError( ErrorCode.InvalidParams, "Missing required field for create_dashboard: name" ); } 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('/api/dashboard', createDashboardBody); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "update_dashboard": { const { dashboard_id, ...updateFields } = request.params?.arguments || {}; 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(`/api/dashboard/${dashboard_id}`, updateFields); return { content: [{ type: "text", text: JSON.stringify(response.data, null, 2) }] }; } case "delete_dashboard": { const { dashboard_id, hard_delete = false } = request.params?.arguments || {}; 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 { // Soft delete (archive) const response = await this.axiosInstance.put(`/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); ```