From a166976f8f156ffad6556be57242df0d60cdc860 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Mon, 2 Nov 2026 10:08:48 +0000 Subject: [PATCH] feat(mcp): component_mcp column masking (mcp_http.mask_columns) Ports mysql-mcp-server's MYSQL_MCP_MASK_COLUMNS rules: a result column whose name contains one of the comma-separated patterns (ASCII case-insensitive, empty patterns ignored) has every non-NULL value replaced by "********". RFC 0001 listed masking as a parity gap and required the endpoint to disable run_query while it is set; with the port, the same setting gives the same output on both servers. test-mcp-endpoint.sh: masked, unmasked, NULL and binary values, with spaced and mixed-case patterns. 33 checks. Part of #256 (RFC 0001 Phase 5). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/MCP_COMPONENTS.md | 3 +- scripts/test-mcp-endpoint.sh | 6 +++- src/mcp/endpoint/component_mcp.cc | 59 +++++++++++++++++++++++++++++-- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7b149c..96a72f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`component_mcp` column masking** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `mcp_http.mask_columns` (new, read-only) applies mysql-mcp-server's `MYSQL_MCP_MASK_COLUMNS` rules: values of result columns whose names contain a pattern (case-insensitive) become `********`, NULL stays NULL. This closes the RFC's masking parity gap, so the endpoint does not need to disable `run_query` when masking is configured. `scripts/test-mcp-endpoint.sh` checks masked, unmasked, NULL and binary values. - **`component_mcp` (RFC 0001 Phase 4 spike, MySQL 26.10).** An MCP Streamable HTTP endpoint inside `mysqld` on MySQL Router's HTTP server (the libraries `component_mysql_rest_service` links), built in the server tree with `MYVECTOR_BUILD_MCP_ENDPOINT=ON ./scripts/build-component-26.10-docker.sh`. It is not for production. - **Endpoint:** loopback, plain HTTP, one bearer token (`mcp_http.token_sha256`) mapped to one account (`mcp_http.account`). - **Tools:** `list_databases` (tool pack) and `run_query` (one statement). diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 4b112f96..170b7a97 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -192,7 +192,7 @@ An MCP **Streamable HTTP** endpoint (JSON-RPC 2.0 over `POST /mcp`) inside `mysq - **Tools:** `list_databases` (through the `mysql_mcp` tool pack) and `run_query` (one statement). - **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`. - **Known spike limit:** the command service's default consumer collects every row while a statement runs, so a statement with a large explicit `LIMIT` is buffered in full in `mysqld` before the reply is cut. Bounding that needs a custom row consumer (`mysql_command_consumer` services), which is Phase 5 work. -- **Missing:** the `account_locked` check, a per-account session cap and column masking. All are in Phase 5. +- **Missing:** the `account_locked` check and a per-account session cap. Both are in Phase 5. ### Build and install @@ -225,6 +225,7 @@ INSTALL COMPONENT 'file://component_mcp' | `mcp_http.port` | TCP port on `127.0.0.1` (read-only, default 33480) | | `mcp_http.account` | The account (`user@host`) every request runs as (read-only) | | `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.mask_columns` | Comma-separated patterns (read-only, default none). A result column whose name contains one, ASCII case-insensitive, 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 | ### How a tool call runs diff --git a/scripts/test-mcp-endpoint.sh b/scripts/test-mcp-endpoint.sh index db9b1049..ff880688 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -70,7 +70,11 @@ expect "no MCP session left" "$(sql "SHOW GLOBAL STATUS LIKE 'mcp.sessions_conne # An account without MCP_USER is not treated as an MCP session (the token maps to app). sql "UNINSTALL COMPONENT 'file://component_mcp'" >/dev/null -expect "reinstall mapped to app" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'app@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX'")" "^$" +expect "reinstall mapped to app, with masked columns" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'app@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX', GLOBAL mcp_http.mask_columns = ' secret, PASS ,,'")" "^$" +# Column masking, as mysql-mcp-server's MYSQL_MCP_MASK_COLUMNS: by column name, substring, +# case-insensitive; NULL stays NULL. +expect "masked columns: values replaced, NULL kept, others untouched" "$(call run_query '{"sql":"SELECT \"a\" AS user_password, \"b\" AS SecretKey, NULL AS secret_null, \"c\" AS ok"}')" 'user_password\\":\\"\*\*\*\*\*\*\*\*\\",\\"SecretKey\\":\\"\*\*\*\*\*\*\*\*\\",\\"secret_null\\":null,\\"ok\\":\\"c\\"' +expect "masked columns: binary values are masked too" "$(call run_query '{"sql":"SELECT X'"'"'FF'"'"' AS pass_hash"}')" 'pass_hash\\":\\"\*\*\*\*\*\*\*\*\\"\}' expect "guard: an account without MCP_USER is not restricted" "$(call run_query '{"sql":"INSERT INTO d1.t VALUES (7)"}')" '"isError":false' expect "the guard cannot be uninstalled while the endpoint is installed" "$(sql "UNINSTALL COMPONENT 'file://component_mcp_guard'")" "ERROR" expect "uninstall component_mcp" "$(sql "UNINSTALL COMPONENT 'file://component_mcp'")" "^$" diff --git a/src/mcp/endpoint/component_mcp.cc b/src/mcp/endpoint/component_mcp.cc index 1854d2da..dcae712a 100644 --- a/src/mcp/endpoint/component_mcp.cc +++ b/src/mcp/endpoint/component_mcp.cc @@ -45,11 +45,13 @@ #include #include +#include #include #include #include #include #include +#include #include "http/base/request.h" #include "http/base/request_handler.h" @@ -90,7 +92,9 @@ namespace { unsigned long opt_port = 33480; char *opt_account = nullptr; // user@host the token maps to char *opt_token_sha256 = nullptr; // hex SHA-256 of the bearer token -bool port_registered = false, account_registered = false, token_registered = false; +char *opt_mask_columns = nullptr; // comma-separated column name patterns to mask +bool port_registered = false, account_registered = false, token_registered = false, + mask_registered = false; bool register_sysvars() { INTEGRAL_CHECK_ARG(ulong) port_arg; @@ -126,6 +130,17 @@ bool register_sysvars() { return true; token_registered = true; } + { + STR_CHECK_ARG(str) mask_arg; + mask_arg.def_val = nullptr; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp_http", "mask_columns", PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY, + "Comma-separated patterns; result columns whose names contain one (case-insensitive) " + "have their values replaced by ********", + nullptr, nullptr, &mask_arg, &opt_mask_columns)) + return true; + mask_registered = true; + } return false; } @@ -138,6 +153,7 @@ bool unregister_sysvars() { else registered = false; }; + drop(mask_registered, "mask_columns"); drop(token_registered, "token_sha256"); drop(account_registered, "account"); drop(port_registered, "port"); @@ -252,6 +268,40 @@ size_t serialized_size(const rapidjson::Value &v) { return b.GetSize(); } +// Column masking, as mysql-mcp-server's MYSQL_MCP_MASK_COLUMNS: a result column whose name +// contains one of the mcp_http.mask_columns patterns (ASCII case-insensitive; empty patterns +// ignored) has every non-NULL value replaced by "********". +constexpr char kMasked[] = "********"; + +std::string ascii_lower(std::string s) { + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +std::vector mask_patterns() { + std::vector out; + if (opt_mask_columns == nullptr) return out; + const std::string all = opt_mask_columns; + size_t b = 0; + while (b <= all.size()) { + size_t e = all.find(',', b); + if (e == std::string::npos) e = all.size(); + size_t x = b, y = e; + while (x < y && std::isspace(static_cast(all[x]))) ++x; + while (y > x && std::isspace(static_cast(all[y - 1]))) --y; + if (y > x) out.push_back(ascii_lower(all.substr(x, y - x))); + b = e + 1; + } + return out; +} + +bool masked_column(const std::string &name, const std::vector &patterns) { + const std::string lower = ascii_lower(name); + for (const auto &p : patterns) + if (lower.find(p) != std::string::npos) return true; + return false; +} + MYSQL_THD session_thd(MYSQL_H h) { auto *handle = reinterpret_cast(h); return handle == nullptr || handle->mysql == nullptr ? nullptr @@ -320,6 +370,9 @@ SqlResult run_as(const std::string &account, const std::string &sql) { &name); names[i] = name != nullptr ? name : "col" + std::to_string(i + 1); } + const std::vector patterns = mask_patterns(); + std::vector masked(n); + for (unsigned int i = 0; i < n; ++i) masked[i] = masked_column(names[i], patterns); auto &a = r.rows.GetAllocator(); MYSQL_ROW_H row = nullptr; ulong *lengths = nullptr; @@ -333,7 +386,9 @@ SqlResult run_as(const std::string &account, const std::string &sql) { rapidjson::Value obj(rapidjson::kObjectType); for (unsigned int i = 0; i < n; ++i) { rapidjson::Value k(names[i].c_str(), a), v; - if (row[i] != nullptr) + if (row[i] != nullptr && masked[i]) + v.SetString(kMasked, sizeof(kMasked) - 1); + else if (row[i] != nullptr) cell_value(row[i], lengths ? lengths[i] : std::strlen(row[i]), &v, a); obj.AddMember(k, v, a); }