@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,1544 @@
1
+ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
+ import { logger } from '../../utils/logger.js';
3
+
4
+ export class CardsHandler {
5
+ constructor(metabaseClient) {
6
+ this.metabaseClient = metabaseClient;
7
+ }
8
+
9
+ routes() {
10
+ return {
11
+ 'mb_question_create': (args) => this.handleCreateQuestion(args),
12
+ 'mb_questions': (args) => this.handleGetQuestions(args?.collection_id),
13
+ 'mb_question_create_parametric': (args) => this.handleCreateParametricQuestion(args),
14
+ 'mb_card_get': (args) => this.handleCardGet(args),
15
+ 'mb_card_update': (args) => this.handleCardUpdate(args),
16
+ 'mb_card_delete': (args) => this.handleCardDelete(args),
17
+ 'mb_card_archive': (args) => this.handleCardArchive(args),
18
+ 'mb_card_data': (args) => this.handleCardData(args),
19
+ 'mb_card_copy': (args) => this.handleCardCopy(args),
20
+ 'mb_card_clone': (args) => this.handleCardClone(args),
21
+ };
22
+ }
23
+
24
+ async handleCreateQuestion(args) {
25
+ const question = await this.metabaseClient.createSQLQuestion(
26
+ args.name,
27
+ args.description,
28
+ args.database_id,
29
+ args.sql,
30
+ args.collection_id
31
+ );
32
+
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'text',
37
+ text: `Question created successfully!\\nName: ${question.name}\\nID: ${question.id}\\nURL: ${process.env.METABASE_URL}/question/${question.id}`,
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ async handleGetQuestions(args) {
44
+ const response = await this.metabaseClient.getQuestions(collectionId);
45
+ const questions = response.data || response; // Handle both formats
46
+
47
+ return {
48
+ content: [
49
+ {
50
+ type: 'text',
51
+ text: `Found ${questions.length} questions:\\n${questions
52
+ .map(q => `- ${q.name} (ID: ${q.id})`)
53
+ .join('\\n')}`,
54
+ },
55
+ ],
56
+ };
57
+ }
58
+
59
+ async handleCreateParametricQuestion(args) {
60
+ try {
61
+ const question = await this.metabaseClient.createParametricQuestion(args);
62
+
63
+ let output = `āœ… Parametric Question Created Successfully!\\n\\n`;
64
+ output += `ā“ Question: ${question.name} (ID: ${question.id})\\n`;
65
+ output += `šŸ”— URL: ${process.env.METABASE_URL}/question/${question.id}\\n`;
66
+
67
+ if (args.parameters && args.parameters.length > 0) {
68
+ output += `\\nšŸŽ›ļø Parameters:\\n`;
69
+ args.parameters.forEach(param => {
70
+ output += `- ${param.display_name} (${param.type})${param.required ? ' *required' : ''}\\n`;
71
+ });
72
+ }
73
+
74
+ return {
75
+ content: [{ type: 'text', text: output }],
76
+ };
77
+
78
+ } catch (error) {
79
+ return {
80
+ content: [{ type: 'text', text: `āŒ Error creating parametric question: ${error.message}` }],
81
+ };
82
+ }
83
+ }
84
+
85
+ async handleCardGet(args) { const { card_id } = args;
86
+
87
+ try {
88
+ const card = await this.metabaseClient.request('GET', `/api/card/${card_id}`);
89
+
90
+ return {
91
+ content: [{
92
+ type: 'text',
93
+ text: `Card Details:\n` +
94
+ ` ID: ${card.id}\n` +
95
+ ` Name: ${card.name}\n` +
96
+ ` Description: ${card.description || 'None'}\n` +
97
+ ` Type: ${card.display}\n` +
98
+ ` Database: ${card.database_id}\n` +
99
+ ` Collection: ${card.collection_id || 'Root'}\n` +
100
+ ` Creator: ${card.creator?.email || 'Unknown'}\n` +
101
+ ` Created: ${card.created_at}\n` +
102
+ ` Updated: ${card.updated_at}\n` +
103
+ ` Archived: ${card.archived}`
104
+ }]
105
+ };
106
+ } catch (error) {
107
+ return { content: [{ type: 'text', text: `āŒ Card get error: ${error.message}` }] };
108
+ }
109
+ }
110
+
111
+ async handleCardUpdate(args) { const { card_id, ...updates } = args;
112
+
113
+ try {
114
+ const card = await this.metabaseClient.request('PUT', `/api/card/${card_id}`, updates);
115
+
116
+ return {
117
+ content: [{
118
+ type: 'text',
119
+ text: `āœ… Card ${card_id} updated successfully`
120
+ }]
121
+ };
122
+ } catch (error) {
123
+ return { content: [{ type: 'text', text: `āŒ Card update error: ${error.message}` }] };
124
+ }
125
+ }
126
+
127
+ async handleCardDelete(args) { const { card_id } = args;
128
+
129
+ try {
130
+ await this.metabaseClient.request('DELETE', `/api/card/${card_id}`);
131
+
132
+ return {
133
+ content: [{
134
+ type: 'text',
135
+ text: `āœ… Card ${card_id} deleted permanently`
136
+ }]
137
+ };
138
+ } catch (error) {
139
+ return { content: [{ type: 'text', text: `āŒ Card delete error: ${error.message}` }] };
140
+ }
141
+ }
142
+
143
+ async handleCardArchive(args) { const { card_id } = args;
144
+
145
+ try {
146
+ await this.metabaseClient.request('PUT', `/api/card/${card_id}`, { archived: true });
147
+
148
+ return {
149
+ content: [{
150
+ type: 'text',
151
+ text: `āœ… Card ${card_id} archived`
152
+ }]
153
+ };
154
+ } catch (error) {
155
+ return { content: [{ type: 'text', text: `āŒ Card archive error: ${error.message}` }] };
156
+ }
157
+ }
158
+
159
+ async handleCardData(args) { const { card_id, format = 'json', parameters } = args;
160
+
161
+ try {
162
+ let endpoint = `/api/card/${card_id}/query`;
163
+ if (format === 'csv') {
164
+ endpoint += '/csv';
165
+ } else if (format === 'xlsx') {
166
+ endpoint += '/xlsx';
167
+ }
168
+
169
+ const result = await this.metabaseClient.request('POST', endpoint, { parameters });
170
+
171
+ if (format === 'json') {
172
+ const data = result.data || result;
173
+ const rows = data.rows || [];
174
+ const cols = data.cols || [];
175
+
176
+ return {
177
+ content: [{
178
+ type: 'text',
179
+ text: `Card ${card_id} data (${rows.length} rows):\n` +
180
+ `Columns: ${cols.map(c => c.display_name || c.name).join(', ')}\n\n` +
181
+ `Sample (first 10 rows):\n${JSON.stringify(rows.slice(0, 10), null, 2)}`
182
+ }]
183
+ };
184
+ } else {
185
+ return {
186
+ content: [{
187
+ type: 'text',
188
+ text: `Card ${card_id} data exported as ${format.toUpperCase()}`
189
+ }]
190
+ };
191
+ }
192
+ } catch (error) {
193
+ return { content: [{ type: 'text', text: `āŒ Card data error: ${error.message}` }] };
194
+ }
195
+ }
196
+
197
+ async handleCardCopy(args) { const { card_id, collection_id, new_name } = args;
198
+
199
+ try {
200
+ // Get source card
201
+ const sourceCard = await this.metabaseClient.request('GET', `/api/card/${card_id}`);
202
+
203
+ // Create copy
204
+ const newCard = {
205
+ name: new_name || `Copy of ${sourceCard.name}`,
206
+ description: sourceCard.description,
207
+ display: sourceCard.display,
208
+ dataset_query: sourceCard.dataset_query,
209
+ visualization_settings: sourceCard.visualization_settings,
210
+ collection_id: collection_id || sourceCard.collection_id
211
+ };
212
+
213
+ const createdCard = await this.metabaseClient.request('POST', '/api/card', newCard);
214
+
215
+ return {
216
+ content: [{
217
+ type: 'text',
218
+ text: `āœ… Card copied successfully:\n New Card ID: ${createdCard.id}\n Name: ${createdCard.name}`
219
+ }]
220
+ };
221
+ } catch (error) {
222
+ return { content: [{ type: 'text', text: `āŒ Card copy error: ${error.message}` }] };
223
+ }
224
+ }
225
+
226
+ async handleCardClone(args) { const { card_id, target_table_id, collection_id, column_mappings = {} } = args;
227
+
228
+ try {
229
+ // Get source card
230
+ const sourceCard = await this.metabaseClient.request('GET', `/api/card/${card_id}`);
231
+
232
+ // Clone and retarget the query
233
+ const query = { ...sourceCard.dataset_query };
234
+ if (query.query) {
235
+ query.query['source-table'] = target_table_id;
236
+
237
+ // Apply column mappings if provided
238
+ if (Object.keys(column_mappings).length > 0) {
239
+ // This is simplified - full implementation would need to traverse the query structure
240
+ const queryStr = JSON.stringify(query);
241
+ let mappedQuery = queryStr;
242
+ for (const [oldCol, newCol] of Object.entries(column_mappings)) {
243
+ mappedQuery = mappedQuery.replace(new RegExp(oldCol, 'g'), newCol);
244
+ }
245
+ Object.assign(query, JSON.parse(mappedQuery));
246
+ }
247
+ }
248
+
249
+ const newCard = {
250
+ name: `Clone of ${sourceCard.name}`,
251
+ description: `Cloned from card ${card_id}, retargeted to table ${target_table_id}`,
252
+ display: sourceCard.display,
253
+ dataset_query: query,
254
+ visualization_settings: sourceCard.visualization_settings,
255
+ collection_id: collection_id || sourceCard.collection_id
256
+ };
257
+
258
+ const createdCard = await this.metabaseClient.request('POST', '/api/card', newCard);
259
+
260
+ return {
261
+ content: [{
262
+ type: 'text',
263
+ text: `āœ… Card cloned and retargeted:\n New Card ID: ${createdCard.id}\n Target Table: ${target_table_id}`
264
+ }]
265
+ };
266
+ } catch (error) {
267
+ return { content: [{ type: 'text', text: `āŒ Card clone error: ${error.message}` }] };
268
+ }
269
+ }
270
+
271
+
272
+ async handleCreateDashboard(args) {
273
+ const dashboard = await this.metabaseClient.createDashboard(args);
274
+
275
+ return {
276
+ content: [
277
+ {
278
+ type: 'text',
279
+ text: `Dashboard created successfully!\\nName: ${dashboard.name}\\nID: ${dashboard.id}\\nURL: ${process.env.METABASE_URL}/dashboard/${dashboard.id}`,
280
+ },
281
+ ],
282
+ };
283
+ }
284
+
285
+
286
+ async handleGetDashboards() {
287
+ const response = await this.metabaseClient.getDashboards();
288
+ const dashboards = response.data || response; // Handle both formats
289
+
290
+ return {
291
+ content: [
292
+ {
293
+ type: 'text',
294
+ text: `Found ${dashboards.length} dashboards:\\n${dashboards
295
+ .map(d => `- ${d.name} (ID: ${d.id})`)
296
+ .join('\\n')}`,
297
+ },
298
+ ],
299
+ };
300
+ }
301
+
302
+
303
+ async handleCreateExecutiveDashboard(args) {
304
+ try {
305
+ const { name, database_id, business_domain = 'general', time_period = 'last_30_days', collection_id, schema_name } = args;
306
+
307
+ // Step 1: Analyze database schema to understand available data
308
+ const schemas = await this.metabaseClient.getDatabaseSchemas(database_id);
309
+ const targetSchema = schema_name || schemas.find(s => s.name && !['information_schema', 'pg_catalog'].includes(s.name))?.name;
310
+
311
+ if (!targetSchema) {
312
+ throw new Error('No suitable schema found for analysis');
313
+ }
314
+
315
+ // Step 2: Get tables and analyze structure
316
+ const directClient = await this.getDirectClient(database_id);
317
+ const tables = await directClient.exploreSchemaTablesDetailed(targetSchema, true, 10);
318
+
319
+ if (tables.length === 0) {
320
+ throw new Error(`No tables found in schema '${targetSchema}'`);
321
+ }
322
+
323
+ // Step 3: Create dashboard
324
+ const dashboard = await this.metabaseClient.createDashboard({
325
+ name: name,
326
+ description: `Executive dashboard for ${business_domain} - Auto-generated with AI analysis`,
327
+ collection_id: collection_id
328
+ });
329
+
330
+ // Step 4: Generate executive questions based on business domain
331
+ const executiveQuestions = await this.generateExecutiveQuestions(database_id, targetSchema, tables, business_domain, time_period);
332
+
333
+ let output = `āœ… Executive Dashboard Created Successfully!\\n\\n`;
334
+ output += `šŸ“Š Dashboard: ${name} (ID: ${dashboard.id})\\n`;
335
+ output += `šŸ”— URL: ${process.env.METABASE_URL}/dashboard/${dashboard.id}\\n\\n`;
336
+ output += `šŸ“ˆ Generated ${executiveQuestions.length} executive questions:\\n`;
337
+
338
+ // Step 5: Add questions to dashboard with proper layout
339
+ for (let i = 0; i < executiveQuestions.length; i++) {
340
+ const question = executiveQuestions[i];
341
+ output += `- ${question.name}\\n`;
342
+
343
+ // Calculate position based on executive layout
344
+ const position = this.calculateExecutiveLayout(i, executiveQuestions.length);
345
+
346
+ // Add card to dashboard (you'll need to implement this in MetabaseClient)
347
+ try {
348
+ await this.metabaseClient.addCardToDashboard(dashboard.id, question.id, position);
349
+ } catch (error) {
350
+ output += ` āš ļø Warning: Could not add to dashboard: ${error.message}\\n`;
351
+ }
352
+ }
353
+
354
+ output += `\\nšŸŽÆ Executive Dashboard Features:\\n`;
355
+ output += `- KPI overview cards\\n`;
356
+ output += `- Trend analysis charts\\n`;
357
+ output += `- Performance metrics\\n`;
358
+ output += `- Time-based filtering\\n`;
359
+
360
+ return {
361
+ content: [{ type: 'text', text: output }],
362
+ };
363
+
364
+ } catch (error) {
365
+ return {
366
+ content: [{ type: 'text', text: `āŒ Error creating executive dashboard: ${error.message}` }],
367
+ };
368
+ }
369
+ }
370
+
371
+
372
+ async handleCreateParametricQuestion(args) {
373
+ try {
374
+ const question = await this.metabaseClient.createParametricQuestion(args);
375
+
376
+ let output = `āœ… Parametric Question Created Successfully!\\n\\n`;
377
+ output += `ā“ Question: ${question.name} (ID: ${question.id})\\n`;
378
+ output += `šŸ”— URL: ${process.env.METABASE_URL}/question/${question.id}\\n`;
379
+
380
+ if (args.parameters && args.parameters.length > 0) {
381
+ output += `\\nšŸŽ›ļø Parameters:\\n`;
382
+ args.parameters.forEach(param => {
383
+ output += `- ${param.display_name} (${param.type})${param.required ? ' *required' : ''}\\n`;
384
+ });
385
+ }
386
+
387
+ return {
388
+ content: [{ type: 'text', text: output }],
389
+ };
390
+
391
+ } catch (error) {
392
+ return {
393
+ content: [{ type: 'text', text: `āŒ Error creating parametric question: ${error.message}` }],
394
+ };
395
+ }
396
+ }
397
+
398
+
399
+ async handleAddCardToDashboard(args) {
400
+ try {
401
+ // Normalize position parameters (support both flat and nested structure)
402
+ // AI sometimes sends flat: { row: 0, col: 0, size_x: 4 }
403
+ // Or nested: { position: { row: 0, col: 0, sizeX: 4 } }
404
+ let position = args.position || {};
405
+
406
+ // If args has direct position props, merge them
407
+ if (args.row !== undefined) position.row = args.row;
408
+ if (args.col !== undefined) position.col = args.col;
409
+
410
+ // Handle size_x vs sizeX and size_y vs sizeY
411
+ if (args.size_x !== undefined) position.sizeX = args.size_x;
412
+ if (args.size_y !== undefined) position.sizeY = args.size_y;
413
+ if (args.sizeX !== undefined) position.sizeX = args.sizeX;
414
+ if (args.sizeY !== undefined) position.sizeY = args.sizeY;
415
+
416
+ // Map back to format expected by client
417
+ // The client expects: Options object with optional row, col, sizeX, sizeY
418
+ // But we need to make sure we pass the right keys to client.addCardToDashboard
419
+
420
+ // Create a normalized options object for the client
421
+ const options = {
422
+ row: position.row,
423
+ col: position.col,
424
+ sizeX: position.sizeX || position.size_x,
425
+ sizeY: position.sizeY || position.size_y,
426
+ parameter_mappings: args.parameter_mappings || []
427
+ };
428
+
429
+ const result = await this.metabaseClient.addCardToDashboard(
430
+ args.dashboard_id,
431
+ args.question_id,
432
+ options, // Pass normalized options instead of raw args
433
+ args.parameter_mappings // Double pass, just in case (client signature check needed)
434
+ );
435
+
436
+ // VERIFICATION: Check if card was actually added
437
+ try {
438
+ const dashboard = await this.metabaseClient.getDashboard(args.dashboard_id);
439
+ const cardExists = dashboard.ordered_cards?.some(c => c.card_id === args.question_id);
440
+ const cardCount = dashboard.ordered_cards?.length || 0;
441
+
442
+ if (cardExists) {
443
+ return {
444
+ content: [{
445
+ type: 'text',
446
+ text: `āœ… Card verified!\\n` +
447
+ `Dashboard: ${dashboard.name} (ID: ${args.dashboard_id})\\n` +
448
+ `Total cards: ${cardCount}`
449
+ }],
450
+ };
451
+ } else {
452
+ return {
453
+ content: [{
454
+ type: 'text',
455
+ text: `āš ļø Card addition appears to have failed!\\n` +
456
+ `API reported success but card was not found on dashboard.\\n` +
457
+ `Dashboard ID: ${args.dashboard_id}, Question ID: ${args.question_id}\\n` +
458
+ `Please verify the question ID is valid.`
459
+ }],
460
+ };
461
+ }
462
+ } catch (verifyError) {
463
+ // Verification failed but original call might have succeeded
464
+ return {
465
+ content: [{
466
+ type: 'text',
467
+ text: `āœ… Card added (verification unavailable)\\n` +
468
+ `Dashboard ID: ${args.dashboard_id}\\n` +
469
+ `Card ID: ${result?.id || 'N/A'}`
470
+ }],
471
+ };
472
+ }
473
+
474
+ } catch (error) {
475
+ return {
476
+ content: [{ type: 'text', text: `āŒ Card addition error: ${error.message}` }],
477
+ };
478
+ }
479
+ }
480
+
481
+
482
+ // ==================== DASHBOARD CRUD HANDLERS ====================
483
+
484
+ async handleDashboardGet(args) { const { dashboard_id } = args;
485
+
486
+ try {
487
+ const dashboard = await this.metabaseClient.request('GET', `/api/dashboard/${dashboard_id}`);
488
+
489
+ return {
490
+ content: [{
491
+ type: 'text',
492
+ text: `Dashboard Details:\n` +
493
+ ` ID: ${dashboard.id}\n` +
494
+ ` Name: ${dashboard.name}\n` +
495
+ ` Description: ${dashboard.description || 'None'}\n` +
496
+ ` Collection: ${dashboard.collection_id || 'Root'}\n` +
497
+ ` Cards: ${(dashboard.dashcards || dashboard.ordered_cards || []).length}\n` +
498
+ ` Parameters: ${(dashboard.parameters || []).length}\n` +
499
+ ` Creator: ${dashboard.creator?.email || 'Unknown'}\n` +
500
+ ` Created: ${dashboard.created_at}\n` +
501
+ ` Updated: ${dashboard.updated_at}\n` +
502
+ ` Embedding Enabled: ${dashboard.enable_embedding || false}`
503
+ }]
504
+ };
505
+ } catch (error) {
506
+ return { content: [{ type: 'text', text: `āŒ Dashboard get error: ${error.message}` }] };
507
+ }
508
+ }
509
+
510
+
511
+ async handleDashboardUpdate(args) { const { dashboard_id, ...updates } = args;
512
+
513
+ try {
514
+ await this.metabaseClient.request('PUT', `/api/dashboard/${dashboard_id}`, updates);
515
+
516
+ return {
517
+ content: [{
518
+ type: 'text',
519
+ text: `āœ… Dashboard ${dashboard_id} updated successfully`
520
+ }]
521
+ };
522
+ } catch (error) {
523
+ return { content: [{ type: 'text', text: `āŒ Dashboard update error: ${error.message}` }] };
524
+ }
525
+ }
526
+
527
+
528
+ async handleDashboardDelete(args) { const { dashboard_id } = args;
529
+
530
+ try {
531
+ await this.metabaseClient.request('DELETE', `/api/dashboard/${dashboard_id}`);
532
+
533
+ return {
534
+ content: [{
535
+ type: 'text',
536
+ text: `āœ… Dashboard ${dashboard_id} deleted`
537
+ }]
538
+ };
539
+ } catch (error) {
540
+ return { content: [{ type: 'text', text: `āŒ Dashboard delete error: ${error.message}` }] };
541
+ }
542
+ }
543
+
544
+
545
+ async handleDashboardCardUpdate(args) { const { dashboard_id, card_id, row, col, size_x, size_y } = args;
546
+
547
+ try {
548
+ // Get current dashboard
549
+ const dashboard = await this.metabaseClient.request('GET', `/api/dashboard/${dashboard_id}`);
550
+ const cards = dashboard.dashcards || dashboard.ordered_cards || [];
551
+
552
+ // Find and update the card
553
+ const cardToUpdate = cards.find(c => c.id === card_id);
554
+ if (!cardToUpdate) {
555
+ return { content: [{ type: 'text', text: `āŒ Card ${card_id} not found on dashboard ${dashboard_id}` }] };
556
+ }
557
+
558
+ const updatedCard = {
559
+ ...cardToUpdate,
560
+ ...(row !== undefined && { row }),
561
+ ...(col !== undefined && { col }),
562
+ ...(size_x !== undefined && { size_x }),
563
+ ...(size_y !== undefined && { size_y })
564
+ };
565
+
566
+ await this.metabaseClient.request('PUT', `/api/dashboard/${dashboard_id}/cards`, {
567
+ cards: cards.map(c => c.id === card_id ? updatedCard : c)
568
+ });
569
+
570
+ return {
571
+ content: [{
572
+ type: 'text',
573
+ text: `āœ… Dashboard card ${card_id} position/size updated`
574
+ }]
575
+ };
576
+ } catch (error) {
577
+ return { content: [{ type: 'text', text: `āŒ Dashboard card update error: ${error.message}` }] };
578
+ }
579
+ }
580
+
581
+
582
+ async handleDashboardCardRemove(args) { const { dashboard_id, card_id } = args;
583
+
584
+ try {
585
+ await this.metabaseClient.request('DELETE', `/api/dashboard/${dashboard_id}/cards`, {
586
+ dashcardId: card_id
587
+ });
588
+
589
+ return {
590
+ content: [{
591
+ type: 'text',
592
+ text: `āœ… Card ${card_id} removed from dashboard ${dashboard_id}`
593
+ }]
594
+ };
595
+ } catch (error) {
596
+ return { content: [{ type: 'text', text: `āŒ Dashboard card remove error: ${error.message}` }] };
597
+ }
598
+ }
599
+
600
+
601
+ async handleDashboardCopy(args) { const { dashboard_id, collection_id, new_name, deep_copy = true } = args;
602
+
603
+ try {
604
+ // Get source dashboard
605
+ const sourceDashboard = await this.metabaseClient.request('GET', `/api/dashboard/${dashboard_id}`);
606
+
607
+ // Create new dashboard
608
+ const newDashboard = await this.metabaseClient.request('POST', '/api/dashboard', {
609
+ name: new_name || `Copy of ${sourceDashboard.name}`,
610
+ description: sourceDashboard.description,
611
+ collection_id: collection_id || sourceDashboard.collection_id,
612
+ parameters: sourceDashboard.parameters
613
+ });
614
+
615
+ // Copy cards
616
+ const sourceCards = sourceDashboard.dashcards || sourceDashboard.ordered_cards || [];
617
+ const cardIdMap = {};
618
+
619
+ for (const dashcard of sourceCards) {
620
+ let cardId = dashcard.card_id;
621
+
622
+ // If deep copy, copy the actual card first
623
+ if (deep_copy && cardId) {
624
+ const copiedCard = await this.handleCardCopy({
625
+ card_id: cardId,
626
+ collection_id: collection_id || sourceDashboard.collection_id
627
+ });
628
+ // Extract new card ID from response
629
+ const match = copiedCard.content[0].text.match(/New Card ID: (\d+)/);
630
+ if (match) {
631
+ cardIdMap[cardId] = parseInt(match[1]);
632
+ cardId = cardIdMap[cardId];
633
+ }
634
+ }
635
+
636
+ // Add card to new dashboard
637
+ if (cardId) {
638
+ await this.metabaseClient.request('POST', `/api/dashboard/${newDashboard.id}/cards`, {
639
+ cardId: cardId,
640
+ row: dashcard.row,
641
+ col: dashcard.col,
642
+ size_x: dashcard.size_x,
643
+ size_y: dashcard.size_y,
644
+ parameter_mappings: dashcard.parameter_mappings
645
+ });
646
+ }
647
+ }
648
+
649
+ return {
650
+ content: [{
651
+ type: 'text',
652
+ text: `āœ… Dashboard copied:\n New Dashboard ID: ${newDashboard.id}\n Name: ${newDashboard.name}\n Cards copied: ${sourceCards.length}`
653
+ }]
654
+ };
655
+ } catch (error) {
656
+ return { content: [{ type: 'text', text: `āŒ Dashboard copy error: ${error.message}` }] };
657
+ }
658
+ }
659
+
660
+
661
+ async handleCreateMetric(args) {
662
+ try {
663
+ const metric = await this.metabaseClient.createMetric(args);
664
+
665
+ return {
666
+ content: [{
667
+ type: 'text',
668
+ text: `āœ… Metric created successfully!\\nName: ${metric.name}\\nID: ${metric.id}\\nType: ${args.aggregation.type}`
669
+ }],
670
+ };
671
+
672
+ } catch (error) {
673
+ return {
674
+ content: [{ type: 'text', text: `āŒ Error creating metric: ${error.message}` }],
675
+ };
676
+ }
677
+ }
678
+
679
+
680
+ async handleAddDashboardFilter(args) {
681
+ try {
682
+ const filter = await this.metabaseClient.addDashboardFilter(args.dashboard_id, args);
683
+
684
+ return {
685
+ content: [{
686
+ type: 'text',
687
+ text: `āœ… Dashboard filter added successfully!\\nFilter: ${args.name} (${args.type})\\nFilter ID: ${filter.id}`
688
+ }],
689
+ };
690
+
691
+ } catch (error) {
692
+ return {
693
+ content: [{ type: 'text', text: `āŒ Error adding dashboard filter: ${error.message}` }],
694
+ };
695
+ }
696
+ }
697
+
698
+
699
+ async handleOptimizeDashboardLayout(args) {
700
+ try {
701
+ const result = await this.metabaseClient.optimizeDashboardLayout(args);
702
+
703
+ return {
704
+ content: [{
705
+ type: 'text',
706
+ text: `āœ… Dashboard layout optimized!\\nStyle: ${args.layout_style}\\nCards repositioned: ${result.repositioned_cards}\\nOptimizations applied: ${result.optimizations.join(', ')}`
707
+ }],
708
+ };
709
+
710
+ } catch (error) {
711
+ return {
712
+ content: [{ type: 'text', text: `āŒ Error optimizing dashboard layout: ${error.message}` }],
713
+ };
714
+ }
715
+ }
716
+
717
+
718
+ async handleAutoDescribe(args) {
719
+ try {
720
+ const { database_id, target_type = 'all', force_update = false } = args;
721
+ const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
722
+ const aiSignature = `[Generated by AI on ${timestamp}@AI]`;
723
+
724
+ let updated = {
725
+ databases: 0,
726
+ tables: 0,
727
+ fields: 0
728
+ };
729
+
730
+ // Auto-describe database
731
+ if (target_type === 'database' || target_type === 'all') {
732
+ await this.connectionManager.executeQuery(database_id, `
733
+ UPDATE metabase_database
734
+ SET description = CASE
735
+ WHEN name LIKE '%metabase%' OR name LIKE '%app%' THEN CONCAT('Metabase system database containing application metadata, dashboards, user management, and analytics configurations. ', '${aiSignature}')
736
+ WHEN engine = 'postgres' THEN CONCAT('PostgreSQL database containing business data and analytics tables. ', '${aiSignature}')
737
+ WHEN engine = 'mysql' THEN CONCAT('MySQL database for application data storage and reporting. ', '${aiSignature}')
738
+ ELSE CONCAT('Database containing structured data for business intelligence and analytics. ', '${aiSignature}')
739
+ END
740
+ WHERE id = $1 AND (description IS NULL OR description = '' ${force_update ? 'OR TRUE' : ''})
741
+ `, [database_id]);
742
+ updated.databases++;
743
+ }
744
+
745
+ // Auto-describe tables
746
+ if (target_type === 'tables' || target_type === 'all') {
747
+ const tableDescriptions = {
748
+ 'citizen': `Primary citizen registry table containing personal information and demographic data. Key table for population analytics and citizen management systems. ${aiSignature}`,
749
+ 'core_user': `Metabase user management table storing user accounts, authentication details, and user preferences. ${aiSignature}`,
750
+ 'report_dashboard': `Dashboard configuration table storing dashboard metadata, layout settings, and user permissions. ${aiSignature}`,
751
+ 'report_card': `Question/chart definitions table storing SQL queries, visualization settings, and report configurations. ${aiSignature}`,
752
+ 'query_execution': `Query performance monitoring table tracking execution times, success rates, and optimization metrics. ${aiSignature}`,
753
+ 'audit_log': `Security audit trail table recording user actions, data access patterns, and system events. ${aiSignature}`
754
+ };
755
+
756
+ for (const [tableName, description] of Object.entries(tableDescriptions)) {
757
+ await this.connectionManager.executeQuery(database_id, `
758
+ UPDATE metabase_table
759
+ SET description = $1
760
+ WHERE name = $2 AND db_id = $3 AND (description IS NULL OR description = '' ${force_update ? 'OR TRUE' : ''})
761
+ `, [description, tableName, database_id]);
762
+ updated.tables++;
763
+ }
764
+ }
765
+
766
+ // Auto-describe fields
767
+ if (target_type === 'fields' || target_type === 'all') {
768
+ const fieldDescriptions = {
769
+ 'id': `Primary key identifier for unique record identification. ${aiSignature}`,
770
+ 'uid': `Unique identifier for record indexing and relationships. ${aiSignature}`,
771
+ 'name': `Name field for identification and display purposes. ${aiSignature}`,
772
+ 'email': `Email address for communication and user identification. ${aiSignature}`,
773
+ 'created_at': `Record creation timestamp for audit and chronological tracking. ${aiSignature}`,
774
+ 'updated_at': `Last modification timestamp for change tracking. ${aiSignature}`,
775
+ 'birth_date': `Date of birth for age calculation and demographic analysis. ${aiSignature}`,
776
+ 'gender': `Gender classification for demographic analysis and reporting. ${aiSignature}`
777
+ };
778
+
779
+ for (const [fieldName, description] of Object.entries(fieldDescriptions)) {
780
+ await this.connectionManager.executeQuery(database_id, `
781
+ UPDATE metabase_field f
782
+ SET description = $1
783
+ FROM metabase_table t
784
+ WHERE f.table_id = t.id
785
+ AND t.db_id = $2
786
+ AND f.name = $3
787
+ AND (f.description IS NULL OR f.description = '' ${force_update ? 'OR TRUE' : ''})
788
+ `, [description, database_id, fieldName]);
789
+ updated.fields++;
790
+ }
791
+ }
792
+
793
+ return {
794
+ content: [{
795
+ type: 'text',
796
+ text: `āœ… AI descriptions generated successfully!\\n\\nšŸ“Š **Summary:**\\n- Databases: ${updated.databases} updated\\n- Tables: ${updated.tables} updated\\n- Fields: ${updated.fields} updated\\n\\nšŸ¤– All descriptions include AI signature: ${aiSignature}\\n\\nšŸ’” **Features:**\\n- Smart categorization based on table names\\n- Contextual descriptions for business intelligence\\n- Timestamp tracking for audit purposes\\n- Batch processing for efficiency`
797
+ }]
798
+ };
799
+
800
+ } catch (error) {
801
+ return {
802
+ content: [{ type: 'text', text: `āŒ Error generating AI descriptions: ${error.message}` }]
803
+ };
804
+ }
805
+ }
806
+
807
+
808
+ // === VISUALIZATION HANDLERS ===
809
+
810
+ async handleVisualizationSettings(args) {
811
+ try {
812
+ const questionId = args.question_id;
813
+
814
+ // Get current question
815
+ const question = await this.metabaseClient.getQuestion(questionId);
816
+
817
+ // If updating
818
+ if (args.display || args.settings) {
819
+ const updateData = {};
820
+ if (args.display) updateData.display = args.display;
821
+ if (args.settings) updateData.visualization_settings = args.settings;
822
+
823
+ const updated = await this.metabaseClient.updateQuestion(questionId, updateData);
824
+
825
+ return {
826
+ content: [{
827
+ type: 'text',
828
+ text: `āœ… **Visualization Updated!**\\n\\n` +
829
+ `šŸ†” Question ID: ${questionId}\\n` +
830
+ `šŸ“Š Display Type: ${updated.display || args.display}\\n` +
831
+ `āš™ļø Settings Applied: ${Object.keys(args.settings || {}).length} properties`
832
+ }]
833
+ };
834
+ }
835
+
836
+ // Return current settings
837
+ return {
838
+ content: [{
839
+ type: 'text',
840
+ text: `šŸ“Š **Visualization Settings: ${question.name}**\\n\\n` +
841
+ `šŸ†” Question ID: ${questionId}\\n` +
842
+ `šŸ“ˆ Display Type: ${question.display}\\n\\n` +
843
+ `āš™ļø **Current Settings:**\\n\`\`\`json\\n${JSON.stringify(question.visualization_settings || {}, null, 2)}\\n\`\`\``
844
+ }]
845
+ };
846
+
847
+ } catch (error) {
848
+ return {
849
+ content: [{ type: 'text', text: `āŒ Visualization settings error: ${error.message}` }]
850
+ };
851
+ }
852
+ }
853
+
854
+
855
+ async handleVisualizationRecommend(args) {
856
+ try {
857
+ const question = await this.metabaseClient.getQuestion(args.question_id);
858
+ const purpose = args.purpose || 'general';
859
+
860
+ // Analyze the result metadata
861
+ const resultMetadata = question.result_metadata || [];
862
+ const columnTypes = resultMetadata.map(col => ({
863
+ name: col.name,
864
+ baseType: col.base_type,
865
+ semanticType: col.semantic_type
866
+ }));
867
+
868
+ const hasDate = columnTypes.some(c => c.baseType?.includes('Date') || c.baseType?.includes('Timestamp'));
869
+ const hasNumeric = columnTypes.some(c => c.baseType?.includes('Integer') || c.baseType?.includes('Float') || c.baseType?.includes('Decimal'));
870
+ const hasCategory = columnTypes.some(c => c.semanticType?.includes('Category') || c.baseType?.includes('Text'));
871
+ const columnCount = columnTypes.length;
872
+
873
+ let recommendations = [];
874
+
875
+ if (purpose === 'trend' || (hasDate && hasNumeric)) {
876
+ recommendations.push({
877
+ type: 'line',
878
+ reason: 'Best for showing trends over time',
879
+ settings: { 'graph.dimensions': [columnTypes.find(c => c.baseType?.includes('Date'))?.name] }
880
+ });
881
+ }
882
+
883
+ if (purpose === 'comparison' || hasCategory) {
884
+ recommendations.push({
885
+ type: 'bar',
886
+ reason: 'Best for comparing values across categories',
887
+ settings: { 'graph.show_values': true }
888
+ });
889
+ }
890
+
891
+ if (purpose === 'composition' || (hasNumeric && columnCount <= 5)) {
892
+ recommendations.push({
893
+ type: 'pie',
894
+ reason: 'Best for showing parts of a whole',
895
+ settings: { 'pie.show_legend': true, 'pie.show_total': true }
896
+ });
897
+ }
898
+
899
+ if (purpose === 'kpi' || columnCount === 1) {
900
+ recommendations.push({
901
+ type: 'scalar',
902
+ reason: 'Best for single KPI values',
903
+ settings: {}
904
+ });
905
+ }
906
+
907
+ if (purpose === 'distribution') {
908
+ recommendations.push({
909
+ type: 'bar',
910
+ reason: 'Best for showing value distributions',
911
+ settings: { 'graph.x_axis.scale': 'histogram' }
912
+ });
913
+ }
914
+
915
+ if (recommendations.length === 0) {
916
+ recommendations.push({ type: 'table', reason: 'Default for complex data', settings: {} });
917
+ }
918
+
919
+ let output = `šŸ“Š **Visualization Recommendations: ${question.name}**\\n\\n`;
920
+ output += `šŸ“‹ **Data Profile:**\\n`;
921
+ output += `• Columns: ${columnCount}\\n`;
922
+ output += `• Has Date: ${hasDate ? 'Yes' : 'No'}\\n`;
923
+ output += `• Has Numeric: ${hasNumeric ? 'Yes' : 'No'}\\n`;
924
+ output += `• Has Category: ${hasCategory ? 'Yes' : 'No'}\\n\\n`;
925
+
926
+ output += `šŸ’” **Recommendations:**\\n`;
927
+ recommendations.forEach((rec, i) => {
928
+ output += `${i + 1}. **${rec.type.toUpperCase()}** - ${rec.reason}\\n`;
929
+ });
930
+
931
+ return {
932
+ content: [{ type: 'text', text: output }]
933
+ };
934
+
935
+ } catch (error) {
936
+ return {
937
+ content: [{ type: 'text', text: `āŒ Visualization recommendation failed: ${error.message}` }]
938
+ };
939
+ }
940
+ }
941
+
942
+
943
+ // === FIELD METADATA HANDLERS ===
944
+
945
+ async handleFieldMetadata(args) {
946
+ try {
947
+ const fieldId = args.field_id;
948
+
949
+ // Get current field
950
+ const field = await this.metabaseClient.request('GET', `/api/field/${fieldId}`);
951
+
952
+ // If updating
953
+ if (args.display_name || args.description || args.semantic_type || args.visibility_type || args.has_field_values) {
954
+ const updateData = {};
955
+ if (args.display_name) updateData.display_name = args.display_name;
956
+ if (args.description) updateData.description = args.description;
957
+ if (args.semantic_type) updateData.semantic_type = args.semantic_type;
958
+ if (args.visibility_type) updateData.visibility_type = args.visibility_type;
959
+ if (args.has_field_values) updateData.has_field_values = args.has_field_values;
960
+
961
+ const updated = await this.metabaseClient.request('PUT', `/api/field/${fieldId}`, updateData);
962
+
963
+ return {
964
+ content: [{
965
+ type: 'text',
966
+ text: `āœ… **Field Metadata Updated!**\\n\\n` +
967
+ `šŸ†” Field ID: ${fieldId}\\n` +
968
+ `šŸ“‹ Display Name: ${updated.display_name}\\n` +
969
+ `šŸ·ļø Semantic Type: ${updated.semantic_type || 'None'}\\n` +
970
+ `šŸ‘ļø Visibility: ${updated.visibility_type}`
971
+ }]
972
+ };
973
+ }
974
+
975
+ // Return current metadata
976
+ return {
977
+ content: [{
978
+ type: 'text',
979
+ text: `šŸ“‹ **Field Metadata: ${field.display_name}**\\n\\n` +
980
+ `šŸ†” Field ID: ${fieldId}\\n` +
981
+ `šŸ“› Name: ${field.name}\\n` +
982
+ `šŸ“‹ Display Name: ${field.display_name}\\n` +
983
+ `šŸ“ Description: ${field.description || 'None'}\\n` +
984
+ `šŸ·ļø Semantic Type: ${field.semantic_type || 'None'}\\n` +
985
+ `šŸ“Š Base Type: ${field.base_type}\\n` +
986
+ `šŸ‘ļø Visibility: ${field.visibility_type}\\n` +
987
+ `šŸ” Has Field Values: ${field.has_field_values}`
988
+ }]
989
+ };
990
+
991
+ } catch (error) {
992
+ return {
993
+ content: [{ type: 'text', text: `āŒ Field metadata error: ${error.message}` }]
994
+ };
995
+ }
996
+ }
997
+
998
+
999
+ async handleTableMetadata(args) {
1000
+ try {
1001
+ const tableId = args.table_id;
1002
+
1003
+ // Get current table
1004
+ const table = await this.metabaseClient.request('GET', `/api/table/${tableId}`);
1005
+
1006
+ // If updating
1007
+ if (args.display_name || args.description || args.visibility_type) {
1008
+ const updateData = {};
1009
+ if (args.display_name) updateData.display_name = args.display_name;
1010
+ if (args.description) updateData.description = args.description;
1011
+ if (args.visibility_type) updateData.visibility_type = args.visibility_type;
1012
+
1013
+ const updated = await this.metabaseClient.request('PUT', `/api/table/${tableId}`, updateData);
1014
+
1015
+ return {
1016
+ content: [{
1017
+ type: 'text',
1018
+ text: `āœ… **Table Metadata Updated!**\\n\\n` +
1019
+ `šŸ†” Table ID: ${tableId}\\n` +
1020
+ `šŸ“‹ Display Name: ${updated.display_name}\\n` +
1021
+ `šŸ‘ļø Visibility: ${updated.visibility_type}`
1022
+ }]
1023
+ };
1024
+ }
1025
+
1026
+ // Return current metadata
1027
+ return {
1028
+ content: [{
1029
+ type: 'text',
1030
+ text: `šŸ“‹ **Table Metadata: ${table.display_name}**\\n\\n` +
1031
+ `šŸ†” Table ID: ${tableId}\\n` +
1032
+ `šŸ“› Name: ${table.name}\\n` +
1033
+ `šŸ“‹ Display Name: ${table.display_name}\\n` +
1034
+ `šŸ“ Description: ${table.description || 'None'}\\n` +
1035
+ `šŸ‘ļø Visibility: ${table.visibility_type}\\n` +
1036
+ `šŸ—ƒļø Schema: ${table.schema}\\n` +
1037
+ `šŸ“Š Fields: ${table.fields?.length || 0}`
1038
+ }]
1039
+ };
1040
+
1041
+ } catch (error) {
1042
+ return {
1043
+ content: [{ type: 'text', text: `āŒ Table metadata error: ${error.message}` }]
1044
+ };
1045
+ }
1046
+ }
1047
+
1048
+
1049
+ async handleFieldValues(args) {
1050
+ try {
1051
+ const fieldId = args.field_id;
1052
+
1053
+ const values = await this.metabaseClient.request('GET', `/api/field/${fieldId}/values`);
1054
+
1055
+ let output = `šŸ“‹ **Field Values (ID: ${fieldId})**\\n\\n`;
1056
+
1057
+ if (values.values && values.values.length > 0) {
1058
+ const displayValues = values.values.slice(0, 20);
1059
+ displayValues.forEach((val, i) => {
1060
+ const displayVal = Array.isArray(val) ? val[0] : val;
1061
+ output += `${i + 1}. ${displayVal}\\n`;
1062
+ });
1063
+
1064
+ if (values.values.length > 20) {
1065
+ output += `\\n... and ${values.values.length - 20} more values`;
1066
+ }
1067
+ } else {
1068
+ output += 'No values found or field values not cached.';
1069
+ }
1070
+
1071
+ return {
1072
+ content: [{ type: 'text', text: output }]
1073
+ };
1074
+
1075
+ } catch (error) {
1076
+ return {
1077
+ content: [{ type: 'text', text: `āŒ Field values error: ${error.message}` }]
1078
+ };
1079
+ }
1080
+ }
1081
+
1082
+
1083
+ // === EMBEDDING HANDLERS ===
1084
+
1085
+ async handleEmbedUrlGenerate(args) {
1086
+ try {
1087
+ // Get embedding secret key from environment or settings
1088
+ const secretKey = process.env.METABASE_EMBEDDING_SECRET_KEY;
1089
+
1090
+ if (!secretKey) {
1091
+ return {
1092
+ content: [{
1093
+ type: 'text',
1094
+ text: `āš ļø **Embedding Secret Key Not Configured**\\n\\n` +
1095
+ `Please set METABASE_EMBEDDING_SECRET_KEY in your environment.\\n\\n` +
1096
+ `You can find this in Metabase Admin > Settings > Embedding.`
1097
+ }]
1098
+ };
1099
+ }
1100
+
1101
+ // Import JWT library dynamically
1102
+ const jwt = await import('jsonwebtoken');
1103
+
1104
+ const resourceType = args.resource_type;
1105
+ const resourceId = args.resource_id;
1106
+ const params = args.params || {};
1107
+ const expMinutes = args.exp_minutes || 10;
1108
+
1109
+ // Create JWT payload
1110
+ const payload = {
1111
+ resource: { [resourceType]: resourceId },
1112
+ params: params,
1113
+ exp: Math.round(Date.now() / 1000) + (expMinutes * 60)
1114
+ };
1115
+
1116
+ const token = jwt.default.sign(payload, secretKey);
1117
+
1118
+ // Build embed URL
1119
+ const baseUrl = process.env.METABASE_URL;
1120
+ let embedUrl = `${baseUrl}/embed/${resourceType}/${token}`;
1121
+
1122
+ // Add theme and options
1123
+ const urlParams = [];
1124
+ if (args.theme && args.theme !== 'light') urlParams.push(`theme=${args.theme}`);
1125
+ if (args.bordered === false) urlParams.push('bordered=false');
1126
+ if (args.titled === false) urlParams.push('titled=false');
1127
+
1128
+ if (urlParams.length > 0) {
1129
+ embedUrl += '#' + urlParams.join('&');
1130
+ }
1131
+
1132
+ return {
1133
+ content: [{
1134
+ type: 'text',
1135
+ text: `āœ… **Embed URL Generated!**\\n\\n` +
1136
+ `šŸ“Š Resource: ${resourceType} (ID: ${resourceId})\\n` +
1137
+ `ā±ļø Expires: ${expMinutes} minutes\\n` +
1138
+ `šŸ”’ Parameters: ${Object.keys(params).length} locked\\n\\n` +
1139
+ `šŸ”— **Embed URL:**\\n\`\`\`\\n${embedUrl}\\n\`\`\`\\n\\n` +
1140
+ `šŸ“‹ **HTML:**\\n\`\`\`html\\n<iframe src="${embedUrl}" width="100%" height="600" frameborder="0"></iframe>\\n\`\`\``
1141
+ }]
1142
+ };
1143
+
1144
+ } catch (error) {
1145
+ return {
1146
+ content: [{ type: 'text', text: `āŒ Embed URL generation failed: ${error.message}` }]
1147
+ };
1148
+ }
1149
+ }
1150
+
1151
+
1152
+ async handleEmbedSettings(args) {
1153
+ try {
1154
+ // Get embedding settings from Metabase
1155
+ const settings = await this.metabaseClient.request('GET', '/api/setting');
1156
+
1157
+ const embeddingEnabled = settings['enable-embedding'] || settings.find?.(s => s.key === 'enable-embedding')?.value;
1158
+ const embedSecretSet = !!process.env.METABASE_EMBEDDING_SECRET_KEY;
1159
+
1160
+ return {
1161
+ content: [{
1162
+ type: 'text',
1163
+ text: `šŸ“Š **Embedding Settings**\\n\\n` +
1164
+ `šŸ”’ Embedding Enabled: ${embeddingEnabled ? 'Yes' : 'No'}\\n` +
1165
+ `šŸ”‘ Secret Key Configured: ${embedSecretSet ? 'Yes' : 'No'}\\n\\n` +
1166
+ `šŸ’” **To Enable Embedding:**\\n` +
1167
+ `1. Go to Metabase Admin > Settings > Embedding\\n` +
1168
+ `2. Enable embedding and copy the secret key\\n` +
1169
+ `3. Set METABASE_EMBEDDING_SECRET_KEY in your environment`
1170
+ }]
1171
+ };
1172
+
1173
+ } catch (error) {
1174
+ return {
1175
+ content: [{ type: 'text', text: `āŒ Embed settings error: ${error.message}` }]
1176
+ };
1177
+ }
1178
+ }
1179
+
1180
+
1181
+ // ==================== SEARCH HANDLER ====================
1182
+
1183
+ async handleSearch(args) { const { query, models, collection_id, limit = 50 } = args;
1184
+
1185
+ try {
1186
+ let endpoint = `/api/search?q=${encodeURIComponent(query)}`;
1187
+
1188
+ if (models && models.length > 0) {
1189
+ endpoint += `&models=${models.join(',')}`;
1190
+ }
1191
+ if (collection_id) {
1192
+ endpoint += `&collection=${collection_id}`;
1193
+ }
1194
+ endpoint += `&limit=${limit}`;
1195
+
1196
+ const results = await this.metabaseClient.request('GET', endpoint);
1197
+ const items = results.data || results;
1198
+
1199
+ // Group by type
1200
+ const grouped = {};
1201
+ for (const item of items) {
1202
+ if (!grouped[item.model]) {
1203
+ grouped[item.model] = [];
1204
+ }
1205
+ grouped[item.model].push(item);
1206
+ }
1207
+
1208
+ let output = `Search results for "${query}" (${items.length} items):\n\n`;
1209
+
1210
+ for (const [type, typeItems] of Object.entries(grouped)) {
1211
+ output += `${type.toUpperCase()}S (${typeItems.length}):\n`;
1212
+ output += typeItems.map(i => ` - [${i.id}] ${i.name}`).join('\n') + '\n\n';
1213
+ }
1214
+
1215
+ return {
1216
+ content: [{ type: 'text', text: output }]
1217
+ };
1218
+ } catch (error) {
1219
+ return { content: [{ type: 'text', text: `āŒ Search error: ${error.message}` }] };
1220
+ }
1221
+ }
1222
+
1223
+
1224
+ // ==================== SEGMENT HANDLERS ====================
1225
+
1226
+ async handleSegmentCreate(args) { const { name, description, table_id, definition } = args;
1227
+
1228
+ try {
1229
+ const segment = await this.metabaseClient.request('POST', '/api/segment', {
1230
+ name,
1231
+ description,
1232
+ table_id,
1233
+ definition
1234
+ });
1235
+
1236
+ return {
1237
+ content: [{
1238
+ type: 'text',
1239
+ text: `āœ… Segment created:\n ID: ${segment.id}\n Name: ${segment.name}\n Table: ${segment.table_id}`
1240
+ }]
1241
+ };
1242
+ } catch (error) {
1243
+ return { content: [{ type: 'text', text: `āŒ Segment create error: ${error.message}` }] };
1244
+ }
1245
+ }
1246
+
1247
+
1248
+ async handleSegmentList(args) { const { table_id } = args;
1249
+
1250
+ try {
1251
+ let segments = await this.metabaseClient.request('GET', '/api/segment');
1252
+
1253
+ if (table_id) {
1254
+ segments = segments.filter(s => s.table_id === table_id);
1255
+ }
1256
+
1257
+ return {
1258
+ content: [{
1259
+ type: 'text',
1260
+ text: `Found ${segments.length} segments:\n${segments.map(s =>
1261
+ ` - [${s.id}] ${s.name} (Table: ${s.table_id})`
1262
+ ).join('\n')}`
1263
+ }]
1264
+ };
1265
+ } catch (error) {
1266
+ return { content: [{ type: 'text', text: `āŒ Segment list error: ${error.message}` }] };
1267
+ }
1268
+ }
1269
+
1270
+
1271
+ // ==================== BOOKMARK HANDLERS ====================
1272
+
1273
+ async handleBookmarkCreate(args) { const { type, id } = args;
1274
+
1275
+ try {
1276
+ await this.metabaseClient.request('POST', `/api/${type}/${id}/bookmark`);
1277
+
1278
+ return {
1279
+ content: [{
1280
+ type: 'text',
1281
+ text: `āœ… Bookmarked ${type} ${id}`
1282
+ }]
1283
+ };
1284
+ } catch (error) {
1285
+ return { content: [{ type: 'text', text: `āŒ Bookmark create error: ${error.message}` }] };
1286
+ }
1287
+ }
1288
+
1289
+
1290
+ async handleBookmarkList(args) {
1291
+ try {
1292
+ const bookmarks = await this.metabaseClient.request('GET', '/api/bookmark');
1293
+
1294
+ return {
1295
+ content: [{
1296
+ type: 'text',
1297
+ text: `Found ${bookmarks.length} bookmarks:\n${bookmarks.map(b =>
1298
+ ` - [${b.type}:${b.item_id}] ${b.name || 'Unnamed'}`
1299
+ ).join('\n')}`
1300
+ }]
1301
+ };
1302
+ } catch (error) {
1303
+ return { content: [{ type: 'text', text: `āŒ Bookmark list error: ${error.message}` }] };
1304
+ }
1305
+ }
1306
+
1307
+
1308
+ async handleBookmarkDelete(args) { const { type, id } = args;
1309
+
1310
+ try {
1311
+ await this.metabaseClient.request('DELETE', `/api/${type}/${id}/bookmark`);
1312
+
1313
+ return {
1314
+ content: [{
1315
+ type: 'text',
1316
+ text: `āœ… Bookmark removed for ${type} ${id}`
1317
+ }]
1318
+ };
1319
+ } catch (error) {
1320
+ return { content: [{ type: 'text', text: `āŒ Bookmark delete error: ${error.message}` }] };
1321
+ }
1322
+ }
1323
+
1324
+
1325
+ // ==================== DATABASE SYNC & CACHE HANDLERS ====================
1326
+
1327
+ async handleDbSyncSchema(args) { const { database_id } = args;
1328
+
1329
+ try {
1330
+ await this.metabaseClient.request('POST', `/api/database/${database_id}/sync_schema`);
1331
+
1332
+ return {
1333
+ content: [{
1334
+ type: 'text',
1335
+ text: `āœ… Schema sync triggered for database ${database_id}`
1336
+ }]
1337
+ };
1338
+ } catch (error) {
1339
+ return { content: [{ type: 'text', text: `āŒ Schema sync error: ${error.message}` }] };
1340
+ }
1341
+ }
1342
+
1343
+
1344
+ async handleCacheInvalidate(args) { const { database_id, card_id } = args;
1345
+
1346
+ try {
1347
+ if (card_id) {
1348
+ // Invalidate specific card cache
1349
+ await this.metabaseClient.request('POST', `/api/card/${card_id}/query`, {
1350
+ ignore_cache: true
1351
+ });
1352
+ return {
1353
+ content: [{
1354
+ type: 'text',
1355
+ text: `āœ… Cache invalidated for card ${card_id}`
1356
+ }]
1357
+ };
1358
+ } else if (database_id) {
1359
+ // Invalidate database cache by triggering rescan
1360
+ await this.metabaseClient.request('POST', `/api/database/${database_id}/rescan_values`);
1361
+ return {
1362
+ content: [{
1363
+ type: 'text',
1364
+ text: `āœ… Cache invalidated for database ${database_id}`
1365
+ }]
1366
+ };
1367
+ } else {
1368
+ return {
1369
+ content: [{
1370
+ type: 'text',
1371
+ text: `āŒ Please specify either database_id or card_id`
1372
+ }]
1373
+ };
1374
+ }
1375
+ } catch (error) {
1376
+ return { content: [{ type: 'text', text: `āŒ Cache invalidate error: ${error.message}` }] };
1377
+ }
1378
+ }
1379
+
1380
+
1381
+ async handleCollectionCopy(args) { const { collection_id, destination_id, new_name } = args;
1382
+
1383
+ try {
1384
+ // Get source collection
1385
+ const sourceCollection = await this.metabaseClient.request('GET', `/api/collection/${collection_id}`);
1386
+
1387
+ // Create new collection
1388
+ const newCollection = await this.metabaseClient.request('POST', '/api/collection', {
1389
+ name: new_name || `Copy of ${sourceCollection.name}`,
1390
+ description: sourceCollection.description,
1391
+ parent_id: destination_id || sourceCollection.parent_id
1392
+ });
1393
+
1394
+ // Get items in source collection
1395
+ const items = await this.metabaseClient.request('GET', `/api/collection/${collection_id}/items`);
1396
+ const allItems = items.data || items;
1397
+
1398
+ let copiedCards = 0;
1399
+ let copiedDashboards = 0;
1400
+
1401
+ // Copy each item
1402
+ for (const item of allItems) {
1403
+ if (item.model === 'card') {
1404
+ await this.handleCardCopy({
1405
+ card_id: item.id,
1406
+ collection_id: newCollection.id
1407
+ });
1408
+ copiedCards++;
1409
+ } else if (item.model === 'dashboard') {
1410
+ await this.handleDashboardCopy({
1411
+ dashboard_id: item.id,
1412
+ collection_id: newCollection.id,
1413
+ deep_copy: false // Don't deep copy cards as they're already being copied
1414
+ });
1415
+ copiedDashboards++;
1416
+ }
1417
+ }
1418
+
1419
+ return {
1420
+ content: [{
1421
+ type: 'text',
1422
+ text: `āœ… Collection copied:\n New Collection ID: ${newCollection.id}\n Name: ${newCollection.name}\n Cards copied: ${copiedCards}\n Dashboards copied: ${copiedDashboards}`
1423
+ }]
1424
+ };
1425
+ } catch (error) {
1426
+ return { content: [{ type: 'text', text: `āŒ Collection copy error: ${error.message}` }] };
1427
+ }
1428
+ }
1429
+
1430
+
1431
+ // Helper method for executive dashboard layout
1432
+ calculateExecutiveLayout(index, total) {
1433
+ const layouts = {
1434
+ 'kpi': { sizeX: 3, sizeY: 3 }, // KPI cards
1435
+ 'chart': { sizeX: 6, sizeY: 4 }, // Charts
1436
+ 'table': { sizeX: 12, sizeY: 6 }, // Tables
1437
+ 'metric': { sizeX: 4, sizeY: 3 } // Metrics
1438
+ };
1439
+
1440
+ // Executive layout:
1441
+ // Row 0: 4 KPI cards (3x3 each)
1442
+ // Row 1: 2 charts (6x4 each)
1443
+ // Row 2: 1 table (12x6)
1444
+
1445
+ if (index < 4) {
1446
+ // KPI cards in top row
1447
+ return {
1448
+ row: 0,
1449
+ col: index * 3,
1450
+ sizeX: 3,
1451
+ sizeY: 3
1452
+ };
1453
+ } else if (index < 6) {
1454
+ // Charts in second row
1455
+ return {
1456
+ row: 1,
1457
+ col: (index - 4) * 6,
1458
+ sizeX: 6,
1459
+ sizeY: 4
1460
+ };
1461
+ } else {
1462
+ // Tables/detailed views in subsequent rows
1463
+ return {
1464
+ row: 2 + Math.floor((index - 6) / 1),
1465
+ col: 0,
1466
+ sizeX: 12,
1467
+ sizeY: 6
1468
+ };
1469
+ }
1470
+ }
1471
+
1472
+
1473
+ // Helper method to generate executive questions based on business domain
1474
+ async generateExecutiveQuestions(databaseId, schemaName, tables, businessDomain, timePeriod) {
1475
+ const questions = [];
1476
+
1477
+ // Analyze tables to find relevant business entities
1478
+ const salesTables = tables.filter(t =>
1479
+ t.name.toLowerCase().includes('sale') ||
1480
+ t.name.toLowerCase().includes('order') ||
1481
+ t.name.toLowerCase().includes('transaction')
1482
+ );
1483
+
1484
+ const customerTables = tables.filter(t =>
1485
+ t.name.toLowerCase().includes('customer') ||
1486
+ t.name.toLowerCase().includes('user') ||
1487
+ t.name.toLowerCase().includes('client')
1488
+ );
1489
+
1490
+ const productTables = tables.filter(t =>
1491
+ t.name.toLowerCase().includes('product') ||
1492
+ t.name.toLowerCase().includes('item') ||
1493
+ t.name.toLowerCase().includes('inventory')
1494
+ );
1495
+
1496
+ // Generate KPI questions based on domain
1497
+ if (salesTables.length > 0) {
1498
+ const salesTable = salesTables[0];
1499
+ questions.push({
1500
+ name: "Total Revenue",
1501
+ sql: `SELECT SUM(COALESCE(amount, total, price, 0)) as revenue FROM ${schemaName}.${salesTable.name} WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`,
1502
+ visualization: "number"
1503
+ });
1504
+
1505
+ questions.push({
1506
+ name: "Sales Trend",
1507
+ sql: `SELECT DATE(created_at) as date, SUM(COALESCE(amount, total, price, 0)) as daily_revenue FROM ${schemaName}.${salesTable.name} WHERE created_at >= CURRENT_DATE - INTERVAL '30 days' GROUP BY DATE(created_at) ORDER BY date`,
1508
+ visualization: "line"
1509
+ });
1510
+ }
1511
+
1512
+ if (customerTables.length > 0) {
1513
+ const customerTable = customerTables[0];
1514
+ questions.push({
1515
+ name: "Total Customers",
1516
+ sql: `SELECT COUNT(*) as customer_count FROM ${schemaName}.${customerTable.name}`,
1517
+ visualization: "number"
1518
+ });
1519
+
1520
+ questions.push({
1521
+ name: "New Customers (30d)",
1522
+ sql: `SELECT COUNT(*) as new_customers FROM ${schemaName}.${customerTable.name} WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'`,
1523
+ visualization: "number"
1524
+ });
1525
+ }
1526
+
1527
+ // Create questions in Metabase (simplified for demo)
1528
+ for (const q of questions) {
1529
+ try {
1530
+ const question = await this.metabaseClient.createSQLQuestion(
1531
+ q.name,
1532
+ `Executive KPI - ${q.name}`,
1533
+ databaseId,
1534
+ q.sql
1535
+ );
1536
+ questions[questions.indexOf(q)] = question;
1537
+ } catch (error) {
1538
+ console.error(`Error creating question ${q.name}:`, error);
1539
+ }
1540
+ }
1541
+
1542
+ return questions;
1543
+ }
1544
+ }