Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion docs/MCP_COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <token>` 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

Expand Down
6 changes: 5 additions & 1 deletion scripts/test-mcp-endpoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'")" "^$"
Expand Down
59 changes: 57 additions & 2 deletions src/mcp/endpoint/component_mcp.cc
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@
#include <openssl/evp.h>

#include <atomic>
#include <cctype>
#include <cstring>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <vector>

#include "http/base/request.h"
#include "http/base/request_handler.h"
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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");
Expand Down Expand Up @@ -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<char>(std::tolower(static_cast<unsigned char>(c)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match Go's Unicode case-folding behavior

When a result column name and mask pattern differ only by non-ASCII case (for example, ПАРОЛЬ and пароль), this byte-wise std::tolower does not case-fold the UTF-8 characters, so the value is returned unmasked. The Go implementation uses strings.ToLower on both sides and masks this case, so this breaks the stated parity and can expose a sensitive column; use Unicode-aware folding or narrow the parity claim. Go maskResults

Useful? React with 👍 / 👎.

return s;
}

std::vector<std::string> mask_patterns() {
std::vector<std::string> 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<unsigned char>(all[x]))) ++x;
while (y > x && std::isspace(static_cast<unsigned char>(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<std::string> &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<Mysql_handle *>(h);
return handle == nullptr || handle->mysql == nullptr ? nullptr
Expand Down Expand Up @@ -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<std::string> patterns = mask_patterns();
std::vector<bool> 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;
Expand All @@ -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);
}
Expand Down
Loading