diff --git a/CHANGELOG.md b/CHANGELOG.md index e404d56..cf9f45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 9aa6f80..93bf26f 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -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. @@ -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= 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: @@ -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 ` 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 ` 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. diff --git a/scripts/test-mcp-guard.sh b/scripts/test-mcp-guard.sh index c439bdb..36ba33e 100755 --- a/scripts/test-mcp-guard.sh +++ b/scripts/test-mcp-guard.sh @@ -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]" @@ -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 --------------------- diff --git a/src/mcp/guard/component_mcp_guard.cc b/src/mcp/guard/component_mcp_guard.cc index 0806de4..6106065 100644 --- a/src/mcp/guard/component_mcp_guard.cc +++ b/src/mcp/guard/component_mcp_guard.cc @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +52,7 @@ #include #include +#include #include #include #include @@ -75,6 +78,12 @@ REQUIRES_SERVICE_PLACEHOLDER(mysql_string_factory); REQUIRES_SERVICE_PLACEHOLDER(mysql_string_converter); REQUIRES_SERVICE_PLACEHOLDER(log_builtins); REQUIRES_SERVICE_PLACEHOLDER(log_builtins_string); +REQUIRES_SERVICE_PLACEHOLDER(mysql_security_context_options); +REQUIRES_SERVICE_PLACEHOLDER(mysql_audit_api_message); +REQUIRES_SERVICE_PLACEHOLDER(pfs_plugin_table_v1); +REQUIRES_SERVICE_PLACEHOLDER(pfs_plugin_column_string_v2); +REQUIRES_SERVICE_PLACEHOLDER(pfs_plugin_column_bigint_v1); +REQUIRES_SERVICE_PLACEHOLDER(pfs_plugin_column_timestamp_v2); // LogEvent() uses these. SERVICE_TYPE(log_builtins) *log_bi = nullptr; @@ -155,6 +164,7 @@ bool opt_read_only = true; bool opt_deny_system_schemas = true; bool opt_log_denials = true; unsigned long opt_max_execution_time_ms = 0; // 0: no floor +unsigned long opt_tool_calls_history_size = 1000; // rows of performance_schema.mcp_tool_calls char *opt_allowed_schemas = nullptr; // server-managed (PLUGIN_VAR_MEMALLOC) // Parsed mcp.allowed_schemas, swapped as a whole on update; readers copy the pointer. @@ -206,6 +216,7 @@ const BoolVar kBoolVars[kBoolVarCount] = { bool bool_registered[kBoolVarCount] = {}; bool allowed_registered = false; bool time_registered = false; +bool history_registered = false; // Returns true if a variable could not be unregistered; it then stays marked as // registered and deinit fails, so the server keeps this library (and the update callback @@ -234,6 +245,13 @@ bool unregister_sysvars() { else time_registered = false; } + if (history_registered) { + if (mysql_service_component_sys_variable_unregister->unregister_variable( + "mcp", "tool_calls_history_size")) + failed = true; + else + history_registered = false; + } return failed; } @@ -288,6 +306,20 @@ bool register_sysvars() { nullptr, nullptr, &time_arg, &opt_max_execution_time_ms)) return true; time_registered = true; + { + INTEGRAL_CHECK_ARG(ulong) history_arg; + history_arg.def_val = 1000; + history_arg.min_val = 0; + history_arg.max_val = 100000; + history_arg.blk_sz = 0; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp", "tool_calls_history_size", + PLUGIN_VAR_LONG | PLUGIN_VAR_UNSIGNED | PLUGIN_VAR_RQCMDARG | PLUGIN_VAR_READONLY, + "Tool calls kept in performance_schema.mcp_tool_calls (read-only; 0: none)", nullptr, + nullptr, &history_arg, &opt_tool_calls_history_size)) + return true; + history_registered = true; + } // A persisted or command-line value is stored without the update callback: parse it. set_allowed_schemas(opt_allowed_schemas); return false; @@ -322,6 +354,11 @@ constexpr unsigned long kTimeLimitNotApplied = ~0UL; struct SessionState { unsigned long applied_time_limit = kTimeLimitNotApplied; // last mcp.max_execution_time_ms set + std::string account; // priv_user@priv_host + unsigned long long call_seq = 0; // performance_schema.mcp_tool_calls row of the current call + std::string call_tool, call_req; // its tool and request id + bool in_call_statement = false; // a labeled top-level statement is running + std::chrono::steady_clock::time_point statement_start; }; mysql_thd_store_slot session_slot = nullptr; @@ -337,7 +374,6 @@ SessionState *session_state(MYSQL_THD thd) { return static_cast(mysql_service_mysql_thd_store->get(thd, session_slot)); } -bool is_mcp_session(MYSQL_THD thd) { return session_state(thd) != nullptr; } // Returns true on failure. An MCP session that cannot be marked would not be guarded, so // the caller refuses it. @@ -360,6 +396,280 @@ bool mark_session(MYSQL_THD thd, bool mcp) { return false; } +// ----- performance_schema.mcp_tool_calls ---------------------------------------------- + +// One tool call of mysql-mcp-server: the labeled statements (/* mcp:tool=... req=... */) +// with one request id on one connection. +struct CallRecord { + unsigned long long seq = 0; // 0: empty slot + unsigned long long connection_id = 0; + std::string account, tool, request_id; + unsigned long long statements = 0, errors = 0, denied = 0, latency_us = 0; + unsigned long long first_seen_us = 0, last_seen_us = 0; // microseconds since the epoch +}; + +unsigned long long now_us() { + return static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); +} + +// A fixed-size ring of the most recent tool calls (mcp.tool_calls_history_size). +class CallLog { + public: + void resize(size_t n) { + std::lock_guard lock(mutex_); + buf_.assign(n, CallRecord()); + } + + unsigned long long start(unsigned long long connection_id, const std::string &account, + const std::string &tool, const std::string &request_id) { + std::lock_guard lock(mutex_); + if (buf_.empty()) return 0; + const unsigned long long seq = next_seq_++; + CallRecord &r = buf_[(seq - 1) % buf_.size()]; + r = CallRecord(); + r.seq = seq; + r.connection_id = connection_id; + r.account = account; + r.tool = tool; + r.request_id = request_id; + r.first_seen_us = r.last_seen_us = now_us(); + return seq; + } + + // Applies f to the record of seq, if it is still in the ring. + template + void update(unsigned long long seq, F f) { + if (seq == 0) return; + std::lock_guard lock(mutex_); + if (buf_.empty()) return; + CallRecord &r = buf_[(seq - 1) % buf_.size()]; + if (r.seq == seq) f(r); + } + + std::vector snapshot() { + std::vector out; + { + std::lock_guard lock(mutex_); + for (const CallRecord &r : buf_) + if (r.seq != 0) out.push_back(r); + } + std::sort(out.begin(), out.end(), + [](const CallRecord &a, const CallRecord &b) { return a.seq < b.seq; }); + return out; + } + + unsigned long long size() { + std::lock_guard lock(mutex_); + unsigned long long n = 0; + for (const CallRecord &r : buf_) n += r.seq != 0; + return n; + } + + private: + std::mutex mutex_; + std::vector buf_; + unsigned long long next_seq_ = 1; +}; + +CallLog call_log; + +// The table handle holds a snapshot taken when the table is opened. pos is the current +// row and is also the position the server saves (m_ref_length bytes, exposed by +// open_table) and restores before rnd_pos; next_pos is where rnd_next continues. +struct CallTableHandle { + std::vector rows = call_log.snapshot(); + size_t pos = 0; + size_t next_pos = 0; +}; + +unsigned long long call_table_row_count() { return call_log.size(); } + +int call_table_rnd_init(PSI_table_handle *h, bool) { + auto *t = reinterpret_cast(h); + t->pos = t->next_pos = 0; + return 0; +} + +int call_table_rnd_next(PSI_table_handle *h) { + auto *t = reinterpret_cast(h); + t->pos = t->next_pos; + if (t->pos >= t->rows.size()) return PFS_HA_ERR_END_OF_FILE; + t->next_pos = t->pos + 1; + return 0; +} + +// The server has written a saved position into pos. +int call_table_rnd_pos(PSI_table_handle *h) { + auto *t = reinterpret_cast(h); + return t->pos < t->rows.size() ? 0 : PFS_HA_ERR_END_OF_FILE; +} + +void call_table_reset_position(PSI_table_handle *h) { + auto *t = reinterpret_cast(h); + t->pos = t->next_pos = 0; +} + +int call_table_read_column(PSI_table_handle *h, PSI_field *f, unsigned int index) { + auto *t = reinterpret_cast(h); + if (t->pos >= t->rows.size()) return PFS_HA_ERR_END_OF_FILE; + const CallRecord &r = t->rows[t->pos]; + auto *str = mysql_service_pfs_plugin_column_string_v2; + auto *big = mysql_service_pfs_plugin_column_bigint_v1; + auto *ts = mysql_service_pfs_plugin_column_timestamp_v2; + switch (index) { + case 0: big->set_unsigned(f, {r.connection_id, false}); break; + case 1: str->set_varchar_utf8mb4(f, r.account.c_str()); break; + case 2: str->set_varchar_utf8mb4(f, r.tool.c_str()); break; + case 3: str->set_varchar_utf8mb4(f, r.request_id.c_str()); break; + case 4: big->set_unsigned(f, {r.statements, false}); break; + case 5: big->set_unsigned(f, {r.errors, false}); break; + case 6: big->set_unsigned(f, {r.denied, false}); break; + case 7: big->set_unsigned(f, {r.latency_us, false}); break; + case 8: ts->set2(f, r.first_seen_us); break; + case 9: ts->set2(f, r.last_seen_us); break; + default: break; + } + return 0; +} + +PSI_table_handle *call_table_open(PSI_pos **pos) { + auto *t = new (std::nothrow) CallTableHandle(); + if (t != nullptr) *pos = reinterpret_cast(&t->pos); + return reinterpret_cast(t); +} + +void call_table_close(PSI_table_handle *h) { delete reinterpret_cast(h); } + +PFS_engine_table_share_proxy call_table_share; +PFS_engine_table_share_proxy *call_table_shares[1] = {&call_table_share}; +bool call_table_registered = false; + +bool register_call_table() { + call_table_share.m_table_name = "mcp_tool_calls"; + call_table_share.m_table_name_length = sizeof("mcp_tool_calls") - 1; + call_table_share.m_table_definition = + "CONNECTION_ID BIGINT UNSIGNED NOT NULL, ACCOUNT VARCHAR(288) NOT NULL, " + "TOOL VARCHAR(64) NOT NULL, REQUEST_ID VARCHAR(64) NOT NULL, " + "STATEMENTS BIGINT UNSIGNED NOT NULL, ERRORS BIGINT UNSIGNED NOT NULL, " + "DENIED BIGINT UNSIGNED NOT NULL, LATENCY_US BIGINT UNSIGNED NOT NULL, " + "FIRST_SEEN TIMESTAMP(6) NOT NULL, LAST_SEEN TIMESTAMP(6) NOT NULL"; + call_table_share.m_ref_length = sizeof(size_t); + call_table_share.m_acl = READONLY; + call_table_share.get_row_count = call_table_row_count; + call_table_share.delete_all_rows = nullptr; + call_table_share.m_proxy_engine_table = {.rnd_next = call_table_rnd_next, + .rnd_init = call_table_rnd_init, + .rnd_pos = call_table_rnd_pos, + .index_init = nullptr, + .index_read = nullptr, + .index_next = nullptr, + .read_column_value = call_table_read_column, + .reset_position = call_table_reset_position, + .write_column_value = nullptr, + .write_row_values = nullptr, + .update_column_value = nullptr, + .update_row_values = nullptr, + .delete_row_values = nullptr, + .open_table = call_table_open, + .close_table = call_table_close}; + if (mysql_service_pfs_plugin_table_v1->add_tables(call_table_shares, 1)) return true; + call_table_registered = true; + return false; +} + +// Returns true if the table could not be removed (the library must then stay loaded). +bool unregister_call_table() { + if (!call_table_registered) return false; + if (mysql_service_pfs_plugin_table_v1->delete_tables(call_table_shares, 1)) return true; + call_table_registered = false; + return false; +} + +// Records the start of a top-level statement of an MCP session: a labeled statement +// opens a new call row (or continues the current one, same request id). +void track_statement_start(SessionState *state, unsigned long long connection_id, + const char *query, size_t len) { + std::string tool, req; + if (!mcp_guard::parse_call_label(query, len, &tool, &req)) { + state->in_call_statement = false; + return; + } + if (req != state->call_req || state->call_seq == 0) { + state->call_seq = call_log.start(connection_id, state->account, tool, req); + state->call_tool = tool; + state->call_req = req; + } + state->in_call_statement = true; + state->statement_start = std::chrono::steady_clock::now(); + const unsigned long long now = now_us(); + call_log.update(state->call_seq, [&](CallRecord &r) { + ++r.statements; + r.last_seen_us = now; + }); +} + +void track_statement_end(SessionState *state, int status) { + if (!state->in_call_statement) return; + state->in_call_statement = false; + const auto took = std::chrono::steady_clock::now() - state->statement_start; + const unsigned long long us = static_cast( + std::chrono::duration_cast(took).count()); + const unsigned long long now = now_us(); + call_log.update(state->call_seq, [&](CallRecord &r) { + r.latency_us += us; + if (status != 0) ++r.errors; + r.last_seen_us = now; + }); +} + +// The account of the session on thd, as priv_user@priv_host. +std::string session_account(MYSQL_THD thd) { + Security_context_handle ctx = nullptr; + if (mysql_service_mysql_thd_security_context->get(thd, &ctx) || ctx == nullptr) return ""; + MYSQL_LEX_CSTRING user{nullptr, 0}, host{nullptr, 0}; + mysql_service_mysql_security_context_options->get(ctx, "priv_user", &user); + mysql_service_mysql_security_context_options->get(ctx, "priv_host", &host); + std::string out(user.str ? user.str : "", user.str ? user.length : 0); + out += "@"; + out.append(host.str ? host.str : "", host.str ? host.length : 0); + return out; +} + +// Emits a refusal as an audit message (MYSQL_AUDIT_MESSAGE_INTERNAL, component +// "mcp_guard", producer "component_mcp_guard"), for audit log consumers. +void audit_denial(const char *option, unsigned long long connection_id, const SessionState *state, + const std::string &what) { + auto str = [](const char *k, const std::string &v) { + mysql_event_message_key_value_t kv; + kv.key = {k, std::strlen(k)}; + kv.value_type = MYSQL_AUDIT_MESSAGE_VALUE_TYPE_STR; + kv.value.str = {v.c_str(), v.size()}; + return kv; + }; + const std::string opt = option; + const bool call = state != nullptr && state->in_call_statement; + mysql_event_message_key_value_t kvs[5]; + size_t n = 0; + kvs[n++] = str("option", opt); + kvs[n].key = {"connection_id", sizeof("connection_id") - 1}; + kvs[n].value_type = MYSQL_AUDIT_MESSAGE_VALUE_TYPE_NUM; + kvs[n++].value.num = static_cast(connection_id); + kvs[n++] = str("statement", what); + if (call) { + kvs[n++] = str("tool", state->call_tool); + kvs[n++] = str("request_id", state->call_req); + } + static const char kComponent[] = "mcp_guard"; + static const char kProducer[] = "component_mcp_guard"; + static const char kMessage[] = "refused"; + mysql_service_mysql_audit_api_message->emit(MYSQL_AUDIT_MESSAGE_INTERNAL, kComponent, + sizeof(kComponent) - 1, kProducer, + sizeof(kProducer) - 1, kMessage, + sizeof(kMessage) - 1, kvs, n); +} + // Writes a refusal to the error log, at most kLogBurst lines per minute; the number left // out is reported with the next line after the minute. constexpr int kLogBurst = 10; @@ -406,9 +716,12 @@ void log_denial(const char *option, unsigned long long connection_id, std::strin } bool deny(const char *option, std::atomic &counter, unsigned long long connection_id, - std::string_view what) { + std::string_view what, SessionState *state = nullptr) { ++counter; log_denial(option, connection_id, what); + audit_denial(option, connection_id, state, mcp_guard::redact_for_log(what.data(), what.size())); + if (state != nullptr && state->in_call_statement) + call_log.update(state->call_seq, [](CallRecord &r) { ++r.denied; }); mysql_error_service_emit_printf(mysql_service_mysql_runtime_error, kErOptionPreventsStatement, 0, option); return true; @@ -460,8 +773,12 @@ bool Event_tracking_connection_implementation::callback( if (mcp) { SessionState *state = session_state(thd); // COM_CHANGE_USER resets session variables: apply the limit again. - if (state != nullptr && data->event_subclass == EVENT_TRACKING_CONNECTION_CHANGE_USER) + if (state != nullptr && data->event_subclass == EVENT_TRACKING_CONNECTION_CHANGE_USER) { state->applied_time_limit = kTimeLimitNotApplied; + state->call_seq = 0; // a new account starts new tool calls + state->call_req.clear(); + } + if (state != nullptr) state->account = session_account(thd); apply_time_limit(thd, state); } return false; @@ -518,27 +835,38 @@ bool Event_tracking_global_variable_implementation::callback( // Query events: statements, including those nested in stored programs and those run by // EXECUTE, which raise their own event with their own sql_command. mysql_event_tracking_query_subclass_t Event_tracking_query_implementation::filtered_sub_events = - EVENT_TRACKING_QUERY_STATUS_END | EVENT_TRACKING_QUERY_NESTED_STATUS_END; + EVENT_TRACKING_QUERY_NESTED_STATUS_END; bool Event_tracking_query_implementation::callback(const mysql_event_tracking_query_data *data) { MYSQL_THD thd = current_thd_or_null(); SessionState *state = session_state(thd); if (state == nullptr) return false; - if (data->event_subclass == EVENT_TRACKING_QUERY_START) { - ++statements_total; - apply_time_limit(thd, state); // picks up a changed mcp.max_execution_time_ms + if (data->event_subclass == EVENT_TRACKING_QUERY_STATUS_END) { + track_statement_end(state, data->status); // performance_schema.mcp_tool_calls + return false; } const char *cmd = data->sql_command ? data->sql_command : ""; const char *query = data->query.str ? data->query.str : ""; const size_t len = data->query.str ? data->query.length : 0; + if (data->event_subclass == EVENT_TRACKING_QUERY_START) { + ++statements_total; + apply_time_limit(thd, state); // picks up a changed mcp.max_execution_time_ms + track_statement_start(state, data->connection_id, query, len); + } const std::string_view text(query, len); - if (!opt_enabled) return deny("mcp.enabled", statements_denied, data->connection_id, text); + // A statement refused at its start raises no STATUS_END event: end it here. + const auto refuse = [&](const char *option) { + const bool r = deny(option, statements_denied, data->connection_id, text, state); + if (data->event_subclass == EVENT_TRACKING_QUERY_START) track_statement_end(state, 1); + return r; + }; + if (!opt_enabled) return refuse("mcp.enabled"); if (opt_read_only) { const char *why = mcp_guard::read_only_violation(cmd, query, len); - if (why != nullptr) return deny(why, statements_denied, data->connection_id, text); + if (why != nullptr) return refuse(why); } const char *why = mcp_guard::time_limit_violation(cmd, query, len, opt_max_execution_time_ms); - if (why != nullptr) return deny(why, statements_denied, data->connection_id, text); + if (why != nullptr) return refuse(why); return false; } @@ -550,7 +878,8 @@ mysql_event_tracking_table_access_subclass_t bool Event_tracking_table_access_implementation::callback( const mysql_event_tracking_table_access_data *data) { MYSQL_THD thd = current_thd_or_null(); - if (!is_mcp_session(thd)) return false; + SessionState *state = session_state(thd); + if (state == nullptr) return false; const std::string_view db(data->table_database.str ? data->table_database.str : "", data->table_database.str ? data->table_database.length : 0); const std::string_view table(data->table_name.str ? data->table_name.str : "", @@ -560,7 +889,7 @@ bool Event_tracking_table_access_implementation::callback( const auto refuse = [&](const char *option) { const std::string what = std::string(write ? "write " : "read ") + std::string(db) + "." + std::string(table); - return deny(option, table_access_denied, data->connection_id, what); + return deny(option, table_access_denied, data->connection_id, what, state); }; if (!opt_enabled) return refuse("mcp.enabled"); const auto allowed = current_allowed_schemas(); @@ -586,6 +915,8 @@ mysql_service_status_t mcp_guard_deinit() { // callback is still referenced), so keep everything else, including the session marker // the event callbacks enforce with, in place and fail. if (unregister_sysvars()) return 1; + if (unregister_call_table()) return 1; // its callbacks are in this library + call_log.resize(0); bool failed = false; if (status_registered) { if (mysql_service_status_variable_registration->unregister_variable(status_vars)) @@ -623,6 +954,11 @@ mysql_service_status_t mcp_guard_init() { return 1; } status_registered = true; + call_log.resize(opt_tool_calls_history_size); + if (register_call_table()) { + mcp_guard_deinit(); + return 1; + } read_lower_case_table_names(); return 0; } @@ -651,6 +987,10 @@ REQUIRES_SERVICE(dynamic_privilege_register), REQUIRES_SERVICE(global_grants_che REQUIRES_SERVICE(mysql_system_variable_update_integer), REQUIRES_SERVICE(mysql_string_factory), REQUIRES_SERVICE(mysql_string_converter), REQUIRES_SERVICE(log_builtins), REQUIRES_SERVICE(log_builtins_string), + REQUIRES_SERVICE(mysql_security_context_options), REQUIRES_SERVICE(mysql_audit_api_message), + REQUIRES_SERVICE(pfs_plugin_table_v1), REQUIRES_SERVICE(pfs_plugin_column_string_v2), + REQUIRES_SERVICE(pfs_plugin_column_bigint_v1), + REQUIRES_SERVICE(pfs_plugin_column_timestamp_v2), END_COMPONENT_REQUIRES(); BEGIN_COMPONENT_METADATA(mcp_guard) diff --git a/src/mcp/guard/mcp_guard_policy.h b/src/mcp/guard/mcp_guard_policy.h index 9e6b05b..e3f825a 100644 --- a/src/mcp/guard/mcp_guard_policy.h +++ b/src/mcp/guard/mcp_guard_policy.h @@ -344,6 +344,30 @@ inline std::string redact_for_log(const char *s, size_t n, size_t max_len = 256) return out + "'?'..."; } +// ----- Tool call labels ------------------------------------------------------------------- + +// mysql-mcp-server starts each statement of a tool call with +// /* mcp:tool= req= */ ... +// (values [A-Za-z0-9_.-], at most 64 bytes). Returns false when the text has no label. +inline bool parse_call_label(const char *s, size_t n, std::string *tool, std::string *req) { + static constexpr std::string_view kStart = "/* mcp:tool="; + if (n < kStart.size() || std::string_view(s, kStart.size()) != kStart) return false; + const auto value_at = [&](size_t pos, std::string *out) -> size_t { + size_t e = pos; + while (e < n && e - pos < 64 && + (std::isalnum(static_cast(s[e])) || s[e] == '_' || s[e] == '-' || + s[e] == '.')) + ++e; + out->assign(s + pos, e - pos); + return e; + }; + size_t i = value_at(kStart.size(), tool); + if (tool->empty() || i + 5 > n || std::string_view(s + i, 5) != " req=") return false; + i = value_at(i + 5, req); + if (req->empty() || i + 3 > n || std::string_view(s + i, 3) != " */") return false; + return true; +} + // Parses mcp.allowed_schemas: comma-separated schema names, spaces trimmed, empty // entries dropped. An empty list means every schema. inline std::vector parse_schema_list(std::string_view v) { diff --git a/src/mcp/guard/test_mcp_guard_policy.cc b/src/mcp/guard/test_mcp_guard_policy.cc index ff2f16c..70d498a 100644 --- a/src/mcp/guard/test_mcp_guard_policy.cc +++ b/src/mcp/guard/test_mcp_guard_policy.cc @@ -166,6 +166,25 @@ int main() { } } + // Tool call labels. + { + std::string tool, req; + const char *ok_label = "/* mcp:tool=list_tables req=3f9a2c11-17 */ SELECT 1"; + if (!parse_call_label(ok_label, std::strlen(ok_label), &tool, &req) || tool != "list_tables" || + req != "3f9a2c11-17") { + std::printf("FAIL parse_call_label: %s %s\n", tool.c_str(), req.c_str()); + ++failures; + } + for (const char *bad : {"SELECT 1", "/* mcp:tool= req=1 */ x", "/* mcp:tool=a req= */ x", + "/* mcp:tool=a */ x", " /* mcp:tool=a req=1 */ x", + "/* mcp:tool=a req=1 x"}) { + if (parse_call_label(bad, std::strlen(bad), &tool, &req)) { + std::printf("FAIL parse_call_label accepted: %s\n", bad); + ++failures; + } + } + } + // Tokenizer details. { const char *q = "SELECT 'it''s' , \"a\\\"b\" FROM `we``ird`";