metabase-ai-assistant 3.3.0 → 3.4.1
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.
- package/README_MCP.md +31 -1
- package/package.json +2 -2
- package/src/mcp/job-store.js +199 -0
- package/src/mcp/server.js +554 -35
- package/src/metabase/client.js +105 -41
package/README_MCP.md
CHANGED
|
@@ -45,7 +45,7 @@ After updating the configuration file, restart your MCP client to load the new s
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
-
## Available Tools (
|
|
48
|
+
## Available Tools (111 Total)
|
|
49
49
|
|
|
50
50
|
### Database Operations
|
|
51
51
|
- **db_list**: List all databases in Metabase
|
|
@@ -55,6 +55,10 @@ After updating the configuration file, restart your MCP client to load the new s
|
|
|
55
55
|
|
|
56
56
|
### SQL Operations
|
|
57
57
|
- **sql_execute**: Execute native SQL query
|
|
58
|
+
- **sql_submit**: Asynchronously create a long-running SQL query job
|
|
59
|
+
- **sql_status**: Check the status of a SQL job
|
|
60
|
+
- **sql_cancel**: Cancel a running SQL job
|
|
61
|
+
- **db_table_profile**: Profile a table (smart dim/ref detection)
|
|
58
62
|
- **ai_sql_generate**: Generate SQL from natural language description
|
|
59
63
|
- **ai_sql_optimize**: Optimize SQL query for performance
|
|
60
64
|
- **ai_sql_explain**: Explain what a SQL query does
|
|
@@ -213,6 +217,32 @@ After updating the configuration file, restart your MCP client to load the new s
|
|
|
213
217
|
}
|
|
214
218
|
```
|
|
215
219
|
|
|
220
|
+
### sql_submit (Async Query)
|
|
221
|
+
```json
|
|
222
|
+
{
|
|
223
|
+
"database_id": 1,
|
|
224
|
+
"sql": "SELECT SLEEP(120)",
|
|
225
|
+
"timeout_seconds": 300
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### sql_status
|
|
230
|
+
```json
|
|
231
|
+
{
|
|
232
|
+
"job_id": "550e8400-e29b-41d4-a716-446655440000"
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### db_table_profile
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"database_id": 1,
|
|
240
|
+
"schema": "public",
|
|
241
|
+
"table": "dim_customers",
|
|
242
|
+
"sample_rows": 3
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
216
246
|
### ai_sql_generate
|
|
217
247
|
```json
|
|
218
248
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metabase-ai-assistant",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.1",
|
|
4
4
|
"mcpName": "io.github.enessari/metabase-ai-assistant",
|
|
5
5
|
"description": "The most powerful MCP Server for Metabase - 111+ tools for AI-powered SQL generation, dashboard automation, user management & enterprise BI. Works with Claude, Cursor, and any MCP-compatible AI.",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -96,4 +96,4 @@
|
|
|
96
96
|
"eslint": "^8.56.0",
|
|
97
97
|
"jest": "^29.7.0"
|
|
98
98
|
}
|
|
99
|
-
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* QueryJobStore - Manages async query jobs
|
|
6
|
+
* Tracks job status, results, and handles cleanup
|
|
7
|
+
*/
|
|
8
|
+
export class QueryJobStore {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.jobs = new Map();
|
|
11
|
+
|
|
12
|
+
// Auto-cleanup every 10 minutes
|
|
13
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 10 * 60 * 1000);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a new query job
|
|
18
|
+
*/
|
|
19
|
+
create(databaseId, sql, timeoutSeconds = 300) {
|
|
20
|
+
const jobId = randomUUID();
|
|
21
|
+
const job = {
|
|
22
|
+
id: jobId,
|
|
23
|
+
database_id: databaseId,
|
|
24
|
+
sql: sql,
|
|
25
|
+
status: 'pending', // pending | running | complete | failed | timeout | cancelled
|
|
26
|
+
submitted_at: Date.now(),
|
|
27
|
+
started_at: null,
|
|
28
|
+
completed_at: null,
|
|
29
|
+
timeout_ms: timeoutSeconds * 1000,
|
|
30
|
+
result: null,
|
|
31
|
+
error: null,
|
|
32
|
+
row_count: 0,
|
|
33
|
+
abortController: new AbortController()
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this.jobs.set(jobId, job);
|
|
37
|
+
logger.info(`Query job created: ${jobId}`);
|
|
38
|
+
return job;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get job by ID
|
|
43
|
+
*/
|
|
44
|
+
get(jobId) {
|
|
45
|
+
return this.jobs.get(jobId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Update job properties
|
|
50
|
+
*/
|
|
51
|
+
update(jobId, updates) {
|
|
52
|
+
const job = this.jobs.get(jobId);
|
|
53
|
+
if (job) {
|
|
54
|
+
Object.assign(job, updates);
|
|
55
|
+
logger.debug(`Job ${jobId} updated: ${updates.status || 'props'}`);
|
|
56
|
+
}
|
|
57
|
+
return job;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Mark job as running
|
|
62
|
+
*/
|
|
63
|
+
markRunning(jobId) {
|
|
64
|
+
return this.update(jobId, {
|
|
65
|
+
status: 'running',
|
|
66
|
+
started_at: Date.now()
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mark job as complete with results
|
|
72
|
+
*/
|
|
73
|
+
markComplete(jobId, result, rowCount) {
|
|
74
|
+
return this.update(jobId, {
|
|
75
|
+
status: 'complete',
|
|
76
|
+
completed_at: Date.now(),
|
|
77
|
+
result: result,
|
|
78
|
+
row_count: rowCount
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark job as failed
|
|
84
|
+
*/
|
|
85
|
+
markFailed(jobId, error) {
|
|
86
|
+
return this.update(jobId, {
|
|
87
|
+
status: 'failed',
|
|
88
|
+
completed_at: Date.now(),
|
|
89
|
+
error: error.message || String(error)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mark job as timed out
|
|
95
|
+
*/
|
|
96
|
+
markTimeout(jobId) {
|
|
97
|
+
const job = this.jobs.get(jobId);
|
|
98
|
+
return this.update(jobId, {
|
|
99
|
+
status: 'timeout',
|
|
100
|
+
completed_at: Date.now(),
|
|
101
|
+
error: `Query timed out after ${job?.timeout_ms / 1000} seconds`
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Mark job as cancelled
|
|
107
|
+
*/
|
|
108
|
+
markCancelled(jobId) {
|
|
109
|
+
return this.update(jobId, {
|
|
110
|
+
status: 'cancelled',
|
|
111
|
+
completed_at: Date.now(),
|
|
112
|
+
error: 'Query cancelled by user'
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get elapsed time for a job in seconds
|
|
118
|
+
*/
|
|
119
|
+
getElapsedSeconds(jobId) {
|
|
120
|
+
const job = this.jobs.get(jobId);
|
|
121
|
+
if (!job) return 0;
|
|
122
|
+
|
|
123
|
+
const startTime = job.started_at || job.submitted_at;
|
|
124
|
+
const endTime = job.completed_at || Date.now();
|
|
125
|
+
return Math.round((endTime - startTime) / 1000);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* List all jobs (optionally filtered by status)
|
|
130
|
+
*/
|
|
131
|
+
list(status = null) {
|
|
132
|
+
const jobs = [];
|
|
133
|
+
for (const [id, job] of this.jobs) {
|
|
134
|
+
if (!status || job.status === status) {
|
|
135
|
+
jobs.push({
|
|
136
|
+
id: job.id,
|
|
137
|
+
database_id: job.database_id,
|
|
138
|
+
status: job.status,
|
|
139
|
+
submitted_at: job.submitted_at,
|
|
140
|
+
elapsed_seconds: this.getElapsedSeconds(job.id)
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return jobs;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get running jobs count
|
|
149
|
+
*/
|
|
150
|
+
getRunningCount() {
|
|
151
|
+
let count = 0;
|
|
152
|
+
for (const job of this.jobs.values()) {
|
|
153
|
+
if (job.status === 'running' || job.status === 'pending') {
|
|
154
|
+
count++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return count;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Cleanup old completed jobs (older than 1 hour)
|
|
162
|
+
*/
|
|
163
|
+
cleanup() {
|
|
164
|
+
const oneHourAgo = Date.now() - 3600000;
|
|
165
|
+
let cleaned = 0;
|
|
166
|
+
|
|
167
|
+
for (const [id, job] of this.jobs) {
|
|
168
|
+
if (job.status !== 'running' && job.status !== 'pending') {
|
|
169
|
+
if (job.submitted_at < oneHourAgo) {
|
|
170
|
+
this.jobs.delete(id);
|
|
171
|
+
cleaned++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (cleaned > 0) {
|
|
177
|
+
logger.info(`Cleaned up ${cleaned} old query jobs`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stop the cleanup interval (for graceful shutdown)
|
|
183
|
+
*/
|
|
184
|
+
destroy() {
|
|
185
|
+
if (this.cleanupInterval) {
|
|
186
|
+
clearInterval(this.cleanupInterval);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Singleton instance
|
|
192
|
+
let instance = null;
|
|
193
|
+
|
|
194
|
+
export function getJobStore() {
|
|
195
|
+
if (!instance) {
|
|
196
|
+
instance = new QueryJobStore();
|
|
197
|
+
}
|
|
198
|
+
return instance;
|
|
199
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
// Utils
|
|
29
29
|
import { CacheManager, CacheKeys, globalCache } from '../utils/cache.js';
|
|
30
30
|
import { config as appConfig } from '../utils/config.js';
|
|
31
|
+
import { getJobStore } from './job-store.js';
|
|
31
32
|
import {
|
|
32
33
|
ResponseFormat,
|
|
33
34
|
formatListResponse,
|
|
@@ -209,10 +210,43 @@ class MetabaseMCPServer {
|
|
|
209
210
|
required: ['database_id'],
|
|
210
211
|
},
|
|
211
212
|
},
|
|
213
|
+
{
|
|
214
|
+
name: 'db_table_profile',
|
|
215
|
+
description: 'Get comprehensive table profile: row count, column types, distinct values, sample data. Auto-detects dimension/reference tables (dim_, ref_, lookup_ prefix). Ideal for understanding lookup tables before writing queries.',
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
database_id: {
|
|
220
|
+
type: 'number',
|
|
221
|
+
description: 'Database ID',
|
|
222
|
+
},
|
|
223
|
+
table_name: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
description: 'Table name (with or without schema prefix)',
|
|
226
|
+
},
|
|
227
|
+
schema_name: {
|
|
228
|
+
type: 'string',
|
|
229
|
+
description: 'Schema name (default: public)',
|
|
230
|
+
default: 'public',
|
|
231
|
+
},
|
|
232
|
+
show_distinct_values: {
|
|
233
|
+
type: 'boolean',
|
|
234
|
+
description: 'Show distinct values for each column (auto-enabled for dim/ref tables)',
|
|
235
|
+
default: true,
|
|
236
|
+
},
|
|
237
|
+
sample_rows: {
|
|
238
|
+
type: 'number',
|
|
239
|
+
description: 'Number of sample rows to display (default: 3)',
|
|
240
|
+
default: 3,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
required: ['database_id', 'table_name'],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
212
246
|
// === SQL EXECUTION ===
|
|
213
247
|
{
|
|
214
248
|
name: 'sql_execute',
|
|
215
|
-
description: 'Run SQL queries against database - supports SELECT, DDL with security controls, returns formatted results',
|
|
249
|
+
description: 'Run SQL queries against database - supports SELECT, DDL with security controls, returns formatted results. For long-running queries (>60s), use sql_submit instead.',
|
|
216
250
|
inputSchema: {
|
|
217
251
|
type: 'object',
|
|
218
252
|
properties: {
|
|
@@ -224,10 +258,65 @@ class MetabaseMCPServer {
|
|
|
224
258
|
type: 'string',
|
|
225
259
|
description: 'SQL query to execute',
|
|
226
260
|
},
|
|
261
|
+
full_results: {
|
|
262
|
+
type: 'boolean',
|
|
263
|
+
description: 'Set to true to disable result truncation (useful for DDL/definitions)',
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
required: ['database_id', 'sql'],
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'sql_submit',
|
|
271
|
+
description: 'Submit a long-running SQL query asynchronously. Returns immediately with job_id. Use sql_status to check progress. Ideal for queries that may take minutes.',
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: 'object',
|
|
274
|
+
properties: {
|
|
275
|
+
database_id: {
|
|
276
|
+
type: 'number',
|
|
277
|
+
description: 'Database ID',
|
|
278
|
+
},
|
|
279
|
+
sql: {
|
|
280
|
+
type: 'string',
|
|
281
|
+
description: 'SQL query to execute',
|
|
282
|
+
},
|
|
283
|
+
timeout_seconds: {
|
|
284
|
+
type: 'number',
|
|
285
|
+
description: 'Query timeout in seconds (default: 300, max: 1800)',
|
|
286
|
+
default: 300,
|
|
287
|
+
},
|
|
227
288
|
},
|
|
228
289
|
required: ['database_id', 'sql'],
|
|
229
290
|
},
|
|
230
291
|
},
|
|
292
|
+
{
|
|
293
|
+
name: 'sql_status',
|
|
294
|
+
description: 'Check status of an async query submitted via sql_submit. Returns results when complete.',
|
|
295
|
+
inputSchema: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
properties: {
|
|
298
|
+
job_id: {
|
|
299
|
+
type: 'string',
|
|
300
|
+
description: 'Job ID returned from sql_submit',
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
required: ['job_id'],
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'sql_cancel',
|
|
308
|
+
description: 'Cancel a running async query. Also attempts to cancel on database server.',
|
|
309
|
+
inputSchema: {
|
|
310
|
+
type: 'object',
|
|
311
|
+
properties: {
|
|
312
|
+
job_id: {
|
|
313
|
+
type: 'string',
|
|
314
|
+
description: 'Job ID to cancel',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
required: ['job_id'],
|
|
318
|
+
},
|
|
319
|
+
},
|
|
231
320
|
// === METABASE OBJECTS ===
|
|
232
321
|
{
|
|
233
322
|
name: 'mb_question_create',
|
|
@@ -3147,10 +3236,18 @@ class MetabaseMCPServer {
|
|
|
3147
3236
|
return await this.handleGetDatabaseSchemas(args.database_id);
|
|
3148
3237
|
case 'db_tables':
|
|
3149
3238
|
return await this.handleGetDatabaseTables(args.database_id);
|
|
3239
|
+
case 'db_table_profile':
|
|
3240
|
+
return await this.handleTableProfile(args);
|
|
3150
3241
|
|
|
3151
3242
|
// SQL execution
|
|
3152
3243
|
case 'sql_execute':
|
|
3153
|
-
return await this.handleExecuteSQL(args
|
|
3244
|
+
return await this.handleExecuteSQL(args);
|
|
3245
|
+
case 'sql_submit':
|
|
3246
|
+
return await this.handleSQLSubmit(args);
|
|
3247
|
+
case 'sql_status':
|
|
3248
|
+
return await this.handleSQLStatus(args);
|
|
3249
|
+
case 'sql_cancel':
|
|
3250
|
+
return await this.handleSQLCancel(args);
|
|
3154
3251
|
|
|
3155
3252
|
// Metabase objects
|
|
3156
3253
|
case 'mb_question_create':
|
|
@@ -3452,7 +3549,7 @@ class MetabaseMCPServer {
|
|
|
3452
3549
|
} catch (error) {
|
|
3453
3550
|
logger.error(`Tool ${name} failed:`, error);
|
|
3454
3551
|
|
|
3455
|
-
// Specific error handling
|
|
3552
|
+
// Specific error handling with clearer messages
|
|
3456
3553
|
let errorMessage = error.message;
|
|
3457
3554
|
let errorCode = ErrorCode.InternalError;
|
|
3458
3555
|
|
|
@@ -3468,6 +3565,15 @@ class MetabaseMCPServer {
|
|
|
3468
3565
|
} else if (error.message.includes('not found')) {
|
|
3469
3566
|
errorMessage = `Resource not found: ${error.message}`;
|
|
3470
3567
|
errorCode = ErrorCode.InvalidRequest;
|
|
3568
|
+
} else if (error.message.includes('is not a function')) {
|
|
3569
|
+
errorMessage = `Unexpected API response format. Metabase API may have changed or data is not an array. Details: ${error.message.substring(0, 100)}`;
|
|
3570
|
+
errorCode = ErrorCode.InternalError;
|
|
3571
|
+
} else if (error.message.includes('Cannot read properties of undefined') || error.message.includes('Cannot read property')) {
|
|
3572
|
+
errorMessage = `Expected data not found. Check Metabase API response. Details: ${error.message.substring(0, 100)}`;
|
|
3573
|
+
errorCode = ErrorCode.InternalError;
|
|
3574
|
+
} else if (error.message.includes('timeout') || error.message.includes('ETIMEDOUT')) {
|
|
3575
|
+
errorMessage = `Request timed out. Try a smaller query or use LIMIT.`;
|
|
3576
|
+
errorCode = ErrorCode.InternalError;
|
|
3471
3577
|
}
|
|
3472
3578
|
|
|
3473
3579
|
throw new McpError(errorCode, errorMessage);
|
|
@@ -3565,9 +3671,13 @@ class MetabaseMCPServer {
|
|
|
3565
3671
|
};
|
|
3566
3672
|
}
|
|
3567
3673
|
|
|
3568
|
-
async handleExecuteSQL(
|
|
3674
|
+
async handleExecuteSQL(args) {
|
|
3569
3675
|
await this.ensureInitialized();
|
|
3570
3676
|
|
|
3677
|
+
const databaseId = args.database_id;
|
|
3678
|
+
const sql = args.sql;
|
|
3679
|
+
const fullResults = args.full_results === true;
|
|
3680
|
+
|
|
3571
3681
|
if (this.initError) {
|
|
3572
3682
|
throw new McpError(ErrorCode.InternalError, `Failed to initialize: ${this.initError.message}`);
|
|
3573
3683
|
}
|
|
@@ -3613,44 +3723,78 @@ class MetabaseMCPServer {
|
|
|
3613
3723
|
const rows = result.data.rows || [];
|
|
3614
3724
|
const columns = result.data.cols || [];
|
|
3615
3725
|
|
|
3616
|
-
let output = `✅ **Query
|
|
3617
|
-
output += `📊
|
|
3618
|
-
output += `• Database ID: ${databaseId}\\n`;
|
|
3619
|
-
output += `• Columns: ${columns.length} (${columns.map(col => col.name).join(', ')})\\n`;
|
|
3620
|
-
output += `• Rows returned: ${rows.length}\\n`;
|
|
3621
|
-
output += `• Execution time: ${executionTime}ms\\n\\n`;
|
|
3726
|
+
let output = `✅ **Query successful** (${executionTime}ms)\\n`;
|
|
3727
|
+
output += `📊 ${columns.length} columns, ${rows.length} rows\\n\\n`;
|
|
3622
3728
|
|
|
3623
3729
|
if (rows.length > 0) {
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
// Create table header
|
|
3730
|
+
// Show sample data (max 5 rows)
|
|
3731
|
+
output += `**Data:**\\n\`\`\`\\n`;
|
|
3627
3732
|
const headers = columns.map(col => col.name);
|
|
3628
3733
|
output += headers.join(' | ') + '\\n';
|
|
3629
3734
|
output += headers.map(() => '---').join(' | ') + '\\n';
|
|
3630
3735
|
|
|
3631
|
-
// Add data rows
|
|
3632
3736
|
rows.slice(0, 5).forEach((row) => {
|
|
3633
3737
|
const formattedRow = row.map(cell => {
|
|
3634
3738
|
if (cell === null) return 'NULL';
|
|
3635
|
-
|
|
3636
|
-
|
|
3739
|
+
|
|
3740
|
+
// Smart truncation logic
|
|
3741
|
+
let truncateLimit = 100; // Increased base limit from 30
|
|
3742
|
+
|
|
3743
|
+
// Disable truncation for small result sets (DDL/procedures) or explicit full_results
|
|
3744
|
+
if (fullResults || rows.length <= 2) {
|
|
3745
|
+
truncateLimit = 50000;
|
|
3746
|
+
}
|
|
3747
|
+
// Check specific DDL-related column names
|
|
3748
|
+
else if (columns.some(c => /definition|ddl|source|create_statement|routine_definition/i.test(c.name))) {
|
|
3749
|
+
truncateLimit = 10000;
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
if (typeof cell === 'string' && cell.length > truncateLimit) {
|
|
3753
|
+
return cell.substring(0, truncateLimit - 3) + '...';
|
|
3637
3754
|
}
|
|
3638
3755
|
return String(cell);
|
|
3639
3756
|
});
|
|
3640
3757
|
output += formattedRow.join(' | ') + '\\n';
|
|
3641
3758
|
});
|
|
3642
|
-
|
|
3643
3759
|
output += '\`\`\`\\n';
|
|
3644
3760
|
|
|
3645
3761
|
if (rows.length > 5) {
|
|
3646
|
-
output +=
|
|
3762
|
+
output += `_+${rows.length - 5} more rows_\\n`;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
// Large result warning
|
|
3766
|
+
if (rows.length > 100) {
|
|
3767
|
+
output += `\\n⚠️ **Large result:** ${rows.length} rows returned. Use LIMIT for better performance.\\n`;
|
|
3647
3768
|
}
|
|
3648
3769
|
} else {
|
|
3649
|
-
|
|
3770
|
+
// Empty result - smart detection
|
|
3771
|
+
output += `ℹ️ No results.\\n`;
|
|
3772
|
+
|
|
3773
|
+
// Try to detect if table has data but query returned nothing
|
|
3774
|
+
try {
|
|
3775
|
+
const fromMatch = sql.match(/FROM\s+["']?([^\s"'.(]+)["']?/i) ||
|
|
3776
|
+
sql.match(/FROM\s+["']?[^"'.]+["']?\.["']?([^\s"']+)["']?/i);
|
|
3777
|
+
if (fromMatch) {
|
|
3778
|
+
const tableName = fromMatch[1];
|
|
3779
|
+
const countQuery = `SELECT COUNT(*) FROM ${tableName} LIMIT 1`;
|
|
3780
|
+
try {
|
|
3781
|
+
const countResult = await this.metabaseClient.executeNativeQuery(databaseId, countQuery);
|
|
3782
|
+
const tableRowCount = countResult.data?.rows?.[0]?.[0] || 0;
|
|
3783
|
+
|
|
3784
|
+
if (tableRowCount > 0) {
|
|
3785
|
+
output += `\\n⚠️ **Note:** \`${tableName}\` has ${tableRowCount.toLocaleString()} rows but query returned nothing.\\n`;
|
|
3786
|
+
output += `Possible causes: WHERE clause too restrictive, column name typo, JOIN mismatch\\n`;
|
|
3787
|
+
output += `💡 Use \`db_table_profile\` to inspect table structure.\\n`;
|
|
3788
|
+
}
|
|
3789
|
+
} catch (e) { /* ignore */ }
|
|
3790
|
+
}
|
|
3791
|
+
} catch (e) { /* ignore */ }
|
|
3650
3792
|
}
|
|
3651
3793
|
|
|
3652
|
-
//
|
|
3653
|
-
|
|
3794
|
+
// Tool suggestions (only for SELECT queries with few results)
|
|
3795
|
+
if (sql.toLowerCase().trim().startsWith('select') && rows.length <= 5) {
|
|
3796
|
+
output += `\\n💡 Related: \`db_table_profile\`, \`mb_field_values\`\\n`;
|
|
3797
|
+
}
|
|
3654
3798
|
|
|
3655
3799
|
return {
|
|
3656
3800
|
content: [
|
|
@@ -3678,12 +3822,9 @@ class MetabaseMCPServer {
|
|
|
3678
3822
|
});
|
|
3679
3823
|
}
|
|
3680
3824
|
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
`• Execution time: ${executionTime}ms\\n` +
|
|
3685
|
-
`• Error: ${err.message}\\n\\n` +
|
|
3686
|
-
`🔍 **Failed Query:**\\n\`\`\`sql\\n${sql}\\n\`\`\``;
|
|
3825
|
+
// Compact error format - no query repetition
|
|
3826
|
+
const shortSql = sql.length > 80 ? sql.substring(0, 77) + '...' : sql;
|
|
3827
|
+
const output = `❌ SQL Error: ${err.message}\\nQuery: ${shortSql}`;
|
|
3687
3828
|
|
|
3688
3829
|
return {
|
|
3689
3830
|
content: [
|
|
@@ -3696,6 +3837,201 @@ class MetabaseMCPServer {
|
|
|
3696
3837
|
}
|
|
3697
3838
|
}
|
|
3698
3839
|
|
|
3840
|
+
/**
|
|
3841
|
+
* Submit a long-running SQL query asynchronously
|
|
3842
|
+
* Returns immediately with job_id, executes query in background
|
|
3843
|
+
*/
|
|
3844
|
+
async handleSQLSubmit(args) {
|
|
3845
|
+
try {
|
|
3846
|
+
await this.ensureInitialized();
|
|
3847
|
+
|
|
3848
|
+
const databaseId = args.database_id;
|
|
3849
|
+
const sql = args.sql;
|
|
3850
|
+
const timeoutSeconds = Math.min(args.timeout_seconds || 300, 1800); // Max 30 minutes
|
|
3851
|
+
|
|
3852
|
+
// Check read-only mode for write operations
|
|
3853
|
+
if (isReadOnlyMode() && detectWriteOperation(sql)) {
|
|
3854
|
+
return {
|
|
3855
|
+
content: [{ type: 'text', text: '❌ Write operations blocked in read-only mode' }],
|
|
3856
|
+
};
|
|
3857
|
+
}
|
|
3858
|
+
|
|
3859
|
+
// Get job store and create job
|
|
3860
|
+
const jobStore = getJobStore();
|
|
3861
|
+
const job = jobStore.create(databaseId, sql, timeoutSeconds);
|
|
3862
|
+
|
|
3863
|
+
// Add job marker to SQL for cancellation support
|
|
3864
|
+
const markedSql = `/* job:${job.id} */ ${sql}`;
|
|
3865
|
+
|
|
3866
|
+
// Start query execution in background (non-blocking)
|
|
3867
|
+
this.executeQueryBackground(job.id, databaseId, markedSql, timeoutSeconds * 1000);
|
|
3868
|
+
|
|
3869
|
+
const output = `✅ **Query Submitted**\\n` +
|
|
3870
|
+
`📋 Job ID: \`${job.id}\`\\n` +
|
|
3871
|
+
`⏱️ Timeout: ${timeoutSeconds} seconds\\n` +
|
|
3872
|
+
`📊 Status: pending\\n\\n` +
|
|
3873
|
+
`💡 Use \`sql_status\` with this job_id to check progress.`;
|
|
3874
|
+
|
|
3875
|
+
return {
|
|
3876
|
+
content: [{ type: 'text', text: output }],
|
|
3877
|
+
};
|
|
3878
|
+
|
|
3879
|
+
} catch (error) {
|
|
3880
|
+
return {
|
|
3881
|
+
content: [{ type: 'text', text: `❌ Failed to submit query: ${error.message}` }],
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
/**
|
|
3887
|
+
* Execute query in background and update job status
|
|
3888
|
+
*/
|
|
3889
|
+
async executeQueryBackground(jobId, databaseId, sql, timeoutMs) {
|
|
3890
|
+
const jobStore = getJobStore();
|
|
3891
|
+
const job = jobStore.get(jobId);
|
|
3892
|
+
|
|
3893
|
+
if (!job) return;
|
|
3894
|
+
|
|
3895
|
+
jobStore.markRunning(jobId);
|
|
3896
|
+
|
|
3897
|
+
try {
|
|
3898
|
+
const result = await this.metabaseClient.executeNativeQueryWithTimeout(
|
|
3899
|
+
databaseId,
|
|
3900
|
+
sql,
|
|
3901
|
+
timeoutMs,
|
|
3902
|
+
job.abortController.signal
|
|
3903
|
+
);
|
|
3904
|
+
|
|
3905
|
+
const rows = result.data?.rows || [];
|
|
3906
|
+
jobStore.markComplete(jobId, result, rows.length);
|
|
3907
|
+
|
|
3908
|
+
logger.info(`Query job ${jobId} completed with ${rows.length} rows`);
|
|
3909
|
+
|
|
3910
|
+
} catch (error) {
|
|
3911
|
+
if (error.message.includes('cancelled')) {
|
|
3912
|
+
jobStore.markCancelled(jobId);
|
|
3913
|
+
} else if (error.message.includes('timed out')) {
|
|
3914
|
+
jobStore.markTimeout(jobId);
|
|
3915
|
+
// Try to cancel on database
|
|
3916
|
+
await this.metabaseClient.cancelPostgresQuery(databaseId, `job:${jobId}`);
|
|
3917
|
+
} else {
|
|
3918
|
+
jobStore.markFailed(jobId, error);
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
logger.error(`Query job ${jobId} failed: ${error.message}`);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
/**
|
|
3926
|
+
* Check status of an async query
|
|
3927
|
+
*/
|
|
3928
|
+
async handleSQLStatus(args) {
|
|
3929
|
+
try {
|
|
3930
|
+
const jobStore = getJobStore();
|
|
3931
|
+
const job = jobStore.get(args.job_id);
|
|
3932
|
+
|
|
3933
|
+
if (!job) {
|
|
3934
|
+
return {
|
|
3935
|
+
content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }],
|
|
3936
|
+
};
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
const elapsedSeconds = jobStore.getElapsedSeconds(args.job_id);
|
|
3940
|
+
|
|
3941
|
+
let output = `📋 **Job Status: ${job.id}**\\n`;
|
|
3942
|
+
output += `📊 Status: ${job.status}\\n`;
|
|
3943
|
+
output += `⏱️ Elapsed: ${elapsedSeconds} seconds\\n`;
|
|
3944
|
+
|
|
3945
|
+
if (job.status === 'running' || job.status === 'pending') {
|
|
3946
|
+
output += `\\n💡 Query is still running. Check again later or use \`sql_cancel\` to stop.`;
|
|
3947
|
+
} else if (job.status === 'complete') {
|
|
3948
|
+
const rows = job.result?.data?.rows || [];
|
|
3949
|
+
const columns = job.result?.data?.cols || [];
|
|
3950
|
+
|
|
3951
|
+
output += `✅ **Query Complete!**\\n`;
|
|
3952
|
+
output += `📊 ${columns.length} columns, ${rows.length} rows\\n\\n`;
|
|
3953
|
+
|
|
3954
|
+
if (rows.length > 0) {
|
|
3955
|
+
output += `**Data:**\\n\`\`\`\\n`;
|
|
3956
|
+
const headers = columns.map(col => col.name);
|
|
3957
|
+
output += headers.join(' | ') + '\\n';
|
|
3958
|
+
output += headers.map(() => '---').join(' | ') + '\\n';
|
|
3959
|
+
|
|
3960
|
+
rows.slice(0, 5).forEach((row) => {
|
|
3961
|
+
const formattedRow = row.map(cell => {
|
|
3962
|
+
if (cell === null) return 'NULL';
|
|
3963
|
+
const str = String(cell);
|
|
3964
|
+
return str.length > 30 ? str.substring(0, 27) + '...' : str;
|
|
3965
|
+
});
|
|
3966
|
+
output += formattedRow.join(' | ') + '\\n';
|
|
3967
|
+
});
|
|
3968
|
+
output += '\`\`\`\\n';
|
|
3969
|
+
|
|
3970
|
+
if (rows.length > 5) {
|
|
3971
|
+
output += `_+${rows.length - 5} more rows_\\n`;
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
} else if (job.status === 'failed' || job.status === 'timeout' || job.status === 'cancelled') {
|
|
3975
|
+
output += `\\n❌ ${job.error || 'Query did not complete'}`;
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
return {
|
|
3979
|
+
content: [{ type: 'text', text: output }],
|
|
3980
|
+
};
|
|
3981
|
+
|
|
3982
|
+
} catch (error) {
|
|
3983
|
+
return {
|
|
3984
|
+
content: [{ type: 'text', text: `❌ Failed to check status: ${error.message}` }],
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
/**
|
|
3990
|
+
* Cancel a running async query
|
|
3991
|
+
*/
|
|
3992
|
+
async handleSQLCancel(args) {
|
|
3993
|
+
try {
|
|
3994
|
+
const jobStore = getJobStore();
|
|
3995
|
+
const job = jobStore.get(args.job_id);
|
|
3996
|
+
|
|
3997
|
+
if (!job) {
|
|
3998
|
+
return {
|
|
3999
|
+
content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }],
|
|
4000
|
+
};
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
if (job.status !== 'running' && job.status !== 'pending') {
|
|
4004
|
+
return {
|
|
4005
|
+
content: [{ type: 'text', text: `ℹ️ Job is not running (status: ${job.status})` }],
|
|
4006
|
+
};
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
// Abort the HTTP request
|
|
4010
|
+
job.abortController.abort();
|
|
4011
|
+
|
|
4012
|
+
// Try to cancel on database
|
|
4013
|
+
const dbCancelled = await this.metabaseClient.cancelPostgresQuery(
|
|
4014
|
+
job.database_id,
|
|
4015
|
+
`job:${job.id}`
|
|
4016
|
+
);
|
|
4017
|
+
|
|
4018
|
+
jobStore.markCancelled(args.job_id);
|
|
4019
|
+
|
|
4020
|
+
const output = `✅ **Query Cancelled**\\n` +
|
|
4021
|
+
`📋 Job ID: ${args.job_id}\\n` +
|
|
4022
|
+
`🗄️ Database cancel: ${dbCancelled ? 'sent' : 'not available'}`;
|
|
4023
|
+
|
|
4024
|
+
return {
|
|
4025
|
+
content: [{ type: 'text', text: output }],
|
|
4026
|
+
};
|
|
4027
|
+
|
|
4028
|
+
} catch (error) {
|
|
4029
|
+
return {
|
|
4030
|
+
content: [{ type: 'text', text: `❌ Failed to cancel: ${error.message}` }],
|
|
4031
|
+
};
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
|
|
3699
4035
|
async handleCreateQuestion(args) {
|
|
3700
4036
|
const question = await this.metabaseClient.createSQLQuestion(
|
|
3701
4037
|
args.name,
|
|
@@ -3863,16 +4199,47 @@ class MetabaseMCPServer {
|
|
|
3863
4199
|
args.parameter_mappings
|
|
3864
4200
|
);
|
|
3865
4201
|
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
4202
|
+
// VERIFICATION: Check if card was actually added
|
|
4203
|
+
try {
|
|
4204
|
+
const dashboard = await this.metabaseClient.getDashboard(args.dashboard_id);
|
|
4205
|
+
const cardExists = dashboard.ordered_cards?.some(c => c.card_id === args.question_id);
|
|
4206
|
+
const cardCount = dashboard.ordered_cards?.length || 0;
|
|
4207
|
+
|
|
4208
|
+
if (cardExists) {
|
|
4209
|
+
return {
|
|
4210
|
+
content: [{
|
|
4211
|
+
type: 'text',
|
|
4212
|
+
text: `✅ Card verified!\\n` +
|
|
4213
|
+
`Dashboard: ${dashboard.name} (ID: ${args.dashboard_id})\\n` +
|
|
4214
|
+
`Total cards: ${cardCount}`
|
|
4215
|
+
}],
|
|
4216
|
+
};
|
|
4217
|
+
} else {
|
|
4218
|
+
return {
|
|
4219
|
+
content: [{
|
|
4220
|
+
type: 'text',
|
|
4221
|
+
text: `⚠️ Card addition appears to have failed!\\n` +
|
|
4222
|
+
`API reported success but card was not found on dashboard.\\n` +
|
|
4223
|
+
`Dashboard ID: ${args.dashboard_id}, Question ID: ${args.question_id}\\n` +
|
|
4224
|
+
`Please verify the question ID is valid.`
|
|
4225
|
+
}],
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
4228
|
+
} catch (verifyError) {
|
|
4229
|
+
// Verification failed but original call might have succeeded
|
|
4230
|
+
return {
|
|
4231
|
+
content: [{
|
|
4232
|
+
type: 'text',
|
|
4233
|
+
text: `✅ Card added (verification unavailable)\\n` +
|
|
4234
|
+
`Dashboard ID: ${args.dashboard_id}\\n` +
|
|
4235
|
+
`Card ID: ${result?.id || 'N/A'}`
|
|
4236
|
+
}],
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
3872
4239
|
|
|
3873
4240
|
} catch (error) {
|
|
3874
4241
|
return {
|
|
3875
|
-
content: [{ type: 'text', text: `❌
|
|
4242
|
+
content: [{ type: 'text', text: `❌ Card addition error: ${error.message}` }],
|
|
3876
4243
|
};
|
|
3877
4244
|
}
|
|
3878
4245
|
}
|
|
@@ -5962,6 +6329,147 @@ class MetabaseMCPServer {
|
|
|
5962
6329
|
}
|
|
5963
6330
|
}
|
|
5964
6331
|
|
|
6332
|
+
/**
|
|
6333
|
+
* Handle table profile request - comprehensive table analysis
|
|
6334
|
+
* Automatically detects dim/ref tables and shows distinct values
|
|
6335
|
+
*/
|
|
6336
|
+
async handleTableProfile(args) {
|
|
6337
|
+
try {
|
|
6338
|
+
await this.ensureInitialized();
|
|
6339
|
+
|
|
6340
|
+
const schemaName = args.schema_name || 'public';
|
|
6341
|
+
const tableName = args.table_name;
|
|
6342
|
+
const showDistinct = args.show_distinct_values !== false;
|
|
6343
|
+
const sampleRows = args.sample_rows || 3;
|
|
6344
|
+
|
|
6345
|
+
// Detect if this is a dimension/reference table
|
|
6346
|
+
const isDimTable = /^(dim_|ref_|lookup_|lkp_|d_|r_)/i.test(tableName);
|
|
6347
|
+
|
|
6348
|
+
// Get row count first
|
|
6349
|
+
const countQuery = `SELECT COUNT(*) as cnt FROM "${schemaName}"."${tableName}"`;
|
|
6350
|
+
const countResult = await this.metabaseClient.executeNativeQuery(args.database_id, countQuery);
|
|
6351
|
+
const rowCount = countResult.data?.rows?.[0]?.[0] || 0;
|
|
6352
|
+
|
|
6353
|
+
// Get column info
|
|
6354
|
+
const columnsQuery = `
|
|
6355
|
+
SELECT
|
|
6356
|
+
column_name,
|
|
6357
|
+
data_type,
|
|
6358
|
+
is_nullable,
|
|
6359
|
+
column_default
|
|
6360
|
+
FROM information_schema.columns
|
|
6361
|
+
WHERE table_schema = '${schemaName}' AND table_name = '${tableName}'
|
|
6362
|
+
ORDER BY ordinal_position
|
|
6363
|
+
`;
|
|
6364
|
+
const columnsResult = await this.metabaseClient.executeNativeQuery(args.database_id, columnsQuery);
|
|
6365
|
+
const columns = columnsResult.data?.rows || [];
|
|
6366
|
+
|
|
6367
|
+
let output = '';
|
|
6368
|
+
|
|
6369
|
+
// Header with dim table indicator
|
|
6370
|
+
if (isDimTable) {
|
|
6371
|
+
output += `📊 **Dimension Table: ${schemaName}.${tableName}**\\n`;
|
|
6372
|
+
output += `🏷️ _Detected as lookup/reference table_\\n\\n`;
|
|
6373
|
+
} else {
|
|
6374
|
+
output += `📊 **Table Profile: ${schemaName}.${tableName}**\\n\\n`;
|
|
6375
|
+
}
|
|
6376
|
+
|
|
6377
|
+
output += `📈 **Overview:**\\n`;
|
|
6378
|
+
output += `• Row count: ${rowCount.toLocaleString()}\\n`;
|
|
6379
|
+
output += `• Columns: ${columns.length}\\n\\n`;
|
|
6380
|
+
|
|
6381
|
+
// Column details
|
|
6382
|
+
output += `📋 **Columns:**\\n`;
|
|
6383
|
+
columns.forEach(([name, type, nullable, defaultVal]) => {
|
|
6384
|
+
const nullIndicator = nullable === 'YES' ? '?' : '';
|
|
6385
|
+
output += `• \`${name}\` (${type}${nullIndicator})\\n`;
|
|
6386
|
+
});
|
|
6387
|
+
output += `\\n`;
|
|
6388
|
+
|
|
6389
|
+
// For dim tables or small tables, show distinct values
|
|
6390
|
+
if ((isDimTable || rowCount < 1000) && showDistinct && columns.length > 0) {
|
|
6391
|
+
output += `🔑 **Distinct Values:**\\n`;
|
|
6392
|
+
|
|
6393
|
+
// Get distinct counts and values for key columns (limit to first 5 columns)
|
|
6394
|
+
const keyColumns = columns.slice(0, 5);
|
|
6395
|
+
for (const [colName, colType] of keyColumns) {
|
|
6396
|
+
try {
|
|
6397
|
+
const distinctQuery = `
|
|
6398
|
+
SELECT "${colName}", COUNT(*) as cnt
|
|
6399
|
+
FROM "${schemaName}"."${tableName}"
|
|
6400
|
+
GROUP BY "${colName}"
|
|
6401
|
+
ORDER BY cnt DESC
|
|
6402
|
+
LIMIT 10
|
|
6403
|
+
`;
|
|
6404
|
+
const distinctResult = await this.metabaseClient.executeNativeQuery(args.database_id, distinctQuery);
|
|
6405
|
+
const distinctRows = distinctResult.data?.rows || [];
|
|
6406
|
+
|
|
6407
|
+
if (distinctRows.length > 0) {
|
|
6408
|
+
const totalDistinct = distinctRows.length;
|
|
6409
|
+
const values = distinctRows.slice(0, 5).map(r => r[0] === null ? 'NULL' : String(r[0])).join(', ');
|
|
6410
|
+
output += `• \`${colName}\`: ${values}${totalDistinct > 5 ? ` (+${totalDistinct - 5} more)` : ''}\\n`;
|
|
6411
|
+
}
|
|
6412
|
+
} catch (e) {
|
|
6413
|
+
// Skip columns that can't be queried
|
|
6414
|
+
}
|
|
6415
|
+
}
|
|
6416
|
+
output += `\\n`;
|
|
6417
|
+
}
|
|
6418
|
+
|
|
6419
|
+
// Sample data
|
|
6420
|
+
if (sampleRows > 0 && rowCount > 0) {
|
|
6421
|
+
try {
|
|
6422
|
+
const sampleQuery = `SELECT * FROM "${schemaName}"."${tableName}" LIMIT ${sampleRows}`;
|
|
6423
|
+
const sampleResult = await this.metabaseClient.executeNativeQuery(args.database_id, sampleQuery);
|
|
6424
|
+
const sampleData = sampleResult.data?.rows || [];
|
|
6425
|
+
const sampleCols = sampleResult.data?.cols || [];
|
|
6426
|
+
|
|
6427
|
+
if (sampleData.length > 0) {
|
|
6428
|
+
output += `📝 **Sample Data (${sampleData.length} rows):**\\n\`\`\`\\n`;
|
|
6429
|
+
// Header
|
|
6430
|
+
const headers = sampleCols.map(c => c.name);
|
|
6431
|
+
output += headers.join(' | ') + '\\n';
|
|
6432
|
+
output += headers.map(() => '---').join(' | ') + '\\n';
|
|
6433
|
+
// Data
|
|
6434
|
+
sampleData.forEach(row => {
|
|
6435
|
+
const formattedRow = row.map(cell => {
|
|
6436
|
+
if (cell === null) return 'NULL';
|
|
6437
|
+
const str = String(cell);
|
|
6438
|
+
return str.length > 20 ? str.substring(0, 17) + '...' : str;
|
|
6439
|
+
});
|
|
6440
|
+
output += formattedRow.join(' | ') + '\\n';
|
|
6441
|
+
});
|
|
6442
|
+
output += '\`\`\`\\n';
|
|
6443
|
+
}
|
|
6444
|
+
} catch (e) {
|
|
6445
|
+
output += `_Could not fetch sample data: ${e.message}_\\n`;
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
|
|
6449
|
+
// Recommendations
|
|
6450
|
+
output += `\\n💡 **Tips:**\\n`;
|
|
6451
|
+
if (isDimTable) {
|
|
6452
|
+
output += `• Use this table for JOINs as a lookup\\n`;
|
|
6453
|
+
output += `• Use \`mb_field_values\` to see all values for a specific column\\n`;
|
|
6454
|
+
}
|
|
6455
|
+
if (rowCount === 0) {
|
|
6456
|
+
output += `• ⚠️ Table is empty - data may need to be loaded\\n`;
|
|
6457
|
+
}
|
|
6458
|
+
if (rowCount > 100000) {
|
|
6459
|
+
output += `• Large table - use LIMIT in queries\\n`;
|
|
6460
|
+
}
|
|
6461
|
+
|
|
6462
|
+
return {
|
|
6463
|
+
content: [{ type: 'text', text: output }]
|
|
6464
|
+
};
|
|
6465
|
+
|
|
6466
|
+
} catch (error) {
|
|
6467
|
+
return {
|
|
6468
|
+
content: [{ type: 'text', text: `❌ Table profile error: ${error.message}` }]
|
|
6469
|
+
};
|
|
6470
|
+
}
|
|
6471
|
+
}
|
|
6472
|
+
|
|
5965
6473
|
async handleIndexUsage(args) {
|
|
5966
6474
|
try {
|
|
5967
6475
|
await this.ensureInitialized();
|
|
@@ -6205,8 +6713,19 @@ class MetabaseMCPServer {
|
|
|
6205
6713
|
};
|
|
6206
6714
|
|
|
6207
6715
|
} catch (error) {
|
|
6716
|
+
// Better error messages for common issues
|
|
6717
|
+
let userMessage = error.message;
|
|
6718
|
+
|
|
6719
|
+
if (error.message.includes('already exists') || error.response?.status === 409) {
|
|
6720
|
+
userMessage = `Collection already exists with this name: "${args.name}"`;
|
|
6721
|
+
} else if (error.message.includes('permission') || error.response?.status === 403) {
|
|
6722
|
+
userMessage = `Permission denied. Contact admin for collection creation access.`;
|
|
6723
|
+
} else if (error.message.includes('parent') || (error.message.includes('not found') && args.parent_id)) {
|
|
6724
|
+
userMessage = `Parent collection not found: ID ${args.parent_id}`;
|
|
6725
|
+
}
|
|
6726
|
+
|
|
6208
6727
|
return {
|
|
6209
|
-
content: [{ type: 'text', text: `❌ Collection creation failed: ${
|
|
6728
|
+
content: [{ type: 'text', text: `❌ Collection creation failed: ${userMessage}` }]
|
|
6210
6729
|
};
|
|
6211
6730
|
}
|
|
6212
6731
|
}
|
package/src/metabase/client.js
CHANGED
|
@@ -8,8 +8,10 @@ export class MetabaseClient {
|
|
|
8
8
|
this.password = config.password;
|
|
9
9
|
this.apiKey = config.apiKey;
|
|
10
10
|
this.sessionToken = null;
|
|
11
|
+
this.defaultQueryTimeout = config.queryTimeout || 60000; // 60 seconds default
|
|
11
12
|
this.client = axios.create({
|
|
12
13
|
baseURL: this.baseURL,
|
|
14
|
+
timeout: this.defaultQueryTimeout,
|
|
13
15
|
headers: {
|
|
14
16
|
'Content-Type': 'application/json'
|
|
15
17
|
}
|
|
@@ -79,7 +81,7 @@ export class MetabaseClient {
|
|
|
79
81
|
|
|
80
82
|
async getDatabaseConnectionInfo(id) {
|
|
81
83
|
await this.ensureAuthenticated();
|
|
82
|
-
|
|
84
|
+
|
|
83
85
|
// Önce gerçek credentials'ları MetabaseappDB'den al
|
|
84
86
|
try {
|
|
85
87
|
const realCredentials = await this.getRealCredentials(id);
|
|
@@ -89,11 +91,11 @@ export class MetabaseClient {
|
|
|
89
91
|
} catch (error) {
|
|
90
92
|
logger.warn('Could not get real credentials, using API response:', error.message);
|
|
91
93
|
}
|
|
92
|
-
|
|
94
|
+
|
|
93
95
|
// Fallback: Normal API response
|
|
94
96
|
const response = await this.client.get(`/api/database/${id}`);
|
|
95
97
|
const db = response.data;
|
|
96
|
-
|
|
98
|
+
|
|
97
99
|
return {
|
|
98
100
|
id: db.id,
|
|
99
101
|
name: db.name,
|
|
@@ -116,13 +118,13 @@ export class MetabaseClient {
|
|
|
116
118
|
FROM metabase_database
|
|
117
119
|
WHERE id = ${databaseId}
|
|
118
120
|
`;
|
|
119
|
-
|
|
121
|
+
|
|
120
122
|
const result = await this.executeNativeQuery(6, query, { enforcePrefix: false }); // MetabaseappDB
|
|
121
|
-
|
|
123
|
+
|
|
122
124
|
if (result.data.rows.length > 0) {
|
|
123
125
|
const [name, engine, details] = result.data.rows[0];
|
|
124
126
|
const detailsObj = JSON.parse(details);
|
|
125
|
-
|
|
127
|
+
|
|
126
128
|
return {
|
|
127
129
|
id: databaseId,
|
|
128
130
|
name: name,
|
|
@@ -137,13 +139,13 @@ export class MetabaseClient {
|
|
|
137
139
|
tunnel_enabled: detailsObj['tunnel-enabled'] || false
|
|
138
140
|
};
|
|
139
141
|
}
|
|
140
|
-
|
|
142
|
+
|
|
141
143
|
return null;
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
buildConnectionString(db) {
|
|
145
147
|
const details = db.details;
|
|
146
|
-
|
|
148
|
+
|
|
147
149
|
switch (db.engine) {
|
|
148
150
|
case 'postgres':
|
|
149
151
|
return `postgresql://${details.user}:${details.password}@${details.host}:${details.port}/${details.dbname}`;
|
|
@@ -252,7 +254,7 @@ export class MetabaseClient {
|
|
|
252
254
|
|
|
253
255
|
async createParametricQuestion(questionData) {
|
|
254
256
|
await this.ensureAuthenticated();
|
|
255
|
-
|
|
257
|
+
|
|
256
258
|
// Build native query with parameters
|
|
257
259
|
const nativeQuery = {
|
|
258
260
|
type: 'native',
|
|
@@ -304,17 +306,17 @@ export class MetabaseClient {
|
|
|
304
306
|
// SQL Operations
|
|
305
307
|
async executeNativeQuery(databaseId, sql, options = {}) {
|
|
306
308
|
await this.ensureAuthenticated();
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
+
|
|
310
|
+
// Security check - DDL operations require prefix
|
|
309
311
|
if (options.enforcePrefix !== false && this.isDDLOperation(sql)) {
|
|
310
312
|
this.validateDDLPrefix(sql);
|
|
311
313
|
}
|
|
312
314
|
|
|
313
|
-
// DDL operations
|
|
315
|
+
// DDL operations use different endpoint
|
|
314
316
|
if (this.isDDLOperation(sql)) {
|
|
315
317
|
return await this.executeDDLOperation(databaseId, sql);
|
|
316
318
|
}
|
|
317
|
-
|
|
319
|
+
|
|
318
320
|
const query = {
|
|
319
321
|
database: databaseId,
|
|
320
322
|
type: 'native',
|
|
@@ -325,6 +327,68 @@ export class MetabaseClient {
|
|
|
325
327
|
return await this.runQuery(query);
|
|
326
328
|
}
|
|
327
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Execute query with custom timeout and abort signal
|
|
332
|
+
* Used for async query management
|
|
333
|
+
*/
|
|
334
|
+
async executeNativeQueryWithTimeout(databaseId, sql, timeoutMs, abortSignal = null) {
|
|
335
|
+
await this.ensureAuthenticated();
|
|
336
|
+
|
|
337
|
+
const query = {
|
|
338
|
+
database: databaseId,
|
|
339
|
+
type: 'native',
|
|
340
|
+
native: {
|
|
341
|
+
query: sql
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const config = {
|
|
346
|
+
timeout: timeoutMs
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Add abort signal if provided
|
|
350
|
+
if (abortSignal) {
|
|
351
|
+
config.signal = abortSignal;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
const response = await this.client.post('/api/dataset', query, config);
|
|
356
|
+
return response.data;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
|
359
|
+
throw new Error('Query cancelled');
|
|
360
|
+
}
|
|
361
|
+
if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
|
|
362
|
+
throw new Error(`Query timed out after ${timeoutMs / 1000} seconds`);
|
|
363
|
+
}
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Cancel a running query on PostgreSQL database
|
|
370
|
+
* This sends pg_cancel_backend to stop the query on the server side
|
|
371
|
+
*/
|
|
372
|
+
async cancelPostgresQuery(databaseId, queryMarker) {
|
|
373
|
+
try {
|
|
374
|
+
// Find and cancel queries containing the marker
|
|
375
|
+
const cancelSql = `
|
|
376
|
+
SELECT pg_cancel_backend(pid)
|
|
377
|
+
FROM pg_stat_activity
|
|
378
|
+
WHERE query LIKE '%${queryMarker}%'
|
|
379
|
+
AND state = 'active'
|
|
380
|
+
AND pid != pg_backend_pid()
|
|
381
|
+
`;
|
|
382
|
+
|
|
383
|
+
await this.executeNativeQuery(databaseId, cancelSql, { enforcePrefix: false });
|
|
384
|
+
logger.info(`Attempted to cancel query with marker: ${queryMarker}`);
|
|
385
|
+
return true;
|
|
386
|
+
} catch (error) {
|
|
387
|
+
logger.warn(`Failed to cancel query: ${error.message}`);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
328
392
|
async executeDDLOperation(databaseId, sql) {
|
|
329
393
|
try {
|
|
330
394
|
// DDL için action endpoint kullan
|
|
@@ -333,7 +397,7 @@ export class MetabaseClient {
|
|
|
333
397
|
sql: sql,
|
|
334
398
|
type: 'query'
|
|
335
399
|
});
|
|
336
|
-
|
|
400
|
+
|
|
337
401
|
return {
|
|
338
402
|
status: 'success',
|
|
339
403
|
message: 'DDL operation completed',
|
|
@@ -350,7 +414,7 @@ export class MetabaseClient {
|
|
|
350
414
|
query: sql
|
|
351
415
|
}
|
|
352
416
|
};
|
|
353
|
-
|
|
417
|
+
|
|
354
418
|
await this.runQuery(query);
|
|
355
419
|
return {
|
|
356
420
|
status: 'success',
|
|
@@ -360,7 +424,7 @@ export class MetabaseClient {
|
|
|
360
424
|
};
|
|
361
425
|
} catch (secondError) {
|
|
362
426
|
logger.warn('DDL execution warning:', secondError.message);
|
|
363
|
-
|
|
427
|
+
|
|
364
428
|
// DDL işlemi başarılı olmuş olabilir, kontrol et
|
|
365
429
|
if (secondError.message.includes('Select statement did not produce a ResultSet')) {
|
|
366
430
|
return {
|
|
@@ -371,7 +435,7 @@ export class MetabaseClient {
|
|
|
371
435
|
warning: secondError.message
|
|
372
436
|
};
|
|
373
437
|
}
|
|
374
|
-
|
|
438
|
+
|
|
375
439
|
throw secondError;
|
|
376
440
|
}
|
|
377
441
|
}
|
|
@@ -380,29 +444,29 @@ export class MetabaseClient {
|
|
|
380
444
|
isDDLOperation(sql) {
|
|
381
445
|
const upperSQL = sql.toUpperCase().trim();
|
|
382
446
|
return upperSQL.startsWith('CREATE TABLE') ||
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
447
|
+
upperSQL.startsWith('CREATE VIEW') ||
|
|
448
|
+
upperSQL.startsWith('CREATE MATERIALIZED VIEW') ||
|
|
449
|
+
upperSQL.startsWith('CREATE INDEX') ||
|
|
450
|
+
upperSQL.startsWith('DROP TABLE') ||
|
|
451
|
+
upperSQL.startsWith('DROP VIEW') ||
|
|
452
|
+
upperSQL.startsWith('DROP MATERIALIZED VIEW') ||
|
|
453
|
+
upperSQL.startsWith('DROP INDEX');
|
|
390
454
|
}
|
|
391
455
|
|
|
392
456
|
validateDDLPrefix(sql) {
|
|
393
457
|
const upperSQL = sql.toUpperCase();
|
|
394
|
-
|
|
458
|
+
|
|
395
459
|
// CREATE operations için prefix kontrolü
|
|
396
|
-
if (upperSQL.includes('CREATE TABLE') || upperSQL.includes('CREATE VIEW') ||
|
|
397
|
-
|
|
460
|
+
if (upperSQL.includes('CREATE TABLE') || upperSQL.includes('CREATE VIEW') ||
|
|
461
|
+
upperSQL.includes('CREATE MATERIALIZED VIEW') || upperSQL.includes('CREATE INDEX')) {
|
|
398
462
|
if (!sql.toLowerCase().includes('claude_ai_')) {
|
|
399
463
|
throw new Error('DDL operations must use claude_ai_ prefix for object names');
|
|
400
464
|
}
|
|
401
465
|
}
|
|
402
|
-
|
|
466
|
+
|
|
403
467
|
// DROP operations için sadece prefix'li objelere izin
|
|
404
|
-
if (upperSQL.includes('DROP TABLE') || upperSQL.includes('DROP VIEW') ||
|
|
405
|
-
|
|
468
|
+
if (upperSQL.includes('DROP TABLE') || upperSQL.includes('DROP VIEW') ||
|
|
469
|
+
upperSQL.includes('DROP MATERIALIZED VIEW') || upperSQL.includes('DROP INDEX')) {
|
|
406
470
|
if (!sql.toLowerCase().includes('claude_ai_')) {
|
|
407
471
|
throw new Error('Can only drop objects with claude_ai_ prefix');
|
|
408
472
|
}
|
|
@@ -470,14 +534,14 @@ export class MetabaseClient {
|
|
|
470
534
|
|
|
471
535
|
async addCardToDashboard(dashboardId, cardId, options = {}) {
|
|
472
536
|
await this.ensureAuthenticated();
|
|
473
|
-
|
|
537
|
+
|
|
474
538
|
try {
|
|
475
539
|
// Try the API first (various endpoints)
|
|
476
540
|
const endpoints = [
|
|
477
541
|
`/api/dashboard/${dashboardId}/cards`,
|
|
478
542
|
`/api/dashboard/${dashboardId}/dashcard`
|
|
479
543
|
];
|
|
480
|
-
|
|
544
|
+
|
|
481
545
|
for (const endpoint of endpoints) {
|
|
482
546
|
try {
|
|
483
547
|
const response = await this.client.post(endpoint, {
|
|
@@ -494,10 +558,10 @@ export class MetabaseClient {
|
|
|
494
558
|
continue;
|
|
495
559
|
}
|
|
496
560
|
}
|
|
497
|
-
|
|
561
|
+
|
|
498
562
|
// If API fails, use direct database insertion as fallback
|
|
499
563
|
return await this.addCardToDashboardDirect(dashboardId, cardId, options);
|
|
500
|
-
|
|
564
|
+
|
|
501
565
|
} catch (error) {
|
|
502
566
|
throw new Error(`Failed to add card to dashboard: ${error.message}`);
|
|
503
567
|
}
|
|
@@ -523,10 +587,10 @@ export class MetabaseClient {
|
|
|
523
587
|
$1, $2, $3, $4, $5, $6, $7, $8
|
|
524
588
|
) RETURNING id
|
|
525
589
|
`;
|
|
526
|
-
|
|
590
|
+
|
|
527
591
|
const values = [
|
|
528
592
|
options.sizeX || 4,
|
|
529
|
-
options.sizeY || 4,
|
|
593
|
+
options.sizeY || 4,
|
|
530
594
|
options.row || 0,
|
|
531
595
|
options.col || 0,
|
|
532
596
|
cardId,
|
|
@@ -534,7 +598,7 @@ export class MetabaseClient {
|
|
|
534
598
|
JSON.stringify(options.parameter_mappings || []),
|
|
535
599
|
JSON.stringify(options.visualization_settings || {})
|
|
536
600
|
];
|
|
537
|
-
|
|
601
|
+
|
|
538
602
|
// This would need database connection - placeholder for now
|
|
539
603
|
return { id: 'inserted_via_sql', method: 'direct_sql' };
|
|
540
604
|
}
|
|
@@ -547,11 +611,11 @@ export class MetabaseClient {
|
|
|
547
611
|
|
|
548
612
|
async addDashboardFilter(dashboardId, filter) {
|
|
549
613
|
await this.ensureAuthenticated();
|
|
550
|
-
|
|
614
|
+
|
|
551
615
|
// Get current dashboard to add filter
|
|
552
616
|
const dashboard = await this.client.get(`/api/dashboard/${dashboardId}`);
|
|
553
617
|
const currentFilters = dashboard.data.parameters || [];
|
|
554
|
-
|
|
618
|
+
|
|
555
619
|
// Create proper Metabase filter format
|
|
556
620
|
const newFilter = {
|
|
557
621
|
id: this.generateFilterId(),
|
|
@@ -567,9 +631,9 @@ export class MetabaseClient {
|
|
|
567
631
|
} else if (filter.default_value !== undefined) {
|
|
568
632
|
newFilter.default = filter.default_value;
|
|
569
633
|
}
|
|
570
|
-
|
|
634
|
+
|
|
571
635
|
const updatedFilters = [...currentFilters, newFilter];
|
|
572
|
-
|
|
636
|
+
|
|
573
637
|
return await this.updateDashboard(dashboardId, {
|
|
574
638
|
parameters: updatedFilters
|
|
575
639
|
});
|