metabase-ai-assistant 4.1.0 → 4.2.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 +3 -3
- package/src/mcp/server.js +11 -16
- package/src/metabase/metadata-client.js +42 -107
- package/src/utils/config.js +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metabase-ai-assistant",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"mcpName": "io.github.enessari/metabase-ai-assistant",
|
|
5
5
|
"description": "The most powerful MCP Server for Metabase - 134 tools with structured output, AI-powered SQL generation, dashboard automation, user management & enterprise BI. MCP SDK v1.26.0 compliant. Works with Claude, Cursor, and any MCP-compatible AI.",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"chalk": "^5.3.0",
|
|
82
82
|
"dotenv": "^16.3.1",
|
|
83
83
|
"express": "^4.18.2",
|
|
84
|
+
"he": "^1.2.0",
|
|
84
85
|
"inquirer": "^12.9.3",
|
|
85
86
|
"jsonwebtoken": "^9.0.3",
|
|
86
87
|
"mysql2": "^3.14.3",
|
|
@@ -88,8 +89,7 @@
|
|
|
88
89
|
"ora": "^8.0.1",
|
|
89
90
|
"pg": "^8.16.3",
|
|
90
91
|
"winston": "^3.11.0",
|
|
91
|
-
"zod": "^3.22.4"
|
|
92
|
-
"he": "^1.2.0"
|
|
92
|
+
"zod": "^3.22.4"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
95
|
"@types/node": "^20.11.5",
|
package/src/mcp/server.js
CHANGED
|
@@ -53,7 +53,7 @@ class MetabaseMCPServer {
|
|
|
53
53
|
this.server = new Server(
|
|
54
54
|
{
|
|
55
55
|
name: 'metabase-ai-assistant',
|
|
56
|
-
version: '4.
|
|
56
|
+
version: '4.2.0',
|
|
57
57
|
description: 'AI-powered database operations, SQL queries, metrics, and dashboard automation for Metabase. 134 tools with structured output for enterprise BI.',
|
|
58
58
|
},
|
|
59
59
|
{
|
|
@@ -121,25 +121,20 @@ class MetabaseMCPServer {
|
|
|
121
121
|
});
|
|
122
122
|
logger.info('Activity logger initialized');
|
|
123
123
|
|
|
124
|
-
// Metadata client (optional)
|
|
125
|
-
if (process.env.MB_METADATA_ENABLED === 'true') {
|
|
126
|
-
this.metadataClient = new MetabaseMetadataClient({
|
|
127
|
-
engine: process.env.MB_METADATA_ENGINE || 'postgres',
|
|
128
|
-
host: process.env.MB_METADATA_HOST,
|
|
129
|
-
port: parseInt(process.env.MB_METADATA_PORT) || 5432,
|
|
130
|
-
database: process.env.MB_METADATA_DATABASE,
|
|
131
|
-
user: process.env.MB_METADATA_USER,
|
|
132
|
-
password: process.env.MB_METADATA_PASSWORD,
|
|
133
|
-
ssl: process.env.MB_METADATA_SSL === 'true'
|
|
134
|
-
});
|
|
135
|
-
|
|
124
|
+
// Metadata client (optional - uses Metabase API, no direct DB connection needed)
|
|
125
|
+
if (process.env.MB_METADATA_ENABLED === 'true' && appConfig.METABASE_INTERNAL_DB_ID) {
|
|
136
126
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
127
|
+
this.metadataClient = new MetabaseMetadataClient({
|
|
128
|
+
metabaseClient: this.metabaseClient,
|
|
129
|
+
internalDbId: appConfig.METABASE_INTERNAL_DB_ID
|
|
130
|
+
});
|
|
131
|
+
logger.info(`Metabase metadata client initialized (DB ID: ${appConfig.METABASE_INTERNAL_DB_ID})`);
|
|
139
132
|
} catch (error) {
|
|
140
|
-
logger.warn('Metadata client
|
|
133
|
+
logger.warn('Metadata client initialization failed:', error.message);
|
|
141
134
|
this.metadataClient = null;
|
|
142
135
|
}
|
|
136
|
+
} else if (process.env.MB_METADATA_ENABLED === 'true' && !appConfig.METABASE_INTERNAL_DB_ID) {
|
|
137
|
+
logger.warn('MB_METADATA_ENABLED=true but METABASE_INTERNAL_DB_ID is not set. Use meta_find_internal_db tool to find it.');
|
|
143
138
|
}
|
|
144
139
|
|
|
145
140
|
// Modular handlers (initialized after all deps are ready)
|
|
@@ -1,112 +1,40 @@
|
|
|
1
|
-
import { Client } from 'pg';
|
|
2
|
-
import mysql from 'mysql2/promise';
|
|
3
1
|
import { logger } from '../utils/logger.js';
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
4
|
* Metabase Metadata Database Client
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Queries Metabase's application database via the Metabase API
|
|
7
|
+
* using executeNativeQuery. This eliminates the need for direct
|
|
8
|
+
* database credentials — only METABASE_INTERNAL_DB_ID is required.
|
|
9
|
+
*
|
|
10
|
+
* This client is READ-ONLY for security.
|
|
10
11
|
*
|
|
11
12
|
* Based on: Metabase Internal Database Reference Guide (ONMARTECH LLC)
|
|
12
13
|
*/
|
|
13
14
|
export class MetabaseMetadataClient {
|
|
14
15
|
constructor(config) {
|
|
16
|
+
this.metabaseClient = config.metabaseClient;
|
|
17
|
+
this.internalDbId = config.internalDbId;
|
|
15
18
|
this.config = {
|
|
16
|
-
engine:
|
|
17
|
-
|
|
18
|
-
port: config.port,
|
|
19
|
-
database: config.database,
|
|
20
|
-
user: config.user,
|
|
21
|
-
password: config.password,
|
|
22
|
-
ssl: config.ssl || false
|
|
19
|
+
engine: 'api',
|
|
20
|
+
database: `internal-db-${config.internalDbId}`
|
|
23
21
|
};
|
|
24
|
-
this.client = null;
|
|
25
|
-
this.connected = false;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Connect to Metabase application database
|
|
30
|
-
*/
|
|
31
|
-
async connect() {
|
|
32
|
-
try {
|
|
33
|
-
if (this.connected) {
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
switch (this.config.engine) {
|
|
38
|
-
case 'postgres':
|
|
39
|
-
this.client = new Client({
|
|
40
|
-
host: this.config.host,
|
|
41
|
-
port: this.config.port || 5432,
|
|
42
|
-
database: this.config.database,
|
|
43
|
-
user: this.config.user,
|
|
44
|
-
password: this.config.password,
|
|
45
|
-
ssl: this.config.ssl,
|
|
46
|
-
// Read-only configuration
|
|
47
|
-
options: '-c default_transaction_read_only=on'
|
|
48
|
-
});
|
|
49
|
-
await this.client.connect();
|
|
50
|
-
break;
|
|
51
|
-
|
|
52
|
-
case 'mysql':
|
|
53
|
-
this.client = await mysql.createConnection({
|
|
54
|
-
host: this.config.host,
|
|
55
|
-
port: this.config.port || 3306,
|
|
56
|
-
database: this.config.database,
|
|
57
|
-
user: this.config.user,
|
|
58
|
-
password: this.config.password,
|
|
59
|
-
ssl: this.config.ssl
|
|
60
|
-
});
|
|
61
|
-
// Set read-only mode for MySQL
|
|
62
|
-
await this.client.query('SET SESSION TRANSACTION READ ONLY');
|
|
63
|
-
break;
|
|
64
|
-
|
|
65
|
-
case 'h2':
|
|
66
|
-
logger.warn('H2 database detected - limited support. Consider using PostgreSQL for production.');
|
|
67
|
-
throw new Error('H2 database not supported for metadata access. Please use PostgreSQL or MySQL.');
|
|
68
|
-
|
|
69
|
-
default:
|
|
70
|
-
throw new Error(`Unsupported database engine: ${this.config.engine}`);
|
|
71
|
-
}
|
|
72
22
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return true;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
logger.error('Metabase metadata database connection failed:', error);
|
|
78
|
-
throw error;
|
|
23
|
+
if (!this.metabaseClient) {
|
|
24
|
+
throw new Error('MetabaseMetadataClient requires a metabaseClient instance');
|
|
79
25
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Disconnect from database
|
|
84
|
-
*/
|
|
85
|
-
async disconnect() {
|
|
86
|
-
if (this.client) {
|
|
87
|
-
try {
|
|
88
|
-
if (this.config.engine === 'postgres') {
|
|
89
|
-
await this.client.end();
|
|
90
|
-
} else if (this.config.engine === 'mysql') {
|
|
91
|
-
await this.client.end();
|
|
92
|
-
}
|
|
93
|
-
this.connected = false;
|
|
94
|
-
this.client = null;
|
|
95
|
-
logger.info('Metabase metadata database connection closed');
|
|
96
|
-
} catch (error) {
|
|
97
|
-
logger.error('Error disconnecting from metadata database:', error);
|
|
98
|
-
}
|
|
26
|
+
if (!this.internalDbId) {
|
|
27
|
+
throw new Error('MetabaseMetadataClient requires internalDbId (METABASE_INTERNAL_DB_ID)');
|
|
99
28
|
}
|
|
29
|
+
|
|
30
|
+
logger.info(`MetabaseMetadataClient initialized (API mode, DB ID: ${this.internalDbId})`);
|
|
100
31
|
}
|
|
101
32
|
|
|
102
33
|
/**
|
|
103
|
-
* Execute read-only query
|
|
34
|
+
* Execute read-only query via Metabase API
|
|
35
|
+
* Returns rows as array of objects (like pg client.query().rows)
|
|
104
36
|
*/
|
|
105
37
|
async executeQuery(sql, params = []) {
|
|
106
|
-
if (!this.connected) {
|
|
107
|
-
await this.connect();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
38
|
try {
|
|
111
39
|
// Security check - only SELECT queries allowed
|
|
112
40
|
const sqlUpper = sql.trim().toUpperCase();
|
|
@@ -114,18 +42,26 @@ export class MetabaseMetadataClient {
|
|
|
114
42
|
throw new Error('Only SELECT queries are allowed on metadata database');
|
|
115
43
|
}
|
|
116
44
|
|
|
117
|
-
logger.debug('Executing metadata query:', { sql: sql.substring(0, 100) });
|
|
45
|
+
logger.debug('Executing metadata query via API:', { sql: sql.substring(0, 100) });
|
|
118
46
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
47
|
+
const result = await this.metabaseClient.executeNativeQuery(this.internalDbId, sql);
|
|
48
|
+
|
|
49
|
+
// Convert Metabase API response format to row-object format
|
|
50
|
+
// API returns: { data: { rows: [[v1,v2,...], ...], cols: [{name:'col1'}, ...] } }
|
|
51
|
+
// We need: [{ col1: v1, col2: v2, ... }, ...]
|
|
52
|
+
const cols = result.data?.cols || [];
|
|
53
|
+
const rows = result.data?.rows || [];
|
|
54
|
+
const colNames = cols.map(c => c.name);
|
|
55
|
+
|
|
56
|
+
return rows.map(row => {
|
|
57
|
+
const obj = {};
|
|
58
|
+
colNames.forEach((name, i) => {
|
|
59
|
+
obj[name] = row[i];
|
|
60
|
+
});
|
|
61
|
+
return obj;
|
|
62
|
+
});
|
|
127
63
|
} catch (error) {
|
|
128
|
-
logger.error('Metadata query execution failed:', { sql, error: error.message });
|
|
64
|
+
logger.error('Metadata query execution failed:', { sql: sql.substring(0, 200), error: error.message });
|
|
129
65
|
throw error;
|
|
130
66
|
}
|
|
131
67
|
}
|
|
@@ -573,17 +509,16 @@ export class MetabaseMetadataClient {
|
|
|
573
509
|
// ============================================
|
|
574
510
|
|
|
575
511
|
/**
|
|
576
|
-
* Test connection to metadata database
|
|
512
|
+
* Test connection to metadata database via API
|
|
577
513
|
*/
|
|
578
514
|
async testConnection() {
|
|
579
515
|
try {
|
|
580
|
-
await this.connect();
|
|
581
516
|
const result = await this.executeQuery('SELECT COUNT(*) as table_count FROM information_schema.tables WHERE table_schema = \'public\'');
|
|
582
|
-
await this.disconnect();
|
|
583
517
|
return {
|
|
584
518
|
success: true,
|
|
585
519
|
database: this.config.database,
|
|
586
520
|
engine: this.config.engine,
|
|
521
|
+
internal_db_id: this.internalDbId,
|
|
587
522
|
table_count: result[0]?.table_count || 0
|
|
588
523
|
};
|
|
589
524
|
} catch (error) {
|
|
@@ -913,7 +848,7 @@ export class MetabaseMetadataClient {
|
|
|
913
848
|
...dependencies,
|
|
914
849
|
impact_analysis: {
|
|
915
850
|
severity: criticalQuestions.length > 0 || criticalDashboards.length > 0 ? 'HIGH' :
|
|
916
|
-
|
|
851
|
+
dependencies.questions.length > 0 ? 'MEDIUM' : 'LOW',
|
|
917
852
|
breaking_changes: {
|
|
918
853
|
questions_will_break: dependencies.questions.length,
|
|
919
854
|
dashboards_will_break: dependencies.dashboards.length,
|
|
@@ -1294,12 +1229,12 @@ export class MetabaseMetadataClient {
|
|
|
1294
1229
|
error_count: parseInt(q.error_count),
|
|
1295
1230
|
error_rate: parseFloat(q.error_rate),
|
|
1296
1231
|
severity: parseFloat(q.error_rate) > 50 ? 'CRITICAL' :
|
|
1297
|
-
|
|
1232
|
+
parseFloat(q.error_rate) > 20 ? 'HIGH' : 'MEDIUM',
|
|
1298
1233
|
recommendation: parseFloat(q.error_rate) > 50
|
|
1299
1234
|
? 'CRITICAL: More than half of executions fail. Archive or fix immediately.'
|
|
1300
1235
|
: parseFloat(q.error_rate) > 20
|
|
1301
|
-
|
|
1302
|
-
|
|
1236
|
+
? 'HIGH: Frequent failures. Prioritize fixing this question.'
|
|
1237
|
+
: 'MEDIUM: Occasional failures. Monitor and investigate.'
|
|
1303
1238
|
}));
|
|
1304
1239
|
}
|
|
1305
1240
|
|
|
@@ -1549,7 +1484,7 @@ export class MetabaseMetadataClient {
|
|
|
1549
1484
|
impact.recommendations.push('✓ Set approved: true to execute import');
|
|
1550
1485
|
|
|
1551
1486
|
impact.severity = impact.conflicts.length > 10 ? 'HIGH' :
|
|
1552
|
-
|
|
1487
|
+
impact.conflicts.length > 0 ? 'MEDIUM' : 'LOW';
|
|
1553
1488
|
|
|
1554
1489
|
return impact;
|
|
1555
1490
|
} catch (error) {
|
package/src/utils/config.js
CHANGED
|
@@ -42,16 +42,6 @@ const envSchema = z.object({
|
|
|
42
42
|
.string()
|
|
43
43
|
.default('false')
|
|
44
44
|
.transform(val => val.toLowerCase() === 'true'),
|
|
45
|
-
MB_METADATA_ENGINE: z.enum(['postgres', 'mysql', 'h2']).default('postgres'),
|
|
46
|
-
MB_METADATA_HOST: z.string().optional(),
|
|
47
|
-
MB_METADATA_PORT: z.string().optional().transform(val => val ? parseInt(val, 10) : undefined),
|
|
48
|
-
MB_METADATA_DATABASE: z.string().optional(),
|
|
49
|
-
MB_METADATA_USER: z.string().optional(),
|
|
50
|
-
MB_METADATA_PASSWORD: z.string().optional(),
|
|
51
|
-
MB_METADATA_SSL: z
|
|
52
|
-
.string()
|
|
53
|
-
.default('false')
|
|
54
|
-
.transform(val => val.toLowerCase() === 'true'),
|
|
55
45
|
|
|
56
46
|
// AI Configuration
|
|
57
47
|
ANTHROPIC_API_KEY: z.string().optional(),
|