diff --git a/.github/workflows/integration-scripts.yml b/.github/workflows/integration-scripts.yml index 65e1c0eb..0688b4a4 100644 --- a/.github/workflows/integration-scripts.yml +++ b/.github/workflows/integration-scripts.yml @@ -45,6 +45,7 @@ on: - "sql/**" - "scripts/test-*.py" - "scripts/test-mcp-guard.sh" + - "scripts/test-mcp-endpoint.sh" - "scripts/test-mcp-tool-pack.sh" - "scripts/test-mysql-mcp-image.sh" - "docker/mysql-mcp/**" @@ -60,6 +61,7 @@ on: - "sql/**" - "scripts/test-*.py" - "scripts/test-mcp-guard.sh" + - "scripts/test-mcp-endpoint.sh" - "scripts/test-mcp-tool-pack.sh" - "scripts/test-mysql-mcp-image.sh" - "docker/mysql-mcp/**" @@ -210,6 +212,36 @@ jobs: chmod +x scripts/test-mysql-mcp-image.sh scripts/test-mysql-mcp-image.sh "mysql-mcp-smoke:$MYSQL_VERSION" + mcp-endpoint: + # component_mcp (RFC 0001 Phase 4 spike): built in the MySQL 26.10 tree with the Router + # HTTP libraries, then tested end to end with component_mcp_guard and the tool pack + # (scripts/test-mcp-endpoint.sh): bearer auth, the MCP handshake, both tools, and the + # guard's policy in the endpoint's impersonated sessions. + name: mcp-endpoint (26.10.0) + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build component_mcp_guard and component_mcp + env: + MYVECTOR_BUILD_MCP: "ON" + MYVECTOR_BUILD_MCP_ENDPOINT: "ON" + run: | + chmod +x scripts/build-component-26.10-docker.sh + scripts/build-component-26.10-docker.sh mysql-26.10.0 dist/mcp-26.10.0 + ls -la dist/mcp-26.10.0 + test -f dist/mcp-26.10.0/component_mcp.so + test -f dist/mcp-26.10.0/component_mcp_guard.so + + - name: Endpoint test + run: | + chmod +x scripts/test-mcp-endpoint.sh + scripts/test-mcp-endpoint.sh mysql:26.10.0 dist/mcp-26.10.0 + mcp-tool-pack: # The mysql_mcp SQL tool pack (sql/mcp/, RFC 0001 Phase 1 in askdba/mysql-mcp-server): # install, re-install, and parity of every procedure with mysql-mcp-server's own query, diff --git a/CHANGELOG.md b/CHANGELOG.md index 437d459c..fe7b149c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`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). + - **Sessions:** each call runs in an impersonated `"local"` session, announced with a connection audit event so that `component_mcp_guard` enforces its policy there. + - **Phase 4 exit criterion met:** through the endpoint, an `MCP_USER` account's write and `mysql` read are refused, and an account without `MCP_USER` is not restricted. + - **Measured:** 95 s build, 3.1 MB, 3 threads (MRS: 265 s, 21 MB). + - **Tests:** `scripts/test-mcp-endpoint.sh` (30 checks) and the `mcp-endpoint` CI job. `docs/MCP_COMPONENTS.md` has the details and the Phase 5 facts (locked accounts, `max_user_connections`, Basic-only Router auth). - **`component_mcp_guard` tests for views, multi-statement packets and parity** (RFC 0001 §5.2 item 7, Q3). `scripts/test-mcp-guard.sh` now has 102 checks on 8.4.11, 9.7.2 and 26.10.0. - **Views:** a view over another schema or over `mysql` is refused, nested views included (the rules see base tables). - **Multi-statement packets:** a write or disallowed read in a later statement of one packet is refused, and nothing is written. diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index bc61fdd3..4b112f96 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -6,6 +6,7 @@ Server-side components for [mysql-mcp-server](https://github.com/askdba/mysql-mc |---|---|---| | `component_mcp_guard` | Phase 2: identity, read-only and schema enforcement, time limit, error log, status variables | MySQL 8.4, 9.7, 26.10 | | `mysql_mcp` SQL tool pack ([`sql/mcp/`](https://github.com/askdba/myvector/tree/main/sql/mcp)) | 1.0.0 | MySQL 8.0 to 26.10, MariaDB 11.4, 12.3 | +| `component_mcp` | **Phase 4 spike** (not for production): an MCP endpoint inside `mysqld` | MySQL 26.10 | ## component_mcp_guard @@ -182,3 +183,82 @@ The option is `mcp.read_only`, `mcp.deny_system_schemas`, `mcp.allowed_schemas` `scripts/test-mcp-guard.sh ` runs install, restart, persistence, uninstall and the enforcement cases, the tool-call table and its labels, views, multi-statement packets, statements without table access events, sessions opened by components and the parity table below against the official image (123 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. **Parity with mysql-mcp-server** (RFC 0001 §5.2 item 7): [`sql/mcp/guard-parity.tsv`](https://github.com/askdba/myvector/blob/main/sql/mcp/guard-parity.tsv) lists statements a `run_query` call might send, with the verdict of mysql-mcp-server's validator (`go`) and of the guard under the default settings (`guard`). Every row where they differ says why. The test checks the guard column, sending each statement as one packet. mysql-mcp-server vendors the file and checks the go column. Both reject every write and locking read. The guard allows a few things the validator does not (session `SET`, `information_schema`, `CALL mysql_mcp.*`) and catches what text checks cannot (a write inside a function called from `SELECT`). + +## component_mcp (Phase 4 spike) + +An MCP **Streamable HTTP** endpoint (JSON-RPC 2.0 over `POST /mcp`) inside `mysqld`. It runs on MySQL Router's HTTP server, the libraries `component_mysql_rest_service` links (RFC 0001 §5.3). This is the Phase 4 build spike and is **not for production**. It is limited to the following: + +- **Network and auth:** loopback only (`127.0.0.1`) and plain HTTP, with no TLS yet. One bearer token, mapped to one account. +- **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. + +### Build and install + +It links the Router HTTP libraries, so it is built in the MySQL 26.10 source tree, like MRS: + +```sh +MYVECTOR_BUILD_MCP=ON MYVECTOR_BUILD_MCP_ENDPOINT=ON \ + ./scripts/build-component-26.10-docker.sh mysql-26.10.0 dist/mcp-26.10.0 +``` + +Use a random token and hash it outside MySQL, so the plaintext never appears in statement text (general and slow logs, audit logs, `performance_schema` history): + +```sh +MCP_TOKEN=$(openssl rand -hex 32) +printf '%s' "$MCP_TOKEN" | sha256sum | cut -d' ' -f1 +``` + +```sql +INSTALL COMPONENT 'file://component_mcp_guard'; -- required: install refuses without it +INSTALL COMPONENT 'file://component_mcp' + SET GLOBAL mcp_http.port = 33480, + GLOBAL mcp_http.account = 'mcp@%', + GLOBAL mcp_http.token_sha256 = ''; +``` + +`component_mcp` requires the guard's query consumer (`event_tracking_query.mcp_guard`). The server therefore refuses to install it without `component_mcp_guard`, and refuses to uninstall the guard while it is installed. The guard is what enforces the policy on its sessions. + +| Variable | Meaning | +|---|---| +| `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 | + +### How a tool call runs + +Each `tools/call` opens a `"local"` command-service session that **impersonates** `mcp_http.account`, with no password, on one of the endpoint's I/O threads. It runs one statement, `CALL mysql_mcp.list_databases()` or the `run_query` SQL, then closes the session. Neither `CLIENT_MULTI_STATEMENTS` nor anything similar is set, so `a; b` is a parse error. + +The guard enforces its policy on these sessions only because of two things, both checked by `scripts/test-mcp-endpoint.sh`: + +- The endpoint emits `MYSQL_AUDIT_CONNECTION_CONNECT` for each session. The server starts such sessions **unaudited**, so without this event no statement event would reach the guard. +- The guard classifies a session at its first statement. Such a session raises no connection event the guard could use. + +So an account with `MCP_USER` is read-only through the endpoint, and its schema rules and time limit apply. An account without it is not treated as an MCP session. + +Facts recorded for Phase 5: + +- **Locked accounts:** impersonation skips the login checks, so a locked account (`ACCOUNT LOCK`) can be impersonated. The endpoint must check `account_locked` itself (26.10 has the lookup). +- **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`. +- **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) + +| | `component_mcp` | `component_mysql_rest_service` | +|---|---|---| +| 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 | — | + +### Tests + +`scripts/test-mcp-endpoint.sh ` (30 checks) installs the guard, the tool pack and `component_mcp` on the official image, then checks: + +- bearer auth, `initialize`, `notifications/initialized`, `tools/list` and the JSON-RPC errors; +- both tools, and that `run_query` runs as the mapped account; +- that through the endpoint an `MCP_USER` account's write and a `mysql` read are refused by the guard, and a multi-statement is a parse error; +- that nothing is written and no session leaks; +- that remapped to an account without `MCP_USER`, the same write is not restricted. + +CI (`integration-scripts.yml`, job `mcp-endpoint`) builds and runs it on 26.10.0. diff --git a/scripts/build-component-26.10-docker.sh b/scripts/build-component-26.10-docker.sh index a87e1fab..c4ac0423 100755 --- a/scripts/build-component-26.10-docker.sh +++ b/scripts/build-component-26.10-docker.sh @@ -23,6 +23,7 @@ docker run --rm \ -e HOST_GID="$HOST_GID" \ -e OUTPUT_DIR="$OUTPUT_DIR" \ -e MYVECTOR_BUILD_MCP="${MYVECTOR_BUILD_MCP:-OFF}" \ + -e MYVECTOR_BUILD_MCP_ENDPOINT="${MYVECTOR_BUILD_MCP_ENDPOINT:-OFF}" \ oraclelinux:9 \ bash -c ' set -e @@ -174,6 +175,23 @@ docker run --rm \ fi echo "==> Built: /workspace/$OUTPUT_DIR/libmyvector_component.so" + # component_mcp (RFC 0001 Phase 4 spike), built when MYVECTOR_BUILD_MCP_ENDPOINT=ON. It + # links MySQL Router'"'"'s HTTP libraries, so it is built in the server tree like + # component_mysql_rest_service: copy src/mcp/endpoint to components/mcp, re-run cmake + # (components are globbed), build that one target. + rm -f "/workspace/$OUTPUT_DIR/component_mcp.so" + case "$(printf "%s" "$MYVECTOR_BUILD_MCP_ENDPOINT" | tr "[:lower:]" "[:upper:]")" in + 1 | ON | YES | TRUE | Y) + echo "==> Building component_mcp in the server tree..." + rm -rf "$MYSQL_SRC/components/mcp" + cp -r /workspace/src/mcp/endpoint "$MYSQL_SRC/components/mcp" + cmake -S "$MYSQL_SRC" -B "$MYSQL_BLD" >/dev/null + cmake --build "$MYSQL_BLD" --target component_mcp -j"$(nproc)" + cp "$MYSQL_BLD/plugin_output_directory/component_mcp.so" "/workspace/$OUTPUT_DIR/" + echo "==> Built: /workspace/$OUTPUT_DIR/component_mcp.so" + ;; + esac + # Restore host ownership of the MySQL source workspace so the runner user # can save it via actions/cache@v4 (container runs as root). chown -R "${HOST_UID}:${HOST_GID}" "/workspace/mysql-server-${MYSQL_TAG}" 2>/dev/null || true diff --git a/scripts/test-mcp-endpoint.sh b/scripts/test-mcp-endpoint.sh new file mode 100755 index 00000000..db9b1049 --- /dev/null +++ b/scripts/test-mcp-endpoint.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# End-to-end test of component_mcp (RFC 0001 Phase 4 spike) with component_mcp_guard, on the +# official MySQL 26.10 image: bearer auth, the MCP handshake, both tools, and the Phase 4 exit +# criterion (the guard's policy reaches run_query in the impersonated sessions). +# +# Usage: scripts/test-mcp-endpoint.sh +# e.g. scripts/test-mcp-endpoint.sh mysql:26.10.0 dist/mcp-26.10.0 +# The artifact dir holds component_mcp.so and component_mcp_guard.so (both for that release). +# Requires Docker. bash 3.2+. +set -u +IMAGE=${1:?mysql image, e.g. mysql:26.10.0}; ART=${2:?artifact dir} +REPO=$(cd "$(dirname "$0")/.." && pwd) +PACK=$REPO/sql/mcp/mysql_mcp.sql +CURL_IMAGE=curlimages/curl:8.11.1 +# The token is hashed outside MySQL, as the docs say, so it never appears in statement text. +TOKEN_HEX=$(printf '%s' tok-123 | sha256sum | cut -d' ' -f1) +C=mcp-endpoint-test-$$ +PASS=0; FAIL=0 +ok() { PASS=$((PASS + 1)); printf ' PASS %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s -- %s\n' "$1" "$2"; } +expect() { if printf '%s\n' "$2" | grep -Eq -- "$3"; then ok "$1"; else bad "$1" "got: $(printf '%s' "$2" | head -c 300)"; fi; } +trap 'docker rm -f -v "$C" >/dev/null 2>&1' EXIT +sql() { docker exec -i "$C" mysql -h127.0.0.1 -uroot -ptestpass -N -e "$1" 2>&1 | grep -v "Using a password"; } +# rpc TOKEN JSON: POST to the endpoint from a sidecar in the server's network namespace. +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}}"; } +alive() { docker ps -q -f "name=$C" | grep -q . && echo up || echo DOWN; } + +echo "== $IMAGE" +docker run -d --name "$C" -e MYSQL_ROOT_PASSWORD=testpass "$IMAGE" >/dev/null || exit 1 +for _ in $(seq 1 90); do sql 'select 1' >/dev/null 2>&1 && sql 'select 1' | grep -q '^1$' && break; sleep 2; done +PD=$(sql 'select @@plugin_dir') +for f in component_mcp_guard.so component_mcp.so; do docker cp "$ART/$f" "$C:$PD/$f" && docker exec -u 0 "$C" chmod 0644 "$PD/$f"; done +docker exec -i "$C" mysql -h127.0.0.1 -uroot -ptestpass <"$PACK" 2>&1 | grep -v "Using a password" +expect "install refused without component_mcp_guard" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.account = 'mcp@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX'")" "ERROR" +sql "INSTALL COMPONENT 'file://component_mcp_guard'; GRANT MCP_ADMIN ON *.* TO root@'%'; +CREATE DATABASE d1; CREATE TABLE d1.t (i INT); INSERT INTO d1.t VALUES (1); +CREATE TABLE d1.big (n INT); INSERT INTO d1.big WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 999) SELECT n FROM s; +INSERT INTO d1.big SELECT n + 1000 FROM d1.big WHERE n <= 500; +CREATE USER mcp@'%' IDENTIFIED BY 'p'; GRANT SELECT, INSERT ON d1.* TO mcp@'%'; GRANT SELECT ON mysql.* TO mcp@'%'; +GRANT EXECUTE ON mysql_mcp.* TO mcp@'%'; GRANT MCP_USER ON *.* TO mcp@'%'; +CREATE USER app@'%' IDENTIFIED BY 'p'; GRANT SELECT, INSERT ON d1.* TO app@'%'; GRANT EXECUTE ON mysql_mcp.* TO app@'%';" +expect "install component_mcp" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'mcp@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX'")" "^$" + +expect "the token hash is not readable by other accounts" "$(docker exec -i "$C" mysql -h127.0.0.1 -umcp -pp -N -e "SELECT @@mcp_http.token_sha256" 2>&1 | grep -v "Using a password")" "ERROR.*SENSITIVE_VARIABLES_OBSERVER" +expect "no token: 401" "$(rpc '' '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^401$" +expect "wrong token: 401" "$(rpc nope '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^401$" +expect "initialize" "$(rpc tok-123 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}')" '"serverInfo":\{"name":"component_mcp"' +expect "notification: 202" "$(rpc tok-123 '{"jsonrpc":"2.0","method":"notifications/initialized"}')" "^202$" +expect "tools/list" "$(rpc tok-123 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}')" '"name":"list_databases".*"name":"run_query"' +expect "unknown method" "$(rpc tok-123 '{"jsonrpc":"2.0","id":3,"method":"nope"}')" '"code":-32601' +expect "list_databases via the tool pack" "$(call list_databases '{}')" 'SCHEMA_NAME.*d1.*"isError":false' +expect "large result truncated at 1000 rows" "$(call run_query '{"sql":"SELECT n FROM d1.big"}' | grep -o '\\"n\\":' | wc -l) $(call run_query '{"sql":"SELECT n FROM d1.big"}' | grep -o 'truncated\\":true' | head -1)" "^1000 truncated" +explicit_limit_reply=$(call run_query '{"sql":"SELECT n FROM d1.big LIMIT 5000"}') +expect "explicit LIMIT: the reply is still bounded" "$(printf '%s' "$explicit_limit_reply" | grep -o '\\"n\\":' | wc -l) $(printf '%s' "$explicit_limit_reply" | grep -o 'truncated\\":true' | head -1)" "^1000 truncated" +expect "escaped values count against the bound (no HTTP 500)" "$(call run_query '{"sql":"SELECT REPEAT(CHAR(1), 3000000) AS v"}' | head -c 300)" 'rows\\":\[\],\\"truncated\\":true' +expect "bytes that are not UTF-8 are returned as base64" "$(call run_query '{"sql":"SELECT X'"'"'FF'"'"' AS b"}')" 'b\\":\{\\"base64\\":\\"/w==\\"\}' +expect "valid UTF-8 stays a string" "$(call run_query '{"sql":"SELECT CONVERT(X'"'"'C3A9'"'"' USING utf8mb4) AS s"}')" 's\\":\\"é\\"' +expect "a value over the byte bound is not sent" "$(call run_query '{"sql":"SELECT REPEAT(\"x\", 5000000) AS v"}' | head -c 300)" 'rows\\":\[\],\\"truncated\\":true' +expect "run_query read as the mapped account" "$(call run_query '{"sql":"SELECT i, CURRENT_USER() AS who FROM d1.t"}')" 'i\\":\\"1\\",\\"who\\":\\"mcp@%' +# Phase 4 exit criterion: the guard's policy reaches run_query in impersonated sessions. +expect "guard: an MCP account's write is refused" "$(call run_query '{"sql":"INSERT INTO d1.t VALUES (2)"}')" 'Error 1290: [^"]*mcp.read_only[^"]*"}],"isError":true' +expect "guard: mysql schema refused" "$(call run_query '{"sql":"SELECT COUNT(*) FROM mysql.user"}')" 'Error 1290: [^"]*mcp.deny_system_schemas[^"]*"}],"isError":true' +expect "multi-statement is a parse error" "$(call run_query '{"sql":"SELECT 1; INSERT INTO d1.t VALUES (3)"}')" 'Error 1064: .*"isError":true' +expect "server alive" "$(alive)" "^up$" +expect "nothing written" "$(sql "SELECT GROUP_CONCAT(i) FROM d1.t")" "^1$" +expect "guard counted the endpoint's statements" "$(sql "SHOW GLOBAL STATUS LIKE 'mcp.statements_denied'")" "mcp.statements_denied [1-9]" +expect "no MCP session left" "$(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'")" "^$" +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'")" "^$" +expect "then the guard can be uninstalled" "$(sql "UNINSTALL COMPONENT 'file://component_mcp_guard'")" "^$" +expect "server alive at the end" "$(alive)" "^up$" +echo "== $IMAGE: $PASS passed, $FAIL failed" +[ "$FAIL" = 0 ] diff --git a/src/mcp/endpoint/CMakeLists.txt b/src/mcp/endpoint/CMakeLists.txt new file mode 100644 index 00000000..56ce867f --- /dev/null +++ b/src/mcp/endpoint/CMakeLists.txt @@ -0,0 +1,54 @@ +# component_mcp: Phase 4 spike of RFC 0001 (askdba/mysql-mcp-server). +# An MCP endpoint inside mysqld on MySQL Router's HTTP server. +# Built in-tree like component_mysql_rest_service; needs the Router HTTP targets, which +# MRS adds (it is configured after this directory; targets resolve at generate time). + +DISABLE_MISSING_PROFILE_WARNING() + +IF(NOT WITH_ROUTER AND NOT (WITH_COMPONENT_MRS OR (NOT DEFINED WITH_COMPONENT_MRS AND NOT DEFINED WITHOUT_COMPONENT_MRS))) + MESSAGE(STATUS "component_mcp: needs the Router HTTP libraries (WITH_ROUTER or component_mysql_rest_service)") + RETURN() +ENDIF() + +SET(MCP_ROUTER_LIBRARIES + http_server_lib_object + http_common_object + http_auth_realm_lib_object + http_auth_backend_lib_object + io_component_object + harness-library_object + harness_stdx_object + harness_tls_object + harness_net_ts + router_utils_object + clientlib mytime strings vio mysys +) + +ADD_DEFINITIONS(-DLOG_COMPONENT_TAG="component_mcp") +ADD_DEFINITIONS(-DROUTER_UTILS_STATIC_DEFINE) + +MYSQL_ADD_COMPONENT(mcp + component_mcp.cc + LINK_LIBRARIES PRIVATE + ${MCP_ROUTER_LIBRARIES} + extra::rapidjson + OpenSSL::SSL + OpenSSL::Crypto + MODULE_ONLY +) + +IF(UNIX AND NOT APPLE) + TARGET_LINK_OPTIONS(component_mcp PRIVATE "-Wl,--exclude-libs,ALL") +ENDIF() + +TARGET_INCLUDE_DIRECTORIES(component_mcp PRIVATE + ${CMAKE_SOURCE_DIR}/router/src/router/include + ${CMAKE_SOURCE_DIR}/router/src/harness/include + ${CMAKE_SOURCE_DIR}/router/src/http/src + ${CMAKE_SOURCE_DIR}/router/src/http/include + ${CMAKE_BINARY_DIR}/router + ${CMAKE_BINARY_DIR}/router/include + ${CMAKE_BINARY_DIR}/router/src/harness/include + ${CMAKE_BINARY_DIR}/router/src/router/include +) +ADD_DEPENDENCIES(component_mcp GenError) diff --git a/src/mcp/endpoint/component_mcp.cc b/src/mcp/endpoint/component_mcp.cc new file mode 100644 index 00000000..1854d2da --- /dev/null +++ b/src/mcp/endpoint/component_mcp.cc @@ -0,0 +1,615 @@ +/* + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License, version 2.0, + as published by the Free Software Foundation. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License, version 2.0, for more details. +*/ + +// component_mcp: Phase 4 spike of RFC 0001 (askdba/mysql-mcp-server). An MCP Streamable +// HTTP endpoint (JSON-RPC 2.0 over POST /mcp) inside mysqld, on MySQL Router's HTTP server +// (the libraries component_mysql_rest_service links). Spike scope: +// +// - loopback only, plain HTTP (TLS comes with Phase 5); +// - Authorization: Bearer , checked against mcp_http.token_sha256 (hex SHA-256) +// and mapped to the one account in mcp_http.account (user@host); +// - tools: list_databases (CALL mysql_mcp.list_databases()) and run_query (one statement); +// - each tool call runs in a "local" command-service session that impersonates the +// account, announced with connection audit events so that component_mcp_guard (and +// audit log plugins) see it: such sessions start unaudited. +// +// Install (read-only settings, given at install time): +// INSTALL COMPONENT 'file://component_mcp' +// SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'mcp@%', +// GLOBAL mcp_http.token_sha256 = SHA2('the token', 256); + +#include "my_rapidjson_size_t.h" // before any rapidjson header + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "http/base/request.h" +#include "http/base/request_handler.h" +#include "http/base/uri_path_matcher.h" +#include "http/http_server_context.h" +#include "dim.h" +#include "mysql/harness/logging/logging.h" +#include "mysql/harness/logging/registry.h" +#include "mysqlrouter/io_component.h" +// In-tree, as component_mysql_rest_service: the session THD behind a command handle. +#include "sql/server_component/mysql_command_services_imp.h" + +REQUIRES_SERVICE_PLACEHOLDER(log_builtins); +REQUIRES_SERVICE_PLACEHOLDER(log_builtins_string); +REQUIRES_SERVICE_PLACEHOLDER(component_sys_variable_register); +REQUIRES_SERVICE_PLACEHOLDER(component_sys_variable_unregister); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_factory); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_options); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_query); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_query_result); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_field_info); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_field_metadata); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_error_info); +REQUIRES_SERVICE_PLACEHOLDER(mysql_command_thread); +REQUIRES_SERVICE_PLACEHOLDER(mysql_audit_api_connection); +// component_mcp_guard's query consumer: requiring it makes the server refuse to install the +// endpoint without the guard, and to uninstall the guard while the endpoint is installed. +REQUIRES_SERVICE_PLACEHOLDER_AS(event_tracking_query, mcp_guard_query); + +// Defined by Router's harness (its component log handler); set at init. +extern SERVICE_TYPE(log_builtins) *log_bi; +extern SERVICE_TYPE(log_builtins_string) *log_bs; + +namespace { + +// ----- settings ------------------------------------------------------------------------- + +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; + +bool register_sysvars() { + INTEGRAL_CHECK_ARG(ulong) port_arg; + port_arg.def_val = 33480; + port_arg.min_val = 1; + port_arg.max_val = 65535; + port_arg.blk_sz = 0; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp_http", "port", PLUGIN_VAR_LONG | PLUGIN_VAR_UNSIGNED | PLUGIN_VAR_READONLY, + "TCP port of the MCP endpoint (loopback only)", nullptr, nullptr, &port_arg, &opt_port)) + return true; + port_registered = true; + { + STR_CHECK_ARG(str) account_arg; + account_arg.def_val = nullptr; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp_http", "account", PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY, + "Account (user@host) the bearer token maps to", nullptr, nullptr, &account_arg, + &opt_account)) + return true; + account_registered = true; + } + { + STR_CHECK_ARG(str) token_arg; + token_arg.def_val = nullptr; + if (mysql_service_component_sys_variable_register->register_variable( + "mcp_http", "token_sha256", + // SENSITIVE: readable only with SENSITIVE_VARIABLES_OBSERVER, so accounts (the MCP + // account included) cannot read the hash and guess a weak token offline. + PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY | PLUGIN_VAR_NOPERSIST | + PLUGIN_VAR_SENSITIVE, + "Hex SHA-256 of the bearer token", nullptr, nullptr, &token_arg, &opt_token_sha256)) + return true; + token_registered = true; + } + return false; +} + +bool unregister_sysvars() { + bool failed = false; + auto drop = [&](bool ®istered, const char *name) { + if (!registered) return; + if (mysql_service_component_sys_variable_unregister->unregister_variable("mcp_http", name)) + failed = true; + else + registered = false; + }; + drop(token_registered, "token_sha256"); + drop(account_registered, "account"); + drop(port_registered, "port"); + return failed; +} + +void log_info(const std::string &msg) { + LogComponentErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, ("component_mcp: " + msg).c_str()); +} + +// ----- auth ------------------------------------------------------------------------------- + +std::string sha256_hex(const std::string &s) { + unsigned char md[EVP_MAX_MD_SIZE]; + unsigned int n = 0; + if (EVP_Digest(s.data(), s.size(), md, &n, EVP_sha256(), nullptr) != 1) return ""; + static const char hex[] = "0123456789abcdef"; + std::string out; + for (unsigned int i = 0; i < n; ++i) { + out.push_back(hex[md[i] >> 4]); + out.push_back(hex[md[i] & 15]); + } + return out; +} + +// The bearer token's hash matches mcp_http.token_sha256 (compared in constant time). +bool token_ok(const std::string *authorization) { + if (authorization == nullptr || opt_token_sha256 == nullptr || opt_token_sha256[0] == '\0') + return false; + static const std::string kBearer = "Bearer "; + if (authorization->compare(0, kBearer.size(), kBearer) != 0) return false; + const std::string got = sha256_hex(authorization->substr(kBearer.size())); + std::string want(opt_token_sha256); + for (char &c : want) c = static_cast(std::tolower(static_cast(c))); + return got.size() == want.size() && !got.empty() && + CRYPTO_memcmp(got.data(), want.data(), got.size()) == 0; +} + +// ----- SQL in an impersonated local session ---------------------------------------------- + +// Result bounds: the reply keeps at most kMaxRows rows and kMaxBytes of serialized rows (the +// tool result then escapes them once more inside its text, so the HTTP reply stays well under +// its 16 MiB limit). The session's +// sql_select_limit stops the server past kMaxRows + 1 rows for statements without their own +// LIMIT. The command service's default consumer collects every row while the statement runs +// (store_result only hands that buffer over), so a statement with a large explicit LIMIT is +// still held in full by the server: a bounded custom consumer is Phase 5 work. +constexpr unsigned long long kMaxRows = 1000; +constexpr size_t kMaxBytes = 4u << 20; + +struct SqlResult { + bool ok = false; + unsigned int sql_errno = 0; + std::string error; + rapidjson::Document rows{rapidjson::kArrayType}; // first result set, as objects + bool truncated = false; +}; + +bool valid_utf8(const char *s, size_t n) { + const auto *p = reinterpret_cast(s); + size_t i = 0; + while (i < n) { + const unsigned char c = p[i]; + size_t len; + unsigned int cp; + if (c < 0x80) { + ++i; + continue; + } else if ((c & 0xE0) == 0xC0) { + len = 2, cp = c & 0x1F; + } else if ((c & 0xF0) == 0xE0) { + len = 3, cp = c & 0x0F; + } else if ((c & 0xF8) == 0xF0) { + len = 4, cp = c & 0x07; + } else { + return false; + } + if (i + len > n) return false; + for (size_t k = 1; k < len; ++k) { + if ((p[i + k] & 0xC0) != 0x80) return false; + cp = (cp << 6) | (p[i + k] & 0x3F); + } + // No overlong forms, surrogates or code points past U+10FFFF. + if ((len == 2 && cp < 0x80) || (len == 3 && cp < 0x800) || (len == 4 && cp < 0x10000) || + (cp >= 0xD800 && cp <= 0xDFFF) || cp > 0x10FFFF) + return false; + i += len; + } + return true; +} + +// A result cell: a JSON string when its bytes are valid UTF-8, otherwise +// {"base64": ""}, so a binary value never produces invalid JSON. +void cell_value(const char *s, size_t n, rapidjson::Value *v, + rapidjson::Document::AllocatorType &a) { + if (valid_utf8(s, n)) { + v->SetString(s, n, a); + return; + } + std::string b64(4 * ((n + 2) / 3) + 1, '\0'); + const int len = EVP_EncodeBlock(reinterpret_cast(b64.data()), + reinterpret_cast(s), static_cast(n)); + b64.resize(len > 0 ? static_cast(len) : 0); + v->SetObject(); + v->AddMember("base64", rapidjson::Value(b64.c_str(), a), a); +} + +size_t serialized_size(const rapidjson::Value &v) { + rapidjson::StringBuffer b; + rapidjson::Writer w(b); + v.Accept(w); + return b.GetSize(); +} + +MYSQL_THD session_thd(MYSQL_H h) { + auto *handle = reinterpret_cast(h); + return handle == nullptr || handle->mysql == nullptr ? nullptr + : static_cast(handle->mysql->thd); +} + +void read_error(MYSQL_H h, SqlResult *r) { + char buf[512] = ""; // sql_error copies into the caller's buffer before 26.10 + char *msg = buf; + mysql_service_mysql_command_error_info->sql_errno(h, &r->sql_errno); + mysql_service_mysql_command_error_info->sql_error(h, &msg); + r->error = msg != nullptr ? msg : ""; +} + +// Runs one statement as account (user@host) and returns its first result set. +SqlResult run_as(const std::string &account, const std::string &sql) { + SqlResult r; + const size_t at = account.rfind('@'); + if (at == std::string::npos) { + r.error = "mcp_http.account is not user@host"; + return r; + } + const std::string user = account.substr(0, at), host = account.substr(at + 1); + MYSQL_H h = nullptr; + if (mysql_service_mysql_command_factory->init(&h) || h == nullptr) { + r.error = "cannot create a session"; + return r; + } + // CALL needs multi-result support; multi-statements stay off, so "a; b" is a parse error. + // 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; + 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_factory->connect(h)) { + read_error(h, &r); + if (r.error.empty()) r.error = "cannot open a session for " + account; + } else { + // The session starts unaudited: CONNECT turns auditing on, so the guard sees it. + MYSQL_THD thd = session_thd(h); + static const std::string kLimit = + "SET SESSION sql_select_limit = " + std::to_string(kMaxRows + 1); + if (thd == nullptr || mysql_service_mysql_audit_api_connection->emit( + thd, MYSQL_AUDIT_CONNECTION_CONNECT) != 0) { + r.error = "session refused"; + } else if (mysql_service_mysql_command_query->query(h, kLimit.data(), kLimit.size())) { + read_error(h, &r); + } else if (mysql_service_mysql_command_query->query(h, sql.data(), sql.size())) { + read_error(h, &r); + } else { + r.ok = true; + MYSQL_RES_H res = nullptr; + if (!mysql_service_mysql_command_query_result->store_result(h, &res) && res != nullptr) { + unsigned int n = 0; + mysql_service_mysql_command_field_info->num_fields(res, &n); + // fetch_fields() returns an array of MYSQL_FIELD structs, not of handles: read the + // names one field at a time. + std::vector names(n); + for (unsigned int i = 0; i < n; ++i) { + MYSQL_FIELD_H field = nullptr; + const char *name = nullptr; + if (!mysql_service_mysql_command_field_info->fetch_field(res, &field) && field) + mysql_service_mysql_command_field_metadata->get(field, MYSQL_COMMAND_FIELD_METADATA_NAME, + &name); + names[i] = name != nullptr ? name : "col" + std::to_string(i + 1); + } + auto &a = r.rows.GetAllocator(); + MYSQL_ROW_H row = nullptr; + ulong *lengths = nullptr; + size_t bytes = 0; + while (!mysql_service_mysql_command_query_result->fetch_row(res, &row) && row) { + mysql_service_mysql_command_query_result->fetch_lengths(res, &lengths); + if (r.rows.Size() >= kMaxRows) { + r.truncated = true; + break; + } + 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) + cell_value(row[i], lengths ? lengths[i] : std::strlen(row[i]), &v, a); + obj.AddMember(k, v, a); + } + // The budget counts the row as serialized (escapes included), and a row that does + // not fit ends the result, so the reply stays within the bounds. + const size_t row_bytes = serialized_size(obj); + if (bytes + row_bytes > kMaxBytes) { + r.truncated = true; + break; + } + bytes += row_bytes; + r.rows.PushBack(obj, a); + } + mysql_service_mysql_command_query_result->free_result(res); + } + // Drain any further results (a CALL ends with a status result). + while (mysql_service_mysql_command_query_result->next_result(h) == 0) { + MYSQL_RES_H more = nullptr; + if (!mysql_service_mysql_command_query_result->store_result(h, &more) && more) + mysql_service_mysql_command_query_result->free_result(more); + } + } + MYSQL_THD thd2 = session_thd(h); + if (thd2 != nullptr) + mysql_service_mysql_audit_api_connection->emit(thd2, MYSQL_AUDIT_CONNECTION_DISCONNECT); + } + mysql_service_mysql_command_factory->close(h); + return r; +} + +// ----- MCP JSON-RPC ----------------------------------------------------------------------- + +using Json = rapidjson::Document; + +std::string to_string(const rapidjson::Value &v) { + rapidjson::StringBuffer b; + rapidjson::Writer w(b); + v.Accept(w); + return {b.GetString(), b.GetSize()}; +} + +void tool_result(Json &out, const std::string &text, bool is_error) { + auto &a = out.GetAllocator(); + rapidjson::Value result(rapidjson::kObjectType), content(rapidjson::kArrayType), + item(rapidjson::kObjectType); + item.AddMember("type", "text", a); + item.AddMember("text", rapidjson::Value(text.c_str(), a), a); + content.PushBack(item, a); + result.AddMember("content", content, a); + result.AddMember("isError", is_error, a); + out.AddMember("result", result, a); +} + +void call_tool(const rapidjson::Value ¶ms, Json &out) { + const std::string account = opt_account != nullptr ? opt_account : ""; + const std::string name = + params.HasMember("name") && params["name"].IsString() ? params["name"].GetString() : ""; + std::string sql; + if (name == "list_databases") { + sql = "CALL mysql_mcp.list_databases()"; + } else if (name == "run_query") { + const rapidjson::Value *args = params.HasMember("arguments") ? ¶ms["arguments"] : nullptr; + if (args == nullptr || !args->IsObject() || !args->HasMember("sql") || + !(*args)["sql"].IsString()) { + tool_result(out, "run_query needs a string argument sql", true); + return; + } + sql = (*args)["sql"].GetString(); + } else { + tool_result(out, "unknown tool: " + name, true); + return; + } + SqlResult r = run_as(account, sql); + if (!r.ok) { + tool_result(out, "Error " + std::to_string(r.sql_errno) + ": " + r.error, true); + return; + } + Json reply(rapidjson::kObjectType); + reply.AddMember("rows", rapidjson::Value(r.rows, reply.GetAllocator()), reply.GetAllocator()); + reply.AddMember("truncated", r.truncated, reply.GetAllocator()); + tool_result(out, to_string(reply), false); +} + +void list_tools(Json &out) { + static const char kTools[] = + R"({"tools":[)" + R"({"name":"list_databases","description":"List databases visible to the mapped account",)" + R"("inputSchema":{"type":"object","properties":{}}},)" + R"({"name":"run_query","description":"Run one read-only SQL statement",)" + R"("inputSchema":{"type":"object","properties":{"sql":{"type":"string"}},"required":["sql"]}}]})"; + Json tools; + tools.Parse(kTools); + out.AddMember("result", rapidjson::Value(tools, out.GetAllocator()), out.GetAllocator()); +} + +void initialize(const rapidjson::Value ¶ms, Json &out) { + auto &a = out.GetAllocator(); + rapidjson::Value result(rapidjson::kObjectType), caps(rapidjson::kObjectType), + tools(rapidjson::kObjectType), info(rapidjson::kObjectType); + const char *version = params.IsObject() && params.HasMember("protocolVersion") && + params["protocolVersion"].IsString() + ? params["protocolVersion"].GetString() + : "2025-06-18"; + result.AddMember("protocolVersion", rapidjson::Value(version, a), a); + caps.AddMember("tools", tools, a); + result.AddMember("capabilities", caps, a); + info.AddMember("name", "component_mcp", a); + info.AddMember("version", "0.0.1-spike", a); + result.AddMember("serverInfo", info, a); + out.AddMember("result", result, a); +} + +void rpc_error(Json &out, int code, const char *message) { + auto &a = out.GetAllocator(); + rapidjson::Value err(rapidjson::kObjectType); + err.AddMember("code", code, a); + err.AddMember("message", rapidjson::Value(message, a), a); + out.AddMember("error", err, a); +} + +class McpHandler : public http::base::RequestHandler { + public: + void handle_request(http::base::Request &req) override { + if (req.get_method() != HttpMethod::Post) { + req.get_output_headers().add("Allow", "POST"); + req.send_reply(HttpStatusCode::MethodNotAllowed); + return; + } + if (!token_ok(req.get_input_headers().find("Authorization"))) { + req.get_output_headers().add("WWW-Authenticate", "Bearer"); + req.send_reply(HttpStatusCode::Unauthorized); + return; + } + Json in; + in.Parse(req.get_input_body().c_str()); + if (in.HasParseError() || !in.IsObject() || !in.HasMember("method") || + !in["method"].IsString()) { + reply(req, nullptr, [](Json &out) { rpc_error(out, -32700, "parse error"); }); + return; + } + const std::string method = in["method"].GetString(); + if (!in.HasMember("id")) { // a notification (e.g. notifications/initialized) + req.send_reply(HttpStatusCode::Accepted); + return; + } + static const rapidjson::Value kNoParams(rapidjson::kObjectType); + const rapidjson::Value ¶ms = in.HasMember("params") ? in["params"] : kNoParams; + reply(req, &in["id"], [&](Json &out) { + if (method == "initialize") + initialize(params, out); + else if (method == "tools/list") + list_tools(out); + else if (method == "tools/call" && params.IsObject()) + call_tool(params, out); + else if (method == "ping") + out.AddMember("result", rapidjson::Value(rapidjson::kObjectType), out.GetAllocator()); + else + rpc_error(out, -32601, "method not found"); + }); + } + + private: + template + static void reply(http::base::Request &req, const rapidjson::Value *id, F fill) { + Json out(rapidjson::kObjectType); + auto &a = out.GetAllocator(); + out.AddMember("jsonrpc", "2.0", a); + rapidjson::Value idv; + if (id != nullptr) idv.CopyFrom(*id, a); + out.AddMember("id", idv, a); + fill(out); + req.get_output_headers().add("Content-Type", "application/json"); + req.send_reply(HttpStatusCode::Ok, "OK", to_string(out)); + } +}; + +// ----- HTTP server lifecycle ------------------------------------------------------------- + +std::shared_ptr server; +std::unique_ptr workguard; +std::thread io_thread; +bool io_initialized = false; + +void init_router_logging() { + static mysql_harness::logging::Registry registry; + auto &dim = mysql_harness::DIM::instance(); + dim.set_static_LoggingRegistry(®istry); + auto ® = dim.get_LoggingRegistry(); + mysql_harness::logging::clear_registry(reg); + mysql_harness::logging::create_module_loggers( + reg, mysql_harness::logging::LogLevel::kWarning, + {mysql_harness::logging::kMainLogger, "http_server", "io", "http_common"}, + mysql_harness::logging::kMainLogger); + mysql_harness::logging::create_main_log_handler(reg, "component_mcp", "", true); + reg.set_ready(); +} + +bool start_server() { + init_router_logging(); + auto &io = IoComponent::get_instance(); + // 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; + io_initialized = true; + try { + server = std::make_shared( + &io.io_context(), &io.io_threads(), "127.0.0.1", static_cast(opt_port), + /*max_http_connections=*/64, /*max_request_body_size=*/1 << 20, + /*max_response_body_size=*/16 << 20); + server->add_direct_match_route("", http::base::UriPathMatcher{"/mcp", false, false}, + std::make_unique()); + workguard = std::make_unique(io); + io_thread = std::thread([] { IoComponent::get_instance().run(); }); + server->start(); + } catch (const std::exception &e) { + log_info(std::string("cannot start the endpoint: ") + e.what()); + return true; + } + log_info("MCP endpoint on http://127.0.0.1:" + std::to_string(opt_port) + "/mcp"); + return false; +} + +void stop_server() { + if (server) server->stop(); + workguard.reset(); + if (io_initialized) IoComponent::get_instance().stop(); + if (io_thread.joinable()) io_thread.join(); + server.reset(); + if (io_initialized) { + IoComponent::get_instance().reset(); + IoComponent::get_instance().reset_thread_lifecycle(); + io_initialized = false; + } +} + +mysql_service_status_t mcp_init() { + log_bi = mysql_service_log_builtins; + log_bs = mysql_service_log_builtins_string; + if (register_sysvars()) { + unregister_sysvars(); + return 1; + } + if (start_server()) { + stop_server(); + unregister_sysvars(); + return 1; + } + return 0; +} + +mysql_service_status_t mcp_deinit() { + stop_server(); + return unregister_sysvars() ? 1 : 0; +} + +} // namespace + +BEGIN_COMPONENT_PROVIDES(mcp) +END_COMPONENT_PROVIDES(); + +BEGIN_COMPONENT_REQUIRES(mcp) +REQUIRES_SERVICE(log_builtins), REQUIRES_SERVICE(log_builtins_string), + REQUIRES_SERVICE(component_sys_variable_register), + REQUIRES_SERVICE(component_sys_variable_unregister), REQUIRES_SERVICE(mysql_command_factory), + REQUIRES_SERVICE(mysql_command_options), REQUIRES_SERVICE(mysql_command_query), + REQUIRES_SERVICE(mysql_command_query_result), REQUIRES_SERVICE(mysql_command_field_info), + REQUIRES_SERVICE(mysql_command_field_metadata), REQUIRES_SERVICE(mysql_command_error_info), + REQUIRES_SERVICE(mysql_command_thread), REQUIRES_SERVICE(mysql_audit_api_connection), + REQUIRES_SERVICE_IMPLEMENTATION_AS(event_tracking_query, mcp_guard, mcp_guard_query), + END_COMPONENT_REQUIRES(); + +BEGIN_COMPONENT_METADATA(mcp) +METADATA("mysql.author", "askdba"), METADATA("mysql.license", "GPL"), + END_COMPONENT_METADATA(); + +DECLARE_COMPONENT(mcp, "mysql:mcp") mcp_init, mcp_deinit END_DECLARE_COMPONENT(); + +DECLARE_LIBRARY_COMPONENTS &COMPONENT_REF(mcp) END_DECLARE_LIBRARY_COMPONENTS