@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.
- package/.env.example +38 -0
- package/LICENSE +201 -0
- package/README.md +364 -0
- package/README_MCP.md +279 -0
- package/package.json +99 -0
- package/src/ai/assistant.js +982 -0
- package/src/cli/interactive.js +500 -0
- package/src/database/connection-manager.js +350 -0
- package/src/database/direct-client.js +686 -0
- package/src/index.js +162 -0
- package/src/mcp/handlers/actions.js +213 -0
- package/src/mcp/handlers/ai.js +207 -0
- package/src/mcp/handlers/analytics.js +1647 -0
- package/src/mcp/handlers/cards.js +1544 -0
- package/src/mcp/handlers/collections.js +244 -0
- package/src/mcp/handlers/dashboard.js +207 -0
- package/src/mcp/handlers/dashboard_direct.js +292 -0
- package/src/mcp/handlers/database.js +322 -0
- package/src/mcp/handlers/docs.js +399 -0
- package/src/mcp/handlers/index.js +35 -0
- package/src/mcp/handlers/metadata.js +190 -0
- package/src/mcp/handlers/questions.js +134 -0
- package/src/mcp/handlers/schema.js +1699 -0
- package/src/mcp/handlers/sql.js +559 -0
- package/src/mcp/handlers/users.js +251 -0
- package/src/mcp/job-store.js +199 -0
- package/src/mcp/server.js +428 -0
- package/src/mcp/tool-registry.js +3244 -0
- package/src/mcp/tool-router.js +149 -0
- package/src/metabase/client.js +737 -0
- package/src/metabase/metadata-client.js +1852 -0
- package/src/utils/activity-logger.js +489 -0
- package/src/utils/cache.js +176 -0
- package/src/utils/config.js +131 -0
- package/src/utils/definition-tables.js +938 -0
- package/src/utils/file-operations.js +496 -0
- package/src/utils/logger.js +45 -0
- package/src/utils/parametric-questions.js +627 -0
- package/src/utils/response-optimizer.js +190 -0
- 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
|
+
}
|