MCP components¶
Server-side components for mysql-mcp-server, designed in its RFC 0001. They are opt-in and separate from MyVector: MYVECTOR_BUILD_MCP=ON ./scripts/build-component-<ver>-docker.sh builds component_mcp_guard.so next to the MyVector component.
| Component | Status | Servers |
|---|---|---|
component_mcp_guard |
Phase 2: identity, read-only and schema enforcement, time limit, error log, status variables | MySQL 8.4, 9.7, 26.10 |
mysql_mcp SQL tool pack (sql/mcp/) |
1.0.0 | MySQL 8.0 to 26.10, MariaDB 11.4, 12.3 |
component_mcp_guard¶
Enforces a policy on the sessions of mysql-mcp-server, inside mysqld, so a client cannot widen it. Grants stay the primary control (MCP accounts should be SELECT-only); the guard covers what grants cannot express: a kill switch, read-only even when an account has write grants, and schema limits for accounts with global SELECT.
Install¶
-- copy component_mcp_guard.so into @@plugin_dir first: the build for this server's
-- exact release (a build for another patch release, even 8.4.8 on 8.4.11, can crash it)
INSTALL COMPONENT 'file://component_mcp_guard';
GRANT MCP_ADMIN ON *.* TO 'dba'@'%'; -- who may change mcp.* settings
GRANT MCP_USER ON *.* TO 'mcp'@'%'; -- the account mysql-mcp-server uses
GRANT EXECUTE ON mysql_mcp.* TO 'mcp'@'%'; -- if the SQL tool pack is installed
UNINSTALL COMPONENT 'file://component_mcp_guard' removes the privileges, variables and status variables. Grants of MCP_USER and MCP_ADMIN stay in the grant tables until revoked.
Docker images (mysql-mcp)¶
ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10> (amd64, arm64; also tagged with the exact release, such as 8.4.13) is the official mysql image of the release the component was built for, with component_mcp_guard and the mysql_mcp SQL tool pack. It is separate from the MyVector images. On first start, with an empty data directory, it installs both, grants MCP_ADMIN to the root accounts, and reads these settings:
| Variable | Effect |
|---|---|
MYSQL_MCP_USER, MYSQL_MCP_PASSWORD |
Create this account (host %; a plain name of up to 32 characters from A-Z a-z 0-9 _ . -) for mysql-mcp-server, with MCP_USER, EXECUTE on mysql_mcp and SELECT, SHOW VIEW on the databases below. |
MYSQL_MCP_DATABASES |
Comma-separated databases the account may read. The default is MYSQL_DATABASE. |
MYSQL_MCP_ALLOWED_SCHEMAS |
Persisted as mcp.allowed_schemas. |
MYSQL_MCP_MAX_EXECUTION_TIME_MS |
Persisted as mcp.max_execution_time_ms. |
The other settings keep their defaults (read-only on). The settings are checked before the data directory is initialized, and a container with an invalid combination does not start. Once the data directory exists they are not used or checked again. For example, MYSQL_MCP_USER must differ from MYSQL_USER (or the user in MYSQL_USER_FILE), because the official entrypoint gives that account all privileges on MYSQL_DATABASE. The health check asks mysqld for the port it uses (command line and option files, as mysqld resolves them) and pings it without logging in as root. The official image's own variables (MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, ...) work as usual.
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=shop \
-e MYSQL_MCP_USER=mcp -e MYSQL_MCP_PASSWORD=mcp-secret ghcr.io/askdba/mysql-mcp:9.7
MYSQL_DSN='mcp:mcp-secret@tcp(127.0.0.1:3306)/shop' mysql-mcp-server
The images are built and published with MyVector releases (docker-publish.yml). scripts/test-mysql-mcp-image.sh <image> tests the first-start setup, and CI (integration-scripts.yml, job mcp-guard) runs it on each version.
MCP sessions¶
A session is an MCP session when its account holds MCP_USER and not MCP_ADMIN. This is decided at connect and at COM_CHANGE_USER, from privileges only; connection attributes and statement comments cannot change it.
MCP_ADMINholders are never guarded. After the component is installed,GRANT ALL ON *.*includes both privileges, so an administrator granted that way is not put under the guard.- Privilege changes take effect at the next connection. Sessions already connected when the component is installed, or when
MCP_USER/MCP_ADMINis granted or revoked, keep their previous status. Disconnect them (KILL CONNECTION) or have the client reconnect after such a change. - Grant
MCP_USERto the account itself; a role activated later withSET ROLEis not considered.
Settings¶
| Variable | Default | Effect on MCP sessions |
|---|---|---|
mcp.enabled |
ON |
OFF refuses new MCP connections and every statement of existing ones (kill switch). |
mcp.read_only |
ON |
Only reads, whatever the account's grants (below). |
mcp.deny_system_schemas |
ON |
No table access in mysql and sys. information_schema and performance_schema stay readable (the tools use them; grants govern them). |
mcp.allowed_schemas |
empty | Comma-separated schemas whose tables MCP sessions may access; blank means all. A value with no names in it, such as ,, allows none. Names are compared exactly, or case-insensitively when lower_case_table_names is 1 or 2. The system schemas above are governed by mcp.deny_system_schemas, and mysql_mcp is always allowed. |
mcp.max_execution_time_ms |
0 | Time limit for their SELECT statements, which they cannot raise; 0 turns the guard's limit off (the server's own max_execution_time default still applies) (below). |
mcp.log_denials |
ON |
Write refusals to the error log, at most 10 lines a minute (below). |
mcp.tool_calls_history_size |
1000 | Tool calls kept in performance_schema.mcp_tool_calls (below); 0 keeps none. Read-only: set it at startup or in INSTALL COMPONENT ... SET. |
SET GLOBAL and SET PERSIST of mcp.* need MCP_ADMIN on top of SYSTEM_VARIABLES_ADMIN. Persisted values load at startup as usual. Values given in INSTALL COMPONENT ... SET are taken from the installer.
What mcp.read_only allows¶
Statements are classified by the server's sql_command, so the decision does not depend on parsing. Allowed:
SELECT(includingEXPLAIN), everySHOW,DO,USE,HELP,CHECK,CHECKSUM,HANDLERreads,KILL, transaction control (BEGIN,COMMIT,ROLLBACK, savepoints),SIGNAL/RESIGNAL,GET DIAGNOSTICS,UNLOCK TABLES.SETof session and user variables.CALLinto the tool pack:CALL mysql_mcp.<routine>(...), schema-qualified.PREPARE,EXECUTE,DEALLOCATE PREPARE: the executed statement raises its own event and is checked as itself. Statements sent as binary-protocol prepared statements (as the Go driver does) are checked the same way.
Denied: everything else, including any sql_command a future server adds. Four cases also look at the statement text, after string literals and comments are removed (executable comments /*! ... */ and /*M! ... */ count as code). Where a string ends depends on sql_mode (NO_BACKSLASH_ESCAPES), which a session may change, so the text is read both with and without backslash escapes, and a statement is denied if either reading finds one of these:
SELECT ... INTO OUTFILE/INTO DUMPFILE.- Locking reads:
FOR UPDATE,FOR SHARE,LOCK IN SHARE MODE. SET GLOBAL,SET PERSIST,SET PERSIST_ONLY,@@global.,@@persist..CALLof anything outsidemysql_mcp, or an unqualifiedCALL.
Table access events catch writes the statement class cannot see, for example a stored function that inserts, called from a SELECT.
Because of the double reading, a SELECT whose string literal contains a backslash-escaped quote followed by text such as FOR UPDATE ('it\'s FOR UPDATE') is denied even though it only reads. Write such a literal with a doubled quote ('it''s FOR UPDATE') instead.
CALL is allowed for any routine in the mysql_mcp schema, not only the tool pack's. Keep only the tool pack in mysql_mcp (it is installed and dropped as a whole).
Time limit (mcp.max_execution_time_ms)¶
When an MCP session connects, the guard sets its session max_execution_time to mcp.max_execution_time_ms, so its SELECT statements stop after that many milliseconds (error 3024). The session may lower it, but not raise it. These are refused with error 1290 naming mcp.max_execution_time_ms:
SET [SESSION] max_execution_time(or@@max_execution_time) to 0, above the limit, or to anything but a literal (DEFAULT, an expression, a variable).- The optimizer hints
MAX_EXECUTION_TIME(n)andSET_VAR(max_execution_time = n)with such a value.
A changed limit reaches connected MCP sessions at their next statement, and the limit is applied again after COM_CHANGE_USER and COM_RESET_CONNECTION, which reset session variables. Setting it back to 0 leaves their current session value as it is.
As with the server's own max_execution_time, the limit covers top-level read-only SELECT statements. MySQL ignores it inside stored programs, so CALL mysql_mcp.* tool-pack calls are not limited. Those run catalog queries on information_schema. If they must be limited too, revoke EXECUTE on mysql_mcp from the MCP account: mysql-mcp-server then falls back to its own SQL, whose SELECTs the limit covers.
Error log (mcp.log_denials)¶
Each refusal is written as a warning, with the setting, the connection id and the start of the statement (up to 256 bytes, string literals replaced by '?', line breaks by spaces) or the table:
[Warning] [MY-011071] [mcp_guard] mcp_guard: refused by mcp.read_only: MCP connection 11: INSERT INTO d.t VALUES (1)
At most 10 lines are written per minute; the number left out is reported with the next line after the minute. String literals are redacted, but numbers and identifiers are kept; turn mcp.log_denials off if even those must not reach the error log.
Audit log¶
Each refusal is also emitted as an audit message (the mysql_audit_api_message service, subclass MYSQL_AUDIT_MESSAGE_INTERNAL), so an audit log plugin or component that records message events records it, whatever mcp.log_denials says. The component is mcp_guard, the producer component_mcp_guard and the message refused, with these keys:
| Key | Value |
|---|---|
option |
the setting that refused it, as in the error |
connection_id |
the connection (a number) |
statement |
the statement with string literals redacted as in the error log, or read db.table / write db.table |
tool, request_id |
the tool call, when the statement is labeled (next section) |
performance_schema.mcp_tool_calls¶
mysql-mcp-server starts every statement of a tool call with a label, /* mcp:tool=<tool> req=<request id> */. For MCP sessions the guard groups labeled statements into one row per tool call (one request id on one connection), keeping the latest mcp.tool_calls_history_size calls:
| Column | Meaning |
|---|---|
CONNECTION_ID |
the connection |
ACCOUNT |
the session's account, user@host as granted |
TOOL, REQUEST_ID |
from the label |
STATEMENTS |
labeled top-level statements of the call |
ERRORS |
those that failed, refusals included |
DENIED |
refusals by the guard |
LATENCY_US |
total time of the statements, in microseconds |
FIRST_SEEN, LAST_SEEN |
first statement start and last statement end |
SELECT TOOL, COUNT(*) AS calls, SUM(DENIED) AS denied, SUM(LATENCY_US) / 1000 AS ms
FROM performance_schema.mcp_tool_calls GROUP BY TOOL ORDER BY ms DESC;
The table is read-only and lives in memory: it is empty after a restart or reinstall. The label only attributes statements; who is an MCP session is decided by MCP_USER, so labels from other accounts are ignored, and a session can write any label it likes (the account column is the trusted one). The label must be at the very start of the statement, which the mysql client keeps only with --comments.
Errors¶
A denial fails the statement with error 1290 (ER_OPTION_PREVENTS_STATEMENT), the server's own read_only error, naming the setting:
ERROR 1290 (HY000): The MySQL server is running with the mcp.read_only option so it cannot execute this statement
The option is mcp.read_only, mcp.deny_system_schemas, mcp.allowed_schemas or mcp.enabled. A SET of mcp.* without MCP_ADMIN fails with error 1227 (ER_SPECIFIC_ACCESS_DENIED_ERROR).
Status variables¶
| Variable | Meaning |
|---|---|
mcp.sessions_connected |
MCP sessions now connected |
mcp.statements_total |
Statements started by MCP sessions (nested statements of stored programs not counted) |
mcp.statements_denied |
Statements refused by the statement checks (mcp.read_only, mcp.enabled), including refused connections |
mcp.table_access_denied |
Statements refused by a table access (mcp.allowed_schemas, mcp.deny_system_schemas, a write under mcp.read_only seen only at table level). Add both counters for all refusals |
Limits¶
mcp.allowed_schemaslimits table access, not catalog metadata:information_schemaandSHOWstill list what the account's grants allow.- With
mcp.deny_system_schemas = ON, mysql-mcp-server's opt-inslow_query_logtool (which readsmysql.slow_log) is denied. - The schema rules apply to a statement's base tables. A view in an allowed schema over a table in another schema (or in
mysql) is denied, nested views included. A view in another schema whose base tables are all allowed is allowed. - Stored functions and triggers: their reads are not checked by the schema rules. Their statements run on tables the calling statement has already locked, so the server raises no table access event for them. For example, with
mcp.allowed_schemas = 'd1',SELECT d1.f()returns data fromd2ifd1.f()readsd2. Writes inside them are still refused bymcp.read_only(a nested statement check), and stored procedures are not affected (CALLis limited tomysql_mcpundermcp.read_only, and a procedure's statements raise their own events). Control this with grants: don't give MCP accountsEXECUTEon schemas with functions that read data they must not see. ASQL SECURITY INVOKERfunction runs with the account's own grants, butmcp.allowed_schemasdoesn't limit it. Triggers need noEXECUTE: they run, with their definer's privileges, when a statement writes their table. Undermcp.read_only(the default) MCP sessions cannot write, so no trigger runs for them. If you turnmcp.read_onlyoff, don't give MCP accounts write grants on tables whose triggers read data outside the account's allowed schemas, and review trigger definers.scripts/test-mcp-guard.shkeeps a check that shows this, so a server change is noticed (#247). - A multi-statement packet is checked statement by statement: each statement in it raises its own events.
Tests¶
scripts/test-mcp-guard.sh <mysql-tag> <artifact-dir> runs install, restart, persistence, uninstall and the enforcement cases, the tool-call table and its labels, views, multi-statement packets and the parity table below against the official image (102 checks, including a lower_case_table_names=1 server), and src/mcp/guard/test_mcp_guard_policy.cc unit-tests the classification and the SQL scanner. CI (integration-scripts.yml, job mcp-guard) runs both on MySQL 8.4.11, 9.7.2 and 26.10.0.
Parity with mysql-mcp-server (RFC 0001 ยง5.2 item 7): sql/mcp/guard-parity.tsv lists statements a run_query call might send, with the verdict of mysql-mcp-server's validator (go) and of the guard under the default settings (guard). Every row where they differ says why. The test checks the guard column, sending each statement as one packet. mysql-mcp-server vendors the file and checks the go column. Both reject every write and locking read. The guard allows a few things the validator does not (session SET, information_schema, CALL mysql_mcp.*) and catches what text checks cannot (a write inside a function called from SELECT).