#
tokens: 11149/50000 7/7 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | [![smithery badge](https://smithery.ai/badge/@imlewc/metabase-server)](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 | 
```