Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **`component_mcp_guard` tool calls and audit messages** (Phase 3 of RFC 0001, server side).
- **`performance_schema.mcp_tool_calls`:** one row per mysql-mcp-server tool call (account, tool, request id, statements, errors, refusals, latency, first and last seen). The guard groups the MCP session statements that start with the label `/* mcp:tool=... req=... */`, keeping the last `mcp.tool_calls_history_size` calls (new read-only variable, default 1000).
- **Audit messages:** each refusal is emitted through `mysql_audit_api_message` (component `mcp_guard`, message `refused`, with the option, connection id, redacted statement and the tool call), so audit log consumers record refusals.
- **Tests:** `scripts/test-mcp-guard.sh` now has 90 checks, passing on MySQL 8.4.11, 9.7.2 and 26.10.0. The label parser is unit-tested.
- **`component_mcp_guard` time limit and refusal log** (Phase 2 of RFC 0001, completed).
- **`mcp.max_execution_time_ms`:** sets an MCP session's `max_execution_time` at connect. Attempts to raise it are refused with error 1290: via `SET`, or via the `MAX_EXECUTION_TIME(n)` or `SET_VAR(max_execution_time=n)` hints. Lowering it is allowed.
- **`mcp.log_denials` (default ON):** writes each refusal to the error log as `[MY-011071] [mcp_guard]`, with string literals redacted, at most 10 lines a minute.
Expand Down
37 changes: 35 additions & 2 deletions docs/MCP_COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ A session is an **MCP session** when its account holds `MCP_USER` and not `MCP_A
| `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.

Expand Down Expand Up @@ -87,6 +88,39 @@ Each refusal is written as a warning, with the setting, the connection id and th

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 |

```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:
Expand All @@ -110,8 +144,7 @@ The option is `mcp.read_only`, `mcp.deny_system_schemas`, `mcp.allowed_schemas`

- `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.
- Planned (RFC 0001 Phase 3): denials in the audit log, `performance_schema.mcp_tool_calls`.

### Tests

`scripts/test-mcp-guard.sh <mysql-tag> <artifact-dir>` runs install, restart, persistence, uninstall and the enforcement cases against the official image (80 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.
`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 against the official image (90 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.
22 changes: 22 additions & 0 deletions scripts/test-mcp-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,27 @@ after=$(docker logs "$CNAME" 2>&1 | grep -c 'mcp_guard: refused by')
expect "mcp.log_denials = OFF writes nothing" "$after" "^$before$"
sql "SET GLOBAL mcp.log_denials = ON" >/dev/null

# --- performance_schema.mcp_tool_calls -------------------------------------------------------------
# Statements labeled by mysql-mcp-server (/* mcp:tool=... req=... */) are grouped per tool call.
# --comments: older clients strip comments by default.
mcpc() { docker exec -i "$CNAME" mysql -h127.0.0.1 -umcp -pmcppass1 -N --batch --comments -e "$1" 2>&1 | grep -v "Using a password"; }
calls() { sql "SELECT CONCAT_WS(',', TOOL, ACCOUNT, STATEMENTS, ERRORS, DENIED) FROM performance_schema.mcp_tool_calls WHERE REQUEST_ID = '$1'"; }
expect "mcp.tool_calls_history_size default" "$(sql "SELECT @@mcp.tool_calls_history_size")" "^1000$"
expect "mcp_tool_calls empty before any labeled statement" "$(sql "SELECT COUNT(*) FROM performance_schema.mcp_tool_calls")" "^0$"
mcpc "/* mcp:tool=list_tables req=t-1 */ SELECT 1; /* mcp:tool=list_tables req=t-1 */ SELECT COUNT(*) FROM d1.t" >/dev/null
expect "a tool call with two statements" "$(calls t-1)" "^list_tables,mcp@%,2,0,0$"
mcpc "/* mcp:tool=run_query req=t-2 */ INSERT INTO d1.t VALUES (7)" >/dev/null
expect "a refused tool call counts denied and errors" "$(calls t-2)" "^run_query,mcp@%,1,1,1$"
mcpc "/* mcp:tool=run_query req=t-3 */ SELECT * FROM mysql.user" >/dev/null
expect "a table-level refusal is counted" "$(calls t-3)" "^run_query,mcp@%,1,1,1$"
mcpc "/* mcp:tool=run_query req=t-4 */ SELECT SLEEP(0.2)" >/dev/null
expect "latency is measured" "$(sql "SELECT LATENCY_US >= 200000 AND LAST_SEEN >= FIRST_SEEN FROM performance_schema.mcp_tool_calls WHERE REQUEST_ID = 't-4'")" "^1$"
mcpc "SELECT 1; /* not a label */ SELECT 2; /* mcp:tool=x */ SELECT 3" >/dev/null
docker exec -i "$CNAME" mysql -h127.0.0.1 -uapp -papppass1 --comments -e "/* mcp:tool=list_tables req=t-app */ SELECT 1" >/dev/null 2>&1
expect "unlabeled statements and other accounts add no rows" "$(sql "SELECT COUNT(*) FROM performance_schema.mcp_tool_calls")" "^4$"
expect "rows read in order and by a self-join" "$(sql "SELECT GROUP_CONCAT(REQUEST_ID ORDER BY REQUEST_ID DESC), (SELECT COUNT(*) FROM performance_schema.mcp_tool_calls a JOIN performance_schema.mcp_tool_calls b USING (REQUEST_ID)) FROM performance_schema.mcp_tool_calls")" "^t-4,t-3,t-2,t-1 4$"
expect "mcp_tool_calls is read-only" "$(sql "DELETE FROM performance_schema.mcp_tool_calls")" "ERROR"

status=$(sql "SHOW GLOBAL STATUS LIKE 'mcp.%'")
expect "status: statements_denied counted" "$status" "mcp.statements_denied [1-9]"
expect "status: table_access_denied counted" "$status" "mcp.table_access_denied [1-9]"
Expand All @@ -203,6 +224,7 @@ sql "RESET PERSIST mcp.allowed_schemas; SET GLOBAL mcp.allowed_schemas = ''" >/d
expect "UNINSTALL COMPONENT" "$(sql "UNINSTALL COMPONENT 'file://component_mcp_guard'")" "^$"
expect "MCP_ADMIN unknown after uninstall" "$(sql "GRANT MCP_ADMIN ON *.* TO app@'%'")" "ERROR"
expect "mcp.* variables gone after uninstall" "$(sql "SHOW VARIABLES LIKE 'mcp.%'")" "^$"
expect "performance_schema.mcp_tool_calls gone after uninstall" "$(sql "SELECT COUNT(*) FROM performance_schema.mcp_tool_calls")" "ERROR 1146"
expect "no enforcement after uninstall" "$(mcp "INSERT INTO d1.t VALUES (5); SELECT COUNT(*) FROM d1.t")" "^2$"

# --- lower_case_table_names = 1: allowed_schemas compares case-insensitively ---------------------
Expand Down
Loading
Loading