@onmartech/metabase-ai-assistant 4.0.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 (40) hide show
  1. package/.env.example +38 -0
  2. package/LICENSE +201 -0
  3. package/README.md +364 -0
  4. package/README_MCP.md +279 -0
  5. package/package.json +99 -0
  6. package/src/ai/assistant.js +982 -0
  7. package/src/cli/interactive.js +500 -0
  8. package/src/database/connection-manager.js +350 -0
  9. package/src/database/direct-client.js +686 -0
  10. package/src/index.js +162 -0
  11. package/src/mcp/handlers/actions.js +213 -0
  12. package/src/mcp/handlers/ai.js +207 -0
  13. package/src/mcp/handlers/analytics.js +1647 -0
  14. package/src/mcp/handlers/cards.js +1544 -0
  15. package/src/mcp/handlers/collections.js +244 -0
  16. package/src/mcp/handlers/dashboard.js +207 -0
  17. package/src/mcp/handlers/dashboard_direct.js +292 -0
  18. package/src/mcp/handlers/database.js +322 -0
  19. package/src/mcp/handlers/docs.js +399 -0
  20. package/src/mcp/handlers/index.js +35 -0
  21. package/src/mcp/handlers/metadata.js +190 -0
  22. package/src/mcp/handlers/questions.js +134 -0
  23. package/src/mcp/handlers/schema.js +1699 -0
  24. package/src/mcp/handlers/sql.js +559 -0
  25. package/src/mcp/handlers/users.js +251 -0
  26. package/src/mcp/job-store.js +199 -0
  27. package/src/mcp/server.js +428 -0
  28. package/src/mcp/tool-registry.js +3244 -0
  29. package/src/mcp/tool-router.js +149 -0
  30. package/src/metabase/client.js +737 -0
  31. package/src/metabase/metadata-client.js +1852 -0
  32. package/src/utils/activity-logger.js +489 -0
  33. package/src/utils/cache.js +176 -0
  34. package/src/utils/config.js +131 -0
  35. package/src/utils/definition-tables.js +938 -0
  36. package/src/utils/file-operations.js +496 -0
  37. package/src/utils/logger.js +45 -0
  38. package/src/utils/parametric-questions.js +627 -0
  39. package/src/utils/response-optimizer.js +190 -0
  40. package/src/utils/sql-sanitizer.js +97 -0
@@ -0,0 +1,559 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { logger } from '../../utils/logger.js';
3
+ import { CacheKeys, globalCache } from '../../utils/cache.js';
4
+ import { getJobStore } from '../job-store.js';
5
+ import {
6
+ ResponseFormat,
7
+ formatListResponse,
8
+ minimalDatabase,
9
+ } from '../../utils/response-optimizer.js';
10
+
11
+ /**
12
+ * Handler for SQL Execution Operations
13
+ */
14
+ export class SqlHandler {
15
+ constructor(metabaseClient, cache, activityLogger, aiAssistant) {
16
+ this.metabaseClient = metabaseClient;
17
+ this.cache = cache;
18
+ this.activeJobs = new Map();
19
+ this.activityLogger = activityLogger || null;
20
+ this.aiAssistant = aiAssistant || null;
21
+ this.jobCounter = 0;
22
+ }
23
+
24
+ routes() {
25
+ return {
26
+ 'sql_execute': (args) => this.handleExecuteSQL(args),
27
+ 'sql_submit': (args) => this.handleSQLSubmit(args),
28
+ 'sql_status': (args) => this.handleSQLStatus(args),
29
+ 'sql_cancel': (args) => this.handleSQLCancel(args),
30
+ };
31
+ }
32
+
33
+ async handleExecuteSQL(args) {
34
+ const databaseId = args.database_id;
35
+ const sql = args.sql;
36
+ const fullResults = args.full_results === true;
37
+
38
+ // Read-Only Mode Security Check
39
+ const isReadOnlyMode = process.env.METABASE_READ_ONLY_MODE !== 'false';
40
+ if (isReadOnlyMode) {
41
+ const writePattern = /\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE|EXEC|EXECUTE)\b/i;
42
+ if (writePattern.test(sql)) {
43
+ const blockedOperation = sql.match(writePattern)?.[0]?.toUpperCase() || 'WRITE';
44
+ logger.warn(`Read-only mode: Blocked ${blockedOperation} operation`, { sql: sql.substring(0, 100) });
45
+
46
+ return {
47
+ content: [
48
+ {
49
+ type: 'text',
50
+ text: `🔒 **Read-Only Mode Active**\\n\\n` +
51
+ `⛔ **Operation Blocked:** \`${blockedOperation}\`\\n\\n` +
52
+ `This MCP server is running in read-only mode for security.\\n` +
53
+ `Write operations (INSERT, UPDATE, DELETE, DROP, etc.) are not allowed.\\n\\n` +
54
+ `To enable write operations, set \`METABASE_READ_ONLY_MODE=false\` in your environment.\\n\\n` +
55
+ `🔍 **Attempted Query:**\\n\`\`\`sql\\n${sql.substring(0, 200)}${sql.length > 200 ? '...' : ''}\\n\`\`\``,
56
+ },
57
+ ],
58
+ };
59
+ }
60
+ }
61
+
62
+ const startTime = Date.now();
63
+ let result = null;
64
+ let error = null;
65
+
66
+ try {
67
+ result = await this.metabaseClient.executeNativeQuery(databaseId, sql);
68
+ const executionTime = Date.now() - startTime;
69
+
70
+ // Log the activity
71
+ if (this.activityLogger) {
72
+ await this.activityLogger.logSQLExecution(sql, databaseId, result, executionTime);
73
+ }
74
+
75
+ // Format the result for display
76
+ const rows = result.data.rows || [];
77
+ const columns = result.data.cols || [];
78
+
79
+ let output = `✅ **Query successful** (${executionTime}ms)\\n`;
80
+ output += `📊 ${columns.length} columns, ${rows.length} rows\\n\\n`;
81
+
82
+ if (rows.length > 0) {
83
+ // Show sample data (max 5 rows)
84
+ output += `**Data:**\\n\`\`\`\\n`;
85
+ const headers = columns.map(col => col.name);
86
+ output += headers.join(' | ') + '\\n';
87
+ output += headers.map(() => '---').join(' | ') + '\\n';
88
+
89
+ rows.slice(0, 5).forEach((row) => {
90
+ const formattedRow = row.map(cell => {
91
+ if (cell === null) return 'NULL';
92
+
93
+ // Smart truncation logic
94
+ let truncateLimit = 100; // Increased base limit from 30
95
+
96
+ // Disable truncation for small result sets (DDL/procedures) or explicit full_results
97
+ if (fullResults || rows.length <= 2) {
98
+ truncateLimit = 50000;
99
+ }
100
+ // Check specific DDL-related column names
101
+ else if (columns.some(c => /definition|ddl|source|create_statement|routine_definition/i.test(c.name))) {
102
+ truncateLimit = 10000;
103
+ }
104
+
105
+ if (typeof cell === 'string' && cell.length > truncateLimit) {
106
+ return cell.substring(0, truncateLimit - 3) + '...';
107
+ }
108
+ return String(cell);
109
+ });
110
+ output += formattedRow.join(' | ') + '\\n';
111
+ });
112
+ output += '\`\`\`\\n';
113
+
114
+ if (rows.length > 5) {
115
+ output += `_+${rows.length - 5} more rows_\\n`;
116
+ }
117
+
118
+ // Large result warning
119
+ if (rows.length > 100) {
120
+ output += `\\n⚠️ **Large result:** ${rows.length} rows returned. Use LIMIT for better performance.\\n`;
121
+ }
122
+ } else {
123
+ // Empty result - smart detection
124
+ output += `ℹ️ No results.\\n`;
125
+
126
+ // Try to detect if table has data but query returned nothing
127
+ try {
128
+ const fromMatch = sql.match(/FROM\s+["']?([^\s"'.(]+)["']?/i) ||
129
+ sql.match(/FROM\s+["']?[^"'.]+["']?\.["']?([^\s"']+)["']?/i);
130
+ if (fromMatch) {
131
+ const tableName = fromMatch[1];
132
+ const countQuery = `SELECT COUNT(*) FROM ${tableName} LIMIT 1`;
133
+ try {
134
+ const countResult = await this.metabaseClient.executeNativeQuery(databaseId, countQuery);
135
+ const tableRowCount = countResult.data?.rows?.[0]?.[0] || 0;
136
+
137
+ if (tableRowCount > 0) {
138
+ output += `\\n⚠️ **Note:** \`${tableName}\` has ${tableRowCount.toLocaleString()} rows but query returned nothing.\\n`;
139
+ output += `Possible causes: WHERE clause too restrictive, column name typo, JOIN mismatch\\n`;
140
+ output += `💡 Use \`db_table_profile\` to inspect table structure.\\n`;
141
+ }
142
+ } catch (e) { /* ignore */ }
143
+ }
144
+ } catch (e) { /* ignore */ }
145
+ }
146
+
147
+ // Tool suggestions (only for SELECT queries with few results)
148
+ if (sql.toLowerCase().trim().startsWith('select') && rows.length <= 5) {
149
+ output += `\\n💡 Related: \`db_table_profile\`, \`mb_field_values\`\\n`;
150
+ }
151
+
152
+ return {
153
+ content: [
154
+ {
155
+ type: 'text',
156
+ text: output,
157
+ },
158
+ ],
159
+ };
160
+
161
+ } catch (err) {
162
+ error = err;
163
+ const executionTime = Date.now() - startTime;
164
+
165
+ // Log the failed activity
166
+ if (this.activityLogger) {
167
+ await this.activityLogger.logActivity({
168
+ operation_type: 'sql_execute',
169
+ operation_category: 'query',
170
+ database_id: databaseId,
171
+ source_sql: sql,
172
+ execution_time_ms: executionTime,
173
+ status: 'error',
174
+ error_message: err.message
175
+ });
176
+ }
177
+
178
+ // Compact error format - no query repetition
179
+ const shortSql = sql.length > 80 ? sql.substring(0, 77) + '...' : sql;
180
+ const output = `❌ SQL Error: ${err.message}\\nQuery: ${shortSql}`;
181
+
182
+ return {
183
+ content: [
184
+ {
185
+ type: 'text',
186
+ text: output,
187
+ },
188
+ ],
189
+ };
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Submit a long-running SQL query asynchronously
195
+ * Returns immediately with job_id, executes query in background
196
+ */
197
+ async handleSQLSubmit(args) {
198
+ try {
199
+ const databaseId = args.database_id;
200
+ const sql = args.sql;
201
+ const timeoutSeconds = Math.min(args.timeout_seconds || 300, 1800); // Max 30 minutes
202
+
203
+ // Check read-only mode for write operations
204
+ if (isReadOnlyMode() && detectWriteOperation(sql)) {
205
+ return {
206
+ content: [{ type: 'text', text: '❌ Write operations blocked in read-only mode' }],
207
+ };
208
+ }
209
+
210
+ // Get job store and create job
211
+ const jobStore = getJobStore();
212
+ const job = jobStore.create(databaseId, sql, timeoutSeconds);
213
+
214
+ // Add job marker to SQL for cancellation support
215
+ const markedSql = `/* job:${job.id} */ ${sql}`;
216
+
217
+ // Start query execution in background (non-blocking)
218
+ this.executeQueryBackground(job.id, databaseId, markedSql, timeoutSeconds * 1000);
219
+
220
+ const output = `✅ **Query Submitted**\\n` +
221
+ `📋 Job ID: \`${job.id}\`\\n` +
222
+ `⏱️ Timeout: ${timeoutSeconds} seconds\\n` +
223
+ `📊 Status: pending\\n\\n` +
224
+ `💡 Use \`sql_status\` with this job_id to check progress.`;
225
+
226
+ return {
227
+ content: [{ type: 'text', text: output }],
228
+ };
229
+
230
+ } catch (error) {
231
+ return {
232
+ content: [{ type: 'text', text: `❌ Failed to submit query: ${error.message}` }],
233
+ };
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Execute query in background and update job status
239
+ */
240
+ async executeQueryBackground(jobId, databaseId, sql, timeoutMs) {
241
+ const jobStore = getJobStore();
242
+ const job = jobStore.get(jobId);
243
+
244
+ if (!job) return;
245
+
246
+ jobStore.markRunning(jobId);
247
+
248
+ try {
249
+ const result = await this.metabaseClient.executeNativeQueryWithTimeout(
250
+ databaseId,
251
+ sql,
252
+ timeoutMs,
253
+ job.abortController.signal
254
+ );
255
+
256
+ const rows = result.data?.rows || [];
257
+ jobStore.markComplete(jobId, result, rows.length);
258
+
259
+ logger.info(`Query job ${jobId} completed with ${rows.length} rows`);
260
+
261
+ } catch (error) {
262
+ if (error.message.includes('cancelled')) {
263
+ jobStore.markCancelled(jobId);
264
+ } else if (error.message.includes('timed out')) {
265
+ jobStore.markTimeout(jobId);
266
+ // Try to cancel on database
267
+ await this.metabaseClient.cancelPostgresQuery(databaseId, `job:${jobId}`);
268
+ } else {
269
+ jobStore.markFailed(jobId, error);
270
+ }
271
+
272
+ logger.error(`Query job ${jobId} failed: ${error.message}`);
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Check status of an async query
278
+ */
279
+ async handleSQLStatus(args) {
280
+ try {
281
+ const jobStore = getJobStore();
282
+ const job = jobStore.get(args.job_id);
283
+
284
+ if (!job) {
285
+ return {
286
+ content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }],
287
+ };
288
+ }
289
+
290
+ const elapsedSeconds = jobStore.getElapsedSeconds(args.job_id);
291
+
292
+ let output = `📋 **Job Status: ${job.id}**\\n`;
293
+ output += `📊 Status: ${job.status}\\n`;
294
+ output += `⏱️ Elapsed: ${elapsedSeconds} seconds\\n`;
295
+
296
+ if (job.status === 'running' || job.status === 'pending') {
297
+ let waitSeconds = 3;
298
+ if (elapsedSeconds > 60) waitSeconds = 30;
299
+ else if (elapsedSeconds > 30) waitSeconds = 10;
300
+ else if (elapsedSeconds > 10) waitSeconds = 5;
301
+
302
+ output += `\\n💡 Query is still running. Please wait **${waitSeconds} seconds** before checking again.\\n`;
303
+ output += `(Use \`sql_cancel\` to stop if needed)`;
304
+ } else if (job.status === 'complete') {
305
+ const rows = job.result?.data?.rows || [];
306
+ const columns = job.result?.data?.cols || [];
307
+
308
+ output += `✅ **Query Complete!**\\n`;
309
+ output += `📊 ${columns.length} columns, ${rows.length} rows\\n\\n`;
310
+
311
+ if (rows.length > 0) {
312
+ output += `**Data:**\\n\`\`\`\\n`;
313
+ const headers = columns.map(col => col.name);
314
+ output += headers.join(' | ') + '\\n';
315
+ output += headers.map(() => '---').join(' | ') + '\\n';
316
+
317
+ rows.slice(0, 5).forEach((row) => {
318
+ const formattedRow = row.map(cell => {
319
+ if (cell === null) return 'NULL';
320
+ const str = String(cell);
321
+ return str.length > 30 ? str.substring(0, 27) + '...' : str;
322
+ });
323
+ output += formattedRow.join(' | ') + '\\n';
324
+ });
325
+ output += '\`\`\`\\n';
326
+
327
+ if (rows.length > 5) {
328
+ output += `_+${rows.length - 5} more rows_\\n`;
329
+ }
330
+ }
331
+ } else if (job.status === 'failed' || job.status === 'timeout' || job.status === 'cancelled') {
332
+ output += `\\n❌ ${job.error || 'Query did not complete'}`;
333
+ }
334
+
335
+ return {
336
+ content: [{ type: 'text', text: output }],
337
+ };
338
+
339
+ } catch (error) {
340
+ return {
341
+ content: [{ type: 'text', text: `❌ Failed to check status: ${error.message}` }],
342
+ };
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Cancel a running async query
348
+ */
349
+ async handleSQLCancel(args) {
350
+ try {
351
+ const jobStore = getJobStore();
352
+ const job = jobStore.get(args.job_id);
353
+
354
+ if (!job) {
355
+ return {
356
+ content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }],
357
+ };
358
+ }
359
+
360
+ if (job.status !== 'running' && job.status !== 'pending') {
361
+ return {
362
+ content: [{ type: 'text', text: `ℹ️ Job is not running (status: ${job.status})` }],
363
+ };
364
+ }
365
+
366
+ // Abort the HTTP request
367
+ job.abortController.abort();
368
+
369
+ // Try to cancel on database
370
+ const dbCancelled = await this.metabaseClient.cancelPostgresQuery(
371
+ job.database_id,
372
+ `job:${job.id}`
373
+ );
374
+
375
+ jobStore.markCancelled(args.job_id);
376
+
377
+ const output = `✅ **Query Cancelled**\\n` +
378
+ `📋 Job ID: ${args.job_id}\\n` +
379
+ `🗄️ Database cancel: ${dbCancelled ? 'sent' : 'not available'}`;
380
+
381
+ return {
382
+ content: [{ type: 'text', text: output }],
383
+ };
384
+
385
+ } catch (error) {
386
+ return {
387
+ content: [{ type: 'text', text: `❌ Failed to cancel: ${error.message}` }],
388
+ };
389
+ }
390
+ }
391
+
392
+
393
+ async handleGetDatabases() {
394
+
395
+ // Use cache for database list
396
+ const cacheKey = CacheKeys.databases();
397
+ const cached = await this.cache.getOrSet(cacheKey, async () => {
398
+ const response = await this.metabaseClient.getDatabases();
399
+ return response.data || response;
400
+ });
401
+
402
+ const databases = cached.data;
403
+ const source = cached.source;
404
+
405
+ // Log cache status
406
+ if (source === 'cache') {
407
+ logger.debug('Databases fetched from cache');
408
+ }
409
+
410
+ // Use response optimizer for compact output
411
+ const optimizedResponse = formatListResponse(
412
+ '📊 Available Databases',
413
+ databases,
414
+ minimalDatabase,
415
+ { format: ResponseFormat.COMPACT }
416
+ );
417
+
418
+ // If optimization returned a result, use it; otherwise fall back to standard format
419
+ if (optimizedResponse) {
420
+ // Add cache indicator
421
+ optimizedResponse.content[0].text += source === 'cache' ? '\\n\\n_📦 From cache_' : '';
422
+ return optimizedResponse;
423
+ }
424
+
425
+ return {
426
+ content: [
427
+ {
428
+ type: 'text',
429
+ text: `Found ${databases.length} databases:\\n${databases
430
+ .map(db => `- ${db.name} (${db.engine}) - ID: ${db.id}`)
431
+ .join('\\n')}${source === 'cache' ? '\\n\\n_📦 From cache_' : ''}`,
432
+ },
433
+ ],
434
+ };
435
+ }
436
+
437
+
438
+ async handleGetDatabaseSchemas(args) {
439
+
440
+ const response = await this.metabaseClient.getDatabaseSchemas(args.database_id || args);
441
+
442
+ return {
443
+ content: [
444
+ {
445
+ type: 'text',
446
+ text: `Database Schemas:\n${JSON.stringify(response, null, 2)}`,
447
+ },
448
+ ],
449
+ };
450
+ }
451
+
452
+
453
+ async handleGetDatabaseTables(args) {
454
+
455
+ const response = await this.metabaseClient.getDatabaseTables(args.database_id || args);
456
+ const tables = response.tables || response.data || response; // Handle multiple formats
457
+
458
+ return {
459
+ content: [
460
+ {
461
+ type: 'text',
462
+ text: `Found ${tables.length} tables:\\n${tables
463
+ .map(table => `- ${table.name} (${table.fields?.length || 0} fields)`)
464
+ .join('\\n')}`,
465
+ },
466
+ ],
467
+ };
468
+ }
469
+
470
+
471
+ async handleGenerateSQL(args) {
472
+ if (!this.aiAssistant) {
473
+ throw new Error('AI assistant not configured. Please set ANTHROPIC_API_KEY or OPENAI_API_KEY.');
474
+ }
475
+
476
+ const { description, database_id } = args;
477
+ const tables = await this.metabaseClient.getDatabaseTables(database_id);
478
+ const sql = await this.aiAssistant.generateSQL(description, tables);
479
+
480
+ return {
481
+ content: [
482
+ {
483
+ type: 'text',
484
+ text: `Generated SQL for: "${description}"\\n\\n\`\`\`sql\\n${sql}\\n\`\`\``,
485
+ },
486
+ ],
487
+ };
488
+ }
489
+
490
+
491
+ async handleOptimizeQuery(args) {
492
+ if (!this.aiAssistant) {
493
+ throw new Error('AI assistant not configured. Please set ANTHROPIC_API_KEY or OPENAI_API_KEY.');
494
+ }
495
+
496
+ const optimization = await this.aiAssistant.optimizeQuery(args.sql);
497
+
498
+ return {
499
+ content: [
500
+ {
501
+ type: 'text',
502
+ text: `Optimized SQL:\\n\\n\`\`\`sql\\n${optimization.optimized_sql}\\n\`\`\`\\n\\nOptimizations applied:\\n${optimization.optimizations?.join('\\n- ') || 'None'}\\n\\nExpected improvements:\\n${optimization.improvements || 'Not specified'}`,
503
+ },
504
+ ],
505
+ };
506
+ }
507
+
508
+
509
+ async handleExplainQuery(args) {
510
+ if (!this.aiAssistant) {
511
+ throw new Error('AI assistant not configured. Please set ANTHROPIC_API_KEY or OPENAI_API_KEY.');
512
+ }
513
+
514
+ const explanation = await this.aiAssistant.explainQuery(args.sql);
515
+
516
+ return {
517
+ content: [
518
+ {
519
+ type: 'text',
520
+ text: `Query Explanation:\\n\\n${explanation}`,
521
+ },
522
+ ],
523
+ };
524
+ }
525
+
526
+ async handleTestConnectionSpeed(args) {
527
+ const databaseId = args.database_id;
528
+ const startTime = Date.now();
529
+
530
+ try {
531
+ await this.metabaseClient.executeNativeQuery(databaseId, 'SELECT 1');
532
+ const responseTime = Date.now() - startTime;
533
+
534
+ let speedLabel = 'Fast';
535
+ if (responseTime > 5000) speedLabel = 'Slow';
536
+ else if (responseTime > 2000) speedLabel = 'Moderate';
537
+ else if (responseTime > 500) speedLabel = 'Good';
538
+
539
+ return {
540
+ content: [{
541
+ type: 'text',
542
+ text: `🏎️ **Database Speed Test**\n\n` +
543
+ `📊 Database ID: ${databaseId}\n` +
544
+ `⏱️ Response Time: ${responseTime}ms\n` +
545
+ `📈 Rating: ${speedLabel}\n\n` +
546
+ `💡 Recommended timeout: ${Math.max(responseTime * 10, 30000)}ms`
547
+ }],
548
+ };
549
+ } catch (error) {
550
+ return {
551
+ content: [{
552
+ type: 'text',
553
+ text: `❌ Speed test failed: ${error.message}`
554
+ }],
555
+ };
556
+ }
557
+ }
558
+
559
+ }