@yanzhang123/readonly-db-mcp 0.7.2

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/SECURITY.md ADDED
@@ -0,0 +1,214 @@
1
+ ## MySQL Security Configuration
2
+
3
+ ### Creating a Restricted MySQL User
4
+
5
+ It's crucial to create a dedicated MySQL user with minimal permissions for the MCP server. Never use the root account or a user with full administrative privileges.
6
+
7
+ #### 1. Create a new MySQL user
8
+
9
+ ```sql
10
+ -- Connect as root or administrator
11
+ CREATE USER 'mcp_user'@'localhost' IDENTIFIED BY 'your_secure_password';
12
+ ```
13
+
14
+ #### 2. Grant minimal required permissions
15
+
16
+ Basic read-only access (recommended for exploration and analysis):
17
+ ```sql
18
+ -- Grant SELECT permission only
19
+ GRANT SELECT ON your_database.* TO 'mcp_user'@'localhost';
20
+ ```
21
+
22
+ Do not grant `INSERT`, `UPDATE`, `DELETE`, DDL, routine execution, file, lock, or
23
+ temporary-table privileges to the MCP account. The server rejects those operations,
24
+ and MySQL permissions provide the final security boundary.
25
+
26
+ #### 3. Apply the permissions
27
+ ```sql
28
+ FLUSH PRIVILEGES;
29
+ ```
30
+
31
+ ### Additional Security Measures
32
+
33
+ 1. **Network Access**
34
+ - Restrict the user to connecting only from localhost if the MCP server runs on the same machine
35
+ - If remote access is needed, specify exact IP addresses rather than using wildcards
36
+
37
+ 2. **Query Restrictions**
38
+ - Consider using VIEWs to further restrict data access
39
+ - Set an appropriate `max_queries_per_hour` limit:
40
+ ```sql
41
+ ALTER USER 'mcp_user'@'localhost'
42
+ WITH MAX_QUERIES_PER_HOUR 1000;
43
+ ```
44
+
45
+ 3. **Data Access Control**
46
+ - Grant access only to specific tables when possible
47
+ - Use column-level permissions for sensitive data:
48
+ ```sql
49
+ GRANT SELECT (public_column1, public_column2)
50
+ ON your_database.sensitive_table TO 'mcp_user'@'localhost';
51
+ ```
52
+
53
+ 4. **Regular Auditing**
54
+ - Enable MySQL audit logging for the MCP user
55
+ - Regularly review logs for unusual patterns
56
+ - Periodically review and adjust permissions
57
+
58
+ ### Environment Configuration
59
+
60
+ When setting up the MCP server, use these restricted credentials in your environment:
61
+
62
+ ```bash
63
+ MYSQL_USER=mcp_user
64
+ MYSQL_PASSWORD=your_secure_password
65
+ MYSQL_DATABASE=your_database
66
+ MYSQL_HOST=localhost
67
+ ```
68
+
69
+ ### Monitoring Usage
70
+
71
+ To monitor the MCP user's database usage:
72
+
73
+ ```sql
74
+ -- Check current connections
75
+ SELECT * FROM information_schema.PROCESSLIST
76
+ WHERE user = 'mcp_user';
77
+
78
+ -- View user privileges
79
+ SHOW GRANTS FOR 'mcp_user'@'localhost';
80
+
81
+ -- Check resource limits
82
+ SELECT * FROM mysql.user
83
+ WHERE user = 'mcp_user' AND host = 'localhost';
84
+ ```
85
+
86
+ ### Best Practices
87
+
88
+ 1. **Regular Password Rotation**
89
+ - Change the MCP user's password periodically
90
+ - Use strong, randomly generated passwords
91
+ - Update application configurations after password changes
92
+
93
+ 2. **Permission Review**
94
+ - Regularly audit granted permissions
95
+ - Remove unnecessary privileges
96
+ - Keep permissions as restrictive as possible
97
+
98
+ 3. **Access Patterns**
99
+ - Monitor query patterns for potential issues
100
+ - Set up alerts for unusual activity
101
+ - Maintain detailed logs of database access
102
+
103
+ 4. **Data Protection**
104
+ - Consider encrypting sensitive columns
105
+ - Use SSL/TLS for database connections (see **SSL/TLS Support** below)
106
+ - Implement data masking where appropriate
107
+
108
+ ### Secure Remote Access (SSH Tunneling)
109
+
110
+ If your MySQL server is not on the same network as the MCP server, **do not expose MySQL directly to the internet.** Instead, use the built-in SSH tunneling support:
111
+
112
+ 1. Enable SSH tunneling by setting `MYSQL_SSH_ENABLE=true`.
113
+ 2. Configure your SSH jump host credentials and private key path.
114
+ 3. The MCP server will establish a secure encrypted tunnel and connect to MySQL over `localhost`, keeping your database port closed to the outside world.
115
+
116
+ ### SSL/TLS Support
117
+
118
+ For production environments, always encrypt the connection between the MCP server and MySQL.
119
+
120
+ Use the `MYSQL_SSL_MODE` environment variable to control encryption:
121
+ - `DISABLED`: Allows plaintext only when explicitly selected; do not use in production.
122
+ - `REQUIRED`: Ensures the connection is encrypted.
123
+ - `VERIFY_CA`: Encrypts and verifies the server's certificate against a CA.
124
+ - `VERIFY_IDENTITY`: Encrypts and verifies that the server's hostname matches the certificate.
125
+
126
+ `REQUIRED` is the default, and the MCP verifies after connection that the
127
+ connector reports an encrypted session. Specify the CA certificate path using
128
+ `MYSQL_SSL_CA` when using either verification mode.
129
+
130
+ ### SQL Injection Protection
131
+
132
+ The MCP server includes built-in protection against SQL injection for administrative operations:
133
+ - All database and table identifiers provided via resources or tool arguments are strictly validated against a regex whitelist (`^[a-zA-Z0-9_$]+$`).
134
+ - Identifiers are automatically quoted with backticks in internal queries.
135
+ - `execute_sql` uses a lexer-style guard that ignores ordinary strings and comments but rejects MySQL `/*!...*/` and MariaDB `/*M!...*/` executable comments, multiple statements, DML, DDL, locks, transactions, `SELECT INTO`, and session assignment.
136
+ - Stored functions and UDFs are fail-closed unless sqlglot recognizes them as a
137
+ built-in or their reviewed name is explicitly listed in `allowed_functions`.
138
+ Sequence advancement, lock functions and other known side-effecting functions
139
+ remain blocked even inside `SELECT`.
140
+ - All exposed SQL then runs inside a MySQL read-only transaction. Normal completion rolls back; cancellation or result truncation closes the socket, forcing rollback and stopping unread-row production.
141
+ - Every MCP tool is advertised as read-only and non-destructive.
142
+ - Named profiles can define `allowed_databases`; SQL table/function qualifiers and resource URIs are checked against it with a MySQL AST parser.
143
+ - User-supplied `SHOW` statements must be safely scoped to an allowed database; server-wide account, process, engine, binary-log, status, and variable inspection is blocked.
144
+ - System schemas are blocked for user SQL unless `allow_system_databases=true` is explicitly configured.
145
+ - Full-call, connector socket, and MySQL/MariaDB statement timeouts limit resource exhaustion. MCP cancellation actively closes the connector socket.
146
+ - Audit logs never contain SQL text, result data, credentials, hosts, usernames,
147
+ or SSH key paths. They contain a literal-free fingerprint plus the policy and
148
+ execution metadata needed for investigation.
149
+ - Result cells whose output or source columns match `mask_columns` are replaced
150
+ with `[REDACTED]`. Source-column detection traverses aliases, expressions and
151
+ CTEs; review the patterns for your schema because name-based masking cannot
152
+ replace database views or column-level permissions.
153
+
154
+ These controls enforce the MCP read-only contract even when the supplied MySQL
155
+ account has write privileges. A `SELECT`-only account is still recommended as
156
+ an independent containment layer, and `check_connection` reports broader grants.
157
+
158
+ ### Enterprise Audit Controls
159
+
160
+ For durable audit records, set an absolute `audit_log_file` on each named
161
+ profile. The server writes one canonical JSON object per line and rotates it by
162
+ size. Events include:
163
+
164
+ - schema version, UTC timestamp, unique event ID and MCP request ID
165
+ - MCP operation and client name/version when exposed by the SDK
166
+ - optional `actor`, `purpose`, and `ticket_id`
167
+ - connection profile, target database, query type and literal-free fingerprint
168
+ - allowed/denied policy decision, internal/user-query flag and read-only
169
+ enforcement marker
170
+ - success, denial, error, timeout or cancellation outcome, duration, row count,
171
+ truncation and error type
172
+
173
+ Set `audit_required_context = ["actor", "purpose", "ticket_id"]` to reject
174
+ queries missing attribution before connecting. Set `audit_hmac_key_env` to the
175
+ name of a secret-manager supplied environment variable to sign every event with
176
+ HMAC-SHA256. `audit_fail_closed=true` requires a durable audit file and makes
177
+ audit sink/signing failures fail the MCP call. `audit_fsync=true` trades
178
+ throughput for stronger local durability.
179
+
180
+ Fail-closed query execution uses two-phase audit recording: an fsynced
181
+ `status=started` event must be durable before a database connection is opened,
182
+ then a terminal success, denial, error, timeout, or cancellation event is
183
+ written. Monitoring should alert on started events without a matching terminal
184
+ event, which can indicate process termination or an unavailable audit sink.
185
+
186
+ The audit context and MCP client metadata are caller assertions. For
187
+ cryptographically strong human attribution, terminate the remote transport at
188
+ an authenticated gateway, map the authenticated principal into request
189
+ metadata, and forward the JSONL stream to an append-only SIEM or log service.
190
+ Restrict audit file and HMAC-key access at the operating-system level.
191
+
192
+ Rejected DML, DDL, multi-statement, locking and cross-database attempts are
193
+ audited without sending them to MySQL and without recording their SQL text.
194
+
195
+ Connection diagnostics do not return raw authenticated usernames, grant
196
+ statements, profile-file paths, or database-supplied error messages. They expose
197
+ an irreversible account fingerprint, grant count, privilege classification,
198
+ numeric MySQL error references and policy metadata instead.
199
+
200
+ ### Remote Transport
201
+
202
+ SSE binds to `127.0.0.1` by default. A non-loopback bind is rejected unless
203
+ `MCP_SSE_BEARER_TOKEN` contains at least 32 characters or
204
+ `MCP_SSE_TRUST_PROXY_AUTH=true` explicitly declares that an authenticated
205
+ reverse proxy is the only entry point. DNS-rebinding Host validation remains
206
+ enabled. Never set the proxy-trust flag if clients can reach the MCP port
207
+ directly.
208
+
209
+ ### SSH and Connection Reuse
210
+
211
+ Named SSH tunnels are started once per profile, use a dynamic local port by
212
+ default, are health-checked before reuse, restart after failure, and terminate at
213
+ server shutdown. Connection pools are isolated by profile, endpoint, database,
214
+ and credential fingerprint. Set `pool_size=0` to disable pooling.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const { main } = require("../lib/launcher.cjs");
6
+
7
+ main().catch((error) => {
8
+ const message = error instanceof Error ? error.message : String(error);
9
+ process.stderr.write(`mysql-mcp-server-readonly: ${message}\n`);
10
+ process.exitCode = 1;
11
+ });
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const crypto = require("node:crypto");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { spawn, spawnSync } = require("node:child_process");
8
+
9
+ const manifest = require("../../package.json");
10
+ const packageRoot = path.resolve(__dirname, "..", "..");
11
+
12
+ function pythonCandidates(environment = process.env, platform = process.platform) {
13
+ if (environment.MYSQL_MCP_PYTHON) {
14
+ return [[environment.MYSQL_MCP_PYTHON, []]];
15
+ }
16
+ if (platform === "win32") {
17
+ return [
18
+ ["py", ["-3.11"]],
19
+ ["py", ["-3"]],
20
+ ["python", []],
21
+ ];
22
+ }
23
+ return [
24
+ ["python3", []],
25
+ ["python", []],
26
+ ];
27
+ }
28
+
29
+ function detectPython(options = {}) {
30
+ const candidates = options.candidates || pythonCandidates();
31
+ const run = options.spawnSync || spawnSync;
32
+ for (const [command, prefix] of candidates) {
33
+ const result = run(
34
+ command,
35
+ [
36
+ ...prefix,
37
+ "-c",
38
+ "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
39
+ ],
40
+ {
41
+ encoding: "utf8",
42
+ windowsHide: true,
43
+ },
44
+ );
45
+ if (result.status !== 0) {
46
+ continue;
47
+ }
48
+ const match = String(result.stdout).trim().match(/^(\d+)\.(\d+)$/);
49
+ if (match && (Number(match[1]) > 3 || Number(match[2]) >= 11)) {
50
+ return { command, prefix };
51
+ }
52
+ }
53
+ throw new Error(
54
+ "Python 3.11 or newer is required. Install Python or set MYSQL_MCP_PYTHON.",
55
+ );
56
+ }
57
+
58
+ function cacheBase(environment = process.env, platform = process.platform) {
59
+ if (environment.MYSQL_MCP_NPM_CACHE_DIR) {
60
+ return path.resolve(environment.MYSQL_MCP_NPM_CACHE_DIR);
61
+ }
62
+ if (platform === "win32") {
63
+ return path.join(
64
+ environment.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"),
65
+ "mysql-mcp-server-readonly",
66
+ );
67
+ }
68
+ if (platform === "darwin") {
69
+ return path.join(os.homedir(), "Library", "Caches", "mysql-mcp-server-readonly");
70
+ }
71
+ return path.join(
72
+ environment.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"),
73
+ "mysql-mcp-server-readonly",
74
+ );
75
+ }
76
+
77
+ function bundledWheel(root = packageRoot) {
78
+ const vendor = path.join(root, "npm", "vendor");
79
+ if (!fs.existsSync(vendor)) {
80
+ throw new Error("The npm package is missing its bundled Python wheel.");
81
+ }
82
+ const wheels = fs
83
+ .readdirSync(vendor)
84
+ .filter((name) => name.endsWith(".whl"))
85
+ .sort();
86
+ if (wheels.length !== 1) {
87
+ throw new Error(
88
+ `Expected exactly one bundled Python wheel, found ${wheels.length}.`,
89
+ );
90
+ }
91
+ return path.join(vendor, wheels[0]);
92
+ }
93
+
94
+ function dependencyLock(root = packageRoot) {
95
+ const lock = path.join(root, "npm", "requirements.lock");
96
+ if (!fs.existsSync(lock)) {
97
+ throw new Error("The npm package is missing its hashed Python dependency lock.");
98
+ }
99
+ return lock;
100
+ }
101
+
102
+ function runtimeFingerprint(wheel, lock) {
103
+ const digest = crypto.createHash("sha256");
104
+ digest.update(fs.readFileSync(wheel));
105
+ digest.update(fs.readFileSync(lock));
106
+ return digest.digest("hex");
107
+ }
108
+
109
+ function runtimeIsReady(marker, runtimePython, fingerprint) {
110
+ if (!fs.existsSync(marker) || !fs.existsSync(runtimePython)) {
111
+ return false;
112
+ }
113
+ try {
114
+ const state = JSON.parse(fs.readFileSync(marker, "utf8"));
115
+ return state.runtimeFingerprint === fingerprint;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function virtualenvPython(virtualenv, platform = process.platform) {
122
+ return platform === "win32"
123
+ ? path.join(virtualenv, "Scripts", "python.exe")
124
+ : path.join(virtualenv, "bin", "python");
125
+ }
126
+
127
+ function runChecked(command, args, label) {
128
+ const result = spawnSync(command, args, {
129
+ stdio: "inherit",
130
+ windowsHide: true,
131
+ });
132
+ if (result.error) {
133
+ throw new Error(`${label} failed: ${result.error.message}`);
134
+ }
135
+ if (result.status !== 0) {
136
+ throw new Error(`${label} failed with exit code ${result.status}`);
137
+ }
138
+ }
139
+
140
+ function sleep(milliseconds) {
141
+ Atomics.wait(
142
+ new Int32Array(new SharedArrayBuffer(4)),
143
+ 0,
144
+ 0,
145
+ milliseconds,
146
+ );
147
+ }
148
+
149
+ function acquireInstallLock(lockPath, markerPath) {
150
+ const deadline = Date.now() + 180_000;
151
+ while (true) {
152
+ try {
153
+ return fs.openSync(lockPath, "wx");
154
+ } catch (error) {
155
+ if (!error || error.code !== "EEXIST") {
156
+ throw error;
157
+ }
158
+ if (fs.existsSync(markerPath)) {
159
+ return null;
160
+ }
161
+ const age = Date.now() - fs.statSync(lockPath).mtimeMs;
162
+ if (age > 600_000) {
163
+ fs.rmSync(lockPath, { force: true });
164
+ continue;
165
+ }
166
+ if (Date.now() >= deadline) {
167
+ throw new Error("Timed out waiting for another runtime installation.");
168
+ }
169
+ sleep(250);
170
+ }
171
+ }
172
+ }
173
+
174
+ function ensureRuntime() {
175
+ const wheel = bundledWheel();
176
+ const lockfile = dependencyLock();
177
+ const fingerprint = runtimeFingerprint(wheel, lockfile);
178
+ const runtimeRoot = path.join(cacheBase(), manifest.version);
179
+ const virtualenv = path.join(runtimeRoot, "venv");
180
+ const marker = path.join(runtimeRoot, "ready.json");
181
+ const lock = path.join(runtimeRoot, "install.lock");
182
+ const runtimePython = virtualenvPython(virtualenv);
183
+
184
+ if (runtimeIsReady(marker, runtimePython, fingerprint)) {
185
+ return runtimePython;
186
+ }
187
+
188
+ fs.mkdirSync(runtimeRoot, { recursive: true });
189
+ const lockHandle = acquireInstallLock(lock, marker);
190
+ if (lockHandle === null) {
191
+ return runtimePython;
192
+ }
193
+ try {
194
+ if (runtimeIsReady(marker, runtimePython, fingerprint)) {
195
+ return runtimePython;
196
+ }
197
+ const python = detectPython();
198
+ if (!fs.existsSync(runtimePython)) {
199
+ runChecked(
200
+ python.command,
201
+ [...python.prefix, "-m", "venv", virtualenv],
202
+ "Python virtual environment creation",
203
+ );
204
+ }
205
+ runChecked(
206
+ runtimePython,
207
+ [
208
+ "-m",
209
+ "pip",
210
+ "install",
211
+ "--disable-pip-version-check",
212
+ "--no-input",
213
+ "--require-hashes",
214
+ "--only-binary=:all:",
215
+ "--upgrade",
216
+ "-r",
217
+ lockfile,
218
+ ],
219
+ "Locked Python dependency installation",
220
+ );
221
+ runChecked(
222
+ runtimePython,
223
+ [
224
+ "-m",
225
+ "pip",
226
+ "install",
227
+ "--disable-pip-version-check",
228
+ "--no-input",
229
+ "--no-deps",
230
+ "--upgrade",
231
+ wheel,
232
+ ],
233
+ "Bundled Python package installation",
234
+ );
235
+ fs.writeFileSync(
236
+ marker,
237
+ `${JSON.stringify({
238
+ packageVersion: manifest.version,
239
+ wheel: path.basename(wheel),
240
+ runtimeFingerprint: fingerprint,
241
+ })}\n`,
242
+ "utf8",
243
+ );
244
+ return runtimePython;
245
+ } finally {
246
+ fs.closeSync(lockHandle);
247
+ fs.rmSync(lock, { force: true });
248
+ }
249
+ }
250
+
251
+ async function main(argv = process.argv.slice(2)) {
252
+ if (argv.includes("--launcher-version")) {
253
+ process.stdout.write(`${manifest.version}\n`);
254
+ return;
255
+ }
256
+
257
+ const python = ensureRuntime();
258
+ const child = spawn(python, ["-m", "mysql_mcp_server", ...argv], {
259
+ env: process.env,
260
+ stdio: "inherit",
261
+ windowsHide: true,
262
+ });
263
+
264
+ for (const signal of ["SIGINT", "SIGTERM"]) {
265
+ process.on(signal, () => {
266
+ if (!child.killed) {
267
+ child.kill(signal);
268
+ }
269
+ });
270
+ }
271
+
272
+ await new Promise((resolve, reject) => {
273
+ child.once("error", reject);
274
+ child.once("exit", (code, signal) => {
275
+ if (signal) {
276
+ reject(new Error(`Python MCP exited after signal ${signal}`));
277
+ } else {
278
+ process.exitCode = code === null ? 1 : code;
279
+ resolve();
280
+ }
281
+ });
282
+ });
283
+ }
284
+
285
+ module.exports = {
286
+ bundledWheel,
287
+ cacheBase,
288
+ dependencyLock,
289
+ detectPython,
290
+ ensureRuntime,
291
+ main,
292
+ pythonCandidates,
293
+ virtualenvPython,
294
+ };