metabase-ai-assistant 3.4.3 → 3.6.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/package.json +2 -2
- package/src/mcp/handlers/dashboard_direct.js +316 -0
- package/src/mcp/handlers/metadata.js +199 -0
- package/src/mcp/server.js +205 -2
- package/src/utils/config.js +3 -0
- package/src/utils/logger.js +3 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metabase-ai-assistant",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"mcpName": "io.github.enessari/metabase-ai-assistant",
|
|
5
5
|
"description": "The most powerful MCP Server for Metabase - 111+ tools for AI-powered SQL generation, dashboard automation, user management & enterprise BI. Works with Claude, Cursor, and any MCP-compatible AI.",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -96,4 +96,4 @@
|
|
|
96
96
|
"eslint": "^8.56.0",
|
|
97
97
|
"jest": "^29.7.0"
|
|
98
98
|
}
|
|
99
|
-
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import { config } from '../../utils/config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Handler for Direct SQL Dashboard Operations
|
|
7
|
+
* Bypasses API limitations by directly interacting with Internal DB
|
|
8
|
+
*/
|
|
9
|
+
export class DashboardDirectHandler {
|
|
10
|
+
constructor(metabaseClient, metadataHandler) {
|
|
11
|
+
this.metabaseClient = metabaseClient;
|
|
12
|
+
this.metadataHandler = metadataHandler;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Add Cards to Dashboard via SQL (Batch/Loop Insert)
|
|
17
|
+
* Resolves positioning issues and timeouts found in API
|
|
18
|
+
*/
|
|
19
|
+
async handleAddCardSql(args) {
|
|
20
|
+
const { dashboard_id, cards } = args;
|
|
21
|
+
|
|
22
|
+
// 1. Get Internal DB ID
|
|
23
|
+
// Use MetadataHandler's helper or fallback to 6
|
|
24
|
+
let internalDbId;
|
|
25
|
+
try {
|
|
26
|
+
internalDbId = await this.metadataHandler.getInternalDbId();
|
|
27
|
+
} catch (e) {
|
|
28
|
+
internalDbId = 6; // Fallback from user guide
|
|
29
|
+
logger.warn(`Could not determine Internal DB ID, using default: ${internalDbId}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const results = [];
|
|
33
|
+
const errors = [];
|
|
34
|
+
|
|
35
|
+
logger.info(`Adding ${cards.length} cards to dashboard ${dashboard_id} via Direct SQL (DB: ${internalDbId})`);
|
|
36
|
+
|
|
37
|
+
// 2. Loop through cards (Single Row Insert Constraint)
|
|
38
|
+
// "Metabase/Driver limitations often cause batch inserts to fail silently or partially"
|
|
39
|
+
for (const card of cards) {
|
|
40
|
+
try {
|
|
41
|
+
// Validation / Defaults
|
|
42
|
+
const row = card.row !== undefined ? card.row : 0;
|
|
43
|
+
const col = card.col !== undefined ? card.col : 0;
|
|
44
|
+
const sizeX = card.size_x || 4;
|
|
45
|
+
const sizeY = card.size_y || 4;
|
|
46
|
+
const vizSettings = card.visualization_settings ? JSON.stringify(card.visualization_settings) : '{}';
|
|
47
|
+
const paramMappings = card.parameter_mappings ? JSON.stringify(card.parameter_mappings) : '[]';
|
|
48
|
+
|
|
49
|
+
// 3. Construct SQL
|
|
50
|
+
const sql = `
|
|
51
|
+
INSERT INTO report_dashboardcard
|
|
52
|
+
(card_id, dashboard_id, row, col, size_x, size_y, visualization_settings, parameter_mappings, created_at, updated_at)
|
|
53
|
+
VALUES
|
|
54
|
+
(
|
|
55
|
+
${card.card_id},
|
|
56
|
+
${dashboard_id},
|
|
57
|
+
${row},
|
|
58
|
+
${col},
|
|
59
|
+
${sizeX},
|
|
60
|
+
${sizeY},
|
|
61
|
+
'${vizSettings}',
|
|
62
|
+
'${paramMappings}',
|
|
63
|
+
NOW(),
|
|
64
|
+
NOW()
|
|
65
|
+
)
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
// 4. Execute
|
|
69
|
+
await this.metabaseClient.executeNativeQuery(internalDbId, sql, { enforcePrefix: false });
|
|
70
|
+
|
|
71
|
+
results.push(`✅ Card ${card.card_id} -> (${row}, ${col}) [${sizeX}x${sizeY}]`);
|
|
72
|
+
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const msg = `❌ Failed Card ${card.card_id}: ${error.message}`;
|
|
75
|
+
logger.error(msg);
|
|
76
|
+
errors.push(msg);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 5. Construct Response
|
|
81
|
+
let output = `🏗️ **Direct SQL Card Addition Results**\n\n`;
|
|
82
|
+
output += `Target Dashboard: ${dashboard_id}\n`;
|
|
83
|
+
output += `Success: ${results.length} / ${cards.length}\n\n`;
|
|
84
|
+
|
|
85
|
+
if (results.length > 0) {
|
|
86
|
+
output += `**Successfully Added:**\n${results.join('\n')}\n\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (errors.length > 0) {
|
|
90
|
+
output += `**Errors:**\n${errors.join('\n')}\n`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
output += `\n💡 *Note: You may need to refresh the dashboard execution cache or reload the page to see changes immediately.*`;
|
|
94
|
+
|
|
95
|
+
return { content: [{ type: 'text', text: output }] };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Batch Update Dashboard Layout via SQL
|
|
100
|
+
* Direct UPDATE to report_dashboardcard
|
|
101
|
+
*/
|
|
102
|
+
async handleUpdateLayoutSql(args) {
|
|
103
|
+
const { dashboard_id, updates } = args;
|
|
104
|
+
|
|
105
|
+
// Default to DB 6 if no other info
|
|
106
|
+
let internalDbId;
|
|
107
|
+
try {
|
|
108
|
+
internalDbId = await this.metadataHandler.getInternalDbId();
|
|
109
|
+
} catch (e) {
|
|
110
|
+
internalDbId = 6;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const results = [];
|
|
114
|
+
const errors = [];
|
|
115
|
+
|
|
116
|
+
logger.info(`Updating layout for ${updates.length} cards on dashboard ${dashboard_id} (DB: ${internalDbId})`);
|
|
117
|
+
|
|
118
|
+
for (const update of updates) {
|
|
119
|
+
try {
|
|
120
|
+
if (!update.card_id) continue;
|
|
121
|
+
|
|
122
|
+
const setParts = [];
|
|
123
|
+
if (update.row !== undefined) setParts.push(`row = ${update.row}`);
|
|
124
|
+
if (update.col !== undefined) setParts.push(`col = ${update.col}`);
|
|
125
|
+
if (update.size_x !== undefined) setParts.push(`size_x = ${update.size_x}`);
|
|
126
|
+
if (update.size_y !== undefined) setParts.push(`size_y = ${update.size_y}`);
|
|
127
|
+
|
|
128
|
+
setParts.push(`updated_at = NOW()`);
|
|
129
|
+
|
|
130
|
+
const sql = `
|
|
131
|
+
UPDATE report_dashboardcard
|
|
132
|
+
SET ${setParts.join(', ')}
|
|
133
|
+
WHERE dashboard_id = ${dashboard_id} AND card_id = ${update.card_id}
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
await this.metabaseClient.executeNativeQuery(internalDbId, sql, { enforcePrefix: false });
|
|
137
|
+
results.push(`✅ Card ${update.card_id}`);
|
|
138
|
+
|
|
139
|
+
} catch (error) {
|
|
140
|
+
errors.push(`❌ Card ${update.card_id}: ${error.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
content: [{
|
|
146
|
+
type: 'text',
|
|
147
|
+
text: `🏗️ **Layout Update Results**\nDashboard: ${dashboard_id}\nSuccess: ${results.length}\nErrors: ${errors.length}\n\n${errors.join('\n')}`
|
|
148
|
+
}]
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create Parametric Native SQL Question
|
|
154
|
+
* Constructs the complex dataset_query JSON and inserts directly
|
|
155
|
+
*/
|
|
156
|
+
async handleCreateParametricQuestionSql(args) {
|
|
157
|
+
const { name, description, database_id, query_sql, parameters, collection_id } = args;
|
|
158
|
+
|
|
159
|
+
// 1. Get Internal DB ID
|
|
160
|
+
let internalDbId;
|
|
161
|
+
try {
|
|
162
|
+
internalDbId = await this.metadataHandler.getInternalDbId();
|
|
163
|
+
} catch (e) {
|
|
164
|
+
internalDbId = 6;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. Construct Template Tags
|
|
168
|
+
const templateTags = {};
|
|
169
|
+
if (parameters && Array.isArray(parameters)) {
|
|
170
|
+
for (const param of parameters) {
|
|
171
|
+
// Generate a UUID-like key or just use the name
|
|
172
|
+
const tagId = `tag_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
173
|
+
templateTags[param.name] = {
|
|
174
|
+
"id": tagId,
|
|
175
|
+
"name": param.name,
|
|
176
|
+
"display-name": param.display_name || param.name,
|
|
177
|
+
"type": param.type || "text", // text, number, date, dimension
|
|
178
|
+
"default": param.default || null,
|
|
179
|
+
"required": param.required || false
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Add widget type if specified (e.g. category filter)
|
|
183
|
+
if (param.widget_type) {
|
|
184
|
+
templateTags[param.name]["widget-type"] = param.widget_type;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3. Construct Dataset Query
|
|
190
|
+
const datasetQuery = {
|
|
191
|
+
"type": "native",
|
|
192
|
+
"native": {
|
|
193
|
+
"query": query_sql,
|
|
194
|
+
"template-tags": templateTags
|
|
195
|
+
},
|
|
196
|
+
"database": database_id
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const display = "table"; // Default display type
|
|
200
|
+
const vizSettings = "{}"; // Default empty viz settings
|
|
201
|
+
|
|
202
|
+
// 4. Construct SQL Insert
|
|
203
|
+
// Note: collection_id can be NULL
|
|
204
|
+
const collectionVal = collection_id ? collection_id : 'NULL';
|
|
205
|
+
|
|
206
|
+
// creator_id fallback (usually 1 for admin)
|
|
207
|
+
const creatorId = 1;
|
|
208
|
+
|
|
209
|
+
// Escaping for SQL string literals (basic)
|
|
210
|
+
const safeName = name.replace(/'/g, "''");
|
|
211
|
+
const safeDesc = (description || '').replace(/'/g, "''");
|
|
212
|
+
const safeQueryJson = JSON.stringify(datasetQuery).replace(/'/g, "''");
|
|
213
|
+
|
|
214
|
+
const sql = `
|
|
215
|
+
INSERT INTO report_card
|
|
216
|
+
(name, description, display, dataset_query, visualization_settings,
|
|
217
|
+
creator_id, database_id, query_type, created_at, updated_at,
|
|
218
|
+
collection_id, type, parameters, archived)
|
|
219
|
+
VALUES
|
|
220
|
+
(
|
|
221
|
+
'${safeName}',
|
|
222
|
+
'${safeDesc}',
|
|
223
|
+
'${display}',
|
|
224
|
+
'${safeQueryJson}',
|
|
225
|
+
'${vizSettings}',
|
|
226
|
+
${creatorId},
|
|
227
|
+
${database_id},
|
|
228
|
+
'native',
|
|
229
|
+
NOW(),
|
|
230
|
+
NOW(),
|
|
231
|
+
${collectionVal},
|
|
232
|
+
'question',
|
|
233
|
+
'[]',
|
|
234
|
+
false
|
|
235
|
+
)
|
|
236
|
+
`;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
await this.metabaseClient.executeNativeQuery(internalDbId, sql, { enforcePrefix: false });
|
|
240
|
+
return {
|
|
241
|
+
content: [{
|
|
242
|
+
type: 'text',
|
|
243
|
+
text: `✅ **Parametric Question Created**\n\nName: ${name}\nDB Source: ${database_id}\nParameters: ${Object.keys(templateTags).length}\n\n*Note: Use 'meta_advanced_search' to find the new Card ID.*`
|
|
244
|
+
}]
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
logger.error(`Failed to create parametric question: ${error.message}`);
|
|
248
|
+
throw new McpError(ErrorCode.InternalError, `Failed to create question via SQL: ${error.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Link Dashboard Filter to Card Parameter
|
|
254
|
+
* Updates parameter_mappings JSONB
|
|
255
|
+
*/
|
|
256
|
+
async handleLinkDashboardFilter(args) {
|
|
257
|
+
const { dashboard_id, card_id, mappings } = args;
|
|
258
|
+
|
|
259
|
+
// 1. Get Internal DB ID
|
|
260
|
+
let internalDbId;
|
|
261
|
+
try {
|
|
262
|
+
internalDbId = await this.metadataHandler.getInternalDbId();
|
|
263
|
+
} catch (e) {
|
|
264
|
+
internalDbId = 6;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 2. Construct Mappings JSON
|
|
268
|
+
const mappingArray = mappings.map(m => {
|
|
269
|
+
const mapObj = {
|
|
270
|
+
"parameter_id": m.parameter_id,
|
|
271
|
+
"card_id": card_id,
|
|
272
|
+
"target": m.target_value // already formatted based on type?
|
|
273
|
+
// Wait, implementation plan said type enum.
|
|
274
|
+
// We need to construct the target array structure based on type.
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
if (m.target_type === 'variable') {
|
|
278
|
+
mapObj.target = ["variable", ["template-tag", m.target_value]];
|
|
279
|
+
} else if (m.target_type === 'dimension') {
|
|
280
|
+
// Dimension format: ["dimension", ["field", id, null]] or similar
|
|
281
|
+
// For now, assume user passes the raw dimension array or string if they know it.
|
|
282
|
+
// OR simplify: require target_value to be the full target structure if complex.
|
|
283
|
+
// Let's stick to variable support as primary goal.
|
|
284
|
+
if (Array.isArray(m.target_value)) {
|
|
285
|
+
mapObj.target = m.target_value;
|
|
286
|
+
} else {
|
|
287
|
+
mapObj.target = ["dimension", ["field", m.target_value, null]];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return mapObj;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const jsonMappings = JSON.stringify(mappingArray);
|
|
294
|
+
|
|
295
|
+
// 3. Execute Update
|
|
296
|
+
const sql = `
|
|
297
|
+
UPDATE report_dashboardcard
|
|
298
|
+
SET parameter_mappings = '${jsonMappings}',
|
|
299
|
+
updated_at = NOW()
|
|
300
|
+
WHERE dashboard_id = ${dashboard_id} AND card_id = ${card_id}
|
|
301
|
+
`;
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await this.metabaseClient.executeNativeQuery(internalDbId, sql, { enforcePrefix: false });
|
|
305
|
+
return {
|
|
306
|
+
content: [{
|
|
307
|
+
type: 'text',
|
|
308
|
+
text: `✅ **Filter Linked**\nDashboard: ${dashboard_id}\nCard: ${card_id}\nMappings Applied: ${mappings.length}`
|
|
309
|
+
}]
|
|
310
|
+
};
|
|
311
|
+
} catch (error) {
|
|
312
|
+
logger.error(`Failed to link filters: ${error.message}`);
|
|
313
|
+
throw new McpError(ErrorCode.InternalError, `Link Filter SQL Failed: ${error.message}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { McpError, ErrorCode, McpError as McpErrorSdk } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { logger } from '../../utils/logger.js';
|
|
3
|
+
import { config } from '../../utils/config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Handler for Advanced Metadata Operations
|
|
7
|
+
* Uses direct SQL access to Metabase internal database
|
|
8
|
+
*/
|
|
9
|
+
export class MetadataHandler {
|
|
10
|
+
constructor(metabaseClient) {
|
|
11
|
+
this.metabaseClient = metabaseClient;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Helper to get Internal DB ID with fallback
|
|
16
|
+
*/
|
|
17
|
+
async getInternalDbId(providedId) {
|
|
18
|
+
// 1. Use provided ID if any
|
|
19
|
+
if (providedId) return providedId;
|
|
20
|
+
|
|
21
|
+
// 2. Use Env Var
|
|
22
|
+
if (config.METABASE_INTERNAL_DB_ID) return config.METABASE_INTERNAL_DB_ID;
|
|
23
|
+
|
|
24
|
+
// 3. Throw error (Auto-detect should be explicit tool call)
|
|
25
|
+
throw new Error('Internal Database ID not configured. Use `meta_find_internal_db` to find it, or set METABASE_INTERNAL_DB_ID env var.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Auto-detect Metabase Internal DB
|
|
30
|
+
*/
|
|
31
|
+
async handleFindInternalDb() {
|
|
32
|
+
try {
|
|
33
|
+
const databases = await this.metabaseClient.getDatabases();
|
|
34
|
+
const candidates = [];
|
|
35
|
+
|
|
36
|
+
for (const db of databases) {
|
|
37
|
+
try {
|
|
38
|
+
// Try to query a core table
|
|
39
|
+
await this.metabaseClient.executeNativeQuery(db.id, 'SELECT count(*) FROM report_card');
|
|
40
|
+
candidates.push({ id: db.id, name: db.name, engine: db.engine });
|
|
41
|
+
} catch (e) {
|
|
42
|
+
// Ignore failures, not the internal DB
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (candidates.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text', text: '❌ Could not find Internal Database. Please add Metabase Application DB as a source.' }]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let output = `✅ **Internal Database Candidates Found:**\n\n`;
|
|
53
|
+
candidates.forEach(c => {
|
|
54
|
+
output += `- **${c.name}** (ID: ${c.id}, Engine: ${c.engine})\n`;
|
|
55
|
+
});
|
|
56
|
+
output += `\n💡 Recommended: Set \`METABASE_INTERNAL_DB_ID=${candidates[0].id}\` in .env`;
|
|
57
|
+
|
|
58
|
+
return { content: [{ type: 'text', text: output }] };
|
|
59
|
+
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { content: [{ type: 'text', text: `❌ Discovery failed: ${error.message}` }] };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Audit Logs - Query Performance & Usage
|
|
67
|
+
*/
|
|
68
|
+
async handleAuditLogs(args) {
|
|
69
|
+
const dbId = await this.getInternalDbId(args.internal_db_id);
|
|
70
|
+
const days = args.days || 30;
|
|
71
|
+
const limit = args.limit || 50;
|
|
72
|
+
|
|
73
|
+
const sql = `
|
|
74
|
+
SELECT
|
|
75
|
+
c.name as card_name,
|
|
76
|
+
c.id as card_id,
|
|
77
|
+
u.email as runner_email,
|
|
78
|
+
qe.started_at,
|
|
79
|
+
qe.running_time as duration_ms,
|
|
80
|
+
qe.native as is_native,
|
|
81
|
+
qe.error
|
|
82
|
+
FROM query_execution qe
|
|
83
|
+
LEFT JOIN report_card c ON qe.card_id = c.id
|
|
84
|
+
LEFT JOIN core_user u ON qe.executor_id = u.id
|
|
85
|
+
WHERE qe.started_at > NOW() - INTERVAL '${days} days'
|
|
86
|
+
ORDER BY qe.running_time DESC
|
|
87
|
+
LIMIT ${limit}
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
// Re-use logic from sql_execute via client
|
|
91
|
+
const result = await this.metabaseClient.executeNativeQuery(dbId, sql);
|
|
92
|
+
const rows = result.data.rows || [];
|
|
93
|
+
|
|
94
|
+
let output = `📊 **Audit Logs (Last ${days} days)**\n`;
|
|
95
|
+
output += `Found ${rows.length} execution records (Top Slowest)\n\n`;
|
|
96
|
+
|
|
97
|
+
// Helper to format table
|
|
98
|
+
if (rows.length > 0) {
|
|
99
|
+
output += `| Card | Runner | Duration (ms) | Date | Error |\n`;
|
|
100
|
+
output += `|---|---|---|---|---|\n`;
|
|
101
|
+
rows.forEach(row => {
|
|
102
|
+
const [card, cardId, runner, date, duration, isNative, error] = row;
|
|
103
|
+
const name = card ? `${card} (${cardId})` : (isNative ? 'Ad-hoc SQL' : 'Unknown');
|
|
104
|
+
const errIcon = error ? '❌' : '✅';
|
|
105
|
+
output += `| ${name} | ${runner || 'System'} | ${duration} | ${new Date(date).toLocaleDateString()} | ${errIcon} |\n`;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { content: [{ type: 'text', text: output }] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Lineage - Dependency Graph
|
|
114
|
+
*/
|
|
115
|
+
async handleLineage(args) {
|
|
116
|
+
const dbId = await this.getInternalDbId(args.internal_db_id);
|
|
117
|
+
const term = args.search_term;
|
|
118
|
+
|
|
119
|
+
// 1. Find Questions using this table/field string in their query
|
|
120
|
+
const sqlQuestions = `
|
|
121
|
+
SELECT id, name, collection_id
|
|
122
|
+
FROM report_card
|
|
123
|
+
WHERE dataset_query LIKE '%${term}%'
|
|
124
|
+
AND archived = false
|
|
125
|
+
LIMIT 50
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const resultQ = await this.metabaseClient.executeNativeQuery(dbId, sqlQuestions);
|
|
129
|
+
const questions = resultQ.data.rows || [];
|
|
130
|
+
|
|
131
|
+
// 2. Find Dashboards using these questions (if any questions found)
|
|
132
|
+
let dashboards = [];
|
|
133
|
+
if (questions.length > 0) {
|
|
134
|
+
const qIds = questions.map(r => r[0]).join(',');
|
|
135
|
+
const sqlDash = `
|
|
136
|
+
SELECT DISTINCT d.id, d.name
|
|
137
|
+
FROM report_dashboard d
|
|
138
|
+
JOIN report_dashboardcard dc ON d.id = dc.dashboard_id
|
|
139
|
+
WHERE dc.card_id IN (${qIds})
|
|
140
|
+
AND d.archived = false
|
|
141
|
+
`;
|
|
142
|
+
const resultD = await this.metabaseClient.executeNativeQuery(dbId, sqlDash);
|
|
143
|
+
dashboards = resultD.data.rows || [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let output = `🔗 **Lineage Analysis for:** \`${term}\`\n\n`;
|
|
147
|
+
|
|
148
|
+
if (questions.length === 0) {
|
|
149
|
+
output += `No direct dependencies found in active questions.\n`;
|
|
150
|
+
} else {
|
|
151
|
+
output += `**📉 Dependent Questions (${questions.length}):**\n`;
|
|
152
|
+
questions.forEach(q => output += `- [${q[0]}] ${q[1]}\n`);
|
|
153
|
+
|
|
154
|
+
output += `\n**📊 Impacted Dashboards (${dashboards.length}):**\n`;
|
|
155
|
+
dashboards.forEach(d => output += `- [${d[0]}] ${d[1]}\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { content: [{ type: 'text', text: output }] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Advanced Search
|
|
163
|
+
*/
|
|
164
|
+
async handleAdvancedSearch(args) {
|
|
165
|
+
const dbId = await this.getInternalDbId(args.internal_db_id);
|
|
166
|
+
const query = args.query;
|
|
167
|
+
|
|
168
|
+
const sql = `
|
|
169
|
+
SELECT id, name, 'Question' as type, description
|
|
170
|
+
FROM report_card
|
|
171
|
+
WHERE name ILIKE '%${query}%'
|
|
172
|
+
OR description ILIKE '%${query}%'
|
|
173
|
+
OR dataset_query ILIKE '%${query}%'
|
|
174
|
+
UNION ALL
|
|
175
|
+
SELECT id, name, 'Dashboard' as type, description
|
|
176
|
+
FROM report_dashboard
|
|
177
|
+
WHERE name ILIKE '%${query}%'
|
|
178
|
+
OR description ILIKE '%${query}%'
|
|
179
|
+
LIMIT 20
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
const result = await this.metabaseClient.executeNativeQuery(dbId, sql);
|
|
183
|
+
const rows = result.data.rows || [];
|
|
184
|
+
|
|
185
|
+
let output = `🔍 **Advanced Search Results for:** \`${query}\`\n\n`;
|
|
186
|
+
if (rows.length === 0) {
|
|
187
|
+
output += "No results found.";
|
|
188
|
+
} else {
|
|
189
|
+
rows.forEach(row => {
|
|
190
|
+
const [id, name, type, desc] = row;
|
|
191
|
+
output += `**[${type}] ${name}** (ID: ${id})\n`;
|
|
192
|
+
if (desc) output += `> ${desc}\n`;
|
|
193
|
+
output += `\n`;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { content: [{ type: 'text', text: output }] };
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/mcp/server.js
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
isReadOnlyMode,
|
|
25
25
|
detectWriteOperation,
|
|
26
26
|
} from './handlers/index.js';
|
|
27
|
+
import { MetadataHandler } from './handlers/metadata.js';
|
|
28
|
+
import { DashboardDirectHandler } from './handlers/dashboard_direct.js';
|
|
27
29
|
|
|
28
30
|
// Utils
|
|
29
31
|
import { CacheManager, CacheKeys, globalCache } from '../utils/cache.js';
|
|
@@ -99,6 +101,12 @@ class MetabaseMCPServer {
|
|
|
99
101
|
apiKey: process.env.METABASE_API_KEY,
|
|
100
102
|
});
|
|
101
103
|
|
|
104
|
+
// Initialize Metadata Handler
|
|
105
|
+
this.metadataHandler = new MetadataHandler(this.metabaseClient);
|
|
106
|
+
|
|
107
|
+
// Initialize Direct Dashboard Handler
|
|
108
|
+
this.dashboardDirectHandler = new DashboardDirectHandler(this.metabaseClient, this.metadataHandler);
|
|
109
|
+
|
|
102
110
|
await this.metabaseClient.authenticate();
|
|
103
111
|
logger.info('Metabase client initialized');
|
|
104
112
|
|
|
@@ -569,6 +577,115 @@ class MetabaseMCPServer {
|
|
|
569
577
|
required: ['dashboard_id', 'question_id'],
|
|
570
578
|
},
|
|
571
579
|
},
|
|
580
|
+
{
|
|
581
|
+
name: 'mb_dashboard_add_card_sql',
|
|
582
|
+
description: 'Add multiple cards to a dashboard using direct SQL inserts. Bypasses API limits, ensures precise positioning, and prevents timeouts. Use this for complex layouts.',
|
|
583
|
+
inputSchema: {
|
|
584
|
+
type: 'object',
|
|
585
|
+
properties: {
|
|
586
|
+
dashboard_id: {
|
|
587
|
+
type: 'number',
|
|
588
|
+
description: 'Target Dashboard ID'
|
|
589
|
+
},
|
|
590
|
+
cards: {
|
|
591
|
+
type: 'array',
|
|
592
|
+
description: 'List of cards to add with layout config',
|
|
593
|
+
items: {
|
|
594
|
+
type: 'object',
|
|
595
|
+
properties: {
|
|
596
|
+
card_id: { type: 'number' },
|
|
597
|
+
row: { type: 'number', description: 'Grid row (0-based)' },
|
|
598
|
+
col: { type: 'number', description: 'Grid col (0-based)' },
|
|
599
|
+
size_x: { type: 'number', default: 4 },
|
|
600
|
+
size_y: { type: 'number', default: 4 },
|
|
601
|
+
visualization_settings: { type: 'object', description: 'Optional override settings' },
|
|
602
|
+
parameter_mappings: { type: 'array', description: 'Optional filter mappings' }
|
|
603
|
+
},
|
|
604
|
+
required: ['card_id', 'row', 'col']
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
required: ['dashboard_id', 'cards']
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: 'mb_dashboard_update_layout',
|
|
613
|
+
description: 'Batch update position and size of multiple dashboard cards via direct SQL. Guarantees layout application.',
|
|
614
|
+
inputSchema: {
|
|
615
|
+
type: 'object',
|
|
616
|
+
properties: {
|
|
617
|
+
dashboard_id: { type: 'number' },
|
|
618
|
+
updates: {
|
|
619
|
+
type: 'array',
|
|
620
|
+
items: {
|
|
621
|
+
type: 'object',
|
|
622
|
+
properties: {
|
|
623
|
+
card_id: { type: 'number' },
|
|
624
|
+
row: { type: 'number' },
|
|
625
|
+
col: { type: 'number' },
|
|
626
|
+
size_x: { type: 'number' },
|
|
627
|
+
size_y: { type: 'number' }
|
|
628
|
+
},
|
|
629
|
+
required: ['card_id']
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
required: ['dashboard_id', 'updates']
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: 'mb_create_parametric_question',
|
|
638
|
+
description: 'Create a native SQL question with parameters (variables) directly via SQL. Essential for creating cards that accept dashboard filters.',
|
|
639
|
+
inputSchema: {
|
|
640
|
+
type: 'object',
|
|
641
|
+
properties: {
|
|
642
|
+
name: { type: 'string' },
|
|
643
|
+
description: { type: 'string' },
|
|
644
|
+
database_id: { type: 'number', description: 'ID of the database to query against (not the internal one)' },
|
|
645
|
+
query_sql: { type: 'string', description: 'SQL query with {{variable}} tags' },
|
|
646
|
+
parameters: {
|
|
647
|
+
type: 'array',
|
|
648
|
+
items: {
|
|
649
|
+
type: 'object',
|
|
650
|
+
properties: {
|
|
651
|
+
name: { type: 'string' },
|
|
652
|
+
display_name: { type: 'string' },
|
|
653
|
+
type: { type: 'string', enum: ['text', 'number', 'date', 'dimension'] },
|
|
654
|
+
required: { type: 'boolean' },
|
|
655
|
+
default: { type: 'string' }
|
|
656
|
+
},
|
|
657
|
+
required: ['name', 'type']
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
collection_id: { type: 'number', description: 'Optional collection to place question in' }
|
|
661
|
+
},
|
|
662
|
+
required: ['name', 'database_id', 'query_sql']
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: 'mb_link_dashboard_filter',
|
|
667
|
+
description: 'Link a dashboard filter to a card parameter via SQL. Updates parameter_mappings.',
|
|
668
|
+
inputSchema: {
|
|
669
|
+
type: 'object',
|
|
670
|
+
properties: {
|
|
671
|
+
dashboard_id: { type: 'number' },
|
|
672
|
+
card_id: { type: 'number' },
|
|
673
|
+
mappings: {
|
|
674
|
+
type: 'array',
|
|
675
|
+
items: {
|
|
676
|
+
type: 'object',
|
|
677
|
+
properties: {
|
|
678
|
+
parameter_id: { type: 'string', description: 'The GUID of the dashboard parameter' },
|
|
679
|
+
target_type: { type: 'string', enum: ['variable', 'dimension'] },
|
|
680
|
+
target_value: { type: 'string', description: 'Variable name (without {{}}) or Field ID for dimension' }
|
|
681
|
+
},
|
|
682
|
+
required: ['parameter_id', 'target_type', 'target_value']
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
required: ['dashboard_id', 'card_id', 'mappings']
|
|
687
|
+
}
|
|
688
|
+
},
|
|
572
689
|
{
|
|
573
690
|
name: 'web_fetch_metabase_docs',
|
|
574
691
|
description: 'Fetch specific Metabase documentation page for API details, best practices, and feature information',
|
|
@@ -638,12 +755,80 @@ class MetabaseMCPServer {
|
|
|
638
755
|
description: 'Maximum number of relevant pages to return',
|
|
639
756
|
default: 5,
|
|
640
757
|
minimum: 1,
|
|
641
|
-
maximum:
|
|
758
|
+
maximum: 3
|
|
642
759
|
}
|
|
643
760
|
},
|
|
644
761
|
required: ['query'],
|
|
645
762
|
},
|
|
646
763
|
},
|
|
764
|
+
// === ADVANCED METADATA TOOLS (Internal DB) ===
|
|
765
|
+
{
|
|
766
|
+
name: 'meta_find_internal_db',
|
|
767
|
+
description: 'Auto-detect the Metabase Internal Application Database ID from connected databases. Required for advanced metadata tools.',
|
|
768
|
+
inputSchema: {
|
|
769
|
+
type: 'object',
|
|
770
|
+
properties: {},
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
name: 'meta_audit_logs',
|
|
775
|
+
description: 'Analyze query performance and usage history from internal logs. Finds slow queries and top users.',
|
|
776
|
+
inputSchema: {
|
|
777
|
+
type: 'object',
|
|
778
|
+
properties: {
|
|
779
|
+
days: {
|
|
780
|
+
type: 'number',
|
|
781
|
+
description: 'Number of days to analyze (default: 30)',
|
|
782
|
+
default: 30
|
|
783
|
+
},
|
|
784
|
+
limit: {
|
|
785
|
+
type: 'number',
|
|
786
|
+
description: 'Max results to return (default: 50)',
|
|
787
|
+
default: 50
|
|
788
|
+
},
|
|
789
|
+
internal_db_id: {
|
|
790
|
+
type: 'number',
|
|
791
|
+
description: 'Internal Database ID (optional if configured in env)'
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: 'meta_lineage',
|
|
798
|
+
description: 'Find dependencies: Which dashboards and questions use a specific table or field? (Impact Analysis)',
|
|
799
|
+
inputSchema: {
|
|
800
|
+
type: 'object',
|
|
801
|
+
properties: {
|
|
802
|
+
search_term: {
|
|
803
|
+
type: 'string',
|
|
804
|
+
description: 'Table name, field name, or SQL fragment to search for usage'
|
|
805
|
+
},
|
|
806
|
+
internal_db_id: {
|
|
807
|
+
type: 'number',
|
|
808
|
+
description: 'Internal Database ID (optional if configured in env)'
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
required: ['search_term']
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
name: 'meta_advanced_search',
|
|
816
|
+
description: 'Deep search within SQL code, visualization settings, and descriptions across all questions and dashboards.',
|
|
817
|
+
inputSchema: {
|
|
818
|
+
type: 'object',
|
|
819
|
+
properties: {
|
|
820
|
+
query: {
|
|
821
|
+
type: 'string',
|
|
822
|
+
description: 'Search query (searches SQL, names, descriptions)'
|
|
823
|
+
},
|
|
824
|
+
internal_db_id: {
|
|
825
|
+
type: 'number',
|
|
826
|
+
description: 'Internal Database ID (optional if configured in env)'
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
required: ['query']
|
|
830
|
+
},
|
|
831
|
+
},
|
|
647
832
|
{
|
|
648
833
|
name: 'web_metabase_api_reference',
|
|
649
834
|
description: 'Get comprehensive Metabase API reference with endpoints, parameters, examples, and response formats',
|
|
@@ -3263,7 +3448,15 @@ class MetabaseMCPServer {
|
|
|
3263
3448
|
case 'mb_question_create_parametric':
|
|
3264
3449
|
return await this.handleCreateParametricQuestion(args);
|
|
3265
3450
|
case 'mb_dashboard_add_card':
|
|
3266
|
-
return await this.handleAddCardToDashboard(args);
|
|
3451
|
+
return await this.handlers.dashboard.handleAddCardToDashboard(args, context);
|
|
3452
|
+
case 'mb_dashboard_add_card_sql':
|
|
3453
|
+
return await this.dashboardDirectHandler.handleAddCardSql(args);
|
|
3454
|
+
case 'mb_dashboard_update_layout':
|
|
3455
|
+
return await this.dashboardDirectHandler.handleUpdateLayoutSql(args);
|
|
3456
|
+
case 'mb_create_parametric_question':
|
|
3457
|
+
return await this.dashboardDirectHandler.handleCreateParametricQuestionSql(args);
|
|
3458
|
+
case 'mb_link_dashboard_filter':
|
|
3459
|
+
return await this.dashboardDirectHandler.handleLinkDashboardFilter(args);
|
|
3267
3460
|
case 'mb_metric_create':
|
|
3268
3461
|
return await this.handleCreateMetric(args);
|
|
3269
3462
|
case 'mb_dashboard_add_filter':
|
|
@@ -3540,6 +3733,16 @@ class MetabaseMCPServer {
|
|
|
3540
3733
|
case 'mb_meta_auto_cleanup':
|
|
3541
3734
|
return await this.handleMetadataAutoCleanup(args);
|
|
3542
3735
|
|
|
3736
|
+
// Advanced Metadata (Internal DB)
|
|
3737
|
+
case 'meta_find_internal_db':
|
|
3738
|
+
return await this.metadataHandler.handleFindInternalDb(args);
|
|
3739
|
+
case 'meta_audit_logs':
|
|
3740
|
+
return await this.metadataHandler.handleAuditLogs(args);
|
|
3741
|
+
case 'meta_lineage':
|
|
3742
|
+
return await this.metadataHandler.handleLineage(args);
|
|
3743
|
+
case 'meta_advanced_search':
|
|
3744
|
+
return await this.metadataHandler.handleAdvancedSearch(args);
|
|
3745
|
+
|
|
3543
3746
|
default:
|
|
3544
3747
|
throw new McpError(
|
|
3545
3748
|
ErrorCode.MethodNotFound,
|
package/src/utils/config.js
CHANGED
|
@@ -26,6 +26,9 @@ const envSchema = z.object({
|
|
|
26
26
|
.default('true')
|
|
27
27
|
.transform(val => val.toLowerCase() === 'true'),
|
|
28
28
|
|
|
29
|
+
// Internal Database ID (for advanced metadata features)
|
|
30
|
+
METABASE_INTERNAL_DB_ID: z.string().optional().transform(val => val ? parseInt(val, 10) : undefined),
|
|
31
|
+
|
|
29
32
|
// Database Configuration
|
|
30
33
|
DATABASE_TYPE: z.enum(['postgres', 'mysql', 'sqlite']).default('postgres'),
|
|
31
34
|
DATABASE_HOST: z.string().optional(),
|
package/src/utils/logger.js
CHANGED
|
@@ -6,11 +6,11 @@ const { combine, timestamp, printf, colorize } = winston.format;
|
|
|
6
6
|
// Custom format for console output
|
|
7
7
|
const consoleFormat = printf(({ level, message, timestamp, ...meta }) => {
|
|
8
8
|
let msg = `${timestamp} [${level}]: ${message}`;
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
if (Object.keys(meta).length > 0) {
|
|
11
11
|
msg += ` ${JSON.stringify(meta)}`;
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
return msg;
|
|
15
15
|
});
|
|
16
16
|
|
|
@@ -30,24 +30,8 @@ export const logger = winston.createLogger({
|
|
|
30
30
|
timestamp({ format: 'HH:mm:ss' }),
|
|
31
31
|
consoleFormat
|
|
32
32
|
)
|
|
33
|
-
}),
|
|
34
|
-
// File transport for errors
|
|
35
|
-
new winston.transports.File({
|
|
36
|
-
filename: 'error.log',
|
|
37
|
-
level: 'error',
|
|
38
|
-
format: combine(
|
|
39
|
-
timestamp(),
|
|
40
|
-
winston.format.json()
|
|
41
|
-
)
|
|
42
|
-
}),
|
|
43
|
-
// File transport for all logs
|
|
44
|
-
new winston.transports.File({
|
|
45
|
-
filename: 'combined.log',
|
|
46
|
-
format: combine(
|
|
47
|
-
timestamp(),
|
|
48
|
-
winston.format.json()
|
|
49
|
-
)
|
|
50
33
|
})
|
|
34
|
+
// File transports removed to avoid EPERM in restricted env
|
|
51
35
|
]
|
|
52
36
|
});
|
|
53
37
|
|