A general purpose Model Context Protocol (MCP) server for investigating MySQL 8 databases. It connects to a single instance directly, over a unix socket, or through an SSH tunnel, and exposes a curated set of read-only diagnostic tools: sessions, locks, transactions, slow queries, InnoDB internals, schema, and replication.
It is designed for incident response and day-to-day troubleshooting, so it is read-only by default and avoids impactful actions.
- Connect directly (TCP), via unix socket, or through an SSH tunnel (key,
password, or agent auth, with
known_hostsverification). - Read-only by default. The generic query tool rejects anything that is not
SELECT/SHOW/EXPLAIN/DESCRIBE/WITH/USE, blocks stacked statements, and the driver runs with multi-statements disabled. - System schemas only by default. Queries may only touch MySQL's own
catalogs (
information_schema,performance_schema,mysql,sys), and live statement text is masked, so the server never reads application row data unless you opt in with--allow-user-data. - Per-query timeout and a configurable row cap on every result.
- Session guardrails. Every connection is opened read-only (when
--readonly) with amax_execution_timeand short lock-wait timeouts, so a diagnostic query can never write, run away, or sit waiting on a lock. - Privilege-aware tools. At connect the server probes what the user can do
(
PROCESS,REPLICATION CLIENT,performance_schema,sys) and hides the tools that would just return "access denied". - Toolsets. Expose only the groups you need (
--toolsets health,locks) or cherry-pick individual tools (--tools mysql_query,mysql_ping). - Investigation runbooks. Ships MCP prompts (slow server triage, blocking locks, replication lag, ...) that walk an agent through the right tools in order.
- Targets MySQL 8.0.22 or newer and uses modern syntax (
SHOW REPLICA STATUS,Replica_*/Source_*columns, andSHOW BINARY LOG STATUSon 8.4+). MariaDB and MySQL 5.7 are rejected at connect. - 25 diagnostic tools plus an optional, explicitly gated
mysql_kill.
Requires Go 1.25+.
go build -o mysql-mcp-server ./cmd/mysql-mcp-server
# or
make builddocker build -t mysql-mcp-server .The server speaks MCP over stdio, so run it interactively and pass configuration
as environment variables. From an MCP client, use docker run -i --rm as the
command:
docker run -i --rm \
-e MYSQL_HOST=db.internal -e MYSQL_USER=diag -e MYSQL_PASSWORD \
mysql-mcp-serverThis server is read-only and limited to MySQL's system schemas by default, so it does not read application data unless you opt in. See SECURITY.md for the full safety model, operator guidance, and how to report a vulnerability.
Every setting can be provided as an environment variable or a flag; flags take precedence over environment variables, which take precedence over defaults.
| Env | Flag | Default | Description |
|---|---|---|---|
MYSQL_HOST |
--host |
127.0.0.1 |
MySQL host |
MYSQL_PORT |
--port |
3306 |
MySQL port |
MYSQL_USER |
--user |
(required) | MySQL user |
MYSQL_PASSWORD |
--password |
MySQL password (prefer the env var or --password-file; a flag value is visible in the process list) |
|
MYSQL_PASSWORD_FILE |
--password-file |
Path to a file holding the password (trailing newline trimmed); cannot be combined with MYSQL_PASSWORD/--password |
|
MYSQL_DATABASE |
--database |
Default database/schema | |
MYSQL_SOCKET |
--socket |
Unix socket path (overrides host/port) | |
MYSQL_TLS |
--tls |
false |
false, true, skip-verify, or preferred |
Setting MYSQL_SSH_HOST enables the tunnel. MySQL host/port are then resolved
from the SSH server's perspective (e.g. keep 127.0.0.1:3306 to reach the
database bound to localhost on the remote host).
| Env | Flag | Default | Description |
|---|---|---|---|
MYSQL_SSH_HOST |
--ssh-host |
SSH host; enables SSH when set | |
MYSQL_SSH_PORT |
--ssh-port |
22 |
SSH port |
MYSQL_SSH_USER |
--ssh-user |
SSH user | |
MYSQL_SSH_PASSWORD |
--ssh-password |
SSH password (optional) | |
MYSQL_SSH_KEY |
--ssh-key |
Path to private key | |
MYSQL_SSH_KEY_PASSPHRASE |
--ssh-key-passphrase |
Passphrase for the key | |
MYSQL_SSH_KNOWN_HOSTS |
--ssh-known-hosts |
~/.ssh/known_hosts |
known_hosts file |
MYSQL_SSH_INSECURE_HOST_KEY |
--ssh-insecure-host-key |
false |
Skip host key check (insecure) |
MYSQL_SSH_AGENT |
--ssh-agent |
true |
Use SSH_AUTH_SOCK when available |
| Env | Flag | Default | Description |
|---|---|---|---|
MYSQL_READONLY |
--readonly |
true |
Reject non read-only statements in mysql_query |
MYSQL_ALLOW_USER_DATA |
--allow-user-data |
false |
Allow querying user tables and unmask live statement text |
MYSQL_ALLOW_KILL |
--allow-kill |
false |
Register the impactful mysql_kill tool |
MYSQL_MAX_ROWS |
--max-rows |
500 |
Max rows returned per query |
MYSQL_QUERY_TIMEOUT |
--query-timeout |
30s |
Per-query timeout |
MYSQL_CONNECT_TIMEOUT |
--connect-timeout |
10s |
Connection timeout |
| Env | Flag | Default | Description |
|---|---|---|---|
MYSQL_TOOLSETS |
--toolsets |
(all) | Comma list of toolsets to enable (all for everything) |
MYSQL_TOOLS |
--tools |
Comma list of individual tools to additionally enable | |
MYSQL_PRIVILEGE_FILTERING |
--privilege-filtering |
true |
Hide tools the connected user lacks privileges for |
MYSQL_SESSION_GUARDRAILS |
--session-guardrails |
true |
Bound each connection with read-only, statement, and lock-wait timeouts |
MYSQL_LOCK_WAIT_TIMEOUT |
--lock-wait-timeout |
10s |
Lock-wait timeout applied when guardrails are on |
MYSQL_PROMPTS |
--prompts |
true |
Register the investigation runbook prompts |
Toolset names are: health, query, processes, locks, innodb,
performance, schema, replication. When --toolsets is empty all of them are
enabled; --tools is additive on top of the selected toolsets.
The server speaks MCP over stdio. Point your client at the built binary and pass
configuration via env.
SSH tunnel example (reach a DB on a private host through a bastion):
{
"mcpServers": {
"mysql-prod": {
"command": "/absolute/path/to/mysql-mcp-server",
"env": {
"MYSQL_HOST": "127.0.0.1",
"MYSQL_PORT": "3306",
"MYSQL_USER": "readonly_user",
"MYSQL_PASSWORD": "secret",
"MYSQL_SSH_HOST": "bastion.example.com",
"MYSQL_SSH_USER": "ops",
"MYSQL_SSH_KEY": "/home/me/.ssh/id_ed25519"
}
}
}
}Grant the database user the minimum needed for diagnostics, typically:
SELECT, PROCESS, REPLICATION CLIENT (and SHOW VIEW for schema inspection).
All tools are read-only except mysql_kill, which is only registered when
MYSQL_ALLOW_KILL=true.
- mysql_ping - verify connectivity; report version, user, server time, uptime.
- mysql_server_status - curated health dashboard (connections, QPS, buffer pool hit ratio, temp tables on disk, full scans) with threshold flags.
- mysql_health_report - one-shot triage combining the health snapshot, long-running queries, long-open transactions, lock waits, and replication.
- mysql_show_variables -
SHOW GLOBAL VARIABLESwith an optionalLIKE. - mysql_show_status -
SHOW GLOBAL STATUSwith an optionalLIKE. - mysql_error_log - recent server error-log events from
performance_schema.error_log(8.0.22+).filter=corruption(default) surfaces InnoDB corruption and "page LSN in the future" signals (MY-011971/MY-011972);filter=errorsorall, optionally narrowed bysubsystem. NeedsSELECTonperformance_schema.error_log.
- mysql_processlist - active sessions from the non-locking
performance_schema.processlist(idle hidden by default; filter by time/user). - mysql_connection_summary - activity per user or host (connections,
statements, latency, table scans, file IO) from the
syssummary views. - mysql_lock_waits - InnoDB row-lock waits, pairing blocked and blocking transactions.
- mysql_transactions - open InnoDB transactions, oldest first, with age, rows locked/modified, and current query.
- mysql_metadata_locks - metadata locks (MDL) from performance_schema (8.0+).
- mysql_wait_events - what each active connection is currently waiting on
(
performance_schema.events_waits_current), worst waits first. - mysql_innodb_status -
SHOW ENGINE INNODB STATUS, optionally a single section (deadlock,transactions,buffer-pool,log, ...). - mysql_kill - kill a query or connection. Gated by
MYSQL_ALLOW_KILL.
- mysql_query - run a single read-only statement and return a table.
- mysql_explain - execution plan for a
SELECT(traditional/tree/json, orEXPLAIN ANALYZE). - mysql_explain_for_connection - plan for the statement already running on
another connection (
EXPLAIN FOR CONNECTION);format=jsonrequires--allow-user-data. - mysql_top_queries - most expensive statement digests by latency, count, or rows examined.
- mysql_table_scans - statement digests doing full table scans.
- mysql_io_hotspots - top IO consumers by file or by table (
sysviews). - mysql_index_usage -
unused,redundant, orno-primary-keyindex reports.
- mysql_databases - databases with sizes and default charset/collation.
- mysql_tables - tables in a database with engine, rows, and size.
- mysql_describe_table - columns, indexes, foreign keys, and
CREATE TABLE. - mysql_largest_tables - largest tables server-wide by data+index size.
- mysql_replication_status - replica IO/SQL thread health and lag, plus the binary log / primary position.
Tools are grouped into toolsets so you can expose only what a given client needs.
Restrict them with --toolsets health,locks (or MYSQL_TOOLSETS), and add back
individual tools with --tools mysql_query. The startup banner (on stderr) prints
exactly what was registered.
With --privilege-filtering on (the default), the server probes the connected
user's privileges at connect and hides tools it cannot run: mysql_innodb_status,
mysql_transactions, and mysql_lock_waits need PROCESS;
mysql_replication_status needs REPLICATION CLIENT; the performance,
mysql_processlist, mysql_metadata_locks, and mysql_lock_waits tools need
performance_schema (and mysql_index_usage also needs readable sys views);
mysql_error_log needs SELECT on performance_schema.error_log, which is a
separate grant from the rest of performance_schema. Detection degrades
gracefully: only a genuine access-denied error hides a tool, so a transient
failure never silently drops one.
If filtering is off, or a probe misses an edge case and a tool is still called
without the needed grant, the tool returns the raw MySQL error (for example
Error 1142 ... SELECT command denied ...) plus a short hint naming the missing
privilege, so it is clear what to grant rather than just failing opaquely.
When --prompts is on (the default) the server registers MCP prompts that guide
an agent through a read-only investigation using the tools above:
- slow_server_triage - triage a server that is slow or overloaded right now.
- blocking_locks - trace an InnoDB lock-wait chain to the root blocker.
- replication_lag - diagnose why a replica is falling behind its source.
- connection_saturation - investigate too-many-connections / refused connects.
- query_tuning - find and tune an expensive query (optional
schemaargument). - capacity_review - find where space goes and which indexes are wasteful.
By default the server reads only MySQL's own system schemas and never your application data:
- System-schema allowlist.
mysql_queryandmysql_explainreject any statement whose table references are not ininformation_schema,performance_schema,mysql, orsys. Bare table names resolve against a system schema, and backtick-quoted, comma-joined, and subquery table refs are all checked, soSELECT * FROM appdb.customersis refused. - Masked statement text. Live SQL shown by
mysql_processlist,mysql_transactions,mysql_lock_waits,mysql_health_report,mysql_innodb_status, and replication error fields has its string and numeric literals replaced with?, so values like emails or tokens do not leak while you can still see the query shape and the tables involved.mysql_queryalso masks well-known raw-statement columns (info,sql_text,query_sample_text,argument, ...) in its own results, so a directSELECTfrom aperformance_schemaevents table does not leak literals; the normalizeddigest_textis left intact. This column masking is best effort (an alias or expression can evade it), which is why--allow-user-dataremains the real boundary. - Metadata, not rows. The schema and performance tools report on user tables using catalog metadata only (names, columns, sizes, normalized digests), never their row contents.
INTO OUTFILE/INTO DUMPFILEblocked to prevent writing query results to the server filesystem.- Error-log text is shown verbatim.
mysql_error_logreturns thedatacolumn fromperformance_schema.error_logunmasked, because it holds diagnostic text (LSNs, file paths, object names) rather than query literals, and masking would destroy the values you need to read. It is gated behind its ownSELECTgrant on that table instead.
Set MYSQL_ALLOW_USER_DATA=true (or pass --allow-user-data) only when you
genuinely need to read application data; this lifts the schema allowlist and
disables literal masking. Note that mysql_innodb_status masking covers the
statement text after each thread/query header and the hex/asc record dumps in the
lock sections; InnoDB counters, lock metadata, and table/index names are kept so
the dump stays useful.
- Read-only classification.
mysql_querystrips comments and quoted literals, then requires a single leading read-only verb and rejects embedded semicolons, so keywords or;inside strings cannot smuggle writes. - Driver hardening. Multi-statements are disabled and parameters are not interpolated client-side.
- Bounded results. Every query is capped at
MYSQL_MAX_ROWSand runs under the per-query timeout. - Session guardrails. When enabled, each pooled connection is pinned
transaction_read_only(under--readonly) with amax_execution_timeequal to the query timeout and shortinnodb_lock_wait_timeout/lock_wait_timeout, a server-enforced backstop independent of the SQL classifier. - Impactful actions are opt-in.
mysql_killis hidden unless explicitly enabled and is annotated as destructive.
Read-only mode is a guardrail, not a security boundary. Always connect with a least-privilege database user.
make fmt-check # gofmt
make vet # go vet
make test # unit tests
make build # build the binary
make all # all of the aboveThe tests in internal/tools/integration_test.go and
internal/tools/integration_features_test.go run the full safety chain, session
guardrails, and privilege filtering against a real server. They skip unless
MYSQL_MCP_INTEGRATION is set, so plain make test never needs a database:
MYSQL_MCP_INTEGRATION=1 MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=root \
go test -run Integration -v ./internal/tools/
{ "mcpServers": { "mysql": { "command": "/absolute/path/to/mysql-mcp-server", "env": { "MYSQL_HOST": "127.0.0.1", "MYSQL_PORT": "3306", "MYSQL_USER": "readonly_user", "MYSQL_PASSWORD": "secret", "MYSQL_DATABASE": "appdb" } } } }