@imenam/database-mcp 1.0.9

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.md ADDED
@@ -0,0 +1,157 @@
1
+ # Custom MySQL MCP
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI agents (Cursor, Claude Desktop, etc.) interact with a MySQL, PostgreSQL, or SQLite database — query data, inspect schemas, and optionally execute write operations. Comes with a persistent GUI (tabs, settings) accessible through the central proxy.
4
+
5
+ ---
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |---|---|
11
+ | `list_tables` | List all tables in the connected database |
12
+ | `describe_table` | Get the schema of a table (columns, types, keys, defaults) |
13
+ | `execute_query` | Execute a read-only `SELECT` query and return results |
14
+ | `execute_write` | Execute a write statement (`INSERT`, `UPDATE`, `DELETE`, `ALTER`…) — requires `MYSQL_MCP_ALLOW_WRITE=true` |
15
+
16
+ ---
17
+
18
+ ## Configuration in Cursor
19
+
20
+ Add the following to your Cursor MCP configuration file (`~/.cursor/mcp.json` or your project's `.cursor/mcp.json`):
21
+
22
+ ### Via HTTP proxy (database-exposer)
23
+
24
+ Use this mode when you have a `database-exposer` instance deployed. No database credentials needed locally.
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "my-database": {
30
+ "command": "npx",
31
+ "args": ["-y", "custom-mysql-mcp"],
32
+ "env": {
33
+ "DB_PROXY_URL": "https://data.example.com",
34
+ "DB_PROXY_TOKEN": "<your-query-token>"
35
+ }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ ### Via NPX (direct connection)
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "custom-mysql-mcp": {
47
+ "command": "npx",
48
+ "args": ["-y", "custom-mysql-mcp"],
49
+ "env": {
50
+ "MYSQL_MCP_HOST": "127.0.0.1",
51
+ "MYSQL_MCP_PORT": "3306",
52
+ "MYSQL_MCP_DATABASE": "my_database",
53
+ "MYSQL_MCP_USERNAME": "my_user",
54
+ "MYSQL_MCP_PASSWORD": "my_password",
55
+ "MYSQL_MCP_ALLOW_WRITE": "false",
56
+ "PROXY_URL": "http://localhost:4242",
57
+ "CONFIG_PATH": "/path/to/persistent/config-dir"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### Via local install
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "custom-mysql-mcp": {
70
+ "command": "node",
71
+ "args": ["/absolute/path/to/custom-mysql-mcp/dist/index.js"],
72
+ "env": {
73
+ "MYSQL_MCP_HOST": "127.0.0.1",
74
+ "MYSQL_MCP_PORT": "3306",
75
+ "MYSQL_MCP_DATABASE": "my_database",
76
+ "MYSQL_MCP_USERNAME": "my_user",
77
+ "MYSQL_MCP_PASSWORD": "my_password",
78
+ "MYSQL_MCP_ALLOW_WRITE": "false",
79
+ "PROXY_URL": "http://localhost:4242",
80
+ "CONFIG_PATH": "/path/to/persistent/config-dir"
81
+ }
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Environment Variables
90
+
91
+ ### HTTP Proxy mode (database-exposer)
92
+
93
+ If `DB_PROXY_URL` and `DB_PROXY_TOKEN` are both set, the MCP routes all queries through an HTTP REST endpoint instead of connecting directly to the database. This mode is fully transparent to the agent — the same tools are available.
94
+
95
+ | Variable | Required | Description |
96
+ |---|---|---|
97
+ | `DB_PROXY_URL` | Yes† | Base URL of the `database-exposer` instance (e.g. `https://data.example.com`) |
98
+ | `DB_PROXY_TOKEN` | Yes† | Bearer token for the `database-exposer` API |
99
+
100
+ > **†** Both variables must be set together to activate proxy mode. If only one is present, the server falls back to direct connection mode.
101
+
102
+ > **Read-only:** The `database-exposer` API only accepts `SELECT`, `SHOW`, `DESCRIBE`, and `EXPLAIN` queries. `execute_write` is automatically disabled in proxy mode.
103
+
104
+ ### Direct connection
105
+
106
+ | Variable | Required | Description |
107
+ |---|---|---|
108
+ | `MYSQL_MCP_DB_TYPE` | No | Database driver: `mysql` (default), `postgres`, or `sqlite` |
109
+ | `MYSQL_MCP_HOST` | Yes* | Database server hostname or IP address |
110
+ | `MYSQL_MCP_PORT` | Yes* | Database server port (`3306` for MySQL, `5432` for Postgres) |
111
+ | `MYSQL_MCP_DATABASE` | Yes* | Name of the database to connect to |
112
+ | `MYSQL_MCP_USERNAME` | Yes* | Database username |
113
+ | `MYSQL_MCP_PASSWORD` | Yes* | Database password |
114
+ | `MYSQL_MCP_SQLITE_PATH` | Yes** | Path to the SQLite file (`**` required only when `MYSQL_MCP_DB_TYPE=sqlite`) |
115
+ | `MYSQL_MCP_ALLOW_WRITE` | No | Set to `true` to enable write operations. Defaults to **read-only**. |
116
+ | `MYSQL_MCP_DEFAULT_MAX_ROWS` | No | Maximum rows returned by MCP tools. Defaults to `10`. |
117
+ | `MYSQL_MCP_DEFAULT_MAX_CELL_LENGTH` | No | Max characters per cell before truncation. Defaults to `50`. |
118
+
119
+ > **Security note:** Write operations are disabled by default. You must explicitly set `MYSQL_MCP_ALLOW_WRITE=true` to allow `INSERT`, `UPDATE`, `DELETE`, and other write statements.
120
+
121
+ > **Result limits:** By default, `execute_query` and `execute_write` return at most 10 rows, with cell values truncated at 50 characters. The response includes a `meta.was_row_limited` field to indicate when more results are available. These limits can be overridden per call via the `max_rows` and `max_cell_length` parameters, or globally via environment variables.
122
+
123
+ ### GUI & proxy
124
+
125
+ | Variable | Required | Description |
126
+ |---|---|---|
127
+ | `PROXY_URL` | No | URL of the central proxy. Required to enable the GUI (e.g. `http://localhost:4242`). |
128
+ | `PROXY_APP_PATH` | No | Registration path in the proxy. Defaults to `/mysql-mcp`. |
129
+ | `PROXY_APP_NAME` | No | Display name in the proxy dashboard. Defaults to `MySQL MCP`. |
130
+ | `CONFIG_PATH` | No | Directory where the GUI config file (`config.json`) is stored. Can be absolute or relative to the project root. Defaults to `<project root>/data/`. |
131
+ | `MCP_LOG_DIR` | No | Directory where log files are written. |
132
+
133
+ > **Config persistence:** The GUI saves open tabs (name + SQL content) and general settings (max results) to `config.json` inside `CONFIG_PATH`. Reloading the page or restarting the server restores the exact state. Set `CONFIG_PATH` to a directory outside the MCP installation folder so data survives reinstalls and `npx` cache clears.
134
+
135
+ ---
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ # Install dependencies
141
+ npm install
142
+
143
+ # Run in development mode (no build required)
144
+ npm run dev
145
+
146
+ # Build for production
147
+ npm run build
148
+
149
+ # Start the compiled server
150
+ npm start
151
+ ```
152
+
153
+ ---
154
+
155
+ ## License
156
+
157
+ ISC
package/dist/db.js ADDED
@@ -0,0 +1,257 @@
1
+ import mysql from 'mysql2/promise';
2
+ import pg from 'pg';
3
+ import Database from 'better-sqlite3';
4
+ function getDbConfig() {
5
+ const host = process.env.MYSQL_MCP_HOST;
6
+ const port = process.env.MYSQL_MCP_PORT;
7
+ const database = process.env.MYSQL_MCP_DATABASE;
8
+ const user = process.env.MYSQL_MCP_USERNAME;
9
+ const password = process.env.MYSQL_MCP_PASSWORD;
10
+ if (!host || !port || !database || !user || !password) {
11
+ throw new Error('Missing required environment variables: MYSQL_MCP_HOST, MYSQL_MCP_PORT, MYSQL_MCP_DATABASE, MYSQL_MCP_USERNAME, MYSQL_MCP_PASSWORD');
12
+ }
13
+ return {
14
+ host,
15
+ port: parseInt(port, 10),
16
+ database,
17
+ user,
18
+ password,
19
+ };
20
+ }
21
+ function getDbType() {
22
+ const t = process.env.MYSQL_MCP_DB_TYPE?.toLowerCase();
23
+ if (!t || t === 'mysql')
24
+ return 'mysql';
25
+ if (t === 'postgres')
26
+ return 'postgres';
27
+ if (t === 'sqlite')
28
+ return 'sqlite';
29
+ throw new Error(`Unsupported MYSQL_MCP_DB_TYPE: "${t}". Supported values: mysql, postgres, sqlite`);
30
+ }
31
+ // --- MySQL Adapter ---
32
+ class MysqlAdapter {
33
+ async getConnection() {
34
+ const config = getDbConfig();
35
+ return mysql.createConnection({
36
+ host: config.host,
37
+ port: config.port,
38
+ database: config.database,
39
+ user: config.user,
40
+ password: config.password,
41
+ connectTimeout: 10000,
42
+ });
43
+ }
44
+ async query(sql) {
45
+ const connection = await this.getConnection();
46
+ try {
47
+ const [rows] = await connection.execute(sql);
48
+ return rows;
49
+ }
50
+ finally {
51
+ await connection.end();
52
+ }
53
+ }
54
+ async listTables() {
55
+ const connection = await this.getConnection();
56
+ try {
57
+ const [rows] = await connection.execute('SHOW TABLES');
58
+ return rows.map((row) => Object.values(row)[0]);
59
+ }
60
+ finally {
61
+ await connection.end();
62
+ }
63
+ }
64
+ async describeTable(tableName) {
65
+ if (!/^[\w$]+$/i.test(tableName)) {
66
+ throw new Error(`Invalid table name: "${tableName}". Only alphanumeric characters, underscores and dollar signs are allowed.`);
67
+ }
68
+ const connection = await this.getConnection();
69
+ try {
70
+ const [rows] = await connection.execute(`DESCRIBE \`${tableName}\``);
71
+ return rows;
72
+ }
73
+ finally {
74
+ await connection.end();
75
+ }
76
+ }
77
+ }
78
+ // --- PostgreSQL Adapter ---
79
+ class PostgresAdapter {
80
+ async getClient() {
81
+ const config = getDbConfig();
82
+ const client = new pg.Client({
83
+ host: config.host,
84
+ port: config.port,
85
+ database: config.database,
86
+ user: config.user,
87
+ password: config.password,
88
+ connectionTimeoutMillis: 10000,
89
+ });
90
+ await client.connect();
91
+ return client;
92
+ }
93
+ async query(sql) {
94
+ const client = await this.getClient();
95
+ try {
96
+ const result = await client.query(sql);
97
+ return result.rows;
98
+ }
99
+ finally {
100
+ await client.end();
101
+ }
102
+ }
103
+ async listTables() {
104
+ const client = await this.getClient();
105
+ try {
106
+ const result = await client.query(`SELECT table_name FROM information_schema.tables
107
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
108
+ ORDER BY table_name`);
109
+ return result.rows.map((row) => row.table_name);
110
+ }
111
+ finally {
112
+ await client.end();
113
+ }
114
+ }
115
+ async describeTable(tableName) {
116
+ if (!/^[\w$]+$/i.test(tableName)) {
117
+ throw new Error(`Invalid table name: "${tableName}". Only alphanumeric characters, underscores and dollar signs are allowed.`);
118
+ }
119
+ const client = await this.getClient();
120
+ try {
121
+ const result = await client.query(`SELECT
122
+ column_name AS "Field",
123
+ data_type AS "Type",
124
+ is_nullable AS "Null",
125
+ COALESCE(column_default, '') AS "Default",
126
+ '' AS "Key",
127
+ '' AS "Extra"
128
+ FROM information_schema.columns
129
+ WHERE table_name = $1
130
+ ORDER BY ordinal_position`, [tableName]);
131
+ return result.rows;
132
+ }
133
+ finally {
134
+ await client.end();
135
+ }
136
+ }
137
+ }
138
+ // --- SQLite Adapter ---
139
+ class SqliteAdapter {
140
+ getDb() {
141
+ const path = process.env.MYSQL_MCP_SQLITE_PATH;
142
+ if (!path) {
143
+ throw new Error('Missing required environment variable: MYSQL_MCP_SQLITE_PATH');
144
+ }
145
+ return new Database(path, { readonly: false });
146
+ }
147
+ async query(sql) {
148
+ const db = this.getDb();
149
+ try {
150
+ const stmt = db.prepare(sql);
151
+ if (stmt.reader) {
152
+ return stmt.all();
153
+ }
154
+ const info = stmt.run();
155
+ return [{ changes: info.changes, lastInsertRowid: String(info.lastInsertRowid) }];
156
+ }
157
+ finally {
158
+ db.close();
159
+ }
160
+ }
161
+ async listTables() {
162
+ const db = this.getDb();
163
+ try {
164
+ const rows = db
165
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
166
+ .all();
167
+ return rows.map((row) => row.name);
168
+ }
169
+ finally {
170
+ db.close();
171
+ }
172
+ }
173
+ async describeTable(tableName) {
174
+ if (!/^[\w$]+$/i.test(tableName)) {
175
+ throw new Error(`Invalid table name: "${tableName}". Only alphanumeric characters, underscores and dollar signs are allowed.`);
176
+ }
177
+ const db = this.getDb();
178
+ try {
179
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
180
+ return rows.map((row) => ({
181
+ Field: row.name,
182
+ Type: row.type,
183
+ Null: row.notnull ? 'NO' : 'YES',
184
+ Key: row.pk ? 'PK' : '',
185
+ Default: row.dflt_value,
186
+ Extra: '',
187
+ }));
188
+ }
189
+ finally {
190
+ db.close();
191
+ }
192
+ }
193
+ }
194
+ // --- HTTP Proxy Adapter ---
195
+ class HttpProxyAdapter {
196
+ baseUrl;
197
+ token;
198
+ constructor(baseUrl, token) {
199
+ this.baseUrl = baseUrl.replace(/\/$/, '');
200
+ this.token = token;
201
+ }
202
+ async postQuery(sql, params = []) {
203
+ const res = await fetch(`${this.baseUrl}/query`, {
204
+ method: 'POST',
205
+ headers: {
206
+ 'Content-Type': 'application/json',
207
+ 'Authorization': `Bearer ${this.token}`,
208
+ },
209
+ body: JSON.stringify({ sql, params }),
210
+ });
211
+ const json = await res.json();
212
+ if (!res.ok) {
213
+ throw new Error(json.error ?? `HTTP ${res.status}`);
214
+ }
215
+ return json.data ?? [];
216
+ }
217
+ async query(sql) {
218
+ return this.postQuery(sql);
219
+ }
220
+ async listTables() {
221
+ const rows = await this.postQuery('SHOW TABLES');
222
+ return rows.map((row) => Object.values(row)[0]);
223
+ }
224
+ async describeTable(tableName) {
225
+ if (!/^[\w$]+$/i.test(tableName)) {
226
+ throw new Error(`Invalid table name: "${tableName}". Only alphanumeric characters, underscores and dollar signs are allowed.`);
227
+ }
228
+ return this.postQuery(`DESCRIBE \`${tableName}\``);
229
+ }
230
+ }
231
+ // --- Factory ---
232
+ export function isProxyMode() {
233
+ return !!(process.env.DB_PROXY_URL && process.env.DB_PROXY_TOKEN);
234
+ }
235
+ function getAdapter() {
236
+ const proxyUrl = process.env.DB_PROXY_URL;
237
+ const proxyToken = process.env.DB_PROXY_TOKEN;
238
+ if (proxyUrl && proxyToken) {
239
+ return new HttpProxyAdapter(proxyUrl, proxyToken);
240
+ }
241
+ const type = getDbType();
242
+ if (type === 'postgres')
243
+ return new PostgresAdapter();
244
+ if (type === 'sqlite')
245
+ return new SqliteAdapter();
246
+ return new MysqlAdapter();
247
+ }
248
+ // --- Public API (unchanged) ---
249
+ export async function executeQuery(sql) {
250
+ return getAdapter().query(sql);
251
+ }
252
+ export async function listTables() {
253
+ return getAdapter().listTables();
254
+ }
255
+ export async function describeTable(tableName) {
256
+ return getAdapter().describeTable(tableName);
257
+ }
package/dist/gui.js ADDED
@@ -0,0 +1,161 @@
1
+ import express from 'express';
2
+ import bodyParser from 'body-parser';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { setupLogging, ProxyClient } from '@imenam/mcp-gui-interface';
7
+ import { executeQuery, listTables, describeTable } from './db.js';
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const rootDir = path.resolve(__dirname, '..');
10
+ setupLogging({ processLabel: 'GUI', logDir: process.env.MCP_LOG_DIR });
11
+ if (!process.env.PROXY_URL) {
12
+ process.stderr.write('[GUI] PROXY_URL non défini — arrêt.\n');
13
+ process.exit(0);
14
+ }
15
+ // --- Config persistence ---
16
+ const configDir = process.env.CONFIG_PATH
17
+ ? (path.isAbsolute(process.env.CONFIG_PATH)
18
+ ? process.env.CONFIG_PATH
19
+ : path.resolve(rootDir, process.env.CONFIG_PATH))
20
+ : path.join(rootDir, 'data');
21
+ const configPath = path.join(configDir, 'config.json');
22
+ const defaultConfig = {
23
+ tabs: [{ id: 'default', name: 'Onglet 1', sql: '' }],
24
+ settings: { maxResults: 100 },
25
+ activeTabId: 'default',
26
+ };
27
+ function loadConfig() {
28
+ try {
29
+ if (fs.existsSync(configPath)) {
30
+ const raw = fs.readFileSync(configPath, 'utf-8');
31
+ return { ...defaultConfig, ...JSON.parse(raw) };
32
+ }
33
+ }
34
+ catch (e) {
35
+ console.error('[GUI] Failed to load config:', e);
36
+ }
37
+ return { ...defaultConfig };
38
+ }
39
+ function saveConfig(config) {
40
+ try {
41
+ const dir = path.dirname(configPath);
42
+ if (!fs.existsSync(dir)) {
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ }
45
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
46
+ }
47
+ catch (e) {
48
+ console.error('[GUI] Failed to save config:', e);
49
+ }
50
+ }
51
+ // --- Query helpers ---
52
+ const READ_ONLY_PREFIXES = ['SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'WITH'];
53
+ function isReadOnly(sql) {
54
+ const normalized = sql.trim().toUpperCase();
55
+ return READ_ONLY_PREFIXES.some((prefix) => normalized.startsWith(prefix));
56
+ }
57
+ // --- Express app ---
58
+ const app = express();
59
+ app.use(bodyParser.json());
60
+ app.use(express.static(path.join(__dirname, '../public/dist')));
61
+ app.get(/^(?!\/api\/|\/proxy\/)/, (_req, res) => {
62
+ res.sendFile(path.join(__dirname, '../public/dist/index.html'));
63
+ });
64
+ app.get('/proxy/health', (_req, res) => {
65
+ res.status(200).json({ status: 'healthy' });
66
+ });
67
+ app.get('/api/config', (_req, res) => {
68
+ const config = loadConfig();
69
+ res.json({ success: true, config });
70
+ });
71
+ app.put('/api/config', (req, res) => {
72
+ const config = req.body;
73
+ if (!config || !Array.isArray(config.tabs) || !config.settings) {
74
+ res.status(400).json({ success: false, error: 'Invalid config' });
75
+ return;
76
+ }
77
+ saveConfig(config);
78
+ res.json({ success: true });
79
+ });
80
+ app.get('/api/tables', async (_req, res) => {
81
+ try {
82
+ const tables = await listTables();
83
+ res.json({ success: true, tables });
84
+ }
85
+ catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ res.status(500).json({ success: false, error: message });
88
+ }
89
+ });
90
+ app.get('/api/describe/:table', async (req, res) => {
91
+ try {
92
+ const columns = await describeTable(req.params.table);
93
+ res.json({ success: true, columns });
94
+ }
95
+ catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error);
97
+ res.status(500).json({ success: false, error: message });
98
+ }
99
+ });
100
+ app.post('/api/query', async (req, res) => {
101
+ const { sql, max_rows } = req.body;
102
+ if (!sql || typeof sql !== 'string') {
103
+ res.status(400).json({ success: false, error: 'sql is required' });
104
+ return;
105
+ }
106
+ if (!isReadOnly(sql) && process.env.MYSQL_MCP_ALLOW_WRITE?.trim().toLowerCase() !== 'true') {
107
+ res.status(403).json({
108
+ success: false,
109
+ error: 'Write operations are disabled. Set MYSQL_MCP_ALLOW_WRITE=true to enable them.',
110
+ });
111
+ return;
112
+ }
113
+ try {
114
+ const rawResult = await executeQuery(sql);
115
+ const result = Array.isArray(rawResult) && max_rows != null
116
+ ? rawResult.slice(0, max_rows)
117
+ : rawResult;
118
+ res.json({ success: true, result });
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ res.status(500).json({ success: false, error: message });
123
+ }
124
+ });
125
+ async function startServer() {
126
+ const proxyClient = new ProxyClient(process.env.PROXY_URL);
127
+ const result = await proxyClient.register({
128
+ path: process.env.PROXY_APP_PATH ?? '/mysql-mcp',
129
+ name: process.env.PROXY_APP_NAME ?? 'MySQL MCP',
130
+ });
131
+ if (!result.success) {
132
+ process.stderr.write('[GUI] Enregistrement proxy échoué — arrêt.\n');
133
+ process.exit(1);
134
+ }
135
+ const port = result.port;
136
+ app.post('/api/stop', async (_req, res) => {
137
+ console.error('POST /api/stop called');
138
+ res.json({ success: true, message: 'Stopping...' });
139
+ await proxyClient.unregister();
140
+ setTimeout(() => {
141
+ if (process.send && process.connected) {
142
+ process.send({ type: 'STOP' });
143
+ }
144
+ else {
145
+ process.exit(0);
146
+ }
147
+ }, 500);
148
+ });
149
+ const serverListener = app.listen(port, () => {
150
+ console.error(`GUI Server running at http://localhost:${port}`);
151
+ });
152
+ serverListener.on('error', (err) => {
153
+ if (err.code === 'EADDRINUSE') {
154
+ console.error(`PORT CONFLICT: Port ${port} is already in use.`);
155
+ }
156
+ else {
157
+ console.error(`SERVER ERROR: ${err.message}`);
158
+ }
159
+ });
160
+ }
161
+ startServer();