diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c5c50..ba4c976 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` 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. - **`component_mcp` bounded row consumer** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `run_query` sessions use the component's own row and value consumer services, which stop retaining rows past 1001 rows or 4 MiB of values while a statement runs, so a large explicit `LIMIT` or wide rows no longer buffer the whole result in `mysqld`. The budget counts values plus the per-row and per-cell overhead of the default consumer, NULL cells included. `scripts/test-mcp-endpoint.sh` now has 41 checks, including peak RSS for `LIMIT 1000000` and for 1 MB rows, 8000 NULL columns, DOUBLE widths, and errors raised after some rows. - **`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. diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index f0c3c05..09a4688 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -193,7 +193,7 @@ 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:** a per-account session cap and column masking. Both are in Phase 5. +- **Missing:** column masking (Phase 5). ### Build and install @@ -224,6 +224,8 @@ INSTALL COMPONENT 'file://component_mcp' | Variable | Meaning | |---|---| | `mcp_http.port` | TCP port on `127.0.0.1` (read-only, default 33480) | +| `mcp_http.threads` | I/O worker threads (read-only, default 4, 1 to 64). Each serves one call at a time, so this many calls run at once; connections are assigned to the workers in turn | +| `mcp_http.max_sessions_per_account` | Concurrent endpoint sessions per account (read-only, default 4, 1 to 1024). A call over the cap fails with error 1203. The server's `max_user_connections` does not apply to these sessions | | `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 | @@ -241,7 +243,7 @@ So an account with `MCP_USER` is read-only through the endpoint, and its schema Facts recorded for Phase 5: - **Locked accounts:** impersonation skips the login checks, so a locked account (`ACCOUNT LOCK`) can be impersonated. The endpoint checks `account_locked` itself (done in Phase 5, above). -- **Connection limits:** `max_user_connections` is not enforced for these sessions, so the endpoint needs its own per-account cap. They do count against `max_connections`. +- **Connection limits:** `max_user_connections` is not enforced for these sessions, so the endpoint has its own per-account cap (`mcp_http.max_sessions_per_account`, Phase 5). They count against `max_connections`, and by default the command service lets them exceed it; the endpoint turns that off (`MYSQL_COMMAND_BYPASS_MAX_CONNECTIONS`, 26.10), so a call fails with error 1040 when the server is full. - **Auth backends:** Router's `http_auth_realm` / `http_auth_backend` support Basic auth only, so the endpoint checks bearer tokens itself (SHA-256, constant-time compare). ### Spike measurements (MySQL 26.10.0, arm64, 16 cores) @@ -250,7 +252,7 @@ Facts recorded for Phase 5: |---|---|---| | Build of the one target, fresh tree (after a 36 s configure) | 95 s | 265 s | | `.so` size | 3.1 MB | 21 MB | -| Threads added to `mysqld` (one I/O worker) | 3, released on uninstall | — | +| Threads added to `mysqld` (one I/O worker, the spike) | 3, released on uninstall | — | ### Tests diff --git a/scripts/test-mcp-endpoint.sh b/scripts/test-mcp-endpoint.sh index 8c7a739..bd23225 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -25,6 +25,15 @@ sql() { docker exec -i "$C" mysql -h127.0.0.1 -uroot -ptestpass -N -e "$1" 2>&1 rpc() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -w '\n%{http_code}' -X POST \ http://127.0.0.1:33480/mcp -H 'Content-Type: application/json' ${1:+-H "Authorization: Bearer $1"} -d "$2"; } call() { rpc tok-123 "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}"; } +# par N SQL: N concurrent run_query calls from one sidecar; prints the replies (they can +# interleave, so count matches with grep -o), then the wall time in seconds as "elapsed=S". +par() { docker run --rm --network "container:$C" --entrypoint sh "$CURL_IMAGE" -c ' + n=$0; body=$1; start=$(date +%s); i=0 + while [ "$i" -lt "$n" ]; do + curl -s -m 30 -w "\n" -X POST http://127.0.0.1:33480/mcp -H "Content-Type: application/json" \ + -H "Authorization: Bearer tok-123" -d "$body" & i=$((i + 1)) + done; wait; echo "elapsed=$(( $(date +%s) - start ))"' "$1" \ + "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"run_query\",\"arguments\":{\"sql\":\"$2\"}}}"; } alive() { docker ps -q -f "name=$C" | grep -q . && echo up || echo DOWN; } echo "== $IMAGE" @@ -80,7 +89,9 @@ expect "a NULL-only result is not cut" "$(call run_query '{"sql":"SELECT NULL AS # the large LIMIT may then raise the peak only by what the consumer retains past it: under 16 MiB # (measured +3 MiB; without the NULL accounting, +37 MiB). nulls=$(seq -s, -f 'NULL AS n%g' 1 8000) -call run_query "{\"sql\":\"SELECT $nulls FROM d1.big LIMIT 60\"}" >/dev/null +# Connections go to the I/O workers in turn and each thread has its own malloc arena, so the +# warm-up runs once per worker (mcp_http.threads, default 4). +for _ in 1 2 3 4; do call run_query "{\"sql\":\"SELECT $nulls FROM d1.big LIMIT 60\"}" >/dev/null; done hwm0=$(hwm_kb) null_reply=$(call run_query "{\"sql\":\"SELECT $nulls FROM d1.big a, d1.big b LIMIT 5000\"}") expect "8000 NULL columns: the reply is bounded and truncated" "$(printf '%s' "$null_reply" | grep -o 'n8000\\":null' | head -1) $(printf '%s' "$null_reply" | grep -o 'truncated\\":true' | head -1)" 'n8000\\":null truncated' @@ -111,6 +122,29 @@ sql "ALTER USER mcp@'%' ACCOUNT UNLOCK" expect "the account works again once unlocked" "$(call run_query '{"sql":"SELECT 1 AS one"}')" 'one\\":\\"1\\".*"isError":false' expect "no MCP session left" "$(sql "SHOW GLOBAL STATUS LIKE 'mcp.sessions_connected'")" " 0$" +# Concurrency: mcp_http.threads (default 4) I/O workers serve calls at once. +expect "threads and session cap defaults" "$(sql "SELECT @@mcp_http.threads, @@mcp_http.max_sessions_per_account")" "^4 4$" +four=$(par 4 "SELECT SLEEP(2) AS s") +expect "4 concurrent calls all succeed" "$(printf '%s\n' "$four" | grep -o '"isError":false' | wc -l | tr -d ' ')" "^4$" +expect "4 concurrent calls overlap (4 x 2 s in under 4 s)" "$(printf '%s\n' "$four" | grep elapsed=)" "^elapsed=[0-3]$" +# max_user_connections does not apply to these sessions; mcp_http.max_sessions_per_account does. +sql "UNINSTALL COMPONENT 'file://component_mcp'" >/dev/null +expect "reinstall with a session cap of 2" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.max_sessions_per_account = 2, GLOBAL mcp_http.account = 'mcp@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX'")" "^$" +capped=$(par 4 "SELECT SLEEP(2) AS s") +expect "cap 2: two of four concurrent calls succeed" "$(printf '%s\n' "$capped" | grep -o '"isError":false' | wc -l | tr -d ' ')" "^2$" +expect "cap 2: the others are refused (1203)" "$(printf '%s\n' "$capped" | grep -o "Error 1203: User mcp already has 2 active endpoint sessions (mcp_http.max_sessions_per_account)" | wc -l | tr -d ' ')" "^2$" +expect "cap 2: a session slot is free again afterwards" "$(call run_query '{"sql":"SELECT 1 AS one"}')" '"isError":false' +# The sessions count against max_connections and, unlike the command service's default, are +# subject to it: one idle client fills max_connections = 1. +sql "SET GLOBAL max_connections = 1" +sql "SELECT SLEEP(4)" >/dev/null & +sleep 1.5 +expect "max_connections applies to endpoint sessions" "$(call run_query '{"sql":"SELECT 1 AS one"}')" 'Error 1040: Too many connections.*"isError":true' +wait +sql "SET GLOBAL max_connections = 151" +expect "a call works again below max_connections" "$(call run_query '{"sql":"SELECT 1 AS one"}')" '"isError":false' +expect "no MCP session left after concurrent calls" "$(sql "SHOW GLOBAL STATUS LIKE 'mcp.sessions_connected'")" " 0$" + # 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'")" "^$" diff --git a/src/mcp/endpoint/component_mcp.cc b/src/mcp/endpoint/component_mcp.cc index 6a2f723..7d00cc3 100644 --- a/src/mcp/endpoint/component_mcp.cc +++ b/src/mcp/endpoint/component_mcp.cc @@ -49,7 +49,9 @@ #include #include +#include #include +#include #include #include #include @@ -102,9 +104,12 @@ namespace { // ----- settings ------------------------------------------------------------------------- unsigned long opt_port = 33480; +unsigned long opt_threads = 4; // I/O workers: calls served at once +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 -bool port_registered = false, account_registered = false, token_registered = false; +bool port_registered = false, threads_registered = false, max_sessions_registered = false, + account_registered = false, token_registered = false; bool register_sysvars() { INTEGRAL_CHECK_ARG(ulong) port_arg; @@ -117,6 +122,33 @@ bool register_sysvars() { "TCP port of the MCP endpoint (loopback only)", nullptr, nullptr, &port_arg, &opt_port)) return true; port_registered = true; + { + INTEGRAL_CHECK_ARG(ulong) threads_arg; + threads_arg.def_val = 4; + threads_arg.min_val = 1; + threads_arg.max_val = 64; + threads_arg.blk_sz = 0; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp_http", "threads", PLUGIN_VAR_LONG | PLUGIN_VAR_UNSIGNED | PLUGIN_VAR_READONLY, + "I/O worker threads; each serves one call at a time", nullptr, nullptr, &threads_arg, + &opt_threads)) + return true; + threads_registered = true; + } + { + INTEGRAL_CHECK_ARG(ulong) max_sessions_arg; + max_sessions_arg.def_val = 4; + max_sessions_arg.min_val = 1; + max_sessions_arg.max_val = 1024; + max_sessions_arg.blk_sz = 0; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp_http", "max_sessions_per_account", + PLUGIN_VAR_LONG | PLUGIN_VAR_UNSIGNED | PLUGIN_VAR_READONLY, + "Concurrent endpoint sessions per account (max_user_connections does not apply)", + nullptr, nullptr, &max_sessions_arg, &opt_max_sessions_per_account)) + return true; + max_sessions_registered = true; + } { STR_CHECK_ARG(str) account_arg; account_arg.def_val = nullptr; @@ -154,6 +186,8 @@ bool unregister_sysvars() { }; drop(token_registered, "token_sha256"); drop(account_registered, "account"); + drop(max_sessions_registered, "max_sessions_per_account"); + drop(threads_registered, "threads"); drop(port_registered, "port"); return failed; } @@ -435,6 +469,34 @@ void read_error(MYSQL_H h, SqlResult *r) { r->error = msg != nullptr ? msg : ""; } +// Per-account count of open endpoint sessions. The server does not apply max_user_connections +// to command-service sessions, so mcp_http.max_sessions_per_account caps them here. +class SessionSlot { + public: + explicit SessionSlot(const std::string &account) : account_(account) { + std::lock_guard lock(mutex_); + unsigned long &n = open_[account_]; + if (n >= opt_max_sessions_per_account) return; + ++n; + held_ = true; + } + ~SessionSlot() { + if (!held_) return; + std::lock_guard lock(mutex_); + auto it = open_.find(account_); + if (--it->second == 0) open_.erase(it); + } + SessionSlot(const SessionSlot &) = delete; + SessionSlot &operator=(const SessionSlot &) = delete; + bool held() const { return held_; } + + private: + static inline std::mutex mutex_; + static inline std::map open_; + std::string account_; + bool held_ = false; +}; + // Impersonation skips the login checks, ACCOUNT LOCK included, so the endpoint refuses a // locked account itself. The session's security context records the lock (the account lookup // at connect sets it). Fails closed: a context that cannot be read counts as locked. @@ -471,6 +533,13 @@ SqlResult run_as(const std::string &account, const std::string &sql) { return r; } const std::string user = account.substr(0, at), host = account.substr(at + 1); + SessionSlot slot(account); + if (!slot.held()) { + r.sql_errno = ER_TOO_MANY_USER_CONNECTIONS; + r.error = "User " + user + " already has " + std::to_string(opt_max_sessions_per_account) + + " active endpoint sessions (mcp_http.max_sessions_per_account)"; + return r; + } MYSQL_H h = nullptr; if (mysql_service_mysql_command_factory->init(&h) || h == nullptr) { r.error = "cannot create a session"; @@ -480,10 +549,14 @@ SqlResult run_as(const std::string &account, const std::string &sql) { // Client protocol capabilities (mysql_com.h, which components cannot include). constexpr uint32_t kClientMultiResults = 1UL << 17, kClientPsMultiResults = 1UL << 18; const uint32_t client_flags = kClientMultiResults | kClientPsMultiResults; + // Unlike the command service's default, the sessions are subject to max_connections. + static constexpr bool kBypassMaxConnections = false; if (mysql_service_mysql_command_options->set(h, MYSQL_COMMAND_PROTOCOL, "local") || mysql_service_mysql_command_options->set(h, MYSQL_COMMAND_CLIENT_FLAGS, &client_flags) || mysql_service_mysql_command_options->set(h, MYSQL_COMMAND_USER_NAME, user.c_str()) || mysql_service_mysql_command_options->set(h, MYSQL_COMMAND_HOST_NAME, host.c_str()) || + mysql_service_mysql_command_options->set(h, MYSQL_COMMAND_BYPASS_MAX_CONNECTIONS, + &kBypassMaxConnections) || !set_bounded_consumer(h) || mysql_service_mysql_command_factory->connect(h)) { read_error(h, &r); if (r.error.empty()) r.error = "cannot open a session for " + account; @@ -744,7 +817,7 @@ bool start_server() { // HTTP work runs on the io threads, which run SQL through the command services. io.set_thread_lifecycle({[](size_t) { mysql_service_mysql_command_thread->init(); }, [](size_t) { mysql_service_mysql_command_thread->end(); }}); - if (!io.init(1, IoBackend::preferred())) return true; + if (!io.init(opt_threads, IoBackend::preferred())) return true; io_initialized = true; try { server = std::make_shared(