@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,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
|
+
}
|