README
¶
mysql-mcp-server
SQL access to MySQL/MariaDB for AI agents over MCP. One server beside the database, every agent on the team connects to it. Read-only by default, full access as an explicit opt-in.
An MCP server that gives AI agents a window into a MySQL or MariaDB database. It is built to run as a shared service, not as a subprocess: you deploy one instance on a host near the database, and every agent on the team points at its URL with a bearer token. The transport is streamable HTTP (MCP spec 2026-07-28, stateless). There is no stdio mode.
It exposes a single tool, query, runs the SQL you send, and returns rows as
JSON objects.
The point is to let a coding agent (Claude Code, or anything that speaks MCP)
answer real data questions and explore a schema — without the risk of it
writing, dropping a table, or dragging a whole dataset across the wire. In the
default read_only mode that safety is enforced by the database, not by
parsing your SQL. For disposable environments like staging, mode: full_access
drops the server-side write block and lets the MySQL user's grants decide what
the agent may do — writes included.
Quickstart
Zero to a working server against a database you already have, all on one machine, so you can watch it answer before putting it on a host. You need a reachable MySQL or MariaDB and enough access on it to create a user. The team setup is under Deploy; the steps are the same, spread across two machines.
1. Create a read-only user. As an admin, with your own database name and password:
-- Both host variants: a default MariaDB install keeps anonymous ''@'localhost'
-- users that shadow '%' users on local connections.
CREATE USER 'mcp_readonly'@'%' IDENTIFIED BY 'a-strong-password';
CREATE USER 'mcp_readonly'@'localhost' IDENTIFIED BY 'a-strong-password';
GRANT SELECT ON yourdb.* TO 'mcp_readonly'@'%';
GRANT SELECT ON yourdb.* TO 'mcp_readonly'@'localhost';
FLUSH PRIVILEGES;
verify:
mysql -h 127.0.0.1 -u mcp_readonly -p -e "SELECT 1"
2. Install it.
curl -fsSL https://raw.githubusercontent.com/mshddev/mysql-mcp-server/main/install.sh | sh
Other routes (Go install, build from source, manual download) are under Install. Verify:
mysql-mcp-server --version
3. Write the config.
curl -fsSL -o config.yaml https://raw.githubusercontent.com/mshddev/mysql-mcp-server/main/config.example.yaml
(From a clone, that's cp config.example.yaml config.yaml.) Edit database
to match step 1 — host, port, username, dbname. Then trim
masking.mask to columns your schema actually has; it ships as a starter list
because forgetting one is the failure mode. Everything else has a working
default, and Configure documents the rest.
4. Run it.
export MYSQL_MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
echo "$MYSQL_MCP_AUTH_TOKEN" # your client needs this in step 5
export MYSQL_PASSWORD='a-strong-password'
mysql-mcp-server --config ./config.yaml
It stays in the foreground, and a healthy start logs one line:
{"time":"...","level":"INFO","msg":"startup","listen":"127.0.0.1:3000","database":"127.0.0.1","mode":"read_only","masking":true,"version":"0.0.2"}
If it exits instead, the error says why — Troubleshooting has the common ones.
5. Ask it something. In a second terminal, with TOKEN set to what step 4
printed:
curl -s -X POST http://127.0.0.1:3000/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query","arguments":{"sql":"SHOW TABLES"}}}'
The reply is a server-sent-event data: line carrying the JSON-RPC result. If
curl gets rows back, an MCP client will too — wire one up under
Connect a Client, then move the server to a host under
Deploy.
Safety Model
- Two modes, chosen per deployment.
read_only(the default, and what the rest of this list assumes) blocks all writes at the session level;full_accessremoves that block for environments where agent writes are wanted — there the user's grants are the only fence, so scope them deliberately and point production atread_onlyalways. - Read-only, enforced by the database — connect with a
SELECT-only user; every pooled connection also runsSET SESSION TRANSACTION READ ONLY. The SQL text is never inspected — grants are the fence. - PII masking (optional) — values of configured columns come back as
"<masked>". This is hygiene for cooperative callers — keeping personal data out of agent transcripts — not an airtight boundary. When it is on, it is enforced: the server reads every query and traces each result column back to its real source, so a personal column stays masked through renames, sub-queries, CTEs, andUNIONs, and a computed column built from a personal one (CONCAT(phone),GROUP_CONCAT(phone),MAX(phone)) is masked too (a plainCOUNTis a number and passes). A query it can't verify — aSELECT *inside a sub-query/join/union, or syntax it can't parse — is refused with a message telling the agent to simplify it.- Still not a wall against a determined caller. Known gaps, documented by
design (for those, use database-level controls — e.g. a user restricted to
redacted views):
- views — the server does not read view definitions, so a personal column
exposed through a view is only masked if you add the view's column to the
rules (e.g.
contact); - values can still be inferred without ever appearing in the output —
through a
WHEREcondition (WHERE phone LIKE '0812%'), or a window function'sPARTITION BY/ORDER BYover a personal column (which reveals ordering or uniqueness, not the value) — neither of which masking inspects; - stored functions that return personal data from inside their body;
- MariaDB-only syntax the (MySQL-dialect) parser can't read is refused rather than run;
- under
full_access, masking degrades further — a write can copy personal data into tables the rules don't name, and writes, DDL, and unparseable statements run with wire-metadata masking only. This follows from the mode, so there is nothing to switch on; the server logs a warning at startup whenever masking runs alongside write access; - query text in the server log is not scrubbed — and with file logging it persists on disk, so protect log files like the data they describe.
- views — the server does not read view definitions, so a personal column
exposed through a view is only masked if you add the view's column to the
rules (e.g.
- Still not a wall against a determined caller. Known gaps, documented by
design (for those, use database-level controls — e.g. a user restricted to
redacted views):
- Response cap (default 500 KB of result JSON, ~125K tokens) — rows stream in and stop once the cap is hit; the response says so and hints to narrow the query. The raw HTTP body is roughly double the cap, because MCP encodes tool results twice.
- Timeout (default 30s) — the query is killed server-side with a
KILL QUERYfrom a separate connection, with the engine's own statement timeout as backup (MariaDBmax_statement_time/ MySQLmax_execution_time). One asymmetry: MySQL's variable only coversSELECTs, so underfull_accesson MySQL a long-running write is stopped by theKILLalone; MariaDB's covers every statement except stored procedures. - Connection pool (default 10) — doubles as the concurrency brake.
- Bearer token — checked on every request, compared in constant time. The only exceptions are the two health probes, which reveal up or down and nothing else (see Deploy).
Requirements
- A reachable MySQL or MariaDB
- A host to run it on, near the database, plus a reverse proxy to terminate TLS in front of it. The Quickstart skips both and runs on your laptop; Deploy covers them.
- Go 1.26 or newer, only if you build from source
- A
SELECT-only database user (or, forfull_access, a user whose grants say exactly what the agent may do) — Quickstart has theGRANT, andseed/seed.sqla throwaway database to try it against
Day-to-day development runs against MariaDB 10.11; MySQL is supported and the suite accommodates both, so open an issue if a real MySQL 8 deployment disagrees.
Install
Grab a prebuilt binary (Linux and macOS, amd64 and arm64):
curl -fsSL https://raw.githubusercontent.com/mshddev/mysql-mcp-server/main/install.sh | sh
It picks the latest release, verifies the sha256 against the release's
checksums.txt, and puts mysql-mcp-server in /usr/local/bin if that's
writable, otherwise ~/.local/bin. It never calls sudo. VERSION=v0.0.2
pins a release and INSTALL_DIR=... changes the destination. Piping a script
into sh is a trust decision; install.sh is short, so read it
first if you'd rather.
Archives for every platform, including Windows, are on the Releases page if you'd rather download by hand.
With a Go toolchain, install straight into $GOBIN:
go install github.com/mshddev/mysql-mcp-server@latest
Or build from source:
git clone https://github.com/mshddev/mysql-mcp-server.git
cd mysql-mcp-server
go build -o mysql-mcp-server .
Whichever route you took, verify:
mysql-mcp-server --version
Release binaries and go install builds report their tag. A local build reports
the nearest tag with a +dirty or pseudo-version suffix.
Configure
Copy the example and adjust it for your database:
cp config.example.yaml config.yaml
config.yaml is gitignored. ${VAR} placeholders are pulled from the
environment, so nothing sensitive lands in the file. They work in listen,
auth_token, host, username, password, dbname and logging.file; an
unset variable is a startup error naming it, never a silent empty string:
mode: read_only # or full_access: writes allowed, grants are the fence
server:
listen: "127.0.0.1:3000" # loopback on purpose; a reverse proxy fronts it (see Deploy)
auth_token: ${MYSQL_MCP_AUTH_TOKEN}
database:
host: 127.0.0.1
port: 3306
username: mcp_readonly
password: ${MYSQL_PASSWORD}
dbname: mcp_dev
limits:
timeout_seconds: 30
max_response_bytes: 512000
max_connections: 10
logging:
output: stdout # or "file" with a `file:` path and optional `rotation:`
level: info
masking: # optional; omit the section to run without masking
enabled: true
mask: [phone, "*_phone", email, name, address]
except: ["room_types.display_name"]
| Key | Meaning |
|---|---|
mode |
read_only (default) or full_access. See Safety Model. |
server.listen |
Address to bind. Loopback by default, with a TLS-terminating proxy in front. |
server.auth_token |
Bearer token clients must present. |
database.host / port |
Where the database lives. |
database.username / password |
The database user and its password. Read-only under read_only; under full_access its grants are the write fence. |
database.dbname |
Default database (schema) to connect to. |
limits.timeout_seconds |
Per-query timeout before a server-side kill. |
limits.max_response_bytes |
Result-size cap before truncation. |
limits.max_connections |
Pool size, doubling as the concurrency ceiling. |
logging.output |
stdout (default) or file. |
logging.file |
Log file path; required with output: file. |
logging.level |
debug, info (default), warn, or error. |
logging.rotation |
For file output: max_size_mb (rotate at this size, default 100), max_backups / max_age_days (0 = keep everything, the default), compress. |
masking.enabled |
Kill-switch. Defaults to true when rules are present. |
masking.mask |
Case-insensitive globs of column names to mask — bare (phone) matches every table, qualified (users.address) just one. |
masking.except |
Carve-outs for false positives; beats mask. |
Masking strictness is not configurable — it follows mode. Under read_only
every query is enforced. Under full_access reads the server can parse are
still enforced, while writes, DDL, and unparseable statements fall back to
wire-metadata masking; it warns at startup when it starts in that state.
config.example.yaml ships a starter mask list to trim, not a blank page —
forgetting a column is the failure mode. A masking section that is enabled
but has no mask rules refuses to start; opt out explicitly with
enabled: false or by omitting the section.
Startup fails fast if the database is unreachable or a referenced env var is unset.
Run
export MYSQL_MCP_AUTH_TOKEN=... # token clients must present
export MYSQL_PASSWORD=... # password of the DB user
mysql-mcp-server --config ./config.yaml
Logs are one JSON line per query — time, SQL, duration, truncated flag, error
if any. Results are never logged. They go to stdout by default; logging.output: file writes them to a log file instead, rotated by size with configurable
retention (see config.example.yaml). A log file that can't be created or
written fails startup rather than running silent.
Deploy
One instance, on a host near the database, shared by everyone's agents. The binary binds to loopback and speaks plain HTTP, so a deployment has three parts: the binary, a reverse proxy on the same host terminating TLS in front of it, and a firewall that lets only the proxy's port through.
1. Install on the host. The installer from Install works there
too, run as root so the binary lands in /usr/local/bin, where the unit below
expects it (it never calls sudo itself, and would otherwise fall back to
~/.local/bin). Then give the service its own user, put the config in place,
and keep the two secrets in an env file only that user can read:
curl -fsSL https://raw.githubusercontent.com/mshddev/mysql-mcp-server/main/install.sh | sudo sh
sudo useradd --system --no-create-home --shell /usr/sbin/nologin mysql-mcp
sudo install -d -m 750 -o root -g mysql-mcp /etc/mysql-mcp-server
sudo curl -fsSL -o /etc/mysql-mcp-server/config.yaml \
https://raw.githubusercontent.com/mshddev/mysql-mcp-server/main/config.example.yaml
sudo tee /etc/mysql-mcp-server/env > /dev/null <<EOF
MYSQL_MCP_AUTH_TOKEN=$(openssl rand -hex 32)
MYSQL_PASSWORD=a-strong-password
EOF
sudo chmod 640 /etc/mysql-mcp-server/env
sudo chgrp mysql-mcp /etc/mysql-mcp-server/env
Edit config.yaml as in the Quickstart. Leave listen at 127.0.0.1:3000;
the proxy is what faces the network.
2. Run it under systemd. Save as /etc/systemd/system/mysql-mcp-server.service:
[Unit]
Description=mysql-mcp-server
After=network-online.target
Wants=network-online.target
[Service]
User=mysql-mcp
EnvironmentFile=/etc/mysql-mcp-server/env
ExecStart=/usr/local/bin/mysql-mcp-server --config /etc/mysql-mcp-server/config.yaml
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# Uncomment with logging.output: file, and match logging.file's directory.
# ReadWritePaths=/var/log/mysql-mcp-server
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now mysql-mcp-server
verify:
journalctl -u mysql-mcp-server -n 20
The startup line from the Quickstart is in there. With logging.output: stdout (the default) journald keeps the query log; journalctl -u mysql-mcp-server -f follows it. If the unit is restart-looping instead, the
error is in the same place: startup refuses to listen until the config resolves
and the database answers, and Restart=on-failure keeps retrying every five
seconds.
3. Terminate TLS in front. Any reverse proxy works. With
Caddy, the whole Caddyfile is:
mysql-mcp.internal.example.com {
reverse_proxy 127.0.0.1:3000
}
Caddy fetches a public certificate on its own when the name resolves publicly.
For a name that only resolves inside your network, either hand it your own
certificate with tls cert.pem key.pem, or add tls internal and install
Caddy's root CA on every laptop that will connect.
Two things any proxy has to get right. Its upstream timeout must exceed
limits.timeout_seconds with room to spare, or a slow query comes back as a
504 instead of a result. And it must not buffer text/event-stream
responses. Caddy does both out of the box; nginx needs proxy_read_timeout
raised and proxy_buffering off.
4. Open only the proxy's port. 443 in, from wherever the agents run.
3000 stays on loopback and never appears in a firewall rule.
If the host is already on a private network you trust (a VPC, a VPN, a
Tailscale tailnet), you can skip the proxy: set listen: ":3000" and let
clients use http:// on that network. The token then crosses that network in
the clear, so make that call deliberately.
5. Hand out the URL and the token. The token is the one step 1 wrote to
/etc/mysql-mcp-server/env. Everyone gets the same two values, and
Connect a Client shows where they go. There is one token
per deployment, so the query log tells you what ran but not who ran it; rotate
it by editing the env file and restarting the unit.
verify, from a laptop:
curl -s https://mysql-mcp.internal.example.com/readyz
{"status":"ok"} means the whole path works: proxy, TLS, server, database.
Add the token and run the Quickstart's tools/call curl against the same host
to prove the last step too.
Health checks. Two paths answer a bare GET (or HEAD) with no token,
which is what load balancers, container probes, and uptime monitors send:
| Path | Answers | Use it for |
|---|---|---|
/healthz |
200 {"status":"ok"} as long as the process serves HTTP. Never touches the database. |
Liveness: restart the process if this fails. |
/readyz |
200 {"status":"ok"} when a database ping succeeds, 503 {"status":"degraded"} when it doesn't. |
Readiness: route traffic or page someone. |
Startup already refuses to run without the database, so /readyz is for the
database going away later; the server stays up and reports it here while every
query fails. A result is cached for five seconds, so probing it in a loop costs
the database one ping per five seconds no matter how many probers there are.
The body says up or down and nothing else; the reason is in the server log as a
readiness warning. Both paths return 405 to any other method, and every
other path still requires the token.
Connect a Client
Every agent points at the same server. A client needs three things:
- Endpoint — the URL your deployment answers on, such as
https://mysql-mcp.internal.example.com/mcp, orhttp://127.0.0.1:3000/mcpfor the Quickstart trial (any path works except the two health probes;/mcpis the convention) - Header —
Authorization: Bearer <your token> - Tool —
query, one string argument,sql
The transport is streamable HTTP only — there is no stdio mode, so a client that only launches subprocesses can't talk to this.
The server is stateless, so there is no session handshake to do first: a client
can call tools/list or tools/call cold, which is also why the curl in
Quickstart works on its own.
Claude Code
Point .mcp.json at the server, keeping the token out of git with ${VAR}
expansion:
{
"mcpServers": {
"mysql": {
"url": "https://mysql-mcp.internal.example.com/mcp",
"type": "http",
"headers": {
"Authorization": "Bearer ${MYSQL_MCP_AUTH_TOKEN}"
}
}
}
}
Commit that file and everyone on the repo gets the same server; the token comes
from each person's shell. Export MYSQL_MCP_AUTH_TOKEN in your shell profile —
the same value the server runs with — then restart Claude Code and ask a data
question. The agent will use SHOW TABLES / DESCRIBE to find its way around,
then SELECT.
Other clients
Any client that speaks streamable HTTP and can set a header takes the same three values. If yours can't set one, put a proxy in front that adds it: the token is checked on every request, and it is the only way in.
The query Tool
One tool, one argument:
query(sql: string)
Rows come back as JSON objects keyed by the column label as written in the query:
{
"columns": ["id", "name", "phone"],
"rows": [
{"id": 1, "name": "Andi", "phone": "0812..."},
{"id": 2, "name": "Budi", "phone": null}
],
"truncated": false
}
A few rules worth knowing:
- Duplicate labels —
SELECT u.id, b.idon a join — are qualified with the table alias (u.id,b.id); with no table to qualify by, they get a numeric suffix (x,x_2).columnslists the keys once, in SELECT order. NULLis JSONnull. Binary cells become"<binary, N bytes>".- Columns caught by the server's PII policy come back as
"<masked>"(theirNULLs staynull); the response names them inmasked_columnsand thenotesays why, so the agent won't mistake the placeholder for data. DECIMALstays a string to keep precision, and so do integers past ±2^53 (BIGINTIDs) — the MCP SDK round-trips numbers through a float64, which would otherwise corrupt them.- MySQL errors pass through verbatim, so the agent can read them and self-correct.
- Under
full_access, a statement that returns no rows (INSERT/UPDATE/DELETE/ DDL) reportsaffected_rows— present even at 0 — andlast_insert_idwhen there is one. Each call is one statement with autocommit: no semicolon batches, no transactions spanning calls, and session state (SET ...) does not persist between calls.
Troubleshooting
Startup problems are loud on purpose — the server refuses to listen until the config resolves and the database answers.
| What you see | What it means |
|---|---|
config references unset environment variables: [MYSQL_MCP_AUTH_TOKEN] |
A ${VAR} in the config has nothing behind it. Export it, or write the literal value in if it isn't a secret. |
database unreachable: dial tcp …: connect: connection refused |
Wrong host or port, or the database is down. |
database login refused: … ERROR 1045 (28000): Access denied for user … |
Wrong MYSQL_PASSWORD, or the user doesn't exist for the host you connect from. A default MariaDB install keeps an anonymous ''@'localhost' that shadows 'user'@'%' on local connections, so create the @'localhost' variant too. |
database login refused: … ERROR 1044 (42000): Access denied for user … to database … |
The user has no grant on database.dbname — misspelled, or the GRANT named a different schema. |
create log directory: mkdir …: read-only file system |
logging.output: file pointing somewhere it can't write. The server creates the directory when it can, and fails startup when it can't, rather than running silent. |
401 unauthorized on every call |
Token mismatch. Compare what the client sends with MYSQL_MCP_AUTH_TOKEN, and check the header reads Authorization: Bearer <token>. |
405 Method Not Allowed |
You sent a GET to the MCP endpoint, or a POST to a health probe. MCP calls are POST; /healthz and /readyz are GET. |
/readyz says degraded |
The database stopped answering after startup. The readiness warning in the server log has the driver's error. |
502 or 504 from the proxy |
502: the service is down, so check systemctl status mysql-mcp-server. 504: the proxy's upstream timeout is shorter than limits.timeout_seconds. |
curl: (60) SSL certificate problem |
The proxy is using a certificate your machine doesn't trust — tls internal or self-signed. Install its root CA, or give the proxy a certificate from a CA you already trust. |
ERROR 1142 (42000): … command denied to user … |
Read-only doing its job: the grants refused a write. |
PII masking refused this query: only SELECT/SHOW/DESCRIBE/EXPLAIN are allowed in read_only mode (this is a DELETE) |
The same refusal one layer earlier — with masking on, the parser stops a write before the database sees it. |
PII masking refused this query: a SELECT * inside a sub-query, join, or union can't be verified |
Masking can't trace * back to real columns. List them explicitly. |
PII masking refused this query: could not parse it to verify masking |
The MySQL-dialect parser couldn't read the statement, usually MariaDB-only syntax. Rewrite it, or run that deployment without masking. |
A column comes back "<masked>" and shouldn't |
A rule matched its name. Put the qualified column in masking.except — it beats mask. |
| A column you wanted masked comes back in the clear | Nothing matched it. Rules match a column's real name, so a view that renames one needs the view's own column added. See the views gap in Safety Model. |
truncated at ~N bytes — narrow the query (add WHERE or LIMIT) |
The response cap. Narrow the query, or raise limits.max_response_bytes. |
| Every query dies at the same duration | limits.timeout_seconds. The kill runs server-side, so the database stops working on it too. |
Writes still fail under full_access |
Grants are the only fence there. Check SHOW GRANTS, and confirm the startup line says "mode":"full_access". |
| The agent keeps hitting refusals it can't fix | Masking strictness follows mode and can't be tuned. Either simplify the queries, name the columns, or run that deployment with masking.enabled: false. |
Local Development
Seed a throwaway database — a couple of tables, fake rows, a SELECT-only
user (mcp_readonly), and a full-access user (mcp_write) for exercising
full_access mode — into a local MySQL/MariaDB:
mysql -h 127.0.0.1 -u root < seed/seed.sql
Run against it:
MYSQL_MCP_AUTH_TOKEN=localsecret123 MYSQL_PASSWORD=devpassword ./mysql-mcp-server
Run the tests:
go test ./...
The unit tests always run. The integration tests are skipped unless
MYSQL_TEST_ADDR points at a seeded database:
MYSQL_TEST_ADDR=127.0.0.1:3306 go test ./...
Override the credentials with MYSQL_TEST_USER, MYSQL_TEST_PASSWORD and
MYSQL_TEST_DATABASE if yours differ from the seed.
Contributing
Contributions are welcome — see CONTRIBUTING.md for the dev setup, tests, and PR flow, and the Code of Conduct for the ground rules.
Security
Found a vulnerability? Report it privately — see SECURITY.md. Do not open a public issue for security problems.
License
MIT © mshddev
If you find this useful, a star on GitHub is appreciated — and feel free to contribute.
Documentation
¶
There is no documentation for this package.