# Directory Structure ``` ├── .gitignore ├── Dockerfile ├── package-lock.json ├── package.json ├── README.md ├── smithery.yaml ├── src │ └── index.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* 5 | /dist 6 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # metabase-server MCP Server 2 | 3 | [](https://smithery.ai/server/@imlewc/metabase-server) 4 | 5 | A Model Context Protocol server for Metabase integration. 6 | 7 | This is a TypeScript-based MCP server that implements integration with Metabase API. It allows AI assistants to interact with Metabase, providing access to: 8 | 9 | - Dashboards, questions/cards, and databases as resources 10 | - Tools for listing and executing Metabase queries 11 | - Ability to view and interact with Metabase data 12 | 13 | ## Features 14 | 15 | ### Resources 16 | - List and access Metabase resources via `metabase://` URIs 17 | - Access dashboards, cards/questions, and databases 18 | - JSON content type for structured data access 19 | 20 | ### Tools 21 | - `list_dashboards` - List all dashboards in Metabase 22 | - `list_cards` - List all questions/cards in Metabase 23 | - `list_databases` - List all databases in Metabase 24 | - `execute_card` - Execute a Metabase question/card and get results 25 | - `get_dashboard_cards` - Get all cards in a dashboard 26 | - `execute_query` - Execute a SQL query against a Metabase database 27 | 28 | ## Configuration 29 | 30 | Before running the server, you need to set environment variables for authentication. The server supports two methods: 31 | 32 | 1. **API Key (Preferred):** 33 | * `METABASE_URL`: The URL of your Metabase instance (e.g., `https://your-metabase-instance.com`). 34 | * `METABASE_API_KEY`: Your Metabase API key. 35 | 36 | 2. **Username/Password (Fallback):** 37 | * `METABASE_URL`: The URL of your Metabase instance. 38 | * `METABASE_USERNAME`: Your Metabase username. 39 | * `METABASE_PASSWORD`: Your Metabase password. 40 | 41 | 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. 42 | 43 | **Example setup:** 44 | 45 | Using API Key: 46 | ```bash 47 | # Required environment variables 48 | export METABASE_URL=https://your-metabase-instance.com 49 | export METABASE_API_KEY=your_metabase_api_key 50 | ``` 51 | 52 | Or, using Username/Password: 53 | ```bash 54 | # Required environment variables 55 | export METABASE_URL=https://your-metabase-instance.com 56 | export METABASE_USERNAME=your_username 57 | export METABASE_PASSWORD=your_password 58 | ``` 59 | You can set these environment variables in your shell profile or use a `.env` file with a package like `dotenv`. 60 | 61 | ## Development 62 | 63 | Install dependencies: 64 | ```bash 65 | npm install 66 | ``` 67 | 68 | Build the server: 69 | ```bash 70 | npm run build 71 | ``` 72 | 73 | For development with auto-rebuild: 74 | ```bash 75 | npm run watch 76 | ``` 77 | 78 | ## Installation 79 | ```bash 80 | # Oneliner, suitable for CI environment 81 | git clone https://github.com/imlewc/metabase-server.git && cd metabase-server && npm i && npm run build && npm link 82 | ``` 83 | 84 | To use with Claude Desktop, add the server config: 85 | 86 | On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 87 | On Windows: `%APPDATA%/Claude/claude_desktop_config.json` 88 | 89 | ```json 90 | { 91 | "mcpServers": { 92 | "metabase-server": { 93 | "command": "metabase-server", 94 | "env": { 95 | "METABASE_URL": "https://your-metabase-instance.com", 96 | // Use API Key (preferred) 97 | "METABASE_API_KEY": "your_metabase_api_key" 98 | // Or Username/Password (if API Key is not set) 99 | // "METABASE_USERNAME": "your_username", 100 | // "METABASE_PASSWORD": "your_password" 101 | } 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | Note: You can also set these environment variables in your system instead of in the config file if you prefer. 108 | 109 | ### Installing via Smithery 110 | 111 | To install metabase-server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@imlewc/metabase-server): 112 | 113 | ```bash 114 | npx -y @smithery/cli install @imlewc/metabase-server --client claude 115 | ``` 116 | 117 | ### Debugging 118 | 119 | 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: 120 | 121 | ```bash 122 | npm run inspector 123 | ``` 124 | 125 | The Inspector will provide a URL to access debugging tools in your browser. 126 | 127 | ## Testing 128 | 129 | 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. 130 | 131 | ### 1. Testing with API Key Authentication 132 | 133 | 1. Set the `METABASE_URL` and `METABASE_API_KEY` environment variables with your Metabase instance URL and a valid API key. 134 | 2. Ensure `METABASE_USERNAME` and `METABASE_PASSWORD` are unset or leave them, as the API key should take precedence. 135 | 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). 136 | 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."). 137 | 5. Using an MCP client or the MCP Inspector, try calling a tool, for example, `tools/call` with `{"name": "list_dashboards"}`. 138 | 6. Verify that the tool call is successful and you receive the expected data. 139 | 140 | ### 2. Testing with Username/Password Authentication (Fallback) 141 | 142 | 1. Ensure the `METABASE_API_KEY` environment variable is unset. 143 | 2. Set `METABASE_URL`, `METABASE_USERNAME`, and `METABASE_PASSWORD` with valid credentials for your Metabase instance. 144 | 3. Start the server. 145 | 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..."). 146 | 5. Using an MCP client or the MCP Inspector, try calling the `list_dashboards` tool. 147 | 6. Verify that the tool call is successful. 148 | 149 | ### 3. Testing Authentication Failures 150 | 151 | * **Invalid API Key:** 152 | 1. Set `METABASE_URL` and an invalid `METABASE_API_KEY`. Ensure `METABASE_USERNAME` and `METABASE_PASSWORD` variables are unset. 153 | 2. Start the server. 154 | 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"). 155 | * **Invalid Username/Password:** 156 | 1. Ensure `METABASE_API_KEY` is unset. Set `METABASE_URL` and invalid `METABASE_USERNAME`/`METABASE_PASSWORD`. 157 | 2. Start the server. 158 | 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". 159 | * **Missing Credentials:** 160 | 1. Unset `METABASE_API_KEY`, `METABASE_USERNAME`, and `METABASE_PASSWORD`. Set only `METABASE_URL`. 161 | 2. Attempt to start the server. 162 | 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"). 163 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM node:lts-alpine 3 | 4 | # Create app directory 5 | WORKDIR /usr/src/app 6 | 7 | # Copy package files for dependency installation 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install --ignore-scripts 12 | 13 | # Copy the rest of the project files 14 | COPY . . 15 | 16 | # Build the project 17 | RUN npm run build 18 | 19 | # Expose any ports if needed (optional) 20 | 21 | # Set environment variables from Docker if desired (they can also be set externally) 22 | # ENV METABASE_URL=https://your-metabase-instance.com \ 23 | # METABASE_USERNAME=your_username \ 24 | # METABASE_PASSWORD=your_password 25 | 26 | # Use the node binary to run the built server 27 | CMD ["node", "build/index.js"] 28 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "metabase-server", 3 | "version": "0.1.0", 4 | "description": "A Model Context Protocol server", 5 | "private": true, 6 | "type": "module", 7 | "bin": { 8 | "metabase-server": "./build/index.js" 9 | }, 10 | "files": [ 11 | "build" 12 | ], 13 | "scripts": { 14 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\" && mkdir -p dist && cp build/index.js dist/index.js", 15 | "prepare": "npm run build", 16 | "watch": "tsc --watch", 17 | "inspector": "npx @modelcontextprotocol/inspector build/index.js" 18 | }, 19 | "dependencies": { 20 | "@modelcontextprotocol/sdk": "^0.6.1", 21 | "abort-controller": "^3.0.0", 22 | "axios": "^1.8.2" 23 | }, 24 | "devDependencies": { 25 | "@types/axios": "^0.14.4", 26 | "@types/node": "^20.17.22", 27 | "typescript": "^5.3.3" 28 | } 29 | } 30 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - metabaseUrl 10 | - metabaseUsername 11 | - metabasePassword 12 | properties: 13 | metabaseUrl: 14 | type: string 15 | description: URL of the Metabase instance (e.g. https://your-metabase-instance.com) 16 | metabaseUsername: 17 | type: string 18 | description: Username for authenticating with Metabase 19 | metabasePassword: 20 | type: string 21 | description: Password for authenticating with Metabase 22 | commandFunction: 23 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 24 | |- 25 | (config) => ({ 26 | command: 'node', 27 | args: ['build/index.js'], 28 | env: { 29 | METABASE_URL: config.metabaseUrl, 30 | METABASE_USERNAME: config.metabaseUsername, 31 | METABASE_PASSWORD: config.metabasePassword 32 | } 33 | }) 34 | exampleConfig: 35 | metabaseUrl: https://example-metabase.com 36 | metabaseUsername: example_user 37 | metabasePassword: example_password 38 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | // 为老版本 Node.js 添加 AbortController polyfill 4 | import AbortController from 'abort-controller'; 5 | global.AbortController = global.AbortController || AbortController; 6 | 7 | /** 8 | * Metabase MCP 服务器 9 | * 实现与 Metabase API 的交互,提供以下功能: 10 | * - 获取仪表板列表 11 | * - 获取问题列表 12 | * - 获取数据库列表 13 | * - 执行问题查询 14 | * - 获取仪表板详情 15 | */ 16 | 17 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 18 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 19 | import { 20 | ListResourcesRequestSchema, 21 | ReadResourceRequestSchema, 22 | CallToolRequestSchema, 23 | ListResourcesResult, 24 | ReadResourceResult, 25 | ResourceSchema, 26 | ToolSchema 27 | } from "@modelcontextprotocol/sdk/types.js"; 28 | import { z } from "zod"; 29 | import axios, { AxiosInstance } from "axios"; 30 | 31 | // 自定义错误枚举 32 | enum ErrorCode { 33 | InternalError = "internal_error", 34 | InvalidRequest = "invalid_request", 35 | InvalidParams = "invalid_params", 36 | MethodNotFound = "method_not_found" 37 | } 38 | 39 | // 自定义错误类 40 | class McpError extends Error { 41 | code: ErrorCode; 42 | 43 | constructor(code: ErrorCode, message: string) { 44 | super(message); 45 | this.code = code; 46 | this.name = "McpError"; 47 | } 48 | } 49 | 50 | // 从环境变量获取 Metabase 配置 51 | const METABASE_URL = process.env.METABASE_URL; 52 | const METABASE_USERNAME = process.env.METABASE_USERNAME; 53 | const METABASE_PASSWORD = process.env.METABASE_PASSWORD; 54 | const METABASE_API_KEY = process.env.METABASE_API_KEY; 55 | 56 | if (!METABASE_URL || (!METABASE_API_KEY && (!METABASE_USERNAME || !METABASE_PASSWORD))) { 57 | throw new Error( 58 | "Either (METABASE_URL and METABASE_API_KEY) or (METABASE_URL, METABASE_USERNAME, and METABASE_PASSWORD) environment variables are required" 59 | ); 60 | } 61 | 62 | // 创建自定义 Schema 对象,使用 z.object 63 | const ListResourceTemplatesRequestSchema = z.object({ 64 | method: z.literal("resources/list_templates") 65 | }); 66 | 67 | const ListToolsRequestSchema = z.object({ 68 | method: z.literal("tools/list") 69 | }); 70 | 71 | class MetabaseServer { 72 | private server: Server; 73 | private axiosInstance: AxiosInstance; 74 | private sessionToken: string | null = null; 75 | 76 | constructor() { 77 | this.server = new Server( 78 | { 79 | name: "metabase-server", 80 | version: "0.1.0", 81 | }, 82 | { 83 | capabilities: { 84 | resources: {}, 85 | tools: {}, 86 | }, 87 | } 88 | ); 89 | 90 | this.axiosInstance = axios.create({ 91 | baseURL: METABASE_URL, 92 | headers: { 93 | "Content-Type": "application/json", 94 | }, 95 | }); 96 | 97 | if (METABASE_API_KEY) { 98 | this.logInfo('Using Metabase API Key for authentication.'); 99 | this.axiosInstance.defaults.headers.common['X-API-Key'] = METABASE_API_KEY; 100 | this.sessionToken = "api_key_used"; // Indicate API key is in use 101 | } else if (METABASE_USERNAME && METABASE_PASSWORD) { 102 | this.logInfo('Using Metabase username/password for authentication.'); 103 | // Existing session token logic will apply 104 | } else { 105 | // This case should ideally be caught by the initial environment variable check 106 | // but as a safeguard: 107 | this.logError('Metabase authentication credentials not configured properly.', {}); 108 | throw new Error("Metabase authentication credentials not provided or incomplete."); 109 | } 110 | 111 | this.setupResourceHandlers(); 112 | this.setupToolHandlers(); 113 | 114 | // Enhanced error handling with logging 115 | this.server.onerror = (error: Error) => { 116 | this.logError('Server Error', error); 117 | }; 118 | 119 | process.on('SIGINT', async () => { 120 | this.logInfo('Shutting down server...'); 121 | await this.server.close(); 122 | process.exit(0); 123 | }); 124 | } 125 | 126 | // Add logging utilities 127 | private logInfo(message: string, data?: unknown) { 128 | const logMessage = { 129 | timestamp: new Date().toISOString(), 130 | level: 'info', 131 | message, 132 | data 133 | }; 134 | console.error(JSON.stringify(logMessage)); 135 | // MCP SDK changed, can't directly access session 136 | try { 137 | // Use current session if available 138 | console.error(`INFO: ${message}`); 139 | } catch (e) { 140 | // Ignore if session not available 141 | } 142 | } 143 | 144 | private logError(message: string, error: unknown) { 145 | const errorObj = error as Error; 146 | const apiError = error as { response?: { data?: { message?: string } }, message?: string }; 147 | 148 | const logMessage = { 149 | timestamp: new Date().toISOString(), 150 | level: 'error', 151 | message, 152 | error: errorObj.message || 'Unknown error', 153 | stack: errorObj.stack 154 | }; 155 | console.error(JSON.stringify(logMessage)); 156 | // MCP SDK changed, can't directly access session 157 | try { 158 | console.error(`ERROR: ${message} - ${errorObj.message || 'Unknown error'}`); 159 | } catch (e) { 160 | // Ignore if session not available 161 | } 162 | } 163 | 164 | /** 165 | * 获取 Metabase 会话令牌 166 | */ 167 | private async getSessionToken(): Promise<string> { 168 | if (this.sessionToken) { // Handles both API key ("api_key_used") and actual session tokens 169 | return this.sessionToken; 170 | } 171 | 172 | // This part should only be reached if using username/password and sessionToken is null 173 | this.logInfo('Authenticating with Metabase using username/password...'); 174 | try { 175 | const response = await this.axiosInstance.post('/api/session', { 176 | username: METABASE_USERNAME, 177 | password: METABASE_PASSWORD, 178 | }); 179 | 180 | this.sessionToken = response.data.id; 181 | 182 | // 设置默认请求头 183 | this.axiosInstance.defaults.headers.common['X-Metabase-Session'] = this.sessionToken; 184 | 185 | this.logInfo('Successfully authenticated with Metabase'); 186 | return this.sessionToken as string; 187 | } catch (error) { 188 | this.logError('Authentication failed', error); 189 | throw new McpError( 190 | ErrorCode.InternalError, 191 | 'Failed to authenticate with Metabase' 192 | ); 193 | } 194 | } 195 | 196 | /** 197 | * 设置资源处理程序 198 | */ 199 | private setupResourceHandlers() { 200 | this.server.setRequestHandler(ListResourcesRequestSchema, async (request) => { 201 | this.logInfo('Listing resources...', { requestStructure: JSON.stringify(request) }); 202 | if (!METABASE_API_KEY) { 203 | await this.getSessionToken(); 204 | } 205 | 206 | try { 207 | // 获取仪表板列表 208 | const dashboardsResponse = await this.axiosInstance.get('/api/dashboard'); 209 | 210 | this.logInfo('Successfully listed resources', { count: dashboardsResponse.data.length }); 211 | // 将仪表板作为资源返回 212 | return { 213 | resources: dashboardsResponse.data.map((dashboard: any) => ({ 214 | uri: `metabase://dashboard/${dashboard.id}`, 215 | mimeType: "application/json", 216 | name: dashboard.name, 217 | description: `Metabase dashboard: ${dashboard.name}` 218 | })) 219 | }; 220 | } catch (error) { 221 | this.logError('Failed to list resources', error); 222 | throw new McpError( 223 | ErrorCode.InternalError, 224 | 'Failed to list Metabase resources' 225 | ); 226 | } 227 | }); 228 | 229 | // 资源模板 230 | this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { 231 | return { 232 | resourceTemplates: [ 233 | { 234 | uriTemplate: 'metabase://dashboard/{id}', 235 | name: 'Dashboard by ID', 236 | mimeType: 'application/json', 237 | description: 'Get a Metabase dashboard by its ID', 238 | }, 239 | { 240 | uriTemplate: 'metabase://card/{id}', 241 | name: 'Card by ID', 242 | mimeType: 'application/json', 243 | description: 'Get a Metabase question/card by its ID', 244 | }, 245 | { 246 | uriTemplate: 'metabase://database/{id}', 247 | name: 'Database by ID', 248 | mimeType: 'application/json', 249 | description: 'Get a Metabase database by its ID', 250 | }, 251 | ], 252 | }; 253 | }); 254 | 255 | // 读取资源 256 | this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 257 | this.logInfo('Reading resource...', { requestStructure: JSON.stringify(request) }); 258 | if (!METABASE_API_KEY) { 259 | await this.getSessionToken(); 260 | } 261 | 262 | const uri = request.params?.uri; 263 | let match; 264 | 265 | try { 266 | // 处理仪表板资源 267 | if ((match = uri.match(/^metabase:\/\/dashboard\/(\d+)$/))) { 268 | const dashboardId = match[1]; 269 | const response = await this.axiosInstance.get(`/api/dashboard/${dashboardId}`); 270 | 271 | return { 272 | contents: [{ 273 | uri: request.params?.uri, 274 | mimeType: "application/json", 275 | text: JSON.stringify(response.data, null, 2) 276 | }] 277 | }; 278 | } 279 | 280 | // 处理问题/卡片资源 281 | else if ((match = uri.match(/^metabase:\/\/card\/(\d+)$/))) { 282 | const cardId = match[1]; 283 | const response = await this.axiosInstance.get(`/api/card/${cardId}`); 284 | 285 | return { 286 | contents: [{ 287 | uri: request.params?.uri, 288 | mimeType: "application/json", 289 | text: JSON.stringify(response.data, null, 2) 290 | }] 291 | }; 292 | } 293 | 294 | // 处理数据库资源 295 | else if ((match = uri.match(/^metabase:\/\/database\/(\d+)$/))) { 296 | const databaseId = match[1]; 297 | const response = await this.axiosInstance.get(`/api/database/${databaseId}`); 298 | 299 | return { 300 | contents: [{ 301 | uri: request.params?.uri, 302 | mimeType: "application/json", 303 | text: JSON.stringify(response.data, null, 2) 304 | }] 305 | }; 306 | } 307 | 308 | else { 309 | throw new McpError( 310 | ErrorCode.InvalidRequest, 311 | `Invalid URI format: ${uri}` 312 | ); 313 | } 314 | } catch (error) { 315 | if (axios.isAxiosError(error)) { 316 | throw new McpError( 317 | ErrorCode.InternalError, 318 | `Metabase API error: ${error.response?.data?.message || error.message}` 319 | ); 320 | } 321 | throw error; 322 | } 323 | }); 324 | } 325 | 326 | /** 327 | * 设置工具处理程序 328 | */ 329 | private setupToolHandlers() { 330 | // No session token needed for listing tools, as it's static data 331 | this.server.setRequestHandler(ListToolsRequestSchema, async () => { 332 | return { 333 | tools: [ 334 | { 335 | name: "list_dashboards", 336 | description: "List all dashboards in Metabase", 337 | inputSchema: { 338 | type: "object", 339 | properties: {} 340 | } 341 | }, 342 | { 343 | name: "list_cards", 344 | description: "List all questions/cards in Metabase", 345 | inputSchema: { 346 | type: "object", 347 | properties: { 348 | f: { 349 | type: "string", 350 | description: "Optional filter function, possible values: archived, table, database, using_model, bookmarked, using_segment, all, mine" 351 | } 352 | } 353 | } 354 | }, 355 | { 356 | name: "list_databases", 357 | description: "List all databases in Metabase", 358 | inputSchema: { 359 | type: "object", 360 | properties: {} 361 | } 362 | }, 363 | { 364 | name: "execute_card", 365 | description: "Execute a Metabase question/card and get results", 366 | inputSchema: { 367 | type: "object", 368 | properties: { 369 | card_id: { 370 | type: "number", 371 | description: "ID of the card/question to execute" 372 | }, 373 | parameters: { 374 | type: "object", 375 | description: "Optional parameters for the query" 376 | } 377 | }, 378 | required: ["card_id"] 379 | } 380 | }, 381 | { 382 | name: "get_dashboard_cards", 383 | description: "Get all cards in a dashboard", 384 | inputSchema: { 385 | type: "object", 386 | properties: { 387 | dashboard_id: { 388 | type: "number", 389 | description: "ID of the dashboard" 390 | } 391 | }, 392 | required: ["dashboard_id"] 393 | } 394 | }, 395 | { 396 | name: "execute_query", 397 | description: "Execute a SQL query against a Metabase database", 398 | inputSchema: { 399 | type: "object", 400 | properties: { 401 | database_id: { 402 | type: "number", 403 | description: "ID of the database to query" 404 | }, 405 | query: { 406 | type: "string", 407 | description: "SQL query to execute" 408 | }, 409 | native_parameters: { 410 | type: "array", 411 | description: "Optional parameters for the query", 412 | items: { 413 | type: "object" 414 | } 415 | } 416 | }, 417 | required: ["database_id", "query"] 418 | } 419 | }, 420 | { 421 | name: "create_card", 422 | description: "Create a new Metabase question (card).", 423 | inputSchema: { 424 | type: "object", 425 | properties: { 426 | name: { type: "string", description: "Name of the card" }, 427 | dataset_query: { type: "object", description: "The query for the card (e.g., MBQL or native query)" }, 428 | display: { type: "string", description: "Display type (e.g., 'table', 'line', 'bar')" }, 429 | visualization_settings: { type: "object", description: "Settings for the visualization" }, 430 | collection_id: { type: "number", description: "Optional ID of the collection to save the card in" }, 431 | description: { type: "string", description: "Optional description for the card" } 432 | }, 433 | required: ["name", "dataset_query", "display", "visualization_settings"] 434 | } 435 | }, 436 | { 437 | name: "update_card", 438 | description: "Update an existing Metabase question (card).", 439 | inputSchema: { 440 | type: "object", 441 | properties: { 442 | card_id: { type: "number", description: "ID of the card to update" }, 443 | name: { type: "string", description: "New name for the card" }, 444 | dataset_query: { type: "object", description: "New query for the card" }, 445 | display: { type: "string", description: "New display type" }, 446 | visualization_settings: { type: "object", description: "New visualization settings" }, 447 | collection_id: { type: "number", description: "New collection ID" }, 448 | description: { type: "string", description: "New description" }, 449 | archived: { type: "boolean", description: "Set to true to archive the card" } 450 | }, 451 | required: ["card_id"] 452 | } 453 | }, 454 | { 455 | name: "delete_card", 456 | description: "Delete a Metabase question (card).", 457 | inputSchema: { 458 | type: "object", 459 | properties: { 460 | card_id: { type: "number", description: "ID of the card to delete" }, 461 | hard_delete: { type: "boolean", description: "Set to true for hard delete, false (default) for archive", default: false } 462 | }, 463 | required: ["card_id"] 464 | } 465 | }, 466 | { 467 | name: "create_dashboard", 468 | description: "Create a new Metabase dashboard.", 469 | inputSchema: { 470 | type: "object", 471 | properties: { 472 | name: { type: "string", description: "Name of the dashboard" }, 473 | description: { type: "string", description: "Optional description for the dashboard" }, 474 | parameters: { type: "array", description: "Optional parameters for the dashboard", items: { type: "object" } }, 475 | collection_id: { type: "number", description: "Optional ID of the collection to save the dashboard in" } 476 | }, 477 | required: ["name"] 478 | } 479 | }, 480 | { 481 | name: "update_dashboard", 482 | description: "Update an existing Metabase dashboard.", 483 | inputSchema: { 484 | type: "object", 485 | properties: { 486 | dashboard_id: { type: "number", description: "ID of the dashboard to update" }, 487 | name: { type: "string", description: "New name for the dashboard" }, 488 | description: { type: "string", description: "New description for the dashboard" }, 489 | parameters: { type: "array", description: "New parameters for the dashboard", items: { type: "object" } }, 490 | collection_id: { type: "number", description: "New collection ID" }, 491 | archived: { type: "boolean", description: "Set to true to archive the dashboard" } 492 | }, 493 | required: ["dashboard_id"] 494 | } 495 | }, 496 | { 497 | name: "delete_dashboard", 498 | description: "Delete a Metabase dashboard.", 499 | inputSchema: { 500 | type: "object", 501 | properties: { 502 | dashboard_id: { type: "number", description: "ID of the dashboard to delete" }, 503 | hard_delete: { type: "boolean", description: "Set to true for hard delete, false (default) for archive", default: false } 504 | }, 505 | required: ["dashboard_id"] 506 | } 507 | } 508 | ] 509 | }; 510 | }); 511 | 512 | this.server.setRequestHandler(CallToolRequestSchema, async (request) => { 513 | this.logInfo('Calling tool...', { requestStructure: JSON.stringify(request) }); 514 | if (!METABASE_API_KEY) { 515 | await this.getSessionToken(); 516 | } 517 | 518 | try { 519 | switch (request.params?.name) { 520 | case "list_dashboards": { 521 | const response = await this.axiosInstance.get('/api/dashboard'); 522 | return { 523 | content: [{ 524 | type: "text", 525 | text: JSON.stringify(response.data, null, 2) 526 | }] 527 | }; 528 | } 529 | 530 | case "list_cards": { 531 | const f = request.params?.arguments?.f || "all"; 532 | const response = await this.axiosInstance.get(`/api/card?f=${f}`); 533 | return { 534 | content: [{ 535 | type: "text", 536 | text: JSON.stringify(response.data, null, 2) 537 | }] 538 | }; 539 | } 540 | 541 | case "list_databases": { 542 | const response = await this.axiosInstance.get('/api/database'); 543 | return { 544 | content: [{ 545 | type: "text", 546 | text: JSON.stringify(response.data, null, 2) 547 | }] 548 | }; 549 | } 550 | 551 | case "execute_card": { 552 | const cardId = request.params?.arguments?.card_id; 553 | if (!cardId) { 554 | throw new McpError( 555 | ErrorCode.InvalidParams, 556 | "Card ID is required" 557 | ); 558 | } 559 | 560 | const parameters = request.params?.arguments?.parameters || {}; 561 | const response = await this.axiosInstance.post(`/api/card/${cardId}/query`, { parameters }); 562 | 563 | return { 564 | content: [{ 565 | type: "text", 566 | text: JSON.stringify(response.data, null, 2) 567 | }] 568 | }; 569 | } 570 | 571 | case "get_dashboard_cards": { 572 | const dashboardId = request.params?.arguments?.dashboard_id; 573 | if (!dashboardId) { 574 | throw new McpError( 575 | ErrorCode.InvalidParams, 576 | "Dashboard ID is required" 577 | ); 578 | } 579 | 580 | const response = await this.axiosInstance.get(`/api/dashboard/${dashboardId}`); 581 | 582 | return { 583 | content: [{ 584 | type: "text", 585 | text: JSON.stringify(response.data.cards, null, 2) 586 | }] 587 | }; 588 | } 589 | 590 | case "execute_query": { 591 | const databaseId = request.params?.arguments?.database_id; 592 | const query = request.params?.arguments?.query; 593 | const nativeParameters = request.params?.arguments?.native_parameters || []; 594 | 595 | if (!databaseId) { 596 | throw new McpError( 597 | ErrorCode.InvalidParams, 598 | "Database ID is required" 599 | ); 600 | } 601 | 602 | if (!query) { 603 | throw new McpError( 604 | ErrorCode.InvalidParams, 605 | "SQL query is required" 606 | ); 607 | } 608 | 609 | // 构建查询请求体 610 | const queryData = { 611 | type: "native", 612 | native: { 613 | query: query, 614 | template_tags: {} 615 | }, 616 | parameters: nativeParameters, 617 | database: databaseId 618 | }; 619 | 620 | const response = await this.axiosInstance.post('/api/dataset', queryData); 621 | 622 | return { 623 | content: [{ 624 | type: "text", 625 | text: JSON.stringify(response.data, null, 2) 626 | }] 627 | }; 628 | } 629 | 630 | case "create_card": { 631 | const { name, dataset_query, display, visualization_settings, collection_id, description } = request.params?.arguments || {}; 632 | if (!name || !dataset_query || !display || !visualization_settings) { 633 | throw new McpError( 634 | ErrorCode.InvalidParams, 635 | "Missing required fields for create_card: name, dataset_query, display, visualization_settings" 636 | ); 637 | } 638 | const createCardBody: any = { 639 | name, 640 | dataset_query, 641 | display, 642 | visualization_settings, 643 | }; 644 | if (collection_id !== undefined) createCardBody.collection_id = collection_id; 645 | if (description !== undefined) createCardBody.description = description; 646 | 647 | const response = await this.axiosInstance.post('/api/card', createCardBody); 648 | return { 649 | content: [{ 650 | type: "text", 651 | text: JSON.stringify(response.data, null, 2) 652 | }] 653 | }; 654 | } 655 | 656 | case "update_card": { 657 | const { card_id, ...updateFields } = request.params?.arguments || {}; 658 | if (!card_id) { 659 | throw new McpError( 660 | ErrorCode.InvalidParams, 661 | "Card ID is required for update_card" 662 | ); 663 | } 664 | if (Object.keys(updateFields).length === 0) { 665 | throw new McpError( 666 | ErrorCode.InvalidParams, 667 | "No fields provided for update_card" 668 | ); 669 | } 670 | const response = await this.axiosInstance.put(`/api/card/${card_id}`, updateFields); 671 | return { 672 | content: [{ 673 | type: "text", 674 | text: JSON.stringify(response.data, null, 2) 675 | }] 676 | }; 677 | } 678 | 679 | case "delete_card": { 680 | const { card_id, hard_delete = false } = request.params?.arguments || {}; 681 | if (!card_id) { 682 | throw new McpError( 683 | ErrorCode.InvalidParams, 684 | "Card ID is required for delete_card" 685 | ); 686 | } 687 | 688 | if (hard_delete) { 689 | await this.axiosInstance.delete(`/api/card/${card_id}`); 690 | return { 691 | content: [{ 692 | type: "text", 693 | text: `Card ${card_id} permanently deleted.` 694 | }] 695 | }; 696 | } else { 697 | // Soft delete (archive) 698 | const response = await this.axiosInstance.put(`/api/card/${card_id}`, { archived: true }); 699 | return { 700 | content: [{ 701 | type: "text", 702 | // Metabase might return the updated card object or just a success status. 703 | // If response.data is available and meaningful, include it. Otherwise, a generic success message. 704 | text: response.data ? `Card ${card_id} archived. Details: ${JSON.stringify(response.data, null, 2)}` : `Card ${card_id} archived.` 705 | }] 706 | }; 707 | } 708 | } 709 | 710 | case "create_dashboard": { 711 | const { name, description, parameters, collection_id } = request.params?.arguments || {}; 712 | if (!name) { 713 | throw new McpError( 714 | ErrorCode.InvalidParams, 715 | "Missing required field for create_dashboard: name" 716 | ); 717 | } 718 | const createDashboardBody: any = { name }; 719 | if (description !== undefined) createDashboardBody.description = description; 720 | if (parameters !== undefined) createDashboardBody.parameters = parameters; 721 | if (collection_id !== undefined) createDashboardBody.collection_id = collection_id; 722 | 723 | const response = await this.axiosInstance.post('/api/dashboard', createDashboardBody); 724 | return { 725 | content: [{ 726 | type: "text", 727 | text: JSON.stringify(response.data, null, 2) 728 | }] 729 | }; 730 | } 731 | 732 | case "update_dashboard": { 733 | const { dashboard_id, ...updateFields } = request.params?.arguments || {}; 734 | if (!dashboard_id) { 735 | throw new McpError( 736 | ErrorCode.InvalidParams, 737 | "Dashboard ID is required for update_dashboard" 738 | ); 739 | } 740 | if (Object.keys(updateFields).length === 0) { 741 | throw new McpError( 742 | ErrorCode.InvalidParams, 743 | "No fields provided for update_dashboard" 744 | ); 745 | } 746 | const response = await this.axiosInstance.put(`/api/dashboard/${dashboard_id}`, updateFields); 747 | return { 748 | content: [{ 749 | type: "text", 750 | text: JSON.stringify(response.data, null, 2) 751 | }] 752 | }; 753 | } 754 | 755 | case "delete_dashboard": { 756 | const { dashboard_id, hard_delete = false } = request.params?.arguments || {}; 757 | if (!dashboard_id) { 758 | throw new McpError( 759 | ErrorCode.InvalidParams, 760 | "Dashboard ID is required for delete_dashboard" 761 | ); 762 | } 763 | 764 | if (hard_delete) { 765 | await this.axiosInstance.delete(`/api/dashboard/${dashboard_id}`); 766 | return { 767 | content: [{ 768 | type: "text", 769 | text: `Dashboard ${dashboard_id} permanently deleted.` 770 | }] 771 | }; 772 | } else { 773 | // Soft delete (archive) 774 | const response = await this.axiosInstance.put(`/api/dashboard/${dashboard_id}`, { archived: true }); 775 | return { 776 | content: [{ 777 | type: "text", 778 | text: response.data ? `Dashboard ${dashboard_id} archived. Details: ${JSON.stringify(response.data, null, 2)}` : `Dashboard ${dashboard_id} archived.` 779 | }] 780 | }; 781 | } 782 | } 783 | 784 | default: 785 | return { 786 | content: [ 787 | { 788 | type: "text", 789 | text: `Unknown tool: ${request.params?.name}` 790 | } 791 | ], 792 | isError: true 793 | }; 794 | } 795 | } catch (error) { 796 | if (axios.isAxiosError(error)) { 797 | return { 798 | content: [{ 799 | type: "text", 800 | text: `Metabase API error: ${error.response?.data?.message || error.message}` 801 | }], 802 | isError: true 803 | }; 804 | } 805 | throw error; 806 | } 807 | }); 808 | } 809 | 810 | async run() { 811 | try { 812 | this.logInfo('Starting Metabase MCP server...'); 813 | const transport = new StdioServerTransport(); 814 | await this.server.connect(transport); 815 | this.logInfo('Metabase MCP server running on stdio'); 816 | } catch (error) { 817 | this.logError('Failed to start server', error); 818 | throw error; 819 | } 820 | } 821 | } 822 | 823 | // Add global error handlers 824 | process.on('uncaughtException', (error: Error) => { 825 | console.error(JSON.stringify({ 826 | timestamp: new Date().toISOString(), 827 | level: 'fatal', 828 | message: 'Uncaught Exception', 829 | error: error.message, 830 | stack: error.stack 831 | })); 832 | process.exit(1); 833 | }); 834 | 835 | process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => { 836 | const errorMessage = reason instanceof Error ? reason.message : String(reason); 837 | console.error(JSON.stringify({ 838 | timestamp: new Date().toISOString(), 839 | level: 'fatal', 840 | message: 'Unhandled Rejection', 841 | error: errorMessage 842 | })); 843 | }); 844 | 845 | const server = new MetabaseServer(); 846 | server.run().catch(console.error); 847 | ```