blixify-charts-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +162 -0
  2. package/build/index.js +732 -0
  3. package/package.json +29 -0
package/README.md ADDED
@@ -0,0 +1,162 @@
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").
package/build/index.js ADDED
@@ -0,0 +1,732 @@
1
+ #!/usr/bin/env node
2
+ // 为老版本 Node.js 添加 AbortController polyfill
3
+ import AbortController from 'abort-controller';
4
+ global.AbortController = global.AbortController || AbortController;
5
+ /**
6
+ * Metabase MCP 服务器
7
+ * 实现与 Metabase API 的交互,提供以下功能:
8
+ * - 获取仪表板列表
9
+ * - 获取问题列表
10
+ * - 获取数据库列表
11
+ * - 执行问题查询
12
+ * - 获取仪表板详情
13
+ */
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { ListResourcesRequestSchema, ReadResourceRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
17
+ import { z } from "zod";
18
+ import axios from "axios";
19
+ // 自定义错误枚举
20
+ var ErrorCode;
21
+ (function (ErrorCode) {
22
+ ErrorCode["InternalError"] = "internal_error";
23
+ ErrorCode["InvalidRequest"] = "invalid_request";
24
+ ErrorCode["InvalidParams"] = "invalid_params";
25
+ ErrorCode["MethodNotFound"] = "method_not_found";
26
+ })(ErrorCode || (ErrorCode = {}));
27
+ // 自定义错误类
28
+ class McpError extends Error {
29
+ code;
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.code = code;
33
+ this.name = "McpError";
34
+ }
35
+ }
36
+ // 从环境变量获取 Metabase 配置
37
+ const METABASE_URL = process.env.METABASE_URL;
38
+ const METABASE_USERNAME = process.env.METABASE_USERNAME;
39
+ const METABASE_PASSWORD = process.env.METABASE_PASSWORD;
40
+ const METABASE_API_KEY = process.env.METABASE_API_KEY;
41
+ if (!METABASE_URL || (!METABASE_API_KEY && (!METABASE_USERNAME || !METABASE_PASSWORD))) {
42
+ throw new Error("Either (METABASE_URL and METABASE_API_KEY) or (METABASE_URL, METABASE_USERNAME, and METABASE_PASSWORD) environment variables are required");
43
+ }
44
+ // 创建自定义 Schema 对象,使用 z.object
45
+ const ListResourceTemplatesRequestSchema = z.object({
46
+ method: z.literal("resources/list_templates")
47
+ });
48
+ const ListToolsRequestSchema = z.object({
49
+ method: z.literal("tools/list")
50
+ });
51
+ class MetabaseServer {
52
+ server;
53
+ axiosInstance;
54
+ sessionToken = null;
55
+ constructor() {
56
+ this.server = new Server({
57
+ name: "metabase-server",
58
+ version: "0.1.0",
59
+ }, {
60
+ capabilities: {
61
+ resources: {},
62
+ tools: {},
63
+ },
64
+ });
65
+ this.axiosInstance = axios.create({
66
+ baseURL: METABASE_URL,
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ },
70
+ });
71
+ if (METABASE_API_KEY) {
72
+ this.logInfo('Using Metabase API Key for authentication.');
73
+ this.axiosInstance.defaults.headers.common['X-API-Key'] = METABASE_API_KEY;
74
+ this.sessionToken = "api_key_used"; // Indicate API key is in use
75
+ }
76
+ else if (METABASE_USERNAME && METABASE_PASSWORD) {
77
+ this.logInfo('Using Metabase username/password for authentication.');
78
+ // Existing session token logic will apply
79
+ }
80
+ else {
81
+ // This case should ideally be caught by the initial environment variable check
82
+ // but as a safeguard:
83
+ this.logError('Metabase authentication credentials not configured properly.', {});
84
+ throw new Error("Metabase authentication credentials not provided or incomplete.");
85
+ }
86
+ this.setupResourceHandlers();
87
+ this.setupToolHandlers();
88
+ // Enhanced error handling with logging
89
+ this.server.onerror = (error) => {
90
+ this.logError('Server Error', error);
91
+ };
92
+ process.on('SIGINT', async () => {
93
+ this.logInfo('Shutting down server...');
94
+ await this.server.close();
95
+ process.exit(0);
96
+ });
97
+ }
98
+ // Add logging utilities
99
+ logInfo(message, data) {
100
+ const logMessage = {
101
+ timestamp: new Date().toISOString(),
102
+ level: 'info',
103
+ message,
104
+ data
105
+ };
106
+ console.error(JSON.stringify(logMessage));
107
+ // MCP SDK changed, can't directly access session
108
+ try {
109
+ // Use current session if available
110
+ console.error(`INFO: ${message}`);
111
+ }
112
+ catch (e) {
113
+ // Ignore if session not available
114
+ }
115
+ }
116
+ logError(message, error) {
117
+ const errorObj = error;
118
+ const apiError = error;
119
+ const logMessage = {
120
+ timestamp: new Date().toISOString(),
121
+ level: 'error',
122
+ message,
123
+ error: errorObj.message || 'Unknown error',
124
+ stack: errorObj.stack
125
+ };
126
+ console.error(JSON.stringify(logMessage));
127
+ // MCP SDK changed, can't directly access session
128
+ try {
129
+ console.error(`ERROR: ${message} - ${errorObj.message || 'Unknown error'}`);
130
+ }
131
+ catch (e) {
132
+ // Ignore if session not available
133
+ }
134
+ }
135
+ /**
136
+ * 获取 Metabase 会话令牌
137
+ */
138
+ async getSessionToken() {
139
+ if (this.sessionToken) { // Handles both API key ("api_key_used") and actual session tokens
140
+ return this.sessionToken;
141
+ }
142
+ // This part should only be reached if using username/password and sessionToken is null
143
+ this.logInfo('Authenticating with Metabase using username/password...');
144
+ try {
145
+ const response = await this.axiosInstance.post('/api/session', {
146
+ username: METABASE_USERNAME,
147
+ password: METABASE_PASSWORD,
148
+ });
149
+ this.sessionToken = response.data.id;
150
+ // 设置默认请求头
151
+ this.axiosInstance.defaults.headers.common['X-Metabase-Session'] = this.sessionToken;
152
+ this.logInfo('Successfully authenticated with Metabase');
153
+ return this.sessionToken;
154
+ }
155
+ catch (error) {
156
+ this.logError('Authentication failed', error);
157
+ throw new McpError(ErrorCode.InternalError, 'Failed to authenticate with Metabase');
158
+ }
159
+ }
160
+ /**
161
+ * 设置资源处理程序
162
+ */
163
+ setupResourceHandlers() {
164
+ this.server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
165
+ this.logInfo('Listing resources...', { requestStructure: JSON.stringify(request) });
166
+ if (!METABASE_API_KEY) {
167
+ await this.getSessionToken();
168
+ }
169
+ try {
170
+ // 获取仪表板列表
171
+ const dashboardsResponse = await this.axiosInstance.get('/api/dashboard');
172
+ this.logInfo('Successfully listed resources', { count: dashboardsResponse.data.length });
173
+ // 将仪表板作为资源返回
174
+ return {
175
+ resources: dashboardsResponse.data.map((dashboard) => ({
176
+ uri: `metabase://dashboard/${dashboard.id}`,
177
+ mimeType: "application/json",
178
+ name: dashboard.name,
179
+ description: `Metabase dashboard: ${dashboard.name}`
180
+ }))
181
+ };
182
+ }
183
+ catch (error) {
184
+ this.logError('Failed to list resources', error);
185
+ throw new McpError(ErrorCode.InternalError, 'Failed to list Metabase resources');
186
+ }
187
+ });
188
+ // 资源模板
189
+ this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
190
+ return {
191
+ resourceTemplates: [
192
+ {
193
+ uriTemplate: 'metabase://dashboard/{id}',
194
+ name: 'Dashboard by ID',
195
+ mimeType: 'application/json',
196
+ description: 'Get a Metabase dashboard by its ID',
197
+ },
198
+ {
199
+ uriTemplate: 'metabase://card/{id}',
200
+ name: 'Card by ID',
201
+ mimeType: 'application/json',
202
+ description: 'Get a Metabase question/card by its ID',
203
+ },
204
+ {
205
+ uriTemplate: 'metabase://database/{id}',
206
+ name: 'Database by ID',
207
+ mimeType: 'application/json',
208
+ description: 'Get a Metabase database by its ID',
209
+ },
210
+ ],
211
+ };
212
+ });
213
+ // 读取资源
214
+ this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
215
+ this.logInfo('Reading resource...', { requestStructure: JSON.stringify(request) });
216
+ if (!METABASE_API_KEY) {
217
+ await this.getSessionToken();
218
+ }
219
+ const uri = request.params?.uri;
220
+ let match;
221
+ try {
222
+ // 处理仪表板资源
223
+ if ((match = uri.match(/^metabase:\/\/dashboard\/(\d+)$/))) {
224
+ const dashboardId = match[1];
225
+ const response = await this.axiosInstance.get(`/api/dashboard/${dashboardId}`);
226
+ return {
227
+ contents: [{
228
+ uri: request.params?.uri,
229
+ mimeType: "application/json",
230
+ text: JSON.stringify(response.data, null, 2)
231
+ }]
232
+ };
233
+ }
234
+ // 处理问题/卡片资源
235
+ else if ((match = uri.match(/^metabase:\/\/card\/(\d+)$/))) {
236
+ const cardId = match[1];
237
+ const response = await this.axiosInstance.get(`/api/card/${cardId}`);
238
+ return {
239
+ contents: [{
240
+ uri: request.params?.uri,
241
+ mimeType: "application/json",
242
+ text: JSON.stringify(response.data, null, 2)
243
+ }]
244
+ };
245
+ }
246
+ // 处理数据库资源
247
+ else if ((match = uri.match(/^metabase:\/\/database\/(\d+)$/))) {
248
+ const databaseId = match[1];
249
+ const response = await this.axiosInstance.get(`/api/database/${databaseId}`);
250
+ return {
251
+ contents: [{
252
+ uri: request.params?.uri,
253
+ mimeType: "application/json",
254
+ text: JSON.stringify(response.data, null, 2)
255
+ }]
256
+ };
257
+ }
258
+ else {
259
+ throw new McpError(ErrorCode.InvalidRequest, `Invalid URI format: ${uri}`);
260
+ }
261
+ }
262
+ catch (error) {
263
+ if (axios.isAxiosError(error)) {
264
+ throw new McpError(ErrorCode.InternalError, `Metabase API error: ${error.response?.data?.message || error.message}`);
265
+ }
266
+ throw error;
267
+ }
268
+ });
269
+ }
270
+ /**
271
+ * 设置工具处理程序
272
+ */
273
+ setupToolHandlers() {
274
+ // No session token needed for listing tools, as it's static data
275
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
276
+ return {
277
+ tools: [
278
+ {
279
+ name: "list_dashboards",
280
+ description: "List all dashboards in Metabase",
281
+ inputSchema: {
282
+ type: "object",
283
+ properties: {}
284
+ }
285
+ },
286
+ {
287
+ name: "list_cards",
288
+ description: "List all questions/cards in Metabase",
289
+ inputSchema: {
290
+ type: "object",
291
+ properties: {
292
+ f: {
293
+ type: "string",
294
+ description: "Optional filter function, possible values: archived, table, database, using_model, bookmarked, using_segment, all, mine"
295
+ }
296
+ }
297
+ }
298
+ },
299
+ {
300
+ name: "list_databases",
301
+ description: "List all databases in Metabase",
302
+ inputSchema: {
303
+ type: "object",
304
+ properties: {}
305
+ }
306
+ },
307
+ {
308
+ name: "execute_card",
309
+ description: "Execute a Metabase question/card and get results",
310
+ inputSchema: {
311
+ type: "object",
312
+ properties: {
313
+ card_id: {
314
+ type: "number",
315
+ description: "ID of the card/question to execute"
316
+ },
317
+ parameters: {
318
+ type: "object",
319
+ description: "Optional parameters for the query"
320
+ }
321
+ },
322
+ required: ["card_id"]
323
+ }
324
+ },
325
+ {
326
+ name: "get_dashboard_cards",
327
+ description: "Get all cards in a dashboard",
328
+ inputSchema: {
329
+ type: "object",
330
+ properties: {
331
+ dashboard_id: {
332
+ type: "number",
333
+ description: "ID of the dashboard"
334
+ }
335
+ },
336
+ required: ["dashboard_id"]
337
+ }
338
+ },
339
+ {
340
+ name: "execute_query",
341
+ description: "Execute a SQL query against a Metabase database",
342
+ inputSchema: {
343
+ type: "object",
344
+ properties: {
345
+ database_id: {
346
+ type: "number",
347
+ description: "ID of the database to query"
348
+ },
349
+ query: {
350
+ type: "string",
351
+ description: "SQL query to execute"
352
+ },
353
+ native_parameters: {
354
+ type: "array",
355
+ description: "Optional parameters for the query",
356
+ items: {
357
+ type: "object"
358
+ }
359
+ }
360
+ },
361
+ required: ["database_id", "query"]
362
+ }
363
+ },
364
+ {
365
+ name: "create_card",
366
+ description: "Create a new Metabase question (card).",
367
+ inputSchema: {
368
+ type: "object",
369
+ properties: {
370
+ name: { type: "string", description: "Name of the card" },
371
+ dataset_query: { type: "object", description: "The query for the card (e.g., MBQL or native query)" },
372
+ display: { type: "string", description: "Display type (e.g., 'table', 'line', 'bar')" },
373
+ visualization_settings: { type: "object", description: "Settings for the visualization" },
374
+ collection_id: { type: "number", description: "Optional ID of the collection to save the card in" },
375
+ description: { type: "string", description: "Optional description for the card" }
376
+ },
377
+ required: ["name", "dataset_query", "display", "visualization_settings"]
378
+ }
379
+ },
380
+ {
381
+ name: "update_card",
382
+ description: "Update an existing Metabase question (card).",
383
+ inputSchema: {
384
+ type: "object",
385
+ properties: {
386
+ card_id: { type: "number", description: "ID of the card to update" },
387
+ name: { type: "string", description: "New name for the card" },
388
+ dataset_query: { type: "object", description: "New query for the card" },
389
+ display: { type: "string", description: "New display type" },
390
+ visualization_settings: { type: "object", description: "New visualization settings" },
391
+ collection_id: { type: "number", description: "New collection ID" },
392
+ description: { type: "string", description: "New description" },
393
+ archived: { type: "boolean", description: "Set to true to archive the card" }
394
+ },
395
+ required: ["card_id"]
396
+ }
397
+ },
398
+ {
399
+ name: "delete_card",
400
+ description: "Delete a Metabase question (card).",
401
+ inputSchema: {
402
+ type: "object",
403
+ properties: {
404
+ card_id: { type: "number", description: "ID of the card to delete" },
405
+ hard_delete: { type: "boolean", description: "Set to true for hard delete, false (default) for archive", default: false }
406
+ },
407
+ required: ["card_id"]
408
+ }
409
+ },
410
+ {
411
+ name: "create_dashboard",
412
+ description: "Create a new Metabase dashboard.",
413
+ inputSchema: {
414
+ type: "object",
415
+ properties: {
416
+ name: { type: "string", description: "Name of the dashboard" },
417
+ description: { type: "string", description: "Optional description for the dashboard" },
418
+ parameters: { type: "array", description: "Optional parameters for the dashboard", items: { type: "object" } },
419
+ collection_id: { type: "number", description: "Optional ID of the collection to save the dashboard in" }
420
+ },
421
+ required: ["name"]
422
+ }
423
+ },
424
+ {
425
+ name: "update_dashboard",
426
+ description: "Update an existing Metabase dashboard.",
427
+ inputSchema: {
428
+ type: "object",
429
+ properties: {
430
+ dashboard_id: { type: "number", description: "ID of the dashboard to update" },
431
+ name: { type: "string", description: "New name for the dashboard" },
432
+ description: { type: "string", description: "New description for the dashboard" },
433
+ parameters: { type: "array", description: "New parameters for the dashboard", items: { type: "object" } },
434
+ collection_id: { type: "number", description: "New collection ID" },
435
+ archived: { type: "boolean", description: "Set to true to archive the dashboard" }
436
+ },
437
+ required: ["dashboard_id"]
438
+ }
439
+ },
440
+ {
441
+ name: "delete_dashboard",
442
+ description: "Delete a Metabase dashboard.",
443
+ inputSchema: {
444
+ type: "object",
445
+ properties: {
446
+ dashboard_id: { type: "number", description: "ID of the dashboard to delete" },
447
+ hard_delete: { type: "boolean", description: "Set to true for hard delete, false (default) for archive", default: false }
448
+ },
449
+ required: ["dashboard_id"]
450
+ }
451
+ }
452
+ ]
453
+ };
454
+ });
455
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
456
+ this.logInfo('Calling tool...', { requestStructure: JSON.stringify(request) });
457
+ if (!METABASE_API_KEY) {
458
+ await this.getSessionToken();
459
+ }
460
+ try {
461
+ switch (request.params?.name) {
462
+ case "list_dashboards": {
463
+ const response = await this.axiosInstance.get('/api/dashboard');
464
+ return {
465
+ content: [{
466
+ type: "text",
467
+ text: JSON.stringify(response.data, null, 2)
468
+ }]
469
+ };
470
+ }
471
+ case "list_cards": {
472
+ const f = request.params?.arguments?.f || "all";
473
+ const response = await this.axiosInstance.get(`/api/card?f=${f}`);
474
+ return {
475
+ content: [{
476
+ type: "text",
477
+ text: JSON.stringify(response.data, null, 2)
478
+ }]
479
+ };
480
+ }
481
+ case "list_databases": {
482
+ const response = await this.axiosInstance.get('/api/database');
483
+ return {
484
+ content: [{
485
+ type: "text",
486
+ text: JSON.stringify(response.data, null, 2)
487
+ }]
488
+ };
489
+ }
490
+ case "execute_card": {
491
+ const cardId = request.params?.arguments?.card_id;
492
+ if (!cardId) {
493
+ throw new McpError(ErrorCode.InvalidParams, "Card ID is required");
494
+ }
495
+ const parameters = request.params?.arguments?.parameters || {};
496
+ const response = await this.axiosInstance.post(`/api/card/${cardId}/query`, { parameters });
497
+ return {
498
+ content: [{
499
+ type: "text",
500
+ text: JSON.stringify(response.data, null, 2)
501
+ }]
502
+ };
503
+ }
504
+ case "get_dashboard_cards": {
505
+ const dashboardId = request.params?.arguments?.dashboard_id;
506
+ if (!dashboardId) {
507
+ throw new McpError(ErrorCode.InvalidParams, "Dashboard ID is required");
508
+ }
509
+ const response = await this.axiosInstance.get(`/api/dashboard/${dashboardId}`);
510
+ return {
511
+ content: [{
512
+ type: "text",
513
+ text: JSON.stringify(response.data.cards, null, 2)
514
+ }]
515
+ };
516
+ }
517
+ case "execute_query": {
518
+ const databaseId = request.params?.arguments?.database_id;
519
+ const query = request.params?.arguments?.query;
520
+ const nativeParameters = request.params?.arguments?.native_parameters || [];
521
+ if (!databaseId) {
522
+ throw new McpError(ErrorCode.InvalidParams, "Database ID is required");
523
+ }
524
+ if (!query) {
525
+ throw new McpError(ErrorCode.InvalidParams, "SQL query is required");
526
+ }
527
+ // 构建查询请求体
528
+ const queryData = {
529
+ type: "native",
530
+ native: {
531
+ query: query,
532
+ template_tags: {}
533
+ },
534
+ parameters: nativeParameters,
535
+ database: databaseId
536
+ };
537
+ const response = await this.axiosInstance.post('/api/dataset', queryData);
538
+ return {
539
+ content: [{
540
+ type: "text",
541
+ text: JSON.stringify(response.data, null, 2)
542
+ }]
543
+ };
544
+ }
545
+ case "create_card": {
546
+ const { name, dataset_query, display, visualization_settings, collection_id, description } = request.params?.arguments || {};
547
+ if (!name || !dataset_query || !display || !visualization_settings) {
548
+ throw new McpError(ErrorCode.InvalidParams, "Missing required fields for create_card: name, dataset_query, display, visualization_settings");
549
+ }
550
+ const createCardBody = {
551
+ name,
552
+ dataset_query,
553
+ display,
554
+ visualization_settings,
555
+ };
556
+ if (collection_id !== undefined)
557
+ createCardBody.collection_id = collection_id;
558
+ if (description !== undefined)
559
+ createCardBody.description = description;
560
+ const response = await this.axiosInstance.post('/api/card', createCardBody);
561
+ return {
562
+ content: [{
563
+ type: "text",
564
+ text: JSON.stringify(response.data, null, 2)
565
+ }]
566
+ };
567
+ }
568
+ case "update_card": {
569
+ const { card_id, ...updateFields } = request.params?.arguments || {};
570
+ if (!card_id) {
571
+ throw new McpError(ErrorCode.InvalidParams, "Card ID is required for update_card");
572
+ }
573
+ if (Object.keys(updateFields).length === 0) {
574
+ throw new McpError(ErrorCode.InvalidParams, "No fields provided for update_card");
575
+ }
576
+ const response = await this.axiosInstance.put(`/api/card/${card_id}`, updateFields);
577
+ return {
578
+ content: [{
579
+ type: "text",
580
+ text: JSON.stringify(response.data, null, 2)
581
+ }]
582
+ };
583
+ }
584
+ case "delete_card": {
585
+ const { card_id, hard_delete = false } = request.params?.arguments || {};
586
+ if (!card_id) {
587
+ throw new McpError(ErrorCode.InvalidParams, "Card ID is required for delete_card");
588
+ }
589
+ if (hard_delete) {
590
+ await this.axiosInstance.delete(`/api/card/${card_id}`);
591
+ return {
592
+ content: [{
593
+ type: "text",
594
+ text: `Card ${card_id} permanently deleted.`
595
+ }]
596
+ };
597
+ }
598
+ else {
599
+ // Soft delete (archive)
600
+ const response = await this.axiosInstance.put(`/api/card/${card_id}`, { archived: true });
601
+ return {
602
+ content: [{
603
+ type: "text",
604
+ // Metabase might return the updated card object or just a success status.
605
+ // If response.data is available and meaningful, include it. Otherwise, a generic success message.
606
+ text: response.data ? `Card ${card_id} archived. Details: ${JSON.stringify(response.data, null, 2)}` : `Card ${card_id} archived.`
607
+ }]
608
+ };
609
+ }
610
+ }
611
+ case "create_dashboard": {
612
+ const { name, description, parameters, collection_id } = request.params?.arguments || {};
613
+ if (!name) {
614
+ throw new McpError(ErrorCode.InvalidParams, "Missing required field for create_dashboard: name");
615
+ }
616
+ const createDashboardBody = { name };
617
+ if (description !== undefined)
618
+ createDashboardBody.description = description;
619
+ if (parameters !== undefined)
620
+ createDashboardBody.parameters = parameters;
621
+ if (collection_id !== undefined)
622
+ createDashboardBody.collection_id = collection_id;
623
+ const response = await this.axiosInstance.post('/api/dashboard', createDashboardBody);
624
+ return {
625
+ content: [{
626
+ type: "text",
627
+ text: JSON.stringify(response.data, null, 2)
628
+ }]
629
+ };
630
+ }
631
+ case "update_dashboard": {
632
+ const { dashboard_id, ...updateFields } = request.params?.arguments || {};
633
+ if (!dashboard_id) {
634
+ throw new McpError(ErrorCode.InvalidParams, "Dashboard ID is required for update_dashboard");
635
+ }
636
+ if (Object.keys(updateFields).length === 0) {
637
+ throw new McpError(ErrorCode.InvalidParams, "No fields provided for update_dashboard");
638
+ }
639
+ const response = await this.axiosInstance.put(`/api/dashboard/${dashboard_id}`, updateFields);
640
+ return {
641
+ content: [{
642
+ type: "text",
643
+ text: JSON.stringify(response.data, null, 2)
644
+ }]
645
+ };
646
+ }
647
+ case "delete_dashboard": {
648
+ const { dashboard_id, hard_delete = false } = request.params?.arguments || {};
649
+ if (!dashboard_id) {
650
+ throw new McpError(ErrorCode.InvalidParams, "Dashboard ID is required for delete_dashboard");
651
+ }
652
+ if (hard_delete) {
653
+ await this.axiosInstance.delete(`/api/dashboard/${dashboard_id}`);
654
+ return {
655
+ content: [{
656
+ type: "text",
657
+ text: `Dashboard ${dashboard_id} permanently deleted.`
658
+ }]
659
+ };
660
+ }
661
+ else {
662
+ // Soft delete (archive)
663
+ const response = await this.axiosInstance.put(`/api/dashboard/${dashboard_id}`, { archived: true });
664
+ return {
665
+ content: [{
666
+ type: "text",
667
+ text: response.data ? `Dashboard ${dashboard_id} archived. Details: ${JSON.stringify(response.data, null, 2)}` : `Dashboard ${dashboard_id} archived.`
668
+ }]
669
+ };
670
+ }
671
+ }
672
+ default:
673
+ return {
674
+ content: [
675
+ {
676
+ type: "text",
677
+ text: `Unknown tool: ${request.params?.name}`
678
+ }
679
+ ],
680
+ isError: true
681
+ };
682
+ }
683
+ }
684
+ catch (error) {
685
+ if (axios.isAxiosError(error)) {
686
+ return {
687
+ content: [{
688
+ type: "text",
689
+ text: `Metabase API error: ${error.response?.data?.message || error.message}`
690
+ }],
691
+ isError: true
692
+ };
693
+ }
694
+ throw error;
695
+ }
696
+ });
697
+ }
698
+ async run() {
699
+ try {
700
+ this.logInfo('Starting Metabase MCP server...');
701
+ const transport = new StdioServerTransport();
702
+ await this.server.connect(transport);
703
+ this.logInfo('Metabase MCP server running on stdio');
704
+ }
705
+ catch (error) {
706
+ this.logError('Failed to start server', error);
707
+ throw error;
708
+ }
709
+ }
710
+ }
711
+ // Add global error handlers
712
+ process.on('uncaughtException', (error) => {
713
+ console.error(JSON.stringify({
714
+ timestamp: new Date().toISOString(),
715
+ level: 'fatal',
716
+ message: 'Uncaught Exception',
717
+ error: error.message,
718
+ stack: error.stack
719
+ }));
720
+ process.exit(1);
721
+ });
722
+ process.on('unhandledRejection', (reason, promise) => {
723
+ const errorMessage = reason instanceof Error ? reason.message : String(reason);
724
+ console.error(JSON.stringify({
725
+ timestamp: new Date().toISOString(),
726
+ level: 'fatal',
727
+ message: 'Unhandled Rejection',
728
+ error: errorMessage
729
+ }));
730
+ });
731
+ const server = new MetabaseServer();
732
+ server.run().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "blixify-charts-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Blixify Charts MCP",
5
+ "private": false,
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
+ }