diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f97c8..42cfa95 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` validates `Origin`** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). Against DNS rebinding, a request with an `Origin` header that `mcp_http.allowed_origins` (new, read-only, default none) does not list gets 403; requests without `Origin` are not affected. For a listed origin it handles CORS (preflight answered, replies carry `Access-Control-Allow-Origin`), so browser clients such as MCP Inspector connect directly. `scripts/test-mcp-endpoint.sh` checks refusal, allowed origins, case and prefix matching, and the CORS preflight and headers. - **`component_mcp` serves calls concurrently, with session limits** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `mcp_http.threads` (default 4) I/O workers each serve one call at a time; the spike had one. `mcp_http.max_sessions_per_account` (default 4) caps an account's concurrent endpoint sessions (error 1203), since `max_user_connections` does not apply to them, and the sessions are now subject to `max_connections` (error 1040) instead of bypassing it. `scripts/test-mcp-endpoint.sh` checks overlap of 4 calls, the cap and `max_connections`. - **`component_mcp` refuses locked accounts** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). Impersonated sessions skip the login checks, so the endpoint reads `account_locked` from the session's security context and fails the call with error 3118 before any statement runs. `scripts/test-mcp-endpoint.sh` checks lock, both tools and unlock. diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 09eb5fd..25f99dc 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -193,7 +193,6 @@ An MCP **Streamable HTTP** endpoint (JSON-RPC 2.0 over `POST /mcp`) inside `mysq - **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. `SELECT ... LIMIT 1000000` and 100 rows of 1 MB each leave `mysqld`'s peak RSS unchanged (without it: +144 MiB and +61 MiB). - **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. -- **Missing:** column masking (Phase 5). ### Build and install @@ -229,6 +228,7 @@ INSTALL COMPONENT 'file://component_mcp' | `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.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). 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 b5b73f7..5bc899e 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -157,7 +157,11 @@ expect "no MCP session left after concurrent calls" "$(sql "SHOW GLOBAL STATUS L # 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, with allowed origins" "$(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.allowed_origins = 'http://localhost:6274, https://Inspector.example'")" "^$" +expect "reinstall mapped to app, with allowed origins and 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.allowed_origins = 'http://localhost:6274, https://Inspector.example', 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 "an allowed Origin passes" "$(rpc_origin http://localhost:6274 '{"jsonrpc":"2.0","id":1,"method":"ping"}')" '"result":\{\}' expect "allowed origins match case-insensitively, spaces trimmed" "$(rpc_origin https://inspector.example '{"jsonrpc":"2.0","id":1,"method":"ping"}')" '"result":\{\}' # CORS for an allowed browser origin: the preflight is answered and replies name the origin. diff --git a/src/mcp/endpoint/component_mcp.cc b/src/mcp/endpoint/component_mcp.cc index 9b30377..27d9750 100644 --- a/src/mcp/endpoint/component_mcp.cc +++ b/src/mcp/endpoint/component_mcp.cc @@ -57,6 +57,7 @@ #include #include #include +#include #include "http/base/request.h" #include "http/base/request_handler.h" @@ -111,8 +112,10 @@ unsigned long opt_max_sessions_per_account = 4; // concurrent calls per account char *opt_account = nullptr; // user@host the token maps to char *opt_token_sha256 = nullptr; // hex SHA-256 of the bearer token char *opt_allowed_origins = nullptr; // comma-separated Origin values browsers may send +char *opt_mask_columns = nullptr; // comma-separated column name patterns to mask bool port_registered = false, threads_registered = false, max_sessions_registered = false, - account_registered = false, token_registered = false, origins_registered = false; + account_registered = false, token_registered = false, origins_registered = false, + mask_registered = false; bool register_sysvars() { INTEGRAL_CHECK_ARG(ulong) port_arg; @@ -187,6 +190,17 @@ bool register_sysvars() { return true; origins_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; } @@ -199,6 +213,7 @@ bool unregister_sysvars() { else registered = false; }; + drop(mask_registered, "mask_columns"); drop(origins_registered, "allowed_origins"); drop(token_registered, "token_sha256"); drop(account_registered, "account"); @@ -492,6 +507,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 @@ -637,6 +686,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; @@ -650,7 +702,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); }