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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metabase-ai-assistant",
3
- "version": "3.4.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: 20
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,
@@ -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(),
@@ -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