# MCP components Server-side components for [mysql-mcp-server](https://github.com/askdba/mysql-mcp-server), designed in its [RFC 0001](https://github.com/askdba/mysql-mcp-server/blob/main/docs/rfc/0001-mysql-component-architecture.md). They are opt-in and separate from MyVector: `MYVECTOR_BUILD_MCP=ON ./scripts/build-component--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/`](https://github.com/askdba/myvector/tree/main/sql/mcp)) | 1.0.0 | MySQL 8.0 to 26.10, MariaDB 11.4, 12.3 | | `component_mcp` | **Phase 4 spike** (not for production): an MCP endpoint inside `mysqld` | MySQL 26.10 | ## 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 ```sql -- 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. ```sh 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 ` 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_ADMIN` holders 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.** A session's status is decided once (at connect, at `COM_CHANGE_USER`, or at its first statement if neither was seen) and kept. Sessions connected when `MCP_USER` / `MCP_ADMIN` is granted or revoked keep their previous status; disconnect them (`KILL CONNECTION`) or have the client reconnect after such a change. Sessions already connected when the component is installed are classified at their next statement. - Grant `MCP_USER` to the account itself; a role activated later with `SET ROLE` is not considered. - **Sessions opened by components.** A component can run SQL as an account through the command services: a `"local"` session that impersonates the account, as `component_mcp` (RFC 0001 Phase 4) and `component_mysql_rest_service` do. Such a session raises no connection event the guard can use, and it starts **unaudited**: the guard receives no events for it until the component emits `MYSQL_AUDIT_CONNECTION_CONNECT` for the session's THD, which turns auditing on. The guard then classifies the session from its privileges at its first statement. (The CONNECT callback runs on the component's thread, where the session is not current, so the guard does not classify it there.) - The server starts these sessions **unaudited**: no statement or connection event reaches any audit consumer, the guard included. - A component that runs SQL for MCP accounts must therefore emit `MYSQL_AUDIT_CONNECTION_CONNECT` for the session's THD after connecting (`mysql_audit_api_connection`), and `..._DISCONNECT` before closing. The CONNECT event turns auditing on. - `component_mcp` does this. A component that doesn't runs its SQL unseen by the guard and by audit log plugins. - `scripts/test-mcp-guard.sh` checks both cases with a test-only component (`src/mcp/guard/test/component_mcp_session_probe.cc`). ### 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` (including `EXPLAIN`), every `SHOW`, `DO`, `USE`, `HELP`, `CHECK`, `CHECKSUM`, `HANDLER` reads, `KILL`, transaction control (`BEGIN`, `COMMIT`, `ROLLBACK`, savepoints), `SIGNAL`/`RESIGNAL`, `GET DIAGNOSTICS`, `UNLOCK TABLES`. - `SET` of session and user variables. - `CALL` into the tool pack: `CALL mysql_mcp.(...)`, 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.`. - `CALL` of anything outside `mysql_mcp`, or an unqualified `CALL`. 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)` and `SET_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 `SELECT`s 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= req= */`. 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 | ```sql 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_schemas` limits **table access**, not catalog metadata: `information_schema` and `SHOW` still list what the account's grants allow. - With `mcp.deny_system_schemas = ON`, mysql-mcp-server's opt-in `slow_query_log` tool (which reads `mysql.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 from `d2` if `d1.f()` reads `d2`. Writes inside them are still refused by `mcp.read_only` (a nested statement check), and stored procedures are not affected (`CALL` is limited to `mysql_mcp` under `mcp.read_only`, and a procedure's statements raise their own events). Control this with grants: don't give MCP accounts `EXECUTE` on schemas with functions that read data they must not see. A `SQL SECURITY INVOKER` function runs with the account's own grants, but `mcp.allowed_schemas` doesn't limit it. Triggers need no `EXECUTE`: they run, with their definer's privileges, when a statement writes their table. Under `mcp.read_only` (the default) MCP sessions cannot write, so no trigger runs for them. If you turn `mcp.read_only` off, 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.sh` keeps a check that shows this, so a server change is noticed ([#247](https://github.com/askdba/myvector/issues/247)). - A multi-statement packet is checked statement by statement: each statement in it raises its own events. - **Statements whose table reads raise no table access events are refused** while `mcp.deny_system_schemas` is on (the default) or `mcp.allowed_schemas` is set. The server raises table access events only for `SELECT`, data changes, `ANALYZE` and `HANDLER READ`, so these statements would otherwise read any table unseen: a subquery in `SET`, `DO`, a `CALL` argument or `SHOW ... WHERE`, and `CHECK TABLE` / `CHECKSUM TABLE`. Read through `SELECT` instead (`SELECT ... INTO @x`). The refusal names the schema rule in effect. `SET`, `SHOW` and `CALL` without subqueries are unaffected. - With `lower_case_table_names` 1 or 2, the system schema names are compared case-insensitively, as the server compares them. ### Tests `scripts/test-mcp-guard.sh ` runs install, restart, persistence, uninstall and the enforcement cases, the tool-call table and its labels, views, multi-statement packets, statements without table access events, sessions opened by components and the parity table below against the official image (123 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`](https://github.com/askdba/myvector/blob/main/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`). ## component_mcp (Phase 4 spike) An MCP **Streamable HTTP** endpoint (JSON-RPC 2.0 over `POST /mcp`) inside `mysqld`. It runs on MySQL Router's HTTP server, the libraries `component_mysql_rest_service` links (RFC 0001 §5.3). This is the Phase 4 build spike and is **not for production**. It is limited to the following: - **Network and auth:** `127.0.0.1` by default. Plain HTTP is allowed only on loopback: with `mcp_http.bind_address` set to any other address the install fails unless `mcp_http.ssl_cert` and `mcp_http.ssl_key` are set, and then the endpoint serves HTTPS (TLS 1.2+, Router's TLS context). Bearer tokens map to accounts: one token in `mcp_http.token_sha256` (to `mcp_http.account`), and any number in the token table `mysql.mcp_tokens` (below). - **Known limit off loopback:** Router's HTTP server has no handshake or idle timeout, and it counts connections before authentication. A client that opens 64 connections and sends nothing (not even a TLS handshake) blocks others until they close. Off loopback, allow only trusted clients to reach the port (firewall), or put a proxy that enforces timeouts in front. Tracked in [#269](https://github.com/askdba/myvector/issues/269). - `localhost` binds `127.0.0.1`, so a resolver mapping the name elsewhere cannot expose plain HTTP. - **Tools:** the six core tools of RFC 0001 §3, with mysql-mcp-server's names and arguments: - `list_databases`, `list_tables` (`database`) and `describe_table` (`database`, `table`) call the `mysql_mcp` tool pack. Arguments are passed as hex literals (`CONVERT(X'...' USING utf8mb4)`), so they are never parsed as SQL and need no escaping under any `sql_mode`. - `run_query` (`sql`) runs one statement. - `ping` runs `SELECT 1` and returns `{"success", "latency_ms", "message"}`; like mysql-mcp-server, a failure is a result with `success: false`, not a tool error. - `server_info` returns one object: `version`, `server_engine`, `version_comment`, `current_user`, `current_database`, `character_set`, `collation`, `max_connections` (as strings; `current_database` is `""`, as in mysql-mcp-server, since each call's session has no default database). mysql-mcp-server's extra fields (uptime, health, guard settings) are not ported. - **Results:** `run_query` returns at most 1000 rows and 4 MB of serialized rows (escapes counted), as `{"rows": [{"col": value, ...}], "truncated": true|false}`. A value is a JSON string, `null`, or `{"base64": "..."}` when its bytes are not valid UTF-8 (binary data). The session's `sql_select_limit` stops the server at 1001 rows for statements without their own `LIMIT`. - **Bounded consumer:** for statements with their own `LIMIT`, the server's default command-service consumer would collect every row while the statement runs. `component_mcp` sessions select its own row and value consumer services (`mysql_text_consumer_*_v1.mcp`), which pass rows on to the default consumer until the statement has produced 1001 rows or 4 MiB of values. Later rows are dropped as they arrive, and a row that crosses the byte budget is never half stored. The statement still runs to its end; `mcp.max_execution_time_ms`, when set, bounds its time. The budget also counts what the default consumer keeps per row and per cell (a pointer each, NULL cells included), so wide results of NULLs are bounded too. The status variables `mcp_http.last_rows_retained`, `mcp_http.last_bytes_retained` and `mcp_http.last_rows_stored` (the row count the default consumer handed over, observed independently) report what it kept for the last statement an endpoint call ran: 1001 rows for `SELECT ... LIMIT 1000000`, 4 rows for 1 MB values, 65 rows for 8000 NULL columns. - **Locked accounts:** a call for an account with `ACCOUNT LOCK` fails with error 3118 (`Access denied for user '...'@'...'. Account is locked.`) before any statement runs. Impersonation skips the login checks, so the endpoint reads the lock from the session's security context (`account_locked`, set by the account lookup at connect). It checks on every call, so `ACCOUNT LOCK` takes effect on the next one. ### Build and install It links the Router HTTP libraries, so it is built in the MySQL 26.10 source tree, like MRS: ```sh MYVECTOR_BUILD_MCP=ON MYVECTOR_BUILD_MCP_ENDPOINT=ON \ ./scripts/build-component-26.10-docker.sh mysql-26.10.0 dist/mcp-26.10.0 ``` Use a random token and hash it outside MySQL, so the plaintext never appears in statement text (general and slow logs, audit logs, `performance_schema` history): ```sh MCP_TOKEN=$(openssl rand -hex 32) printf '%s' "$MCP_TOKEN" | sha256sum | cut -d' ' -f1 ``` ```sql INSTALL COMPONENT 'file://component_mcp_guard'; -- required: install refuses without it INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'mcp@%', GLOBAL mcp_http.token_sha256 = ''; ``` **Across restarts.** The settings are read-only and `SET GLOBAL` values last only until the server restarts. The component itself is reloaded at startup, but with default settings: loopback, plain HTTP, no account and no token, so every request gets 401. That fails closed, but the endpoint is then unusable until reinstalled. To keep a configuration (verified on 26.10.0): - Install with `SET PERSIST` for every setting except the token: `INSTALL COMPONENT 'file://component_mcp' SET PERSIST mcp_http.port = 33480, PERSIST mcp_http.account = 'mcp@%', PERSIST mcp_http.bind_address = '0.0.0.0', PERSIST mcp_http.ssl_cert = '...', PERSIST mcp_http.ssl_key = '...', GLOBAL mcp_http.token_sha256 = '...'`. The persisted values are in `mysqld-auto.cnf`, see `performance_schema.persisted_variables`. - `mcp_http.token_sha256` cannot be persisted (`NOPERSIST`, error 1238 with `SET PERSIST`), so the hash is not written next to the other settings. Put it in an option file read by `mysqld`, readable only by the `mysqld` user: `[mysqld]` `loose-mcp_http.token_sha256 = `. In the official images that is a file in `/etc/mysql/conf.d/`. - A non-loopback HTTPS setup persisted without `ssl_cert` / `ssl_key` cannot come back as plain HTTP off loopback: without TLS files the component refuses a non-loopback address, and without a persisted `bind_address` it listens on `127.0.0.1`. `component_mcp` requires the guard's query consumer (`event_tracking_query.mcp_guard`). The server therefore refuses to install it without `component_mcp_guard`, and refuses to uninstall the guard while it is installed. The guard is what enforces the policy on its sessions. | Variable | Meaning | |---|---| | `mcp_http.port` | TCP port (read-only, default 33480) | | `mcp_http.bind_address` | Listen address (read-only, default `127.0.0.1`). Other than a loopback address (`127.0.0.0/8`, `::1`, `localhost`), it requires TLS: the install fails without `mcp_http.ssl_cert` and `mcp_http.ssl_key` | | `mcp_http.ssl_cert`, `mcp_http.ssl_key` | PEM certificate and private key files, readable by `mysqld` (read-only). Set both to serve HTTPS. With neither, the endpoint serves plain HTTP, which is allowed only on loopback; the server's own `server-cert.pem` / `server-key.pem` work | | `mcp_http.threads` | I/O worker threads (read-only, default 4, 1 to 64). Each serves one call at a time, so this many calls run at once; connections are assigned to the workers in turn | | `mcp_http.max_sessions_per_account` | Concurrent endpoint sessions per account (read-only, default 4, 1 to 1024). A call over the cap fails with error 1203. The server's `max_user_connections` does not apply to these sessions | | `mcp_http.account` | The account (`user@host`) the `mcp_http.token_sha256` token maps to (read-only). Tokens in `mysql.mcp_tokens` carry their own account | | `mcp_http.token_sha256` | Hex SHA-256 of the bearer token (read-only, not persisted, sensitive: readable only with `SENSITIVE_VARIABLES_OBSERVER`). Requests without the matching `Authorization: Bearer ` get 401 | | `mcp_http.allowed_origins` | Comma-separated `Origin` values accepted (read-only, default none; exact match, ASCII case-insensitive, spaces around commas ignored). Against DNS rebinding, as the MCP transport spec requires: a request that has an `Origin` header not in the list gets 403, before the token is checked. Requests without `Origin` (non-browser clients) are not affected. Add a browser-based client's origin here, e.g. `http://localhost:6274` for MCP Inspector. For a listed origin the endpoint also handles CORS: it answers the browser's `OPTIONS` preflight (204, allowing `POST` with `Authorization`, `Content-Type`, `Mcp-Session-Id` and `MCP-Protocol-Version`, cached 10 minutes) without the token, and every reply carries `Access-Control-Allow-Origin` for it, so the page can connect directly | | `mcp_http.mask_columns` | Comma-separated patterns (read-only, default none; trimmed of Unicode whitespace). A result column whose name contains one, case-insensitive for any Unicode text, has every non-NULL value replaced by `********`. These are the rules of mysql-mcp-server's `MYSQL_MCP_MASK_COLUMNS`, so the same setting gives the same output. Like there, it matches result column names: an alias (`SELECT password AS p`) is not masked, so use grants or views for data an account must not see | ### Token table `src/mcp/endpoint/mcp_tokens.sql`, run once by an administrator, creates: - `mysql.mcp_tokens`: `token_sha256` (lowercase hex SHA-256 of the token, the primary key), `account` (`user@host`), `expires_at` (server time, `NULL` for no expiry), `comment`, `created_at`; - `'mysql.mcp'@'localhost'`: a locked account whose only grant is `SELECT` on that table. The endpoint impersonates it to look tokens up, as MRS does for its metadata. Nobody can log in as it. A request's token is hashed and checked against `mcp_http.token_sha256` first (to `mcp_http.account`), then looked up in the table. A missing or expired row gets 401. Each request reads the table, so `INSERT` issues a token and `DELETE` (or a past `expires_at`) revokes it from the next request. The mapped account then runs the call as usual: the guard's policy for `MCP_USER` accounts, the `ACCOUNT LOCK` check, the per-account session cap. ```sql INSERT INTO mysql.mcp_tokens (token_sha256, account, expires_at, comment) VALUES ('', 'mcp@%', NOW() + INTERVAL 90 DAY, 'claude desktop'); DELETE FROM mysql.mcp_tokens WHERE comment = 'claude desktop'; -- revoke ``` Hash the token outside MySQL (`printf %s "$TOKEN" | sha256sum`): `SHA2('', 256)` works too but puts the token in the statement text. The table is in the `mysql` schema, which `component_mcp_guard` denies to MCP sessions (`mcp.deny_system_schemas`), and ordinary accounts have no grants there; it holds only hashes. Each request that is not the `mcp_http.token_sha256` token, including a wrong token, costs one primary-key lookup in a short session (bounded by `mcp_http.threads` and `max_connections`). ### How a tool call runs Each `tools/call` opens a `"local"` command-service session that **impersonates** `mcp_http.account`, with no password, on one of the endpoint's I/O threads. It runs one statement for the tool: `CALL mysql_mcp.list_databases()`, `CALL mysql_mcp.list_tables(...)`, `CALL mysql_mcp.describe_table(...)`, the `run_query` SQL, `SELECT 1` for `ping`, or the server information `SELECT` for `server_info`, then closes the session. Neither `CLIENT_MULTI_STATEMENTS` nor anything similar is set, so `a; b` is a parse error. The guard enforces its policy on these sessions only because of two things, both checked by `scripts/test-mcp-endpoint.sh`: - The endpoint emits `MYSQL_AUDIT_CONNECTION_CONNECT` for each session. The server starts such sessions **unaudited**, so without this event no statement event would reach the guard. - The guard classifies a session at its first statement. Such a session raises no connection event the guard could use. So an account with `MCP_USER` is read-only through the endpoint, and its schema rules and time limit apply. An account without it is not treated as an MCP session. Facts recorded for Phase 5: - **Locked accounts:** impersonation skips the login checks, so a locked account (`ACCOUNT LOCK`) can be impersonated. The endpoint checks `account_locked` itself (done in Phase 5, above). - **Connection limits:** `max_user_connections` is not enforced for these sessions, so the endpoint has its own per-account cap (`mcp_http.max_sessions_per_account`, Phase 5). They count against `max_connections`, and by default the command service lets them exceed it; the endpoint turns that off (`MYSQL_COMMAND_BYPASS_MAX_CONNECTIONS`, 26.10), so a call fails with error 1040 when the server is full. - **Auth backends:** Router's `http_auth_realm` / `http_auth_backend` support Basic auth only, so the endpoint checks bearer tokens itself (SHA-256, constant-time compare). ### Spike measurements (MySQL 26.10.0, arm64, 16 cores) | | `component_mcp` | `component_mysql_rest_service` | |---|---|---| | Build of the one target, fresh tree (after a 36 s configure) | 95 s | 265 s | | `.so` size | 3.1 MB | 21 MB | | Threads added to `mysqld` (one I/O worker, the spike) | 3, released on uninstall | — | ### Tests `scripts/test-mcp-endpoint.sh ` installs the guard, the tool pack and `component_mcp` on the official image, then checks: - bearer auth, `initialize`, `notifications/initialized`, `tools/list` and the JSON-RPC errors; - both tools, and that `run_query` runs as the mapped account; - the result bounds: replies cut at 1000 rows or 4 MB, and the bounded consumer (peak RSS for `LIMIT 1000000` and for 1 MB rows, 8000 NULL columns, NULL-only rows, errors raised after some rows or past the bound); - that through the endpoint an `MCP_USER` account's write and a `mysql` read are refused by the guard, and a multi-statement is a parse error; - that nothing is written and no session leaks; - that remapped to an account without `MCP_USER`, the same write is not restricted. CI (`integration-scripts.yml`, job `mcp-endpoint`) builds and runs it on 26.10.0.