@berthojoris/mcp-mysql-server 1.16.2 → 1.16.4

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.
@@ -0,0 +1,319 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SecurityAuditTools = void 0;
7
+ const connection_1 = __importDefault(require("../db/connection"));
8
+ const config_1 = require("../config/config");
9
+ class SecurityAuditTools {
10
+ constructor() {
11
+ this.db = connection_1.default.getInstance();
12
+ }
13
+ validateDatabaseAccess(requestedDatabase) {
14
+ const connectedDatabase = config_1.dbConfig.database;
15
+ if (!connectedDatabase) {
16
+ return {
17
+ valid: false,
18
+ database: "",
19
+ error: "No database specified in connection string. Cannot access any database.",
20
+ };
21
+ }
22
+ if (!requestedDatabase) {
23
+ return { valid: true, database: connectedDatabase };
24
+ }
25
+ if (requestedDatabase !== connectedDatabase) {
26
+ return {
27
+ valid: false,
28
+ database: "",
29
+ error: `Access denied. You can only access the connected database '${connectedDatabase}'. Requested database '${requestedDatabase}' is not allowed.`,
30
+ };
31
+ }
32
+ return { valid: true, database: connectedDatabase };
33
+ }
34
+ async auditDatabaseSecurity(params) {
35
+ try {
36
+ const dbValidation = this.validateDatabaseAccess(params?.database);
37
+ if (!dbValidation.valid) {
38
+ return { status: "error", error: dbValidation.error };
39
+ }
40
+ const includeUsers = params?.include_user_account_checks ?? true;
41
+ const includePrivileges = params?.include_privilege_checks ?? true;
42
+ const findings = [];
43
+ const notes = [
44
+ "This audit is best-effort and depends on the MySQL account's privileges.",
45
+ "The tool only runs hardcoded read-only inspection queries.",
46
+ ];
47
+ // ===== Server variables / configuration checks (generally accessible) =====
48
+ const vars = await this.readVariables([
49
+ "require_secure_transport",
50
+ "have_ssl",
51
+ "tls_version",
52
+ "validate_password.policy",
53
+ "validate_password.length",
54
+ "default_password_lifetime",
55
+ "local_infile",
56
+ "secure_file_priv",
57
+ "skip_name_resolve",
58
+ "bind_address",
59
+ "log_error_verbosity",
60
+ "slow_query_log",
61
+ ]);
62
+ const requireSecureTransport = this.asOnOff(vars["require_secure_transport"]);
63
+ const haveSsl = (vars["have_ssl"] || "").toString().toUpperCase();
64
+ if (requireSecureTransport === "OFF") {
65
+ findings.push({
66
+ severity: "high",
67
+ title: "TLS not enforced (require_secure_transport=OFF)",
68
+ evidence: `require_secure_transport=${vars["require_secure_transport"] ?? "<unknown>"}`,
69
+ recommendation: "Set require_secure_transport=ON and require clients to connect over TLS.",
70
+ });
71
+ }
72
+ else if (requireSecureTransport === "ON") {
73
+ findings.push({
74
+ severity: "info",
75
+ title: "TLS enforced (require_secure_transport=ON)",
76
+ evidence: `require_secure_transport=${vars["require_secure_transport"]}`,
77
+ recommendation: "Keep TLS enforcement enabled.",
78
+ });
79
+ }
80
+ if (haveSsl && haveSsl !== "YES") {
81
+ findings.push({
82
+ severity: "high",
83
+ title: "Server SSL support not available (have_ssl != YES)",
84
+ evidence: `have_ssl=${vars["have_ssl"]}`,
85
+ recommendation: "Enable SSL/TLS support on the server (configure certificates) and verify client TLS connections.",
86
+ });
87
+ }
88
+ const localInfile = this.asOnOff(vars["local_infile"]);
89
+ if (localInfile === "ON") {
90
+ findings.push({
91
+ severity: "medium",
92
+ title: "LOCAL INFILE is enabled (local_infile=ON)",
93
+ evidence: `local_infile=${vars["local_infile"]}`,
94
+ recommendation: "Disable local_infile unless required, as it can expand attack surface for data exfiltration.",
95
+ });
96
+ }
97
+ const secureFilePriv = (vars["secure_file_priv"] ?? "").toString();
98
+ if (secureFilePriv === "") {
99
+ findings.push({
100
+ severity: "medium",
101
+ title: "secure_file_priv is empty (no restriction)",
102
+ evidence: "secure_file_priv=<empty>",
103
+ recommendation: "Set secure_file_priv to a dedicated directory (or NULL) to restrict file import/export paths.",
104
+ });
105
+ }
106
+ const passwordLifetime = this.asInt(vars["default_password_lifetime"]);
107
+ if (passwordLifetime === 0) {
108
+ findings.push({
109
+ severity: "low",
110
+ title: "Password expiration disabled (default_password_lifetime=0)",
111
+ evidence: `default_password_lifetime=${vars["default_password_lifetime"]}`,
112
+ recommendation: "Consider setting a password expiration policy aligned with your org's security requirements.",
113
+ });
114
+ }
115
+ const vPolicy = vars["validate_password.policy"];
116
+ const vLen = this.asInt(vars["validate_password.length"]);
117
+ if (vPolicy === undefined && vLen === undefined) {
118
+ findings.push({
119
+ severity: "low",
120
+ title: "Password validation plugin settings not detected",
121
+ evidence: "validate_password.* variables not present",
122
+ recommendation: "Consider enabling validate_password component/plugin (MySQL version dependent) for stronger password policies.",
123
+ });
124
+ }
125
+ else {
126
+ if (vLen !== undefined && vLen < 12) {
127
+ findings.push({
128
+ severity: "medium",
129
+ title: "Password minimum length may be weak",
130
+ evidence: `validate_password.length=${vars["validate_password.length"]}`,
131
+ recommendation: "Increase password length (e.g. 12+).",
132
+ });
133
+ }
134
+ }
135
+ // ===== User/account checks (requires mysql.user privileges) =====
136
+ if (includeUsers) {
137
+ const userCheck = await this.tryReadUserAccounts();
138
+ if (userCheck.status === "skipped") {
139
+ notes.push(userCheck.note);
140
+ }
141
+ else if (userCheck.status === "ok") {
142
+ findings.push(...userCheck.findings);
143
+ }
144
+ }
145
+ // ===== Privilege checks (information_schema schema/table privileges) =====
146
+ if (includePrivileges) {
147
+ const privCheck = await this.tryReadPrivilegeSummaries();
148
+ if (privCheck.status === "skipped") {
149
+ notes.push(privCheck.note);
150
+ }
151
+ else if (privCheck.status === "ok") {
152
+ findings.push(...privCheck.findings);
153
+ }
154
+ }
155
+ const summary = this.summarizeFindings(findings);
156
+ return {
157
+ status: "success",
158
+ data: {
159
+ database: dbValidation.database,
160
+ findings: findings.sort((a, b) => this.severityRank(b.severity) - this.severityRank(a.severity)),
161
+ summary,
162
+ notes,
163
+ },
164
+ };
165
+ }
166
+ catch (error) {
167
+ return { status: "error", error: error.message };
168
+ }
169
+ }
170
+ severityRank(s) {
171
+ switch (s) {
172
+ case "critical":
173
+ return 5;
174
+ case "high":
175
+ return 4;
176
+ case "medium":
177
+ return 3;
178
+ case "low":
179
+ return 2;
180
+ case "info":
181
+ default:
182
+ return 1;
183
+ }
184
+ }
185
+ summarizeFindings(findings) {
186
+ const summary = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
187
+ for (const f of findings) {
188
+ summary[f.severity]++;
189
+ }
190
+ return summary;
191
+ }
192
+ asOnOff(value) {
193
+ if (value === undefined || value === null)
194
+ return "UNKNOWN";
195
+ const v = value.toString().trim().toUpperCase();
196
+ if (v === "ON" || v === "1" || v === "YES")
197
+ return "ON";
198
+ if (v === "OFF" || v === "0" || v === "NO")
199
+ return "OFF";
200
+ return "UNKNOWN";
201
+ }
202
+ asInt(value) {
203
+ if (value === undefined || value === null)
204
+ return undefined;
205
+ const n = parseInt(value.toString(), 10);
206
+ return Number.isFinite(n) ? n : undefined;
207
+ }
208
+ async readVariables(keys) {
209
+ const results = {};
210
+ const query = "SHOW VARIABLES WHERE Variable_name IN (" + keys.map(() => "?").join(",") + ")";
211
+ const rows = await this.db.query(query, keys);
212
+ for (const row of rows) {
213
+ results[row.Variable_name] = row.Value;
214
+ }
215
+ return results;
216
+ }
217
+ async tryReadUserAccounts() {
218
+ try {
219
+ // We intentionally do NOT return authentication_string or password hashes.
220
+ const rows = await this.db.query(`
221
+ SELECT
222
+ User as user,
223
+ Host as host,
224
+ account_locked,
225
+ password_expired,
226
+ plugin
227
+ FROM mysql.user
228
+ WHERE User NOT IN ('mysql.sys', 'mysql.session', 'mysql.infoschema')
229
+ `);
230
+ const findings = [];
231
+ const anonymous = rows.filter((r) => !r.user);
232
+ if (anonymous.length > 0) {
233
+ findings.push({
234
+ severity: "critical",
235
+ title: "Anonymous MySQL user accounts detected",
236
+ evidence: `Found ${anonymous.length} anonymous account(s) in mysql.user`,
237
+ recommendation: "Remove anonymous users or lock them; restrict Host to localhost if required.",
238
+ });
239
+ }
240
+ const rootRemote = rows.filter((r) => (r.user || "").toLowerCase() === "root" && (r.host || "") !== "localhost");
241
+ if (rootRemote.length > 0) {
242
+ findings.push({
243
+ severity: "high",
244
+ title: "Remote root accounts detected",
245
+ evidence: `root accounts with non-localhost Host found: ${rootRemote
246
+ .slice(0, 5)
247
+ .map((r) => `${r.user}@${r.host}`)
248
+ .join(", ")}${rootRemote.length > 5 ? ", ..." : ""}`,
249
+ recommendation: "Restrict root access to localhost or a tightly controlled admin network; use named admin accounts instead.",
250
+ });
251
+ }
252
+ const wildcardHosts = rows.filter((r) => (r.host || "") === "%");
253
+ if (wildcardHosts.length > 0) {
254
+ findings.push({
255
+ severity: "medium",
256
+ title: "User accounts with wildcard host (%) detected",
257
+ evidence: `Accounts with Host='%': ${wildcardHosts
258
+ .slice(0, 8)
259
+ .map((r) => `${r.user}@${r.host}`)
260
+ .join(", ")}${wildcardHosts.length > 8 ? ", ..." : ""}`,
261
+ recommendation: "Prefer explicit Host values (specific IPs/subnets) instead of '%' where possible.",
262
+ });
263
+ }
264
+ const unlocked = rows.filter((r) => ("" + r.account_locked).toUpperCase() === "N");
265
+ if (unlocked.length > 0) {
266
+ findings.push({
267
+ severity: "info",
268
+ title: "Unlocked user accounts present",
269
+ evidence: `Unlocked accounts count: ${unlocked.length}`,
270
+ recommendation: "Ensure unused accounts are locked or removed, and use strong authentication plugins.",
271
+ });
272
+ }
273
+ return { status: "ok", findings };
274
+ }
275
+ catch (e) {
276
+ return {
277
+ status: "skipped",
278
+ note: `User/account checks skipped: insufficient privileges to read mysql.user (${e.message}).`,
279
+ };
280
+ }
281
+ }
282
+ async tryReadPrivilegeSummaries() {
283
+ try {
284
+ // These INFORMATION_SCHEMA views are generally less sensitive than mysql.user.
285
+ const schemaPriv = await this.db.query(`
286
+ SELECT GRANTEE as grantee, TABLE_SCHEMA as schema_name, PRIVILEGE_TYPE as privilege_type
287
+ FROM INFORMATION_SCHEMA.SCHEMA_PRIVILEGES
288
+ WHERE TABLE_SCHEMA NOT IN ('mysql','performance_schema','information_schema','sys')
289
+ `);
290
+ const findings = [];
291
+ const anyAll = schemaPriv.filter((r) => (r.privilege_type || "").toUpperCase() === "ALL PRIVILEGES");
292
+ if (anyAll.length > 0) {
293
+ findings.push({
294
+ severity: "medium",
295
+ title: "Broad schema privileges detected (ALL PRIVILEGES)",
296
+ evidence: `Found ${anyAll.length} schema privilege rows with ALL PRIVILEGES`,
297
+ recommendation: "Review and apply least privilege for schema-level grants; prefer role-based access.",
298
+ });
299
+ }
300
+ const grantOption = schemaPriv.filter((r) => (r.privilege_type || "").toUpperCase() === "GRANT OPTION");
301
+ if (grantOption.length > 0) {
302
+ findings.push({
303
+ severity: "high",
304
+ title: "GRANT OPTION detected in schema privileges",
305
+ evidence: `Found ${grantOption.length} schema privilege rows with GRANT OPTION`,
306
+ recommendation: "Minimize GRANT OPTION; it can allow privilege escalation if misassigned.",
307
+ });
308
+ }
309
+ return { status: "ok", findings };
310
+ }
311
+ catch (e) {
312
+ return {
313
+ status: "skipped",
314
+ note: `Privilege checks skipped: unable to read INFORMATION_SCHEMA privilege views (${e.message}).`,
315
+ };
316
+ }
317
+ }
318
+ }
319
+ exports.SecurityAuditTools = SecurityAuditTools;
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mysql-mcp",
3
3
  "description": "A Model Context Protocol for MySQL database interaction",
4
- "version": "1.13.0",
4
+ "version": "1.16.4",
5
5
  "tools": [
6
6
  {
7
7
  "name": "list_databases",
@@ -537,5 +537,85 @@
537
537
  }
538
538
  }
539
539
  }
540
+ ,
541
+ {
542
+ "name": "design_schema_from_requirements",
543
+ "description": "Designs a database schema from natural language requirements and outputs proposed DDL (does not execute).",
544
+ "input_schema": {
545
+ "type": "object",
546
+ "properties": {
547
+ "requirements_text": { "type": "string" },
548
+ "entities": {
549
+ "type": "array",
550
+ "items": {
551
+ "type": "object",
552
+ "properties": {
553
+ "name": { "type": "string" },
554
+ "fields": { "type": "array", "items": { "type": "string" } }
555
+ },
556
+ "required": ["name"]
557
+ }
558
+ },
559
+ "naming_convention": { "type": "string", "enum": ["snake_case", "camelCase"] },
560
+ "include_audit_columns": { "type": "boolean" },
561
+ "id_type": { "type": "string", "enum": ["BIGINT", "UUID"] },
562
+ "engine": { "type": "string" },
563
+ "charset": { "type": "string" },
564
+ "collation": { "type": "string" }
565
+ },
566
+ "required": ["requirements_text"]
567
+ },
568
+ "output_schema": {
569
+ "type": "object",
570
+ "properties": {
571
+ "status": { "type": "string" },
572
+ "data": { "type": "object" },
573
+ "error": { "type": ["string", "null"] }
574
+ }
575
+ }
576
+ },
577
+ {
578
+ "name": "audit_database_security",
579
+ "description": "Audits MySQL security configuration and (optionally) accounts/privileges using read-only inspection queries.",
580
+ "input_schema": {
581
+ "type": "object",
582
+ "properties": {
583
+ "database": { "type": "string" },
584
+ "include_user_account_checks": { "type": "boolean" },
585
+ "include_privilege_checks": { "type": "boolean" }
586
+ }
587
+ },
588
+ "output_schema": {
589
+ "type": "object",
590
+ "properties": {
591
+ "status": { "type": "string" },
592
+ "data": { "type": "object" },
593
+ "error": { "type": ["string", "null"] }
594
+ }
595
+ }
596
+ },
597
+ {
598
+ "name": "recommend_indexes",
599
+ "description": "Analyzes performance_schema query digests and suggests concrete CREATE INDEX statements.",
600
+ "input_schema": {
601
+ "type": "object",
602
+ "properties": {
603
+ "database": { "type": "string" },
604
+ "max_query_patterns": { "type": "number" },
605
+ "max_recommendations": { "type": "number" },
606
+ "min_execution_count": { "type": "number" },
607
+ "min_avg_time_ms": { "type": "number" },
608
+ "include_unused_index_warnings": { "type": "boolean" }
609
+ }
610
+ },
611
+ "output_schema": {
612
+ "type": "object",
613
+ "properties": {
614
+ "status": { "type": "string" },
615
+ "data": { "type": "object" },
616
+ "error": { "type": ["string", "null"] }
617
+ }
618
+ }
619
+ }
540
620
  ]
541
621
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@berthojoris/mcp-mysql-server",
3
- "version": "1.16.2",
3
+ "version": "1.16.4",
4
4
  "description": "Model Context Protocol server for MySQL database integration with dynamic per-project permissions, backup/restore, data import/export, and data migration capabilities",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "dev": "ts-node src/index.ts",
16
16
  "dev:mcp": "ts-node src/mcp-server.ts",
17
17
  "dev:api": "ts-node src/server.ts",
18
- "test": "jest",
18
+ "test": "jest --passWithNoTests",
19
19
  "prepare": "npm run build",
20
20
  "prepublishOnly": "npm run build"
21
21
  },