metabase-ai-assistant 3.3.0 → 3.4.1

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/README_MCP.md CHANGED
@@ -45,7 +45,7 @@ After updating the configuration file, restart your MCP client to load the new s
45
45
 
46
46
  ---
47
47
 
48
- ## Available Tools (107 Total)
48
+ ## Available Tools (111 Total)
49
49
 
50
50
  ### Database Operations
51
51
  - **db_list**: List all databases in Metabase
@@ -55,6 +55,10 @@ After updating the configuration file, restart your MCP client to load the new s
55
55
 
56
56
  ### SQL Operations
57
57
  - **sql_execute**: Execute native SQL query
58
+ - **sql_submit**: Asynchronously create a long-running SQL query job
59
+ - **sql_status**: Check the status of a SQL job
60
+ - **sql_cancel**: Cancel a running SQL job
61
+ - **db_table_profile**: Profile a table (smart dim/ref detection)
58
62
  - **ai_sql_generate**: Generate SQL from natural language description
59
63
  - **ai_sql_optimize**: Optimize SQL query for performance
60
64
  - **ai_sql_explain**: Explain what a SQL query does
@@ -213,6 +217,32 @@ After updating the configuration file, restart your MCP client to load the new s
213
217
  }
214
218
  ```
215
219
 
220
+ ### sql_submit (Async Query)
221
+ ```json
222
+ {
223
+ "database_id": 1,
224
+ "sql": "SELECT SLEEP(120)",
225
+ "timeout_seconds": 300
226
+ }
227
+ ```
228
+
229
+ ### sql_status
230
+ ```json
231
+ {
232
+ "job_id": "550e8400-e29b-41d4-a716-446655440000"
233
+ }
234
+ ```
235
+
236
+ ### db_table_profile
237
+ ```json
238
+ {
239
+ "database_id": 1,
240
+ "schema": "public",
241
+ "table": "dim_customers",
242
+ "sample_rows": 3
243
+ }
244
+ ```
245
+
216
246
  ### ai_sql_generate
217
247
  ```json
218
248
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metabase-ai-assistant",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
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,199 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ /**
5
+ * QueryJobStore - Manages async query jobs
6
+ * Tracks job status, results, and handles cleanup
7
+ */
8
+ export class QueryJobStore {
9
+ constructor() {
10
+ this.jobs = new Map();
11
+
12
+ // Auto-cleanup every 10 minutes
13
+ this.cleanupInterval = setInterval(() => this.cleanup(), 10 * 60 * 1000);
14
+ }
15
+
16
+ /**
17
+ * Create a new query job
18
+ */
19
+ create(databaseId, sql, timeoutSeconds = 300) {
20
+ const jobId = randomUUID();
21
+ const job = {
22
+ id: jobId,
23
+ database_id: databaseId,
24
+ sql: sql,
25
+ status: 'pending', // pending | running | complete | failed | timeout | cancelled
26
+ submitted_at: Date.now(),
27
+ started_at: null,
28
+ completed_at: null,
29
+ timeout_ms: timeoutSeconds * 1000,
30
+ result: null,
31
+ error: null,
32
+ row_count: 0,
33
+ abortController: new AbortController()
34
+ };
35
+
36
+ this.jobs.set(jobId, job);
37
+ logger.info(`Query job created: ${jobId}`);
38
+ return job;
39
+ }
40
+
41
+ /**
42
+ * Get job by ID
43
+ */
44
+ get(jobId) {
45
+ return this.jobs.get(jobId);
46
+ }
47
+
48
+ /**
49
+ * Update job properties
50
+ */
51
+ update(jobId, updates) {
52
+ const job = this.jobs.get(jobId);
53
+ if (job) {
54
+ Object.assign(job, updates);
55
+ logger.debug(`Job ${jobId} updated: ${updates.status || 'props'}`);
56
+ }
57
+ return job;
58
+ }
59
+
60
+ /**
61
+ * Mark job as running
62
+ */
63
+ markRunning(jobId) {
64
+ return this.update(jobId, {
65
+ status: 'running',
66
+ started_at: Date.now()
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Mark job as complete with results
72
+ */
73
+ markComplete(jobId, result, rowCount) {
74
+ return this.update(jobId, {
75
+ status: 'complete',
76
+ completed_at: Date.now(),
77
+ result: result,
78
+ row_count: rowCount
79
+ });
80
+ }
81
+
82
+ /**
83
+ * Mark job as failed
84
+ */
85
+ markFailed(jobId, error) {
86
+ return this.update(jobId, {
87
+ status: 'failed',
88
+ completed_at: Date.now(),
89
+ error: error.message || String(error)
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Mark job as timed out
95
+ */
96
+ markTimeout(jobId) {
97
+ const job = this.jobs.get(jobId);
98
+ return this.update(jobId, {
99
+ status: 'timeout',
100
+ completed_at: Date.now(),
101
+ error: `Query timed out after ${job?.timeout_ms / 1000} seconds`
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Mark job as cancelled
107
+ */
108
+ markCancelled(jobId) {
109
+ return this.update(jobId, {
110
+ status: 'cancelled',
111
+ completed_at: Date.now(),
112
+ error: 'Query cancelled by user'
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Get elapsed time for a job in seconds
118
+ */
119
+ getElapsedSeconds(jobId) {
120
+ const job = this.jobs.get(jobId);
121
+ if (!job) return 0;
122
+
123
+ const startTime = job.started_at || job.submitted_at;
124
+ const endTime = job.completed_at || Date.now();
125
+ return Math.round((endTime - startTime) / 1000);
126
+ }
127
+
128
+ /**
129
+ * List all jobs (optionally filtered by status)
130
+ */
131
+ list(status = null) {
132
+ const jobs = [];
133
+ for (const [id, job] of this.jobs) {
134
+ if (!status || job.status === status) {
135
+ jobs.push({
136
+ id: job.id,
137
+ database_id: job.database_id,
138
+ status: job.status,
139
+ submitted_at: job.submitted_at,
140
+ elapsed_seconds: this.getElapsedSeconds(job.id)
141
+ });
142
+ }
143
+ }
144
+ return jobs;
145
+ }
146
+
147
+ /**
148
+ * Get running jobs count
149
+ */
150
+ getRunningCount() {
151
+ let count = 0;
152
+ for (const job of this.jobs.values()) {
153
+ if (job.status === 'running' || job.status === 'pending') {
154
+ count++;
155
+ }
156
+ }
157
+ return count;
158
+ }
159
+
160
+ /**
161
+ * Cleanup old completed jobs (older than 1 hour)
162
+ */
163
+ cleanup() {
164
+ const oneHourAgo = Date.now() - 3600000;
165
+ let cleaned = 0;
166
+
167
+ for (const [id, job] of this.jobs) {
168
+ if (job.status !== 'running' && job.status !== 'pending') {
169
+ if (job.submitted_at < oneHourAgo) {
170
+ this.jobs.delete(id);
171
+ cleaned++;
172
+ }
173
+ }
174
+ }
175
+
176
+ if (cleaned > 0) {
177
+ logger.info(`Cleaned up ${cleaned} old query jobs`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Stop the cleanup interval (for graceful shutdown)
183
+ */
184
+ destroy() {
185
+ if (this.cleanupInterval) {
186
+ clearInterval(this.cleanupInterval);
187
+ }
188
+ }
189
+ }
190
+
191
+ // Singleton instance
192
+ let instance = null;
193
+
194
+ export function getJobStore() {
195
+ if (!instance) {
196
+ instance = new QueryJobStore();
197
+ }
198
+ return instance;
199
+ }
package/src/mcp/server.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  // Utils
29
29
  import { CacheManager, CacheKeys, globalCache } from '../utils/cache.js';
30
30
  import { config as appConfig } from '../utils/config.js';
31
+ import { getJobStore } from './job-store.js';
31
32
  import {
32
33
  ResponseFormat,
33
34
  formatListResponse,
@@ -209,10 +210,43 @@ class MetabaseMCPServer {
209
210
  required: ['database_id'],
210
211
  },
211
212
  },
213
+ {
214
+ name: 'db_table_profile',
215
+ description: 'Get comprehensive table profile: row count, column types, distinct values, sample data. Auto-detects dimension/reference tables (dim_, ref_, lookup_ prefix). Ideal for understanding lookup tables before writing queries.',
216
+ inputSchema: {
217
+ type: 'object',
218
+ properties: {
219
+ database_id: {
220
+ type: 'number',
221
+ description: 'Database ID',
222
+ },
223
+ table_name: {
224
+ type: 'string',
225
+ description: 'Table name (with or without schema prefix)',
226
+ },
227
+ schema_name: {
228
+ type: 'string',
229
+ description: 'Schema name (default: public)',
230
+ default: 'public',
231
+ },
232
+ show_distinct_values: {
233
+ type: 'boolean',
234
+ description: 'Show distinct values for each column (auto-enabled for dim/ref tables)',
235
+ default: true,
236
+ },
237
+ sample_rows: {
238
+ type: 'number',
239
+ description: 'Number of sample rows to display (default: 3)',
240
+ default: 3,
241
+ },
242
+ },
243
+ required: ['database_id', 'table_name'],
244
+ },
245
+ },
212
246
  // === SQL EXECUTION ===
213
247
  {
214
248
  name: 'sql_execute',
215
- description: 'Run SQL queries against database - supports SELECT, DDL with security controls, returns formatted results',
249
+ description: 'Run SQL queries against database - supports SELECT, DDL with security controls, returns formatted results. For long-running queries (>60s), use sql_submit instead.',
216
250
  inputSchema: {
217
251
  type: 'object',
218
252
  properties: {
@@ -224,10 +258,65 @@ class MetabaseMCPServer {
224
258
  type: 'string',
225
259
  description: 'SQL query to execute',
226
260
  },
261
+ full_results: {
262
+ type: 'boolean',
263
+ description: 'Set to true to disable result truncation (useful for DDL/definitions)',
264
+ },
265
+ },
266
+ required: ['database_id', 'sql'],
267
+ },
268
+ },
269
+ {
270
+ name: 'sql_submit',
271
+ description: 'Submit a long-running SQL query asynchronously. Returns immediately with job_id. Use sql_status to check progress. Ideal for queries that may take minutes.',
272
+ inputSchema: {
273
+ type: 'object',
274
+ properties: {
275
+ database_id: {
276
+ type: 'number',
277
+ description: 'Database ID',
278
+ },
279
+ sql: {
280
+ type: 'string',
281
+ description: 'SQL query to execute',
282
+ },
283
+ timeout_seconds: {
284
+ type: 'number',
285
+ description: 'Query timeout in seconds (default: 300, max: 1800)',
286
+ default: 300,
287
+ },
227
288
  },
228
289
  required: ['database_id', 'sql'],
229
290
  },
230
291
  },
292
+ {
293
+ name: 'sql_status',
294
+ description: 'Check status of an async query submitted via sql_submit. Returns results when complete.',
295
+ inputSchema: {
296
+ type: 'object',
297
+ properties: {
298
+ job_id: {
299
+ type: 'string',
300
+ description: 'Job ID returned from sql_submit',
301
+ },
302
+ },
303
+ required: ['job_id'],
304
+ },
305
+ },
306
+ {
307
+ name: 'sql_cancel',
308
+ description: 'Cancel a running async query. Also attempts to cancel on database server.',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ job_id: {
313
+ type: 'string',
314
+ description: 'Job ID to cancel',
315
+ },
316
+ },
317
+ required: ['job_id'],
318
+ },
319
+ },
231
320
  // === METABASE OBJECTS ===
232
321
  {
233
322
  name: 'mb_question_create',
@@ -3147,10 +3236,18 @@ class MetabaseMCPServer {
3147
3236
  return await this.handleGetDatabaseSchemas(args.database_id);
3148
3237
  case 'db_tables':
3149
3238
  return await this.handleGetDatabaseTables(args.database_id);
3239
+ case 'db_table_profile':
3240
+ return await this.handleTableProfile(args);
3150
3241
 
3151
3242
  // SQL execution
3152
3243
  case 'sql_execute':
3153
- return await this.handleExecuteSQL(args.database_id, args.sql);
3244
+ return await this.handleExecuteSQL(args);
3245
+ case 'sql_submit':
3246
+ return await this.handleSQLSubmit(args);
3247
+ case 'sql_status':
3248
+ return await this.handleSQLStatus(args);
3249
+ case 'sql_cancel':
3250
+ return await this.handleSQLCancel(args);
3154
3251
 
3155
3252
  // Metabase objects
3156
3253
  case 'mb_question_create':
@@ -3452,7 +3549,7 @@ class MetabaseMCPServer {
3452
3549
  } catch (error) {
3453
3550
  logger.error(`Tool ${name} failed:`, error);
3454
3551
 
3455
- // Specific error handling
3552
+ // Specific error handling with clearer messages
3456
3553
  let errorMessage = error.message;
3457
3554
  let errorCode = ErrorCode.InternalError;
3458
3555
 
@@ -3468,6 +3565,15 @@ class MetabaseMCPServer {
3468
3565
  } else if (error.message.includes('not found')) {
3469
3566
  errorMessage = `Resource not found: ${error.message}`;
3470
3567
  errorCode = ErrorCode.InvalidRequest;
3568
+ } else if (error.message.includes('is not a function')) {
3569
+ errorMessage = `Unexpected API response format. Metabase API may have changed or data is not an array. Details: ${error.message.substring(0, 100)}`;
3570
+ errorCode = ErrorCode.InternalError;
3571
+ } else if (error.message.includes('Cannot read properties of undefined') || error.message.includes('Cannot read property')) {
3572
+ errorMessage = `Expected data not found. Check Metabase API response. Details: ${error.message.substring(0, 100)}`;
3573
+ errorCode = ErrorCode.InternalError;
3574
+ } else if (error.message.includes('timeout') || error.message.includes('ETIMEDOUT')) {
3575
+ errorMessage = `Request timed out. Try a smaller query or use LIMIT.`;
3576
+ errorCode = ErrorCode.InternalError;
3471
3577
  }
3472
3578
 
3473
3579
  throw new McpError(errorCode, errorMessage);
@@ -3565,9 +3671,13 @@ class MetabaseMCPServer {
3565
3671
  };
3566
3672
  }
3567
3673
 
3568
- async handleExecuteSQL(databaseId, sql) {
3674
+ async handleExecuteSQL(args) {
3569
3675
  await this.ensureInitialized();
3570
3676
 
3677
+ const databaseId = args.database_id;
3678
+ const sql = args.sql;
3679
+ const fullResults = args.full_results === true;
3680
+
3571
3681
  if (this.initError) {
3572
3682
  throw new McpError(ErrorCode.InternalError, `Failed to initialize: ${this.initError.message}`);
3573
3683
  }
@@ -3613,44 +3723,78 @@ class MetabaseMCPServer {
3613
3723
  const rows = result.data.rows || [];
3614
3724
  const columns = result.data.cols || [];
3615
3725
 
3616
- let output = `✅ **Query executed successfully!**\\n\\n`;
3617
- output += `📊 **Results Summary:**\\n`;
3618
- output += `• Database ID: ${databaseId}\\n`;
3619
- output += `• Columns: ${columns.length} (${columns.map(col => col.name).join(', ')})\\n`;
3620
- output += `• Rows returned: ${rows.length}\\n`;
3621
- output += `• Execution time: ${executionTime}ms\\n\\n`;
3726
+ let output = `✅ **Query successful** (${executionTime}ms)\\n`;
3727
+ output += `📊 ${columns.length} columns, ${rows.length} rows\\n\\n`;
3622
3728
 
3623
3729
  if (rows.length > 0) {
3624
- output += `📋 **Sample Data (first 5 rows):**\\n\`\`\`\\n`;
3625
-
3626
- // Create table header
3730
+ // Show sample data (max 5 rows)
3731
+ output += `**Data:**\\n\`\`\`\\n`;
3627
3732
  const headers = columns.map(col => col.name);
3628
3733
  output += headers.join(' | ') + '\\n';
3629
3734
  output += headers.map(() => '---').join(' | ') + '\\n';
3630
3735
 
3631
- // Add data rows
3632
3736
  rows.slice(0, 5).forEach((row) => {
3633
3737
  const formattedRow = row.map(cell => {
3634
3738
  if (cell === null) return 'NULL';
3635
- if (typeof cell === 'string' && cell.length > 50) {
3636
- return cell.substring(0, 47) + '...';
3739
+
3740
+ // Smart truncation logic
3741
+ let truncateLimit = 100; // Increased base limit from 30
3742
+
3743
+ // Disable truncation for small result sets (DDL/procedures) or explicit full_results
3744
+ if (fullResults || rows.length <= 2) {
3745
+ truncateLimit = 50000;
3746
+ }
3747
+ // Check specific DDL-related column names
3748
+ else if (columns.some(c => /definition|ddl|source|create_statement|routine_definition/i.test(c.name))) {
3749
+ truncateLimit = 10000;
3750
+ }
3751
+
3752
+ if (typeof cell === 'string' && cell.length > truncateLimit) {
3753
+ return cell.substring(0, truncateLimit - 3) + '...';
3637
3754
  }
3638
3755
  return String(cell);
3639
3756
  });
3640
3757
  output += formattedRow.join(' | ') + '\\n';
3641
3758
  });
3642
-
3643
3759
  output += '\`\`\`\\n';
3644
3760
 
3645
3761
  if (rows.length > 5) {
3646
- output += `\\n... and ${rows.length - 5} more rows\\n`;
3762
+ output += `_+${rows.length - 5} more rows_\\n`;
3763
+ }
3764
+
3765
+ // Large result warning
3766
+ if (rows.length > 100) {
3767
+ output += `\\n⚠️ **Large result:** ${rows.length} rows returned. Use LIMIT for better performance.\\n`;
3647
3768
  }
3648
3769
  } else {
3649
- output += `ℹ️ No data returned by the query.\\n`;
3770
+ // Empty result - smart detection
3771
+ output += `ℹ️ No results.\\n`;
3772
+
3773
+ // Try to detect if table has data but query returned nothing
3774
+ try {
3775
+ const fromMatch = sql.match(/FROM\s+["']?([^\s"'.(]+)["']?/i) ||
3776
+ sql.match(/FROM\s+["']?[^"'.]+["']?\.["']?([^\s"']+)["']?/i);
3777
+ if (fromMatch) {
3778
+ const tableName = fromMatch[1];
3779
+ const countQuery = `SELECT COUNT(*) FROM ${tableName} LIMIT 1`;
3780
+ try {
3781
+ const countResult = await this.metabaseClient.executeNativeQuery(databaseId, countQuery);
3782
+ const tableRowCount = countResult.data?.rows?.[0]?.[0] || 0;
3783
+
3784
+ if (tableRowCount > 0) {
3785
+ output += `\\n⚠️ **Note:** \`${tableName}\` has ${tableRowCount.toLocaleString()} rows but query returned nothing.\\n`;
3786
+ output += `Possible causes: WHERE clause too restrictive, column name typo, JOIN mismatch\\n`;
3787
+ output += `💡 Use \`db_table_profile\` to inspect table structure.\\n`;
3788
+ }
3789
+ } catch (e) { /* ignore */ }
3790
+ }
3791
+ } catch (e) { /* ignore */ }
3650
3792
  }
3651
3793
 
3652
- // Add query info
3653
- output += `\\n🔍 **Query Details:**\\n\`\`\`sql\\n${sql}\\n\`\`\``;
3794
+ // Tool suggestions (only for SELECT queries with few results)
3795
+ if (sql.toLowerCase().trim().startsWith('select') && rows.length <= 5) {
3796
+ output += `\\n💡 Related: \`db_table_profile\`, \`mb_field_values\`\\n`;
3797
+ }
3654
3798
 
3655
3799
  return {
3656
3800
  content: [
@@ -3678,12 +3822,9 @@ class MetabaseMCPServer {
3678
3822
  });
3679
3823
  }
3680
3824
 
3681
- const output = `❌ **Query execution failed!**\\n\\n` +
3682
- `🚫 **Error Details:**\\n` +
3683
- `• Database ID: ${databaseId}\\n` +
3684
- `• Execution time: ${executionTime}ms\\n` +
3685
- `• Error: ${err.message}\\n\\n` +
3686
- `🔍 **Failed Query:**\\n\`\`\`sql\\n${sql}\\n\`\`\``;
3825
+ // Compact error format - no query repetition
3826
+ const shortSql = sql.length > 80 ? sql.substring(0, 77) + '...' : sql;
3827
+ const output = `❌ SQL Error: ${err.message}\\nQuery: ${shortSql}`;
3687
3828
 
3688
3829
  return {
3689
3830
  content: [
@@ -3696,6 +3837,201 @@ class MetabaseMCPServer {
3696
3837
  }
3697
3838
  }
3698
3839
 
3840
+ /**
3841
+ * Submit a long-running SQL query asynchronously
3842
+ * Returns immediately with job_id, executes query in background
3843
+ */
3844
+ async handleSQLSubmit(args) {
3845
+ try {
3846
+ await this.ensureInitialized();
3847
+
3848
+ const databaseId = args.database_id;
3849
+ const sql = args.sql;
3850
+ const timeoutSeconds = Math.min(args.timeout_seconds || 300, 1800); // Max 30 minutes
3851
+
3852
+ // Check read-only mode for write operations
3853
+ if (isReadOnlyMode() && detectWriteOperation(sql)) {
3854
+ return {
3855
+ content: [{ type: 'text', text: '❌ Write operations blocked in read-only mode' }],
3856
+ };
3857
+ }
3858
+
3859
+ // Get job store and create job
3860
+ const jobStore = getJobStore();
3861
+ const job = jobStore.create(databaseId, sql, timeoutSeconds);
3862
+
3863
+ // Add job marker to SQL for cancellation support
3864
+ const markedSql = `/* job:${job.id} */ ${sql}`;
3865
+
3866
+ // Start query execution in background (non-blocking)
3867
+ this.executeQueryBackground(job.id, databaseId, markedSql, timeoutSeconds * 1000);
3868
+
3869
+ const output = `✅ **Query Submitted**\\n` +
3870
+ `📋 Job ID: \`${job.id}\`\\n` +
3871
+ `⏱️ Timeout: ${timeoutSeconds} seconds\\n` +
3872
+ `📊 Status: pending\\n\\n` +
3873
+ `💡 Use \`sql_status\` with this job_id to check progress.`;
3874
+
3875
+ return {
3876
+ content: [{ type: 'text', text: output }],
3877
+ };
3878
+
3879
+ } catch (error) {
3880
+ return {
3881
+ content: [{ type: 'text', text: `❌ Failed to submit query: ${error.message}` }],
3882
+ };
3883
+ }
3884
+ }
3885
+
3886
+ /**
3887
+ * Execute query in background and update job status
3888
+ */
3889
+ async executeQueryBackground(jobId, databaseId, sql, timeoutMs) {
3890
+ const jobStore = getJobStore();
3891
+ const job = jobStore.get(jobId);
3892
+
3893
+ if (!job) return;
3894
+
3895
+ jobStore.markRunning(jobId);
3896
+
3897
+ try {
3898
+ const result = await this.metabaseClient.executeNativeQueryWithTimeout(
3899
+ databaseId,
3900
+ sql,
3901
+ timeoutMs,
3902
+ job.abortController.signal
3903
+ );
3904
+
3905
+ const rows = result.data?.rows || [];
3906
+ jobStore.markComplete(jobId, result, rows.length);
3907
+
3908
+ logger.info(`Query job ${jobId} completed with ${rows.length} rows`);
3909
+
3910
+ } catch (error) {
3911
+ if (error.message.includes('cancelled')) {
3912
+ jobStore.markCancelled(jobId);
3913
+ } else if (error.message.includes('timed out')) {
3914
+ jobStore.markTimeout(jobId);
3915
+ // Try to cancel on database
3916
+ await this.metabaseClient.cancelPostgresQuery(databaseId, `job:${jobId}`);
3917
+ } else {
3918
+ jobStore.markFailed(jobId, error);
3919
+ }
3920
+
3921
+ logger.error(`Query job ${jobId} failed: ${error.message}`);
3922
+ }
3923
+ }
3924
+
3925
+ /**
3926
+ * Check status of an async query
3927
+ */
3928
+ async handleSQLStatus(args) {
3929
+ try {
3930
+ const jobStore = getJobStore();
3931
+ const job = jobStore.get(args.job_id);
3932
+
3933
+ if (!job) {
3934
+ return {
3935
+ content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }],
3936
+ };
3937
+ }
3938
+
3939
+ const elapsedSeconds = jobStore.getElapsedSeconds(args.job_id);
3940
+
3941
+ let output = `📋 **Job Status: ${job.id}**\\n`;
3942
+ output += `📊 Status: ${job.status}\\n`;
3943
+ output += `⏱️ Elapsed: ${elapsedSeconds} seconds\\n`;
3944
+
3945
+ if (job.status === 'running' || job.status === 'pending') {
3946
+ output += `\\n💡 Query is still running. Check again later or use \`sql_cancel\` to stop.`;
3947
+ } else if (job.status === 'complete') {
3948
+ const rows = job.result?.data?.rows || [];
3949
+ const columns = job.result?.data?.cols || [];
3950
+
3951
+ output += `✅ **Query Complete!**\\n`;
3952
+ output += `📊 ${columns.length} columns, ${rows.length} rows\\n\\n`;
3953
+
3954
+ if (rows.length > 0) {
3955
+ output += `**Data:**\\n\`\`\`\\n`;
3956
+ const headers = columns.map(col => col.name);
3957
+ output += headers.join(' | ') + '\\n';
3958
+ output += headers.map(() => '---').join(' | ') + '\\n';
3959
+
3960
+ rows.slice(0, 5).forEach((row) => {
3961
+ const formattedRow = row.map(cell => {
3962
+ if (cell === null) return 'NULL';
3963
+ const str = String(cell);
3964
+ return str.length > 30 ? str.substring(0, 27) + '...' : str;
3965
+ });
3966
+ output += formattedRow.join(' | ') + '\\n';
3967
+ });
3968
+ output += '\`\`\`\\n';
3969
+
3970
+ if (rows.length > 5) {
3971
+ output += `_+${rows.length - 5} more rows_\\n`;
3972
+ }
3973
+ }
3974
+ } else if (job.status === 'failed' || job.status === 'timeout' || job.status === 'cancelled') {
3975
+ output += `\\n❌ ${job.error || 'Query did not complete'}`;
3976
+ }
3977
+
3978
+ return {
3979
+ content: [{ type: 'text', text: output }],
3980
+ };
3981
+
3982
+ } catch (error) {
3983
+ return {
3984
+ content: [{ type: 'text', text: `❌ Failed to check status: ${error.message}` }],
3985
+ };
3986
+ }
3987
+ }
3988
+
3989
+ /**
3990
+ * Cancel a running async query
3991
+ */
3992
+ async handleSQLCancel(args) {
3993
+ try {
3994
+ const jobStore = getJobStore();
3995
+ const job = jobStore.get(args.job_id);
3996
+
3997
+ if (!job) {
3998
+ return {
3999
+ content: [{ type: 'text', text: `❌ Job not found: ${args.job_id}` }],
4000
+ };
4001
+ }
4002
+
4003
+ if (job.status !== 'running' && job.status !== 'pending') {
4004
+ return {
4005
+ content: [{ type: 'text', text: `ℹ️ Job is not running (status: ${job.status})` }],
4006
+ };
4007
+ }
4008
+
4009
+ // Abort the HTTP request
4010
+ job.abortController.abort();
4011
+
4012
+ // Try to cancel on database
4013
+ const dbCancelled = await this.metabaseClient.cancelPostgresQuery(
4014
+ job.database_id,
4015
+ `job:${job.id}`
4016
+ );
4017
+
4018
+ jobStore.markCancelled(args.job_id);
4019
+
4020
+ const output = `✅ **Query Cancelled**\\n` +
4021
+ `📋 Job ID: ${args.job_id}\\n` +
4022
+ `🗄️ Database cancel: ${dbCancelled ? 'sent' : 'not available'}`;
4023
+
4024
+ return {
4025
+ content: [{ type: 'text', text: output }],
4026
+ };
4027
+
4028
+ } catch (error) {
4029
+ return {
4030
+ content: [{ type: 'text', text: `❌ Failed to cancel: ${error.message}` }],
4031
+ };
4032
+ }
4033
+ }
4034
+
3699
4035
  async handleCreateQuestion(args) {
3700
4036
  const question = await this.metabaseClient.createSQLQuestion(
3701
4037
  args.name,
@@ -3863,16 +4199,47 @@ class MetabaseMCPServer {
3863
4199
  args.parameter_mappings
3864
4200
  );
3865
4201
 
3866
- return {
3867
- content: [{
3868
- type: 'text',
3869
- text: `✅ Card added to dashboard successfully!\\nCard ID: ${result.id}\\nPosition: Row ${args.position?.row || 0}, Col ${args.position?.col || 0}`
3870
- }],
3871
- };
4202
+ // VERIFICATION: Check if card was actually added
4203
+ try {
4204
+ const dashboard = await this.metabaseClient.getDashboard(args.dashboard_id);
4205
+ const cardExists = dashboard.ordered_cards?.some(c => c.card_id === args.question_id);
4206
+ const cardCount = dashboard.ordered_cards?.length || 0;
4207
+
4208
+ if (cardExists) {
4209
+ return {
4210
+ content: [{
4211
+ type: 'text',
4212
+ text: `✅ Card verified!\\n` +
4213
+ `Dashboard: ${dashboard.name} (ID: ${args.dashboard_id})\\n` +
4214
+ `Total cards: ${cardCount}`
4215
+ }],
4216
+ };
4217
+ } else {
4218
+ return {
4219
+ content: [{
4220
+ type: 'text',
4221
+ text: `⚠️ Card addition appears to have failed!\\n` +
4222
+ `API reported success but card was not found on dashboard.\\n` +
4223
+ `Dashboard ID: ${args.dashboard_id}, Question ID: ${args.question_id}\\n` +
4224
+ `Please verify the question ID is valid.`
4225
+ }],
4226
+ };
4227
+ }
4228
+ } catch (verifyError) {
4229
+ // Verification failed but original call might have succeeded
4230
+ return {
4231
+ content: [{
4232
+ type: 'text',
4233
+ text: `✅ Card added (verification unavailable)\\n` +
4234
+ `Dashboard ID: ${args.dashboard_id}\\n` +
4235
+ `Card ID: ${result?.id || 'N/A'}`
4236
+ }],
4237
+ };
4238
+ }
3872
4239
 
3873
4240
  } catch (error) {
3874
4241
  return {
3875
- content: [{ type: 'text', text: `❌ Error adding card to dashboard: ${error.message}` }],
4242
+ content: [{ type: 'text', text: `❌ Card addition error: ${error.message}` }],
3876
4243
  };
3877
4244
  }
3878
4245
  }
@@ -5962,6 +6329,147 @@ class MetabaseMCPServer {
5962
6329
  }
5963
6330
  }
5964
6331
 
6332
+ /**
6333
+ * Handle table profile request - comprehensive table analysis
6334
+ * Automatically detects dim/ref tables and shows distinct values
6335
+ */
6336
+ async handleTableProfile(args) {
6337
+ try {
6338
+ await this.ensureInitialized();
6339
+
6340
+ const schemaName = args.schema_name || 'public';
6341
+ const tableName = args.table_name;
6342
+ const showDistinct = args.show_distinct_values !== false;
6343
+ const sampleRows = args.sample_rows || 3;
6344
+
6345
+ // Detect if this is a dimension/reference table
6346
+ const isDimTable = /^(dim_|ref_|lookup_|lkp_|d_|r_)/i.test(tableName);
6347
+
6348
+ // Get row count first
6349
+ const countQuery = `SELECT COUNT(*) as cnt FROM "${schemaName}"."${tableName}"`;
6350
+ const countResult = await this.metabaseClient.executeNativeQuery(args.database_id, countQuery);
6351
+ const rowCount = countResult.data?.rows?.[0]?.[0] || 0;
6352
+
6353
+ // Get column info
6354
+ const columnsQuery = `
6355
+ SELECT
6356
+ column_name,
6357
+ data_type,
6358
+ is_nullable,
6359
+ column_default
6360
+ FROM information_schema.columns
6361
+ WHERE table_schema = '${schemaName}' AND table_name = '${tableName}'
6362
+ ORDER BY ordinal_position
6363
+ `;
6364
+ const columnsResult = await this.metabaseClient.executeNativeQuery(args.database_id, columnsQuery);
6365
+ const columns = columnsResult.data?.rows || [];
6366
+
6367
+ let output = '';
6368
+
6369
+ // Header with dim table indicator
6370
+ if (isDimTable) {
6371
+ output += `📊 **Dimension Table: ${schemaName}.${tableName}**\\n`;
6372
+ output += `🏷️ _Detected as lookup/reference table_\\n\\n`;
6373
+ } else {
6374
+ output += `📊 **Table Profile: ${schemaName}.${tableName}**\\n\\n`;
6375
+ }
6376
+
6377
+ output += `📈 **Overview:**\\n`;
6378
+ output += `• Row count: ${rowCount.toLocaleString()}\\n`;
6379
+ output += `• Columns: ${columns.length}\\n\\n`;
6380
+
6381
+ // Column details
6382
+ output += `📋 **Columns:**\\n`;
6383
+ columns.forEach(([name, type, nullable, defaultVal]) => {
6384
+ const nullIndicator = nullable === 'YES' ? '?' : '';
6385
+ output += `• \`${name}\` (${type}${nullIndicator})\\n`;
6386
+ });
6387
+ output += `\\n`;
6388
+
6389
+ // For dim tables or small tables, show distinct values
6390
+ if ((isDimTable || rowCount < 1000) && showDistinct && columns.length > 0) {
6391
+ output += `🔑 **Distinct Values:**\\n`;
6392
+
6393
+ // Get distinct counts and values for key columns (limit to first 5 columns)
6394
+ const keyColumns = columns.slice(0, 5);
6395
+ for (const [colName, colType] of keyColumns) {
6396
+ try {
6397
+ const distinctQuery = `
6398
+ SELECT "${colName}", COUNT(*) as cnt
6399
+ FROM "${schemaName}"."${tableName}"
6400
+ GROUP BY "${colName}"
6401
+ ORDER BY cnt DESC
6402
+ LIMIT 10
6403
+ `;
6404
+ const distinctResult = await this.metabaseClient.executeNativeQuery(args.database_id, distinctQuery);
6405
+ const distinctRows = distinctResult.data?.rows || [];
6406
+
6407
+ if (distinctRows.length > 0) {
6408
+ const totalDistinct = distinctRows.length;
6409
+ const values = distinctRows.slice(0, 5).map(r => r[0] === null ? 'NULL' : String(r[0])).join(', ');
6410
+ output += `• \`${colName}\`: ${values}${totalDistinct > 5 ? ` (+${totalDistinct - 5} more)` : ''}\\n`;
6411
+ }
6412
+ } catch (e) {
6413
+ // Skip columns that can't be queried
6414
+ }
6415
+ }
6416
+ output += `\\n`;
6417
+ }
6418
+
6419
+ // Sample data
6420
+ if (sampleRows > 0 && rowCount > 0) {
6421
+ try {
6422
+ const sampleQuery = `SELECT * FROM "${schemaName}"."${tableName}" LIMIT ${sampleRows}`;
6423
+ const sampleResult = await this.metabaseClient.executeNativeQuery(args.database_id, sampleQuery);
6424
+ const sampleData = sampleResult.data?.rows || [];
6425
+ const sampleCols = sampleResult.data?.cols || [];
6426
+
6427
+ if (sampleData.length > 0) {
6428
+ output += `📝 **Sample Data (${sampleData.length} rows):**\\n\`\`\`\\n`;
6429
+ // Header
6430
+ const headers = sampleCols.map(c => c.name);
6431
+ output += headers.join(' | ') + '\\n';
6432
+ output += headers.map(() => '---').join(' | ') + '\\n';
6433
+ // Data
6434
+ sampleData.forEach(row => {
6435
+ const formattedRow = row.map(cell => {
6436
+ if (cell === null) return 'NULL';
6437
+ const str = String(cell);
6438
+ return str.length > 20 ? str.substring(0, 17) + '...' : str;
6439
+ });
6440
+ output += formattedRow.join(' | ') + '\\n';
6441
+ });
6442
+ output += '\`\`\`\\n';
6443
+ }
6444
+ } catch (e) {
6445
+ output += `_Could not fetch sample data: ${e.message}_\\n`;
6446
+ }
6447
+ }
6448
+
6449
+ // Recommendations
6450
+ output += `\\n💡 **Tips:**\\n`;
6451
+ if (isDimTable) {
6452
+ output += `• Use this table for JOINs as a lookup\\n`;
6453
+ output += `• Use \`mb_field_values\` to see all values for a specific column\\n`;
6454
+ }
6455
+ if (rowCount === 0) {
6456
+ output += `• ⚠️ Table is empty - data may need to be loaded\\n`;
6457
+ }
6458
+ if (rowCount > 100000) {
6459
+ output += `• Large table - use LIMIT in queries\\n`;
6460
+ }
6461
+
6462
+ return {
6463
+ content: [{ type: 'text', text: output }]
6464
+ };
6465
+
6466
+ } catch (error) {
6467
+ return {
6468
+ content: [{ type: 'text', text: `❌ Table profile error: ${error.message}` }]
6469
+ };
6470
+ }
6471
+ }
6472
+
5965
6473
  async handleIndexUsage(args) {
5966
6474
  try {
5967
6475
  await this.ensureInitialized();
@@ -6205,8 +6713,19 @@ class MetabaseMCPServer {
6205
6713
  };
6206
6714
 
6207
6715
  } catch (error) {
6716
+ // Better error messages for common issues
6717
+ let userMessage = error.message;
6718
+
6719
+ if (error.message.includes('already exists') || error.response?.status === 409) {
6720
+ userMessage = `Collection already exists with this name: "${args.name}"`;
6721
+ } else if (error.message.includes('permission') || error.response?.status === 403) {
6722
+ userMessage = `Permission denied. Contact admin for collection creation access.`;
6723
+ } else if (error.message.includes('parent') || (error.message.includes('not found') && args.parent_id)) {
6724
+ userMessage = `Parent collection not found: ID ${args.parent_id}`;
6725
+ }
6726
+
6208
6727
  return {
6209
- content: [{ type: 'text', text: `❌ Collection creation failed: ${error.message}` }]
6728
+ content: [{ type: 'text', text: `❌ Collection creation failed: ${userMessage}` }]
6210
6729
  };
6211
6730
  }
6212
6731
  }
@@ -8,8 +8,10 @@ export class MetabaseClient {
8
8
  this.password = config.password;
9
9
  this.apiKey = config.apiKey;
10
10
  this.sessionToken = null;
11
+ this.defaultQueryTimeout = config.queryTimeout || 60000; // 60 seconds default
11
12
  this.client = axios.create({
12
13
  baseURL: this.baseURL,
14
+ timeout: this.defaultQueryTimeout,
13
15
  headers: {
14
16
  'Content-Type': 'application/json'
15
17
  }
@@ -79,7 +81,7 @@ export class MetabaseClient {
79
81
 
80
82
  async getDatabaseConnectionInfo(id) {
81
83
  await this.ensureAuthenticated();
82
-
84
+
83
85
  // Önce gerçek credentials'ları MetabaseappDB'den al
84
86
  try {
85
87
  const realCredentials = await this.getRealCredentials(id);
@@ -89,11 +91,11 @@ export class MetabaseClient {
89
91
  } catch (error) {
90
92
  logger.warn('Could not get real credentials, using API response:', error.message);
91
93
  }
92
-
94
+
93
95
  // Fallback: Normal API response
94
96
  const response = await this.client.get(`/api/database/${id}`);
95
97
  const db = response.data;
96
-
98
+
97
99
  return {
98
100
  id: db.id,
99
101
  name: db.name,
@@ -116,13 +118,13 @@ export class MetabaseClient {
116
118
  FROM metabase_database
117
119
  WHERE id = ${databaseId}
118
120
  `;
119
-
121
+
120
122
  const result = await this.executeNativeQuery(6, query, { enforcePrefix: false }); // MetabaseappDB
121
-
123
+
122
124
  if (result.data.rows.length > 0) {
123
125
  const [name, engine, details] = result.data.rows[0];
124
126
  const detailsObj = JSON.parse(details);
125
-
127
+
126
128
  return {
127
129
  id: databaseId,
128
130
  name: name,
@@ -137,13 +139,13 @@ export class MetabaseClient {
137
139
  tunnel_enabled: detailsObj['tunnel-enabled'] || false
138
140
  };
139
141
  }
140
-
142
+
141
143
  return null;
142
144
  }
143
145
 
144
146
  buildConnectionString(db) {
145
147
  const details = db.details;
146
-
148
+
147
149
  switch (db.engine) {
148
150
  case 'postgres':
149
151
  return `postgresql://${details.user}:${details.password}@${details.host}:${details.port}/${details.dbname}`;
@@ -252,7 +254,7 @@ export class MetabaseClient {
252
254
 
253
255
  async createParametricQuestion(questionData) {
254
256
  await this.ensureAuthenticated();
255
-
257
+
256
258
  // Build native query with parameters
257
259
  const nativeQuery = {
258
260
  type: 'native',
@@ -304,17 +306,17 @@ export class MetabaseClient {
304
306
  // SQL Operations
305
307
  async executeNativeQuery(databaseId, sql, options = {}) {
306
308
  await this.ensureAuthenticated();
307
-
308
- // Güvenlik kontrolü - DDL operasyonları için prefix zorunluluğu
309
+
310
+ // Security check - DDL operations require prefix
309
311
  if (options.enforcePrefix !== false && this.isDDLOperation(sql)) {
310
312
  this.validateDDLPrefix(sql);
311
313
  }
312
314
 
313
- // DDL operations için farklı endpoint kullan
315
+ // DDL operations use different endpoint
314
316
  if (this.isDDLOperation(sql)) {
315
317
  return await this.executeDDLOperation(databaseId, sql);
316
318
  }
317
-
319
+
318
320
  const query = {
319
321
  database: databaseId,
320
322
  type: 'native',
@@ -325,6 +327,68 @@ export class MetabaseClient {
325
327
  return await this.runQuery(query);
326
328
  }
327
329
 
330
+ /**
331
+ * Execute query with custom timeout and abort signal
332
+ * Used for async query management
333
+ */
334
+ async executeNativeQueryWithTimeout(databaseId, sql, timeoutMs, abortSignal = null) {
335
+ await this.ensureAuthenticated();
336
+
337
+ const query = {
338
+ database: databaseId,
339
+ type: 'native',
340
+ native: {
341
+ query: sql
342
+ }
343
+ };
344
+
345
+ const config = {
346
+ timeout: timeoutMs
347
+ };
348
+
349
+ // Add abort signal if provided
350
+ if (abortSignal) {
351
+ config.signal = abortSignal;
352
+ }
353
+
354
+ try {
355
+ const response = await this.client.post('/api/dataset', query, config);
356
+ return response.data;
357
+ } catch (error) {
358
+ if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
359
+ throw new Error('Query cancelled');
360
+ }
361
+ if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
362
+ throw new Error(`Query timed out after ${timeoutMs / 1000} seconds`);
363
+ }
364
+ throw error;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Cancel a running query on PostgreSQL database
370
+ * This sends pg_cancel_backend to stop the query on the server side
371
+ */
372
+ async cancelPostgresQuery(databaseId, queryMarker) {
373
+ try {
374
+ // Find and cancel queries containing the marker
375
+ const cancelSql = `
376
+ SELECT pg_cancel_backend(pid)
377
+ FROM pg_stat_activity
378
+ WHERE query LIKE '%${queryMarker}%'
379
+ AND state = 'active'
380
+ AND pid != pg_backend_pid()
381
+ `;
382
+
383
+ await this.executeNativeQuery(databaseId, cancelSql, { enforcePrefix: false });
384
+ logger.info(`Attempted to cancel query with marker: ${queryMarker}`);
385
+ return true;
386
+ } catch (error) {
387
+ logger.warn(`Failed to cancel query: ${error.message}`);
388
+ return false;
389
+ }
390
+ }
391
+
328
392
  async executeDDLOperation(databaseId, sql) {
329
393
  try {
330
394
  // DDL için action endpoint kullan
@@ -333,7 +397,7 @@ export class MetabaseClient {
333
397
  sql: sql,
334
398
  type: 'query'
335
399
  });
336
-
400
+
337
401
  return {
338
402
  status: 'success',
339
403
  message: 'DDL operation completed',
@@ -350,7 +414,7 @@ export class MetabaseClient {
350
414
  query: sql
351
415
  }
352
416
  };
353
-
417
+
354
418
  await this.runQuery(query);
355
419
  return {
356
420
  status: 'success',
@@ -360,7 +424,7 @@ export class MetabaseClient {
360
424
  };
361
425
  } catch (secondError) {
362
426
  logger.warn('DDL execution warning:', secondError.message);
363
-
427
+
364
428
  // DDL işlemi başarılı olmuş olabilir, kontrol et
365
429
  if (secondError.message.includes('Select statement did not produce a ResultSet')) {
366
430
  return {
@@ -371,7 +435,7 @@ export class MetabaseClient {
371
435
  warning: secondError.message
372
436
  };
373
437
  }
374
-
438
+
375
439
  throw secondError;
376
440
  }
377
441
  }
@@ -380,29 +444,29 @@ export class MetabaseClient {
380
444
  isDDLOperation(sql) {
381
445
  const upperSQL = sql.toUpperCase().trim();
382
446
  return upperSQL.startsWith('CREATE TABLE') ||
383
- upperSQL.startsWith('CREATE VIEW') ||
384
- upperSQL.startsWith('CREATE MATERIALIZED VIEW') ||
385
- upperSQL.startsWith('CREATE INDEX') ||
386
- upperSQL.startsWith('DROP TABLE') ||
387
- upperSQL.startsWith('DROP VIEW') ||
388
- upperSQL.startsWith('DROP MATERIALIZED VIEW') ||
389
- upperSQL.startsWith('DROP INDEX');
447
+ upperSQL.startsWith('CREATE VIEW') ||
448
+ upperSQL.startsWith('CREATE MATERIALIZED VIEW') ||
449
+ upperSQL.startsWith('CREATE INDEX') ||
450
+ upperSQL.startsWith('DROP TABLE') ||
451
+ upperSQL.startsWith('DROP VIEW') ||
452
+ upperSQL.startsWith('DROP MATERIALIZED VIEW') ||
453
+ upperSQL.startsWith('DROP INDEX');
390
454
  }
391
455
 
392
456
  validateDDLPrefix(sql) {
393
457
  const upperSQL = sql.toUpperCase();
394
-
458
+
395
459
  // CREATE operations için prefix kontrolü
396
- if (upperSQL.includes('CREATE TABLE') || upperSQL.includes('CREATE VIEW') ||
397
- upperSQL.includes('CREATE MATERIALIZED VIEW') || upperSQL.includes('CREATE INDEX')) {
460
+ if (upperSQL.includes('CREATE TABLE') || upperSQL.includes('CREATE VIEW') ||
461
+ upperSQL.includes('CREATE MATERIALIZED VIEW') || upperSQL.includes('CREATE INDEX')) {
398
462
  if (!sql.toLowerCase().includes('claude_ai_')) {
399
463
  throw new Error('DDL operations must use claude_ai_ prefix for object names');
400
464
  }
401
465
  }
402
-
466
+
403
467
  // DROP operations için sadece prefix'li objelere izin
404
- if (upperSQL.includes('DROP TABLE') || upperSQL.includes('DROP VIEW') ||
405
- upperSQL.includes('DROP MATERIALIZED VIEW') || upperSQL.includes('DROP INDEX')) {
468
+ if (upperSQL.includes('DROP TABLE') || upperSQL.includes('DROP VIEW') ||
469
+ upperSQL.includes('DROP MATERIALIZED VIEW') || upperSQL.includes('DROP INDEX')) {
406
470
  if (!sql.toLowerCase().includes('claude_ai_')) {
407
471
  throw new Error('Can only drop objects with claude_ai_ prefix');
408
472
  }
@@ -470,14 +534,14 @@ export class MetabaseClient {
470
534
 
471
535
  async addCardToDashboard(dashboardId, cardId, options = {}) {
472
536
  await this.ensureAuthenticated();
473
-
537
+
474
538
  try {
475
539
  // Try the API first (various endpoints)
476
540
  const endpoints = [
477
541
  `/api/dashboard/${dashboardId}/cards`,
478
542
  `/api/dashboard/${dashboardId}/dashcard`
479
543
  ];
480
-
544
+
481
545
  for (const endpoint of endpoints) {
482
546
  try {
483
547
  const response = await this.client.post(endpoint, {
@@ -494,10 +558,10 @@ export class MetabaseClient {
494
558
  continue;
495
559
  }
496
560
  }
497
-
561
+
498
562
  // If API fails, use direct database insertion as fallback
499
563
  return await this.addCardToDashboardDirect(dashboardId, cardId, options);
500
-
564
+
501
565
  } catch (error) {
502
566
  throw new Error(`Failed to add card to dashboard: ${error.message}`);
503
567
  }
@@ -523,10 +587,10 @@ export class MetabaseClient {
523
587
  $1, $2, $3, $4, $5, $6, $7, $8
524
588
  ) RETURNING id
525
589
  `;
526
-
590
+
527
591
  const values = [
528
592
  options.sizeX || 4,
529
- options.sizeY || 4,
593
+ options.sizeY || 4,
530
594
  options.row || 0,
531
595
  options.col || 0,
532
596
  cardId,
@@ -534,7 +598,7 @@ export class MetabaseClient {
534
598
  JSON.stringify(options.parameter_mappings || []),
535
599
  JSON.stringify(options.visualization_settings || {})
536
600
  ];
537
-
601
+
538
602
  // This would need database connection - placeholder for now
539
603
  return { id: 'inserted_via_sql', method: 'direct_sql' };
540
604
  }
@@ -547,11 +611,11 @@ export class MetabaseClient {
547
611
 
548
612
  async addDashboardFilter(dashboardId, filter) {
549
613
  await this.ensureAuthenticated();
550
-
614
+
551
615
  // Get current dashboard to add filter
552
616
  const dashboard = await this.client.get(`/api/dashboard/${dashboardId}`);
553
617
  const currentFilters = dashboard.data.parameters || [];
554
-
618
+
555
619
  // Create proper Metabase filter format
556
620
  const newFilter = {
557
621
  id: this.generateFilterId(),
@@ -567,9 +631,9 @@ export class MetabaseClient {
567
631
  } else if (filter.default_value !== undefined) {
568
632
  newFilter.default = filter.default_value;
569
633
  }
570
-
634
+
571
635
  const updatedFilters = [...currentFilters, newFilter];
572
-
636
+
573
637
  return await this.updateDashboard(dashboardId, {
574
638
  parameters: updatedFilters
575
639
  });