@kmusialik/mysql-mcp-server 1.0.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/LICENSE +21 -0
- package/README.md +68 -0
- package/index.js +193 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Krzysztof Musialik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @kmusialik/mysql-mcp-server
|
|
2
|
+
|
|
3
|
+
Read-only [MCP](https://modelcontextprotocol.io) server for MySQL. It lets Claude and other MCP clients browse schemas and run `SELECT` queries. It can't change your data.
|
|
4
|
+
|
|
5
|
+
## Tools
|
|
6
|
+
|
|
7
|
+
| Tool | What it does |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `list_databases` | Lists the databases visible to the user |
|
|
10
|
+
| `list_tables` | Lists tables and views, with approximate row counts |
|
|
11
|
+
| `describe_table` | Shows a table's columns, types, keys and indexes |
|
|
12
|
+
| `query` | Runs one read-only statement (`SELECT`, `SHOW`, `DESCRIBE`, `EXPLAIN`, `WITH`) |
|
|
13
|
+
|
|
14
|
+
Protections:
|
|
15
|
+
- only allowed statement types, one statement per call
|
|
16
|
+
- every query runs in `START TRANSACTION READ ONLY` and is then rolled back
|
|
17
|
+
- row limit and query timeout
|
|
18
|
+
|
|
19
|
+
For the best protection, also connect as a MySQL user that has only `SELECT` privileges.
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
| Variable | Default |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `MYSQL_HOST` | `localhost` |
|
|
26
|
+
| `MYSQL_PORT` | `3306` |
|
|
27
|
+
| `MYSQL_USER` | `root` |
|
|
28
|
+
| `MYSQL_PASSWORD` | _(empty)_ |
|
|
29
|
+
| `MYSQL_DATABASE` | _(none; pass `database` to the tools)_ |
|
|
30
|
+
| `MYSQL_SSL` | `false` |
|
|
31
|
+
| `MYSQL_SSL_REJECT_UNAUTHORIZED` | `true` |
|
|
32
|
+
| `MYSQL_MAX_ROWS` | `1000` |
|
|
33
|
+
| `MYSQL_QUERY_TIMEOUT_MS` | `30000` |
|
|
34
|
+
|
|
35
|
+
`DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS` and `DB_NAME` also work.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### Claude Code
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude mcp add mysql \
|
|
43
|
+
-e MYSQL_HOST=localhost -e MYSQL_USER=reader -e MYSQL_PASSWORD=secret -e MYSQL_DATABASE=shop \
|
|
44
|
+
-- npx -y @kmusialik/mysql-mcp-server
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Claude Desktop / Cursor (`mcpServers`)
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"mysql": {
|
|
53
|
+
"command": "npx",
|
|
54
|
+
"args": ["-y", "@kmusialik/mysql-mcp-server"],
|
|
55
|
+
"env": {
|
|
56
|
+
"MYSQL_HOST": "localhost",
|
|
57
|
+
"MYSQL_USER": "reader",
|
|
58
|
+
"MYSQL_PASSWORD": "secret",
|
|
59
|
+
"MYSQL_DATABASE": "shop"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import mysql from "mysql2/promise";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
const env = (...keys) => keys.map((k) => process.env[k]).find((v) => v !== undefined && v !== "");
|
|
8
|
+
|
|
9
|
+
const config = {
|
|
10
|
+
host: env("MYSQL_HOST", "DB_HOST") ?? "localhost",
|
|
11
|
+
port: Number(env("MYSQL_PORT", "DB_PORT") ?? 3306),
|
|
12
|
+
user: env("MYSQL_USER", "DB_USER") ?? "root",
|
|
13
|
+
password: env("MYSQL_PASSWORD", "MYSQL_PASS", "DB_PASS", "DB_PASSWORD") ?? "",
|
|
14
|
+
database: env("MYSQL_DATABASE", "DB_NAME"),
|
|
15
|
+
ssl: env("MYSQL_SSL") === "true" ? { rejectUnauthorized: env("MYSQL_SSL_REJECT_UNAUTHORIZED") !== "false" } : undefined,
|
|
16
|
+
};
|
|
17
|
+
const MAX_ROWS = Number(env("MYSQL_MAX_ROWS") ?? 1000);
|
|
18
|
+
const QUERY_TIMEOUT_MS = Number(env("MYSQL_QUERY_TIMEOUT_MS") ?? 30000);
|
|
19
|
+
|
|
20
|
+
const pool = mysql.createPool({
|
|
21
|
+
...config,
|
|
22
|
+
waitForConnections: true,
|
|
23
|
+
connectionLimit: 5,
|
|
24
|
+
multipleStatements: false,
|
|
25
|
+
dateStrings: true,
|
|
26
|
+
supportBigNumbers: true,
|
|
27
|
+
bigNumberStrings: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const READ_ONLY_PREFIX = /^\s*(select|show|describe|desc|explain|with)\b/i;
|
|
31
|
+
|
|
32
|
+
function stripComments(sql) {
|
|
33
|
+
return sql
|
|
34
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
35
|
+
.replace(/(--\s|#)[^\n]*/g, " ")
|
|
36
|
+
.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Every query runs inside a READ ONLY transaction that is always rolled back,
|
|
40
|
+
// so even a statement that slips past the prefix check cannot modify data.
|
|
41
|
+
async function runReadOnly(sql, params = []) {
|
|
42
|
+
const conn = await pool.getConnection();
|
|
43
|
+
try {
|
|
44
|
+
await conn.query("START TRANSACTION READ ONLY");
|
|
45
|
+
const [rows, fields] = await conn.query({ sql, values: params, timeout: QUERY_TIMEOUT_MS });
|
|
46
|
+
return { rows, fields };
|
|
47
|
+
} finally {
|
|
48
|
+
await conn.query("ROLLBACK").catch(() => {});
|
|
49
|
+
conn.release();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const text = (value) => ({
|
|
54
|
+
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
|
|
55
|
+
});
|
|
56
|
+
const fail = (err) => ({ content: [{ type: "text", text: `Error: ${err.message ?? err}` }], isError: true });
|
|
57
|
+
|
|
58
|
+
const ident = (name) => "`" + String(name).replace(/`/g, "``") + "`";
|
|
59
|
+
|
|
60
|
+
const server = new McpServer({ name: "mysql-mcp-server", version: "1.0.0" });
|
|
61
|
+
|
|
62
|
+
server.registerTool(
|
|
63
|
+
"list_databases",
|
|
64
|
+
{
|
|
65
|
+
title: "List databases",
|
|
66
|
+
description: "List all databases visible to the configured MySQL user.",
|
|
67
|
+
inputSchema: {},
|
|
68
|
+
annotations: { readOnlyHint: true },
|
|
69
|
+
},
|
|
70
|
+
async () => {
|
|
71
|
+
try {
|
|
72
|
+
const { rows } = await runReadOnly("SHOW DATABASES");
|
|
73
|
+
return text(rows.map((r) => r.Database));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return fail(err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
server.registerTool(
|
|
81
|
+
"list_tables",
|
|
82
|
+
{
|
|
83
|
+
title: "List tables",
|
|
84
|
+
description: "List tables (and views) in a database, with approximate row counts and comments.",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
database: z.string().optional().describe("Database name. Defaults to MYSQL_DATABASE."),
|
|
87
|
+
},
|
|
88
|
+
annotations: { readOnlyHint: true },
|
|
89
|
+
},
|
|
90
|
+
async ({ database }) => {
|
|
91
|
+
try {
|
|
92
|
+
const db = database ?? config.database;
|
|
93
|
+
if (!db) throw new Error("No database given and MYSQL_DATABASE is not set.");
|
|
94
|
+
const { rows } = await runReadOnly(
|
|
95
|
+
`SELECT TABLE_NAME AS name, TABLE_TYPE AS type, TABLE_ROWS AS approx_rows, TABLE_COMMENT AS comment
|
|
96
|
+
FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME`,
|
|
97
|
+
[db]
|
|
98
|
+
);
|
|
99
|
+
return text(rows);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return fail(err);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
server.registerTool(
|
|
107
|
+
"describe_table",
|
|
108
|
+
{
|
|
109
|
+
title: "Describe table",
|
|
110
|
+
description: "Show columns, types, keys and indexes of a table.",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
table: z.string().describe("Table name"),
|
|
113
|
+
database: z.string().optional().describe("Database name. Defaults to MYSQL_DATABASE."),
|
|
114
|
+
},
|
|
115
|
+
annotations: { readOnlyHint: true },
|
|
116
|
+
},
|
|
117
|
+
async ({ table, database }) => {
|
|
118
|
+
try {
|
|
119
|
+
const db = database ?? config.database;
|
|
120
|
+
if (!db) throw new Error("No database given and MYSQL_DATABASE is not set.");
|
|
121
|
+
const { rows: columns } = await runReadOnly(
|
|
122
|
+
`SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_KEY AS \`key\`,
|
|
123
|
+
COLUMN_DEFAULT AS \`default\`, EXTRA AS extra, COLUMN_COMMENT AS comment
|
|
124
|
+
FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
|
|
125
|
+
[db, table]
|
|
126
|
+
);
|
|
127
|
+
if (columns.length === 0) throw new Error(`Table ${db}.${table} not found.`);
|
|
128
|
+
const { rows: indexes } = await runReadOnly(`SHOW INDEX FROM ${ident(db)}.${ident(table)}`);
|
|
129
|
+
return text({
|
|
130
|
+
columns,
|
|
131
|
+
indexes: indexes.map((i) => ({ name: i.Key_name, column: i.Column_name, unique: i.Non_unique === 0, seq: i.Seq_in_index })),
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return fail(err);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
server.registerTool(
|
|
140
|
+
"query",
|
|
141
|
+
{
|
|
142
|
+
title: "Run read-only SQL",
|
|
143
|
+
description:
|
|
144
|
+
`Run a single read-only SQL statement (SELECT, SHOW, DESCRIBE, EXPLAIN, WITH). ` +
|
|
145
|
+
`Results are capped at ${MAX_ROWS} rows. Use ? placeholders with 'params' for values.`,
|
|
146
|
+
inputSchema: {
|
|
147
|
+
sql: z.string().describe("A single read-only SQL statement"),
|
|
148
|
+
params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional().describe("Values for ? placeholders"),
|
|
149
|
+
database: z.string().optional().describe("Database to USE for this query. Defaults to MYSQL_DATABASE."),
|
|
150
|
+
},
|
|
151
|
+
annotations: { readOnlyHint: true },
|
|
152
|
+
},
|
|
153
|
+
async ({ sql, params, database }) => {
|
|
154
|
+
try {
|
|
155
|
+
const cleaned = stripComments(sql).replace(/;\s*$/, "");
|
|
156
|
+
if (!READ_ONLY_PREFIX.test(cleaned)) {
|
|
157
|
+
throw new Error("Only SELECT, SHOW, DESCRIBE, EXPLAIN and WITH statements are allowed.");
|
|
158
|
+
}
|
|
159
|
+
if (cleaned.includes(";")) throw new Error("Multiple statements are not allowed.");
|
|
160
|
+
|
|
161
|
+
const conn = await pool.getConnection();
|
|
162
|
+
try {
|
|
163
|
+
const db = database ?? config.database;
|
|
164
|
+
if (db) await conn.query(`USE ${ident(db)}`);
|
|
165
|
+
await conn.query("START TRANSACTION READ ONLY");
|
|
166
|
+
const [rows] = await conn.query({ sql: cleaned, values: params ?? [], timeout: QUERY_TIMEOUT_MS });
|
|
167
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
168
|
+
const truncated = list.length > MAX_ROWS;
|
|
169
|
+
return text({
|
|
170
|
+
rowCount: list.length,
|
|
171
|
+
truncated,
|
|
172
|
+
rows: truncated ? list.slice(0, MAX_ROWS) : list,
|
|
173
|
+
});
|
|
174
|
+
} finally {
|
|
175
|
+
await conn.query("ROLLBACK").catch(() => {});
|
|
176
|
+
conn.release();
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return fail(err);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
async function shutdown() {
|
|
185
|
+
await pool.end().catch(() => {});
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
process.on("SIGINT", shutdown);
|
|
189
|
+
process.on("SIGTERM", shutdown);
|
|
190
|
+
|
|
191
|
+
const transport = new StdioServerTransport();
|
|
192
|
+
await server.connect(transport);
|
|
193
|
+
console.error(`mysql-mcp-server connected to ${config.user}@${config.host}:${config.port}${config.database ? "/" + config.database : ""}`);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kmusialik/mysql-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Read-only MCP server for MySQL — let Claude and other MCP clients explore and query your database safely",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mysql-mcp-server": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node index.js",
|
|
19
|
+
"inspect": "npx @modelcontextprotocol/inspector node index.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"mysql",
|
|
24
|
+
"model-context-protocol",
|
|
25
|
+
"claude",
|
|
26
|
+
"llm",
|
|
27
|
+
"read-only"
|
|
28
|
+
],
|
|
29
|
+
"author": "Krzysztof Musialik",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
33
|
+
"mysql2": "^3.24.4",
|
|
34
|
+
"zod": "^4.0.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|