From d33dba6cc8b8380d352009cfac6258c16d8926a3 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 09:48:32 +0000 Subject: [PATCH 01/11] feat(docker): mysql-mcp images with component_mcp_guard and the tool pack (RFC 0001 Phase 3) A separate image family, ghcr.io//mysql-mcp:<8.4|9.7|26.10>: the official mysql image with component_mcp_guard and the mysql_mcp tool pack, installed on first start by docker/mysql-mcp/mysql-mcp-init.sh. Optional MYSQL_MCP_USER/PASSWORD create an MCP account (MCP_USER, tool pack, read access to MYSQL_MCP_DATABASES); MYSQL_MCP_ALLOWED_SCHEMAS and MYSQL_MCP_MAX_EXECUTION_TIME_MS persist guard settings. - docker-publish.yml: the component job builds the guard (MYVECTOR_BUILD_MCP=ON), tests the image and pushes it with releases (dry_run builds only). - integration-scripts.yml (mcp-guard): builds the image on 8.4.11, 9.7.2, 26.10.0 and runs scripts/test-mysql-mcp-image.sh (17 checks). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 38 ++++++++++++ .github/workflows/integration-scripts.yml | 16 +++++ CHANGELOG.md | 1 + docker/mysql-mcp/Dockerfile | 25 ++++++++ docker/mysql-mcp/mysql-mcp-init.sh | 66 ++++++++++++++++++++ docs/MCP_COMPONENTS.md | 21 +++++++ scripts/test-mysql-mcp-image.sh | 73 +++++++++++++++++++++++ 7 files changed, 240 insertions(+) create mode 100644 docker/mysql-mcp/Dockerfile create mode 100644 docker/mysql-mcp/mysql-mcp-init.sh create mode 100755 scripts/test-mysql-mcp-image.sh diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6bd7d46..6f6ddc3 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -213,6 +213,9 @@ jobs: # existing users depending on them, so component images for those versions get # a "-component" tag suffix; mysql26.10 has no plugin equivalent to collide with # (component-only), so it gets the plain tag. + # The same job also builds component_mcp_guard and publishes the separate mysql-mcp + # image family (docker/mysql-mcp/Dockerfile, RFC 0001 in askdba/mysql-mcp-server) as + # ghcr.io//mysql-mcp:. build-and-publish-component: # Publish only from a dispatch that targets a VERSION tag (refs/tags/v*). # Without the version-tag guard, a workflow_dispatch off any branch — or any non-version tag — would @@ -251,6 +254,8 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build MyVector component (amd64/arm64) + env: + MYVECTOR_BUILD_MCP: "ON" run: | set -euo pipefail chmod +x ${{ matrix.build-script }} @@ -263,6 +268,8 @@ jobs: cp build/component-amd64/libmyvector_component.so myvector-component-amd64.so cp build/component-arm64/libmyvector_component.so myvector-component-arm64.so cp build/component-amd64/myvector.json myvector.json + cp build/component-amd64/component_mcp_guard.so component_mcp_guard-amd64.so + cp build/component-arm64/component_mcp_guard.so component_mcp_guard-arm64.so cp sql/myvector_install_component.sql myvector_install_component.sql cp sql/myvector_uninstall_component.sql myvector_uninstall_component.sql @@ -311,3 +318,34 @@ jobs: tags: ghcr.io/${{ github.repository }}:${{ matrix.image-tag }} build-args: | MYSQL_VERSION=${{ matrix.mysql-version }} + + - name: Test mysql-mcp image + env: + MYSQL_VERSION: ${{ matrix.mysql-version }} + run: | + for arch in amd64 arm64; do + docker buildx build \ + --platform "linux/${arch}" \ + --file docker/mysql-mcp/Dockerfile \ + --build-arg MYSQL_VERSION="$MYSQL_VERSION" \ + -t "mysql-mcp-smoke:${MYSQL_VERSION}-${arch}" \ + --load \ + . + done + # Full first-start test natively; arm64 under QEMU checks the contents only. + chmod +x scripts/test-mysql-mcp-image.sh + scripts/test-mysql-mcp-image.sh "mysql-mcp-smoke:${MYSQL_VERSION}-amd64" + docker run --rm --platform linux/arm64 --entrypoint /bin/sh "mysql-mcp-smoke:${MYSQL_VERSION}-arm64" -c \ + "test -f /usr/lib64/mysql/plugin/component_mcp_guard.so && test -f /usr/share/mysql-mcp/mysql_mcp.sql && test -f /docker-entrypoint-initdb.d/50-mysql-mcp.sh" + + - name: Build and push mysql-mcp image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/mysql-mcp/Dockerfile + platforms: linux/amd64,linux/arm64 + # Never push on a dry_run dispatch — build only, to prove the pipeline. + push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + tags: ghcr.io/${{ github.repository_owner }}/mysql-mcp:${{ matrix.mysql-version }} + build-args: | + MYSQL_VERSION=${{ matrix.mysql-version }} diff --git a/.github/workflows/integration-scripts.yml b/.github/workflows/integration-scripts.yml index e533a51..65e1c0e 100644 --- a/.github/workflows/integration-scripts.yml +++ b/.github/workflows/integration-scripts.yml @@ -46,6 +46,8 @@ on: - "scripts/test-*.py" - "scripts/test-mcp-guard.sh" - "scripts/test-mcp-tool-pack.sh" + - "scripts/test-mysql-mcp-image.sh" + - "docker/mysql-mcp/**" - "scripts/build-component*" - "scripts/build-plugin*" - "CMakeLists.txt" @@ -59,6 +61,8 @@ on: - "scripts/test-*.py" - "scripts/test-mcp-guard.sh" - "scripts/test-mcp-tool-pack.sh" + - "scripts/test-mysql-mcp-image.sh" + - "docker/mysql-mcp/**" - "scripts/build-component*" - "scripts/build-plugin*" - "CMakeLists.txt" @@ -194,6 +198,18 @@ jobs: chmod +x scripts/test-mcp-guard.sh scripts/test-mcp-guard.sh "$MYSQL_VERSION" "dist/mcp-guard-${MYSQL_VERSION}" + - name: mysql-mcp image test + # The image docker-publish.yml publishes as ghcr.io//mysql-mcp, built on the + # official image of the same patch release. + env: + MYSQL_VERSION: ${{ matrix.mysql-version }} + run: | + cp "dist/mcp-guard-${MYSQL_VERSION}/component_mcp_guard.so" component_mcp_guard-amd64.so + docker build -f docker/mysql-mcp/Dockerfile --build-arg MYSQL_VERSION="$MYSQL_VERSION" \ + --build-arg TARGETARCH=amd64 -t "mysql-mcp-smoke:$MYSQL_VERSION" . + chmod +x scripts/test-mysql-mcp-image.sh + scripts/test-mysql-mcp-image.sh "mysql-mcp-smoke:$MYSQL_VERSION" + 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 e404d56..7ec19f0 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 +- **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` is the official MySQL image with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (17 checks on 8.4.11, 9.7.2 and 26.10.0). - **`component_mcp_guard` time limit and refusal log** (Phase 2 of RFC 0001, completed). - **`mcp.max_execution_time_ms`:** sets an MCP session's `max_execution_time` at connect. Attempts to raise it are refused with error 1290: via `SET`, or via the `MAX_EXECUTION_TIME(n)` or `SET_VAR(max_execution_time=n)` hints. Lowering it is allowed. - **`mcp.log_denials` (default ON):** writes each refusal to the error log as `[MY-011071] [mcp_guard]`, with string literals redacted, at most 10 lines a minute. diff --git a/docker/mysql-mcp/Dockerfile b/docker/mysql-mcp/Dockerfile new file mode 100644 index 0000000..312ee1b --- /dev/null +++ b/docker/mysql-mcp/Dockerfile @@ -0,0 +1,25 @@ +# mysql-mcp: the official MySQL image with the MCP components of RFC 0001 +# (askdba/mysql-mcp-server): component_mcp_guard and the mysql_mcp SQL tool pack. +# Published as ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10> by docker-publish.yml. +# Build context: the repository root, with component_mcp_guard-.so staged there. +ARG MYSQL_VERSION=9.7 + +FROM mysql:${MYSQL_VERSION} +ARG TARGETARCH + +LABEL org.opencontainers.image.title="mysql-mcp" \ + org.opencontainers.image.description="MySQL with component_mcp_guard and the mysql_mcp tool pack for mysql-mcp-server" \ + org.opencontainers.image.source="https://github.com/askdba/myvector" \ + org.opencontainers.image.documentation="https://github.com/askdba/myvector/blob/main/docs/MCP_COMPONENTS.md" \ + org.opencontainers.image.licenses="GPL-2.0-only AND Apache-2.0" + +# Statically linked against libstdc++/libgcc and built in oraclelinux:9, this image's base. +COPY component_mcp_guard-${TARGETARCH}.so /usr/lib64/mysql/plugin/component_mcp_guard.so +COPY sql/mcp/mysql_mcp.sql sql/mcp/uninstall.sql sql/mcp/LICENSE /usr/share/mysql-mcp/ +COPY docker/mysql-mcp/mysql-mcp-init.sh /docker-entrypoint-initdb.d/50-mysql-mcp.sh + +# checkov:skip=CKV_DOCKER_3:MySQL entrypoint needs root for initialization +HEALTHCHECK --interval=30s --timeout=5s --retries=5 CMD \ + if [ -n "$MYSQL_RANDOM_ROOT_PASSWORD" ] && [ -z "${MYSQL_ROOT_PASSWORD:-}" ]; then exit 0; fi; \ + if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then mysqladmin ping -uroot -p"$MYSQL_ROOT_PASSWORD" --silent; \ + else mysqladmin ping -uroot --silent; fi diff --git a/docker/mysql-mcp/mysql-mcp-init.sh b/docker/mysql-mcp/mysql-mcp-init.sh new file mode 100644 index 0000000..8a57b6a --- /dev/null +++ b/docker/mysql-mcp/mysql-mcp-init.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# First-start setup of the mysql-mcp image. Sourced by the official entrypoint (it is not +# executable), so docker_process_sql and mysql_note are available. +# +# - installs component_mcp_guard and gives root MCP_ADMIN (to change mcp.* settings); +# - installs the mysql_mcp SQL tool pack; +# - MYSQL_MCP_USER / MYSQL_MCP_PASSWORD: creates an account for mysql-mcp-server that +# holds MCP_USER (so the guard applies to it), EXECUTE on mysql_mcp and SELECT, SHOW +# VIEW on MYSQL_MCP_DATABASES (comma-separated; default MYSQL_DATABASE); +# - MYSQL_MCP_ALLOWED_SCHEMAS, MYSQL_MCP_MAX_EXECUTION_TIME_MS: persisted as +# mcp.allowed_schemas and mcp.max_execution_time_ms. + +_mcp_sql_literal() { # a single-quoted SQL string literal + local s=${1//\\/\\\\} + printf "'%s'" "${s//\'/\'\'}" +} +_mcp_sql_ident() { # a backquoted identifier + printf '`%s`' "${1//\`/\`\`}" +} + +mysql_note "mysql-mcp: installing component_mcp_guard" +docker_process_sql --database=mysql <<<"INSTALL COMPONENT 'file://component_mcp_guard'" +# Dynamic privileges registered after root was created: grant them to every root account. +_mcp_roots=$(docker_process_sql --database=mysql -N -B <<<"SELECT CONCAT(QUOTE(user), '@', QUOTE(host)) FROM mysql.user WHERE user = 'root'") +for _mcp_root in $_mcp_roots; do + docker_process_sql --database=mysql <<<"GRANT MCP_ADMIN ON *.* TO $_mcp_root" +done + +mysql_note "mysql-mcp: installing the mysql_mcp tool pack" +docker_process_sql --database=mysql ` (amd64, arm64) is the official `mysql` image with `component_mcp_guard` and the `mysql_mcp` SQL tool pack. It is separate from the MyVector images. On first start, with an empty data directory, it installs both, grants `MCP_ADMIN` to the root accounts, and reads these settings: + +| Variable | Effect | +|---|---| +| `MYSQL_MCP_USER`, `MYSQL_MCP_PASSWORD` | Create this account (host `%`) for mysql-mcp-server, with `MCP_USER`, `EXECUTE` on `mysql_mcp` and `SELECT, SHOW VIEW` on the databases below. | +| `MYSQL_MCP_DATABASES` | Comma-separated databases the account may read. The default is `MYSQL_DATABASE`. | +| `MYSQL_MCP_ALLOWED_SCHEMAS` | Persisted as `mcp.allowed_schemas`. | +| `MYSQL_MCP_MAX_EXECUTION_TIME_MS` | Persisted as `mcp.max_execution_time_ms`. | + +The other settings keep their defaults (read-only on). The official image's own variables (`MYSQL_ROOT_PASSWORD`, `MYSQL_DATABASE`, ...) work as usual. + +```sh +docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=shop \ + -e MYSQL_MCP_USER=mcp -e MYSQL_MCP_PASSWORD=mcp-secret ghcr.io/askdba/mysql-mcp:9.7 +MYSQL_DSN='mcp:mcp-secret@tcp(127.0.0.1:3306)/shop' mysql-mcp-server +``` + +The images are built and published with MyVector releases (`docker-publish.yml`). `scripts/test-mysql-mcp-image.sh ` tests the first-start setup, and CI (`integration-scripts.yml`, job `mcp-guard`) runs it on each version. + ### MCP sessions A session is an **MCP session** when its account holds `MCP_USER` and not `MCP_ADMIN`. This is decided at connect and at `COM_CHANGE_USER`, from privileges only; connection attributes and statement comments cannot change it. diff --git a/scripts/test-mysql-mcp-image.sh b/scripts/test-mysql-mcp-image.sh new file mode 100755 index 0000000..af52dd2 --- /dev/null +++ b/scripts/test-mysql-mcp-image.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Tests a mysql-mcp image (docker/mysql-mcp/Dockerfile): first-start setup of +# component_mcp_guard, the mysql_mcp tool pack and the MYSQL_MCP_* account and settings, +# and that they survive a restart. +# +# Usage: scripts/test-mysql-mcp-image.sh +# e.g. scripts/test-mysql-mcp-image.sh mysql-mcp-smoke:9.7 +# Requires Docker. bash 3.2+. +set -u + +IMAGE=${1:?image, e.g. mysql-mcp-smoke:9.7} +CNAME=mysql-mcp-image-test-$$ +CNAME_PLAIN=$CNAME-plain +PASS=0 +FAIL=0 +ok() { PASS=$((PASS + 1)); printf ' PASS %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf ' FAIL %s -- %s\n' "$1" "$2"; } +cleanup() { docker rm -f -v "$CNAME" "$CNAME_PLAIN" >/dev/null 2>&1; } +trap cleanup EXIT +expect() { + if printf '%s\n' "$2" | grep -Eq -- "$3"; then ok "$1"; else bad "$1" "got: $(printf '%s' "$2" | head -c 300)"; fi +} + +# as CONTAINER USER PASS SQL +as() { docker exec -i "$1" mysql -h127.0.0.1 -u"$2" -p"$3" -N --batch -e "$4" 2>&1 | grep -v "Using a password"; } +wait_ready() { + for _ in $(seq 1 120); do + docker exec "$1" mysql -h127.0.0.1 -uroot -p"$2" -e 'select 1' >/dev/null 2>&1 && return 0 + sleep 2 + done + return 1 +} + +echo "== $IMAGE" +# A database name with _ and a password with quotes check the init script's quoting. +docker run -d --name "$CNAME" -e MYSQL_ROOT_PASSWORD=testpass -e MYSQL_DATABASE=app_db \ + -e MYSQL_MCP_USER=mcp -e "MYSQL_MCP_PASSWORD=it's\"a\\pass" \ + -e MYSQL_MCP_ALLOWED_SCHEMAS=app_db -e MYSQL_MCP_MAX_EXECUTION_TIME_MS=5000 "$IMAGE" >/dev/null || exit 1 +wait_ready "$CNAME" testpass || { docker logs "$CNAME" 2>&1 | tail -30; echo "server did not start"; exit 1; } +root() { as "$CNAME" root testpass "$1"; } +mcp() { as "$CNAME" mcp "it's\"a\\pass" "$1"; } +echo " server: $(root 'select version()')" + +expect "init script ran without errors" "$(docker logs "$CNAME" 2>&1 | grep -E 'mysql-mcp:|ERROR' | tr '\n' ' ')" "mysql-mcp: creating MCP account mcp" +expect "no init errors" "$(docker logs "$CNAME" 2>&1 | grep -c '\[ERROR\] \[Entrypoint\]')" "^0$" +expect "component_mcp_guard installed" "$(root "SELECT component_urn FROM mysql.component")" "component_mcp_guard" +expect "mysql_mcp tool pack installed" "$(root "CALL mysql_mcp.version()")" "^[0-9]+\.[0-9]+\.[0-9]+$" +expect "root has MCP_ADMIN" "$(root "SHOW GRANTS FOR root@'%'")" "MCP_ADMIN" +expect "settings persisted" "$(root "SELECT CONCAT_WS(',', @@mcp.allowed_schemas, @@mcp.max_execution_time_ms, @@mcp.read_only)")" "^app_db,5000,1$" +expect "MCP account grants" "$(root "SHOW GRANTS FOR mcp@'%'" | tr '\n' ' ')" "MCP_USER.*SELECT, SHOW VIEW ON \`app[\\]+_db\`.*EXECUTE ON \`mysql_mcp\`" # --batch doubles the backslash +root "CREATE TABLE app_db.t (i INT); INSERT INTO app_db.t VALUES (1), (2)" >/dev/null +expect "MCP account reads its database" "$(mcp "SELECT COUNT(*) FROM app_db.t")" "^2$" +expect "MCP account uses the tool pack" "$(mcp "CALL mysql_mcp.list_tables('app_db')")" "^t" +expect "MCP account gets the time limit" "$(mcp "SELECT @@max_execution_time")" "^5000$" +expect "guard applies to the MCP account" "$(mcp "SELECT * FROM app_db.t FOR UPDATE")" "ERROR 1290 .*mcp.read_only" +expect "root can change mcp.* settings" "$(root "SET GLOBAL mcp.read_only = ON")" "^$" + +docker restart "$CNAME" >/dev/null && wait_ready "$CNAME" testpass +expect "after restart: guard loaded, settings kept" "$(root "SELECT CONCAT_WS(',', @@mcp.allowed_schemas, @@mcp.max_execution_time_ms)")" "^app_db,5000$" +expect "after restart: setup not run again" "$(docker logs "$CNAME" 2>&1 | grep -c 'mysql-mcp: installing component_mcp_guard')" "^1$" + +# Without MYSQL_MCP_* settings: component and tool pack only. +docker run -d --name "$CNAME_PLAIN" -e MYSQL_ROOT_PASSWORD=testpass "$IMAGE" >/dev/null +if wait_ready "$CNAME_PLAIN" testpass; then + expect "plain: component and tool pack" "$(as "$CNAME_PLAIN" root testpass "SELECT COUNT(*) FROM mysql.component WHERE component_urn LIKE '%mcp_guard'; CALL mysql_mcp.version()" | tr '\n' ' ')" "^1 [0-9]+\.[0-9]+\.[0-9]+ $" + expect "plain: default settings" "$(as "$CNAME_PLAIN" root testpass "SELECT CONCAT_WS(',', @@mcp.enabled, @@mcp.read_only, IFNULL(@@mcp.allowed_schemas, ''))")" "^1,1,$" + expect "plain: no MCP account" "$(as "$CNAME_PLAIN" root testpass "SELECT COUNT(*) FROM mysql.user WHERE user = 'mcp'")" "^0$" +else + bad "plain container" "did not start" +fi + +echo "== $IMAGE: $PASS passed, $FAIL failed" +[ "$FAIL" = 0 ] From 177b70b59210934ca413a967905fc5e4d1a4bdd9 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 11:37:55 +0000 Subject: [PATCH 02/11] fix(docker): base mysql-mcp images on the guard's exact build release The dry-run publish showed component_mcp_guard built against 8.4.8 crashing mysql:8.4 (8.4.11) at its first refusal (signal 11); the same build on mysql:8.4.8 passes. Reproduced locally. The publish job now builds the image FROM mysql: (8.4.8, 9.7.0, 26.10.0) and also tags it with that release; docs say a component must match the server's exact release. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 17 ++++++++++++++--- CHANGELOG.md | 2 +- docker/mysql-mcp/Dockerfile | 4 +++- docs/MCP_COMPONENTS.md | 5 +++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6f6ddc3..515a226 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -319,10 +319,13 @@ jobs: build-args: | MYSQL_VERSION=${{ matrix.mysql-version }} + # A component works only on the patch release it was built against (the 8.4.8 guard + # crashes 8.4.11), so the mysql-mcp image is based on that exact release. - name: Test mysql-mcp image env: - MYSQL_VERSION: ${{ matrix.mysql-version }} + MYSQL_TAG: ${{ matrix.mysql-tag }} run: | + MYSQL_VERSION=${MYSQL_TAG#mysql-} for arch in amd64 arm64; do docker buildx build \ --platform "linux/${arch}" \ @@ -338,6 +341,12 @@ jobs: docker run --rm --platform linux/arm64 --entrypoint /bin/sh "mysql-mcp-smoke:${MYSQL_VERSION}-arm64" -c \ "test -f /usr/lib64/mysql/plugin/component_mcp_guard.so && test -f /usr/share/mysql-mcp/mysql_mcp.sql && test -f /docker-entrypoint-initdb.d/50-mysql-mcp.sh" + - name: mysql-mcp base release + id: mcp-version + env: + MYSQL_TAG: ${{ matrix.mysql-tag }} + run: echo "version=${MYSQL_TAG#mysql-}" >> "$GITHUB_OUTPUT" + - name: Build and push mysql-mcp image uses: docker/build-push-action@v5 with: @@ -346,6 +355,8 @@ jobs: platforms: linux/amd64,linux/arm64 # Never push on a dry_run dispatch — build only, to prove the pipeline. push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} - tags: ghcr.io/${{ github.repository_owner }}/mysql-mcp:${{ matrix.mysql-version }} + tags: | + ghcr.io/${{ github.repository_owner }}/mysql-mcp:${{ matrix.mysql-version }} + ghcr.io/${{ github.repository_owner }}/mysql-mcp:${{ steps.mcp-version.outputs.version }} build-args: | - MYSQL_VERSION=${{ matrix.mysql-version }} + MYSQL_VERSION=${{ steps.mcp-version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec19f0..5ab81a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` is the official MySQL image with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (17 checks on 8.4.11, 9.7.2 and 26.10.0). +- **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (also tagged with the exact release) is the official MySQL image of the release the component is built for, with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (17 checks on 8.4.11, 9.7.2 and 26.10.0). - **`component_mcp_guard` time limit and refusal log** (Phase 2 of RFC 0001, completed). - **`mcp.max_execution_time_ms`:** sets an MCP session's `max_execution_time` at connect. Attempts to raise it are refused with error 1290: via `SET`, or via the `MAX_EXECUTION_TIME(n)` or `SET_VAR(max_execution_time=n)` hints. Lowering it is allowed. - **`mcp.log_denials` (default ON):** writes each refusal to the error log as `[MY-011071] [mcp_guard]`, with string literals redacted, at most 10 lines a minute. diff --git a/docker/mysql-mcp/Dockerfile b/docker/mysql-mcp/Dockerfile index 312ee1b..ec607c7 100644 --- a/docker/mysql-mcp/Dockerfile +++ b/docker/mysql-mcp/Dockerfile @@ -2,7 +2,9 @@ # (askdba/mysql-mcp-server): component_mcp_guard and the mysql_mcp SQL tool pack. # Published as ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10> by docker-publish.yml. # Build context: the repository root, with component_mcp_guard-.so staged there. -ARG MYSQL_VERSION=9.7 +# MYSQL_VERSION must be the exact release the component was built against (for example +# 8.4.8): a component built for another patch release can crash the server. +ARG MYSQL_VERSION=9.7.2 FROM mysql:${MYSQL_VERSION} ARG TARGETARCH diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 73e2f75..3adb53c 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -14,7 +14,8 @@ Enforces a policy on the sessions of mysql-mcp-server, inside `mysqld`, so a cli ### Install ```sql --- copy component_mcp_guard.so into @@plugin_dir first +-- copy component_mcp_guard.so into @@plugin_dir first: the build for this server's +-- exact release (a build for another patch release, even 8.4.8 on 8.4.11, can crash it) INSTALL COMPONENT 'file://component_mcp_guard'; GRANT MCP_ADMIN ON *.* TO 'dba'@'%'; -- who may change mcp.* settings GRANT MCP_USER ON *.* TO 'mcp'@'%'; -- the account mysql-mcp-server uses @@ -25,7 +26,7 @@ GRANT EXECUTE ON mysql_mcp.* TO 'mcp'@'%'; -- if the SQL tool pack is install ### Docker images (mysql-mcp) -`ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (amd64, arm64) is the official `mysql` image with `component_mcp_guard` and the `mysql_mcp` SQL tool pack. It is separate from the MyVector images. On first start, with an empty data directory, it installs both, grants `MCP_ADMIN` to the root accounts, and reads these settings: +`ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (amd64, arm64; also tagged with the exact release, such as `8.4.8`) is the official `mysql` image of the release the component was built for, with `component_mcp_guard` and the `mysql_mcp` SQL tool pack. It is separate from the MyVector images. On first start, with an empty data directory, it installs both, grants `MCP_ADMIN` to the root accounts, and reads these settings: | Variable | Effect | |---|---| From f888106617bf8239c9c26cd189f8f14b91505bd0 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 12:16:10 +0000 Subject: [PATCH 03/11] =?UTF-8?q?fix(docker):=20review=20of=20#244=20?= =?UTF-8?q?=E2=80=94=20required=20release,=20TCP=20healthcheck,=20NO=5FBAC?= =?UTF-8?q?KSLASH=5FESCAPES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: MYSQL_VERSION has no default, so an image cannot pair the guard with a different release by accident. - HEALTHCHECK: mysqladmin ping over TCP without credentials (ping succeeds on access denied), so MYSQL_RANDOM_ROOT_PASSWORD is probed too and the container is healthy only after first-start setup (the temporary server has no TCP). - Init script: batches with string literals clear NO_BACKSLASH_ESCAPES for the session, so backslashes in MYSQL_MCP_PASSWORD and settings are kept as given. - test-mysql-mcp-image.sh: NO_BACKSLASH_ESCAPES server with a backslash password and setting, health probe (20 checks). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docker/mysql-mcp/Dockerfile | 15 ++++++++------- docker/mysql-mcp/mysql-mcp-init.sh | 14 +++++++++----- scripts/test-mysql-mcp-image.sh | 13 +++++++++++++ 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab81a4..a77f0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (also tagged with the exact release) is the official MySQL image of the release the component is built for, with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (17 checks on 8.4.11, 9.7.2 and 26.10.0). +- **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (also tagged with the exact release) is the official MySQL image of the release the component is built for, with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (20 checks on 8.4.11, 9.7.2 and 26.10.0). - **`component_mcp_guard` time limit and refusal log** (Phase 2 of RFC 0001, completed). - **`mcp.max_execution_time_ms`:** sets an MCP session's `max_execution_time` at connect. Attempts to raise it are refused with error 1290: via `SET`, or via the `MAX_EXECUTION_TIME(n)` or `SET_VAR(max_execution_time=n)` hints. Lowering it is allowed. - **`mcp.log_denials` (default ON):** writes each refusal to the error log as `[MY-011071] [mcp_guard]`, with string literals redacted, at most 10 lines a minute. diff --git a/docker/mysql-mcp/Dockerfile b/docker/mysql-mcp/Dockerfile index ec607c7..c0b1a1f 100644 --- a/docker/mysql-mcp/Dockerfile +++ b/docker/mysql-mcp/Dockerfile @@ -2,9 +2,10 @@ # (askdba/mysql-mcp-server): component_mcp_guard and the mysql_mcp SQL tool pack. # Published as ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10> by docker-publish.yml. # Build context: the repository root, with component_mcp_guard-.so staged there. -# MYSQL_VERSION must be the exact release the component was built against (for example -# 8.4.8): a component built for another patch release can crash the server. -ARG MYSQL_VERSION=9.7.2 +# MYSQL_VERSION (required, no default) must be the exact release the component was built +# against, for example 8.4.8: a component built for another patch release can crash the +# server. +ARG MYSQL_VERSION FROM mysql:${MYSQL_VERSION} ARG TARGETARCH @@ -21,7 +22,7 @@ COPY sql/mcp/mysql_mcp.sql sql/mcp/uninstall.sql sql/mcp/LICENSE /usr/share/mysq COPY docker/mysql-mcp/mysql-mcp-init.sh /docker-entrypoint-initdb.d/50-mysql-mcp.sh # checkov:skip=CKV_DOCKER_3:MySQL entrypoint needs root for initialization -HEALTHCHECK --interval=30s --timeout=5s --retries=5 CMD \ - if [ -n "$MYSQL_RANDOM_ROOT_PASSWORD" ] && [ -z "${MYSQL_ROOT_PASSWORD:-}" ]; then exit 0; fi; \ - if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then mysqladmin ping -uroot -p"$MYSQL_ROOT_PASSWORD" --silent; \ - else mysqladmin ping -uroot --silent; fi +# Over TCP, which the entrypoint's temporary server does not listen on, so the container is +# healthy only after first-start setup. ping succeeds even when access is denied, so it +# needs no password (MYSQL_RANDOM_ROOT_PASSWORD included). +HEALTHCHECK --interval=30s --timeout=5s --retries=5 CMD mysqladmin ping -h127.0.0.1 --silent diff --git a/docker/mysql-mcp/mysql-mcp-init.sh b/docker/mysql-mcp/mysql-mcp-init.sh index 8a57b6a..579bc96 100644 --- a/docker/mysql-mcp/mysql-mcp-init.sh +++ b/docker/mysql-mcp/mysql-mcp-init.sh @@ -10,7 +10,10 @@ # - MYSQL_MCP_ALLOWED_SCHEMAS, MYSQL_MCP_MAX_EXECUTION_TIME_MS: persisted as # mcp.allowed_schemas and mcp.max_execution_time_ms. -_mcp_sql_literal() { # a single-quoted SQL string literal +# Batches with literals start with this, so _mcp_sql_literal's escaping holds even when +# the server runs with NO_BACKSLASH_ESCAPES. +_mcp_backslash_escapes="SET SESSION sql_mode = REPLACE(@@SESSION.sql_mode, 'NO_BACKSLASH_ESCAPES', '');" +_mcp_sql_literal() { # a single-quoted SQL string literal (with backslash escapes) local s=${1//\\/\\\\} printf "'%s'" "${s//\'/\'\'}" } @@ -29,7 +32,7 @@ done mysql_note "mysql-mcp: installing the mysql_mcp tool pack" docker_process_sql --database=mysql /dev/null && wait_ready "$CNAME" testpass expect "after restart: guard loaded, settings kept" "$(root "SELECT CONCAT_WS(',', @@mcp.allowed_schemas, @@mcp.max_execution_time_ms)")" "^app_db,5000$" expect "after restart: setup not run again" "$(docker logs "$CNAME" 2>&1 | grep -c 'mysql-mcp: installing component_mcp_guard')" "^1$" +expect "healthy once setup is done" "$(docker exec "$CNAME" mysqladmin ping -h127.0.0.1 --silent 2>&1; echo "rc=$?")" "rc=0" + # Without MYSQL_MCP_* settings: component and tool pack only. docker run -d --name "$CNAME_PLAIN" -e MYSQL_ROOT_PASSWORD=testpass "$IMAGE" >/dev/null if wait_ready "$CNAME_PLAIN" testpass; then @@ -68,6 +70,17 @@ if wait_ready "$CNAME_PLAIN" testpass; then else bad "plain container" "did not start" fi +docker rm -f -v "$CNAME_PLAIN" >/dev/null 2>&1 + +# NO_BACKSLASH_ESCAPES: the password with a backslash still works as configured. +docker run -d --name "$CNAME_PLAIN" -e MYSQL_ROOT_PASSWORD=testpass -e MYSQL_MCP_USER=mcp \ + -e 'MYSQL_MCP_PASSWORD=a\b'"'"'c' -e 'MYSQL_MCP_ALLOWED_SCHEMAS=x\y' "$IMAGE" --sql-mode=NO_BACKSLASH_ESCAPES >/dev/null +if wait_ready "$CNAME_PLAIN" testpass; then + expect "NO_BACKSLASH_ESCAPES: MCP account logs in with its password" "$(as "$CNAME_PLAIN" mcp 'a\b'"'"'c' "SELECT CURRENT_USER()")" "^mcp@%$" + expect "NO_BACKSLASH_ESCAPES: settings keep the backslash" "$(as "$CNAME_PLAIN" root testpass "SELECT @@mcp.allowed_schemas")" '^x\\?\\y$' +else + bad "NO_BACKSLASH_ESCAPES container" "did not start" +fi echo "== $IMAGE: $PASS passed, $FAIL failed" [ "$FAIL" = 0 ] From a778ac000deacd15f692192c6ea3d88cd7c9301c Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 14:21:47 +0000 Subject: [PATCH 04/11] fix(docker): base component images on their exact build release A component works only on the MySQL release it was built against: a component_mcp_guard built for 8.4.8 crashed mysql:8.4 (8.4.11) in the #244 dry run. The component images were built for 8.4.8 / 9.7.0 but based on the floating mysql:8.4 / mysql:9.7. - docker-publish.yml: build the component for 8.4.11, 9.7.2, 26.10.0, base each image on mysql:, also tag it with the release (mysql8.4.11-component, mysql9.7.2-component, mysql26.10.0), and run the artifact smoke test on the same release. - Dockerfile.component: MYSQL_VERSION is required (no default). - docs/DOCKER_IMAGES.md, CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 23 +++++++++++++++++------ CHANGELOG.md | 1 + Dockerfile.component | 5 ++++- docs/DOCKER_IMAGES.md | 6 ++++++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6bd7d46..94d35bd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -226,18 +226,27 @@ jobs: fail-fast: false matrix: include: - - mysql-tag: 'mysql-8.4.8' + # A component works only on the exact release it was built against (a build for + # 8.4.8 crashed 8.4.11 in askdba/myvector#244), so each image is based on + # mysql:, the release of mysql-tag, and also tagged with it. + - mysql-tag: 'mysql-8.4.11' + mysql-release: '8.4.11' mysql-version: '8.4' build-script: scripts/build-component-8.4-docker.sh image-tag: 'mysql8.4-component' - - mysql-tag: 'mysql-9.7.0' + release-image-tag: 'mysql8.4.11-component' + - mysql-tag: 'mysql-9.7.2' + mysql-release: '9.7.2' mysql-version: '9.7' build-script: scripts/build-component-9.7-docker.sh image-tag: 'mysql9.7-component' + release-image-tag: 'mysql9.7.2-component' - mysql-tag: 'mysql-26.10.0' + mysql-release: '26.10.0' mysql-version: '26.10' build-script: scripts/build-component-26.10-docker.sh image-tag: 'mysql26.10' + release-image-tag: 'mysql26.10.0' steps: - name: Checkout MyVector Code uses: actions/checkout@v4 @@ -269,7 +278,7 @@ jobs: - name: Smoke test component artifact run: | chmod +x scripts/smoke-component.sh - COMPONENT_DIR=build/component-amd64 ./scripts/smoke-component.sh "${{ matrix.mysql-version }}" + COMPONENT_DIR=build/component-amd64 ./scripts/smoke-component.sh "${{ matrix.mysql-release }}" - name: Smoke test image contents run: | @@ -277,7 +286,7 @@ jobs: docker buildx build \ --platform "linux/${arch}" \ --file Dockerfile.component \ - --build-arg MYSQL_VERSION=${{ matrix.mysql-version }} \ + --build-arg MYSQL_VERSION=${{ matrix.mysql-release }} \ -t "myvector-component-smoke:${{ matrix.mysql-version }}-${arch}" \ --load \ . @@ -308,6 +317,8 @@ jobs: platforms: linux/amd64,linux/arm64 # Never push on a dry_run dispatch — build only, to prove the pipeline. push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} - tags: ghcr.io/${{ github.repository }}:${{ matrix.image-tag }} + tags: | + ghcr.io/${{ github.repository }}:${{ matrix.image-tag }} + ghcr.io/${{ github.repository }}:${{ matrix.release-image-tag }} build-args: | - MYSQL_VERSION=${{ matrix.mysql-version }} + MYSQL_VERSION=${{ matrix.mysql-release }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e404d56..16d90df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and rebuild the index. See [Declaring a Vector Column](docs/usage.md#declaring-a-vector-column). ### Fixed +- **Component images pinned to their build release.** A component works only on the exact MySQL release it was built against: in a dry run, a `component_mcp_guard` built for 8.4.8 crashed `mysql:8.4` (8.4.11). The images were built for 8.4.8 and 9.7.0 but ran on the floating `mysql:8.4`/`mysql:9.7`. `docker-publish.yml` now builds the component images for **8.4.11, 9.7.2 and 26.10.0** and bases each one on `mysql:`. It also tags each image with its release (`mysql8.4.11-component`, `mysql9.7.2-component`, `mysql26.10.0`), and the artifact smoke test runs on the same release. `Dockerfile.component` now requires `MYSQL_VERSION` (no default). - **Component build scripts reconfigure the server tree after a failed configure.** A `.configure-ok` marker is written only after `cmake` succeeds; before, the scripts trusted `CMakeCache.txt`, which appears before configuration finishes, so a failed configure was skipped on the next run. - **Component build scripts no longer re-clone the MySQL source on every run.** Inside the build container, git refused the host-owned tree ("dubious ownership"), so the tag check always failed and the tree was deleted and cloned again, which also defeated the CI cache of the source tree. The scripts now mark the tree as a git safe directory. - **Component: `myvector_construct()` with a constant argument is built once, not per row** diff --git a/Dockerfile.component b/Dockerfile.component index ea26434..6f36b37 100644 --- a/Dockerfile.component +++ b/Dockerfile.component @@ -1,4 +1,7 @@ -ARG MYSQL_VERSION=8.4 +# MYSQL_VERSION (required, no default) is the exact release the component was built +# against, for example 8.4.11: a component built for another patch release can crash +# the server. +ARG MYSQL_VERSION FROM mysql:${MYSQL_VERSION} ARG TARGETARCH diff --git a/docs/DOCKER_IMAGES.md b/docs/DOCKER_IMAGES.md index 473495b..eb7774c 100644 --- a/docs/DOCKER_IMAGES.md +++ b/docs/DOCKER_IMAGES.md @@ -221,6 +221,12 @@ instructions in the main README: | `ghcr.io/askdba/myvector:mysql26.10` | 26.10.x (Innovation) | Component only (`INSTALL COMPONENT`) | | `ghcr.io/askdba/myvector:latest` | 8.0.x | Plugin (`INSTALL PLUGIN`) | +Each component image is based on the exact MySQL release its component was built +against: a component built for one patch release can crash another (an 8.4.8 build +crashed 8.4.11). The image is also tagged with that release, e.g. +`mysql8.4.11-component`, `mysql9.7.2-component`, `mysql26.10.0`; the version tags +above move to the new release when the build is updated. + Component images (`INSTALL COMPONENT`) contain `/usr/lib/mysql/plugin/myvector.so` + `myvector.json` and `/docker-entrypoint-initdb.d/myvector_install_component.sql`, rather than the plugin's `myvectorplugin.sql`. From 36b31cb24293f01f910d30e6467f01602758db1c Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 14:57:13 +0000 Subject: [PATCH 05/11] fix(docker): pin component images to 8.4.13 and 9.7.4 The floating mysql:8.4 and mysql:9.7 tags have moved to 8.4.13 and 9.7.4; build and base the component images on those, so pinning does not move users to an older server. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 12 ++++++------ CHANGELOG.md | 2 +- Dockerfile.component | 2 +- docs/DOCKER_IMAGES.md | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 94d35bd..7a91d1a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -229,18 +229,18 @@ jobs: # A component works only on the exact release it was built against (a build for # 8.4.8 crashed 8.4.11 in askdba/myvector#244), so each image is based on # mysql:, the release of mysql-tag, and also tagged with it. - - mysql-tag: 'mysql-8.4.11' - mysql-release: '8.4.11' + - mysql-tag: 'mysql-8.4.13' + mysql-release: '8.4.13' mysql-version: '8.4' build-script: scripts/build-component-8.4-docker.sh image-tag: 'mysql8.4-component' - release-image-tag: 'mysql8.4.11-component' - - mysql-tag: 'mysql-9.7.2' - mysql-release: '9.7.2' + release-image-tag: 'mysql8.4.13-component' + - mysql-tag: 'mysql-9.7.4' + mysql-release: '9.7.4' mysql-version: '9.7' build-script: scripts/build-component-9.7-docker.sh image-tag: 'mysql9.7-component' - release-image-tag: 'mysql9.7.2-component' + release-image-tag: 'mysql9.7.4-component' - mysql-tag: 'mysql-26.10.0' mysql-release: '26.10.0' mysql-version: '26.10' diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d90df..01fe300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,7 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and rebuild the index. See [Declaring a Vector Column](docs/usage.md#declaring-a-vector-column). ### Fixed -- **Component images pinned to their build release.** A component works only on the exact MySQL release it was built against: in a dry run, a `component_mcp_guard` built for 8.4.8 crashed `mysql:8.4` (8.4.11). The images were built for 8.4.8 and 9.7.0 but ran on the floating `mysql:8.4`/`mysql:9.7`. `docker-publish.yml` now builds the component images for **8.4.11, 9.7.2 and 26.10.0** and bases each one on `mysql:`. It also tags each image with its release (`mysql8.4.11-component`, `mysql9.7.2-component`, `mysql26.10.0`), and the artifact smoke test runs on the same release. `Dockerfile.component` now requires `MYSQL_VERSION` (no default). +- **Component images pinned to their build release.** A component works only on the exact MySQL release it was built against: in a dry run, a `component_mcp_guard` built for 8.4.8 crashed `mysql:8.4` (8.4.11). The images were built for 8.4.8 and 9.7.0 but ran on the floating `mysql:8.4`/`mysql:9.7`. `docker-publish.yml` now builds the component images for **8.4.13, 9.7.4 and 26.10.0** (the releases the floating tags now point to) and bases each one on `mysql:`. It also tags each image with its release (`mysql8.4.13-component`, `mysql9.7.4-component`, `mysql26.10.0`), and the artifact smoke test runs on the same release. `Dockerfile.component` now requires `MYSQL_VERSION` (no default). - **Component build scripts reconfigure the server tree after a failed configure.** A `.configure-ok` marker is written only after `cmake` succeeds; before, the scripts trusted `CMakeCache.txt`, which appears before configuration finishes, so a failed configure was skipped on the next run. - **Component build scripts no longer re-clone the MySQL source on every run.** Inside the build container, git refused the host-owned tree ("dubious ownership"), so the tag check always failed and the tree was deleted and cloned again, which also defeated the CI cache of the source tree. The scripts now mark the tree as a git safe directory. - **Component: `myvector_construct()` with a constant argument is built once, not per row** diff --git a/Dockerfile.component b/Dockerfile.component index 6f36b37..f6c2e0d 100644 --- a/Dockerfile.component +++ b/Dockerfile.component @@ -1,5 +1,5 @@ # MYSQL_VERSION (required, no default) is the exact release the component was built -# against, for example 8.4.11: a component built for another patch release can crash +# against, for example 8.4.13: a component built for another patch release can crash # the server. ARG MYSQL_VERSION diff --git a/docs/DOCKER_IMAGES.md b/docs/DOCKER_IMAGES.md index eb7774c..2b38733 100644 --- a/docs/DOCKER_IMAGES.md +++ b/docs/DOCKER_IMAGES.md @@ -224,7 +224,7 @@ instructions in the main README: Each component image is based on the exact MySQL release its component was built against: a component built for one patch release can crash another (an 8.4.8 build crashed 8.4.11). The image is also tagged with that release, e.g. -`mysql8.4.11-component`, `mysql9.7.2-component`, `mysql26.10.0`; the version tags +`mysql8.4.13-component`, `mysql9.7.4-component`, `mysql26.10.0`; the version tags above move to the new release when the build is updated. Component images (`INSTALL COMPONENT`) contain `/usr/lib/mysql/plugin/myvector.so` + From cff37484ed7afe50883b3259efd490e0db3cc72d Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 14:57:43 +0000 Subject: [PATCH 06/11] docs(examples): build the movie-finder image on the component's release The example built the component for mysql-9.7.0 and the image FROM the floating mysql:9.7; use 9.7.4 for both (CodeRabbit, #245). Co-Authored-By: Claude Opus 5.5 --- examples/movie-finder/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/movie-finder/README.md b/examples/movie-finder/README.md index 717ddc1..2f52415 100644 --- a/examples/movie-finder/README.md +++ b/examples/movie-finder/README.md @@ -91,10 +91,11 @@ a restart. Until a release includes them, build a local image from `main`: ```bash # from the repository root; ARCH is arm64 or amd64, matching your machine ARCH=arm64 -./scripts/build-component-9.7-docker.sh mysql-9.7.0 build/component-local +./scripts/build-component-9.7-docker.sh mysql-9.7.4 build/component-local cp build/component-local/libmyvector_component.so myvector-component-$ARCH.so cp build/component-local/myvector.json sql/myvector_install_component.sql sql/myvector_uninstall_component.sql . -docker build -f Dockerfile.component --build-arg MYSQL_VERSION=9.7 \ +# MYSQL_VERSION must be the release the component was built for (mysql-9.7.4 above) +docker build -f Dockerfile.component --build-arg MYSQL_VERSION=9.7.4 \ --build-arg TARGETARCH=$ARCH -t myvector-local:mysql9.7-component . cd examples/movie-finder From ea04398c413ab404eb88539877f103d89a53b572 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 14:58:53 +0000 Subject: [PATCH 07/11] fix(docker): base plugin images on their exact build release The plugin images were built for 8.0.45 / 8.4.8 / 9.7.0 but based on the floating mysql:8.0 / 8.4 / 9.7 (now 8.0.46 / 8.4.13 / 9.7.4); a library built for one MySQL release can crash another (an 8.4.8 component crashed 8.4.11 in #244's dry run). - docker-publish.yml (plugin job): build for 8.0.46, 8.4.13, 9.7.4, base each image on mysql:, also tag it with the release; run on a dry_run dispatch too, building and testing without pushing. - Dockerfile.oraclelinux8/9: MYSQL_VERSION is required (no default). - build-docker-local.sh: same releases; reject versions other than 8.0/8.4/9.7. - benchmark-issue79.yml: base the image on 8.4.8, the release it builds. - docs/DOCKER_IMAGES.md, CHANGELOG. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/benchmark-issue79.yml | 2 +- .github/workflows/docker-publish.yml | 33 ++++++++++++++++--------- CHANGELOG.md | 1 + Dockerfile.oraclelinux8 | 4 ++- Dockerfile.oraclelinux9 | 4 ++- docs/DOCKER_IMAGES.md | 5 ++++ scripts/build-docker-local.sh | 19 ++++++++++---- 7 files changed, 49 insertions(+), 19 deletions(-) diff --git a/.github/workflows/benchmark-issue79.yml b/.github/workflows/benchmark-issue79.yml index ebf1626..022d430 100644 --- a/.github/workflows/benchmark-issue79.yml +++ b/.github/workflows/benchmark-issue79.yml @@ -70,7 +70,7 @@ jobs: - name: Build Docker image run: | docker build \ - --build-arg MYSQL_VERSION=8.4 \ + --build-arg MYSQL_VERSION=8.4.8 \ --build-arg TARGETARCH=amd64 \ -t ghcr.io/${{ github.repository }}:mysql8.4-fix-issue79 \ -f Dockerfile.oraclelinux9 \ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6bd7d46..0cd9413 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,22 +20,30 @@ jobs: # Publish plugin images only from a dispatch that targets a VERSION tag # (refs/tags/v*). The version-tag guard stops a branch / non-version-tag # dispatch from overwriting the mutable public plugin tags - # (mysql8.0/8.4/9.7/latest). - if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') + # (mysql8.0/8.4/9.7/latest). A dry_run dispatch builds and tests without pushing. + if: >- + (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v')) || + (github.event_name == 'workflow_dispatch' && inputs.dry_run) runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: include: - - mysql-tag: 'mysql-8.0.45' + # A plugin works only on the exact release it was built against, so each image + # is based on mysql:, the release of mysql-tag, and also tagged + # with it (mysql). + - mysql-tag: 'mysql-8.0.46' + mysql-release: '8.0.46' mysql-version: '8.0' dockerfile: Dockerfile.oraclelinux8 compiler: 10 - - mysql-tag: 'mysql-8.4.8' + - mysql-tag: 'mysql-8.4.13' + mysql-release: '8.4.13' mysql-version: '8.4' dockerfile: Dockerfile.oraclelinux9 compiler: 10 - - mysql-tag: 'mysql-9.7.0' + - mysql-tag: 'mysql-9.7.4' + mysql-release: '9.7.4' mysql-version: '9.7' dockerfile: Dockerfile.oraclelinux9 compiler: 11 @@ -162,7 +170,7 @@ jobs: docker buildx build \ --platform "linux/${arch}" \ --file ${{ matrix.dockerfile }} \ - --build-arg MYSQL_VERSION=${{ matrix.mysql-version }} \ + --build-arg MYSQL_VERSION=${{ matrix.mysql-release }} \ -t "myvector-smoke:${{ matrix.mysql-version }}-${arch}" \ --load \ . @@ -191,10 +199,13 @@ jobs: context: . file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 - push: true - tags: ghcr.io/${{ github.repository }}:mysql${{ matrix.mysql-version }} + # Never push on a dry_run dispatch — build only, to prove the pipeline. + push: ${{ !inputs.dry_run }} + tags: | + ghcr.io/${{ github.repository }}:mysql${{ matrix.mysql-version }} + ghcr.io/${{ github.repository }}:mysql${{ matrix.mysql-release }} build-args: | - MYSQL_VERSION=${{ matrix.mysql-version }} + MYSQL_VERSION=${{ matrix.mysql-release }} - name: Tag and push latest if: matrix.mysql-version == '8.0' @@ -203,10 +214,10 @@ jobs: context: . file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 - push: true + push: ${{ !inputs.dry_run }} tags: ghcr.io/${{ github.repository }}:latest build-args: | - MYSQL_VERSION=${{ matrix.mysql-version }} + MYSQL_VERSION=${{ matrix.mysql-release }} # Component-based images (INSTALL COMPONENT), distinct from the legacy plugin # images built above. mysql8.4/mysql9.7 already ship as plugin images with diff --git a/CHANGELOG.md b/CHANGELOG.md index e404d56..9004787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and rebuild the index. See [Declaring a Vector Column](docs/usage.md#declaring-a-vector-column). ### Fixed +- **Plugin images pinned to their build release.** The plugin images were built for 8.0.45, 8.4.8 and 9.7.0 but based on the floating `mysql:8.0`/`mysql:8.4`/`mysql:9.7` (now 8.0.46, 8.4.13, 9.7.4), and a library built for one MySQL release can crash another. `docker-publish.yml` now builds them for **8.0.46, 8.4.13 and 9.7.4**, bases each image on `mysql:` and also tags it with the release (`mysql8.0.46`, `mysql8.4.13`, `mysql9.7.4`). The plugin job also runs on a `dry_run` dispatch, building and testing without pushing. `Dockerfile.oraclelinux8`/`9` now require `MYSQL_VERSION`. `scripts/build-docker-local.sh` builds 8.0, 8.4 or 9.7 on the same releases and rejects other versions (it used to map 9.0 to 9.0.0). - **Component build scripts reconfigure the server tree after a failed configure.** A `.configure-ok` marker is written only after `cmake` succeeds; before, the scripts trusted `CMakeCache.txt`, which appears before configuration finishes, so a failed configure was skipped on the next run. - **Component build scripts no longer re-clone the MySQL source on every run.** Inside the build container, git refused the host-owned tree ("dubious ownership"), so the tag check always failed and the tree was deleted and cloned again, which also defeated the CI cache of the source tree. The scripts now mark the tree as a git safe directory. - **Component: `myvector_construct()` with a constant argument is built once, not per row** diff --git a/Dockerfile.oraclelinux8 b/Dockerfile.oraclelinux8 index 6e3e047..1e05d7a 100644 --- a/Dockerfile.oraclelinux8 +++ b/Dockerfile.oraclelinux8 @@ -1,4 +1,6 @@ -ARG MYSQL_VERSION=8.0 +# MYSQL_VERSION (required, no default) is the exact release the plugin was built against, +# for example 8.4.13: a plugin built for another release can crash the server. +ARG MYSQL_VERSION FROM mysql:${MYSQL_VERSION} ARG TARGETARCH diff --git a/Dockerfile.oraclelinux9 b/Dockerfile.oraclelinux9 index ad5d605..a90227a 100644 --- a/Dockerfile.oraclelinux9 +++ b/Dockerfile.oraclelinux9 @@ -1,4 +1,6 @@ -ARG MYSQL_VERSION=8.0 +# MYSQL_VERSION (required, no default) is the exact release the plugin was built against, +# for example 8.4.13: a plugin built for another release can crash the server. +ARG MYSQL_VERSION # Provide libstdc++ compatible with plugin (built with gcc-10). Use Ubuntu (lighter than Oracle Linux + gcc-toolset). FROM ubuntu:22.04 AS libstdcxx diff --git a/docs/DOCKER_IMAGES.md b/docs/DOCKER_IMAGES.md index 473495b..4eaf125 100644 --- a/docs/DOCKER_IMAGES.md +++ b/docs/DOCKER_IMAGES.md @@ -221,6 +221,11 @@ instructions in the main README: | `ghcr.io/askdba/myvector:mysql26.10` | 26.10.x (Innovation) | Component only (`INSTALL COMPONENT`) | | `ghcr.io/askdba/myvector:latest` | 8.0.x | Plugin (`INSTALL PLUGIN`) | +Each plugin image is based on the exact MySQL release its plugin was built against: a +plugin or component built for one release can crash another. The image is also +tagged with that release (`mysql8.0.46`, `mysql8.4.13`, `mysql9.7.4`); `mysql8.0`, +`mysql8.4`, `mysql9.7` and `latest` move to the new release when the build is updated. + Component images (`INSTALL COMPONENT`) contain `/usr/lib/mysql/plugin/myvector.so` + `myvector.json` and `/docker-entrypoint-initdb.d/myvector_install_component.sql`, rather than the plugin's `myvectorplugin.sql`. diff --git a/scripts/build-docker-local.sh b/scripts/build-docker-local.sh index 2224d1e..643d927 100755 --- a/scripts/build-docker-local.sh +++ b/scripts/build-docker-local.sh @@ -4,7 +4,7 @@ # On x86_64: builds amd64 plugin and image. # # Usage: ./scripts/build-docker-local.sh [mysql-version] -# Example: ./scripts/build-docker-local.sh 8.4 +# Example: ./scripts/build-docker-local.sh 8.4 (8.0, 8.4 or 9.7) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -12,9 +12,18 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" MYSQL_VERSION="${1:-8.4}" -MYSQL_TAG="mysql-8.4.8" -[ "$MYSQL_VERSION" = "8.0" ] && MYSQL_TAG="mysql-8.0.45" -[ "$MYSQL_VERSION" = "9.0" ] && MYSQL_TAG="mysql-9.0.0" +case "$MYSQL_VERSION" in +8.0) MYSQL_TAG="mysql-8.0.46" ;; +8.4) MYSQL_TAG="mysql-8.4.13" ;; +9.7) MYSQL_TAG="mysql-9.7.4" ;; +*) + echo "unsupported MySQL version: $MYSQL_VERSION (use 8.0, 8.4 or 9.7)" >&2 + exit 1 + ;; +esac +# The image is based on the release the plugin is built against (a plugin built for +# another release can crash the server). +MYSQL_RELEASE="${MYSQL_TAG#mysql-}" # Build for native arch on Apple Silicon; amd64 on x86 # Set MYVECTOR_ARCH=amd64 to force amd64 (QEMU) on arm64 - slower but avoids compiler bugs @@ -91,7 +100,7 @@ cp sql/myvectorplugin.sql . echo "Building Docker image ($PLATFORM)..." docker build -f Dockerfile.oraclelinux9 \ --platform "$PLATFORM" \ - --build-arg MYSQL_VERSION="$MYSQL_VERSION" \ + --build-arg MYSQL_VERSION="$MYSQL_RELEASE" \ -t "myvector:mysql${MYSQL_VERSION}-local" . echo "" From 89dc2c6dae92dd1f7744bd209bea6d7ed25a4336 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 15:52:36 +0000 Subject: [PATCH 08/11] fix(build): configure 9.7 without WebAuthn (9.7.4 enables it by default) The dry run of #245 failed configuring mysql-9.7.4: like 26.10, 9.7.4 builds the WebAuthn plugin by default and needs libudev/libfido2 headers the build container lacks. Turn it off as build-component-26.10-docker.sh does; the component build needs only generated headers. Co-Authored-By: Claude Opus 5.5 --- scripts/build-component-9.7-docker.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/build-component-9.7-docker.sh b/scripts/build-component-9.7-docker.sh index 00de2ee..a3ac677 100755 --- a/scripts/build-component-9.7-docker.sh +++ b/scripts/build-component-9.7-docker.sh @@ -95,6 +95,8 @@ docker run --rm \ # so a failed configure would otherwise be skipped on the next run. if [ ! -f "$MYSQL_BLD/.configure-ok" ]; then cd "$MYSQL_BLD" + # WebAuthn: on by default from 9.7.4 (as in 26.10), it needs libudev/libfido2 + # headers this container does not have; the component build needs none of it. cmake .. \ -DCMAKE_C_COMPILER=/opt/rh/gcc-toolset-14/root/usr/bin/gcc \ -DCMAKE_CXX_COMPILER=/opt/rh/gcc-toolset-14/root/usr/bin/g++ \ @@ -106,6 +108,7 @@ docker run --rm \ -DWITH_NDB=OFF \ -DWITH_NDBCLUSTER=OFF \ -DWITH_GROUP_REPLICATION=OFF \ + -DWITH_AUTHENTICATION_WEBAUTHN=OFF \ -DWITH_EXAMPLE_STORAGE_ENGINE=OFF \ -DCMAKE_BUILD_TYPE=Release touch "$MYSQL_BLD/.configure-ok" From 5badedfb49d8a98bee4c712b36bb07cbbf2a813c Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 16:59:28 +0000 Subject: [PATCH 09/11] fix(docker): health check without root logins, on the server's port Review of #244 (CodeRabbit on f888106): - The probe pinged as root without a password; with FAILED_LOGIN_ATTEMPTS on root it locked root out (reproduced: error 3955 after 3 probes). It now pings as a user that does not exist (ping reports a running server even when access is denied, and a nonexistent account cannot be locked). - It follows the port mysqld was started with (--port=N / --port N), not 3306. - docker/mysql-mcp/mysql-mcp-healthcheck.sh holds the probe. - test-mysql-mcp-image.sh: probes do not lock root; probe follows --port (22). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docker/mysql-mcp/Dockerfile | 8 ++++---- docker/mysql-mcp/mysql-mcp-healthcheck.sh | 16 ++++++++++++++++ scripts/test-mysql-mcp-image.sh | 20 +++++++++++++++----- 4 files changed, 36 insertions(+), 10 deletions(-) create mode 100755 docker/mysql-mcp/mysql-mcp-healthcheck.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a77f0e4..aa5aeda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (also tagged with the exact release) is the official MySQL image of the release the component is built for, with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (20 checks on 8.4.11, 9.7.2 and 26.10.0). +- **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (also tagged with the exact release) is the official MySQL image of the release the component is built for, with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (22 checks on 8.4.11, 9.7.2 and 26.10.0). - **`component_mcp_guard` time limit and refusal log** (Phase 2 of RFC 0001, completed). - **`mcp.max_execution_time_ms`:** sets an MCP session's `max_execution_time` at connect. Attempts to raise it are refused with error 1290: via `SET`, or via the `MAX_EXECUTION_TIME(n)` or `SET_VAR(max_execution_time=n)` hints. Lowering it is allowed. - **`mcp.log_denials` (default ON):** writes each refusal to the error log as `[MY-011071] [mcp_guard]`, with string literals redacted, at most 10 lines a minute. diff --git a/docker/mysql-mcp/Dockerfile b/docker/mysql-mcp/Dockerfile index c0b1a1f..90907fb 100644 --- a/docker/mysql-mcp/Dockerfile +++ b/docker/mysql-mcp/Dockerfile @@ -20,9 +20,9 @@ LABEL org.opencontainers.image.title="mysql-mcp" \ COPY component_mcp_guard-${TARGETARCH}.so /usr/lib64/mysql/plugin/component_mcp_guard.so COPY sql/mcp/mysql_mcp.sql sql/mcp/uninstall.sql sql/mcp/LICENSE /usr/share/mysql-mcp/ COPY docker/mysql-mcp/mysql-mcp-init.sh /docker-entrypoint-initdb.d/50-mysql-mcp.sh +COPY docker/mysql-mcp/mysql-mcp-healthcheck.sh /usr/local/bin/mysql-mcp-healthcheck # checkov:skip=CKV_DOCKER_3:MySQL entrypoint needs root for initialization -# Over TCP, which the entrypoint's temporary server does not listen on, so the container is -# healthy only after first-start setup. ping succeeds even when access is denied, so it -# needs no password (MYSQL_RANDOM_ROOT_PASSWORD included). -HEALTHCHECK --interval=30s --timeout=5s --retries=5 CMD mysqladmin ping -h127.0.0.1 --silent +# Healthy only after first-start setup, on the server's port, without touching root's +# login (see the script). +HEALTHCHECK --interval=30s --timeout=5s --retries=5 CMD ["/usr/local/bin/mysql-mcp-healthcheck"] diff --git a/docker/mysql-mcp/mysql-mcp-healthcheck.sh b/docker/mysql-mcp/mysql-mcp-healthcheck.sh new file mode 100755 index 0000000..4865e75 --- /dev/null +++ b/docker/mysql-mcp/mysql-mcp-healthcheck.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Health check of the mysql-mcp image: is mysqld up and done with first-start setup? +# +# - TCP: the entrypoint's temporary setup server does not listen on TCP, so the check +# passes only once the real server runs. +# - The port mysqld was started with (--port=N or --port N on its command line; else 3306). +# - As a user that does not exist, with no password: ping reports a running server even +# when access is denied, and a nonexistent account cannot be locked by failed logins +# (probing as root could lock out administrators with FAILED_LOGIN_ATTEMPTS). +port=$(tr '\0' '\n' /dev/null | awk ' + prev == "--port" || prev == "-P" { p = $0 } + /^--port=/ { p = substr($0, 8) } + { prev = $0 } + END { print p }') +exec mysqladmin ping --protocol=TCP -h127.0.0.1 -P"${port:-3306}" \ + -umysql-mcp-healthcheck --connect-timeout=3 --silent diff --git a/scripts/test-mysql-mcp-image.sh b/scripts/test-mysql-mcp-image.sh index cb18154..9929fd9 100755 --- a/scripts/test-mysql-mcp-image.sh +++ b/scripts/test-mysql-mcp-image.sh @@ -22,10 +22,12 @@ expect() { } # as CONTAINER USER PASS SQL -as() { docker exec -i "$1" mysql -h127.0.0.1 -u"$2" -p"$3" -N --batch -e "$4" 2>&1 | grep -v "Using a password"; } +# PORT: the server's port (default 3306). +as() { docker exec -i "$1" mysql -h127.0.0.1 -P"${PORT:-3306}" -u"$2" -p"$3" -N --batch -e "$4" 2>&1 | grep -v "Using a password"; } +health() { docker exec "$1" /usr/local/bin/mysql-mcp-healthcheck >/dev/null 2>&1; echo "rc=$?"; } wait_ready() { for _ in $(seq 1 120); do - docker exec "$1" mysql -h127.0.0.1 -uroot -p"$2" -e 'select 1' >/dev/null 2>&1 && return 0 + docker exec "$1" mysql -h127.0.0.1 -P"${PORT:-3306}" -uroot -p"$2" -e 'select 1' >/dev/null 2>&1 && return 0 sleep 2 done return 1 @@ -59,7 +61,12 @@ docker restart "$CNAME" >/dev/null && wait_ready "$CNAME" testpass expect "after restart: guard loaded, settings kept" "$(root "SELECT CONCAT_WS(',', @@mcp.allowed_schemas, @@mcp.max_execution_time_ms)")" "^app_db,5000$" expect "after restart: setup not run again" "$(docker logs "$CNAME" 2>&1 | grep -c 'mysql-mcp: installing component_mcp_guard')" "^1$" -expect "healthy once setup is done" "$(docker exec "$CNAME" mysqladmin ping -h127.0.0.1 --silent 2>&1; echo "rc=$?")" "rc=0" +expect "healthy once setup is done" "$(health "$CNAME")" "rc=0" +# With failed-login locking on root, probes must not lock it (they do not log in as root). +root "ALTER USER root@'%' FAILED_LOGIN_ATTEMPTS 2 PASSWORD_LOCK_TIME 1" >/dev/null +health "$CNAME" >/dev/null; health "$CNAME" >/dev/null; health "$CNAME" >/dev/null +expect "health probes do not lock root" "$(root "SELECT 1")" "^1$" +root "ALTER USER root@'%' FAILED_LOGIN_ATTEMPTS 0 PASSWORD_LOCK_TIME 0" >/dev/null # Without MYSQL_MCP_* settings: component and tool pack only. docker run -d --name "$CNAME_PLAIN" -e MYSQL_ROOT_PASSWORD=testpass "$IMAGE" >/dev/null @@ -72,10 +79,13 @@ else fi docker rm -f -v "$CNAME_PLAIN" >/dev/null 2>&1 -# NO_BACKSLASH_ESCAPES: the password with a backslash still works as configured. +# NO_BACKSLASH_ESCAPES: the password with a backslash still works as configured. This +# server also runs on another port, which the health check must follow. +PORT=3307 docker run -d --name "$CNAME_PLAIN" -e MYSQL_ROOT_PASSWORD=testpass -e MYSQL_MCP_USER=mcp \ - -e 'MYSQL_MCP_PASSWORD=a\b'"'"'c' -e 'MYSQL_MCP_ALLOWED_SCHEMAS=x\y' "$IMAGE" --sql-mode=NO_BACKSLASH_ESCAPES >/dev/null + -e 'MYSQL_MCP_PASSWORD=a\b'"'"'c' -e 'MYSQL_MCP_ALLOWED_SCHEMAS=x\y' "$IMAGE" --sql-mode=NO_BACKSLASH_ESCAPES --port=3307 >/dev/null if wait_ready "$CNAME_PLAIN" testpass; then + expect "health check follows --port" "$(health "$CNAME_PLAIN")" "rc=0" expect "NO_BACKSLASH_ESCAPES: MCP account logs in with its password" "$(as "$CNAME_PLAIN" mcp 'a\b'"'"'c' "SELECT CURRENT_USER()")" "^mcp@%$" expect "NO_BACKSLASH_ESCAPES: settings keep the backslash" "$(as "$CNAME_PLAIN" root testpass "SELECT @@mcp.allowed_schemas")" '^x\\?\\y$' else From f949efaa2dbbe77f68e96f1668551b55ca810377 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 21:07:46 +0000 Subject: [PATCH 10/11] test(mcp-guard): views, multi-statement packets, parity table; document function reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 0001 §5.2 item 7 and Q3: - Views: a view over another schema or over mysql is refused, nested views included; a view elsewhere over allowed base tables is allowed. - Multi-statement packets: a later write or disallowed read in one packet is refused, nothing is written. - sql/mcp/guard-parity.tsv: 34 statements with the verdicts of mysql-mcp-server's run_query validator and of the guard, each difference explained; the guard column is checked here (each statement as one packet). - Known limitation (#247): reads inside stored functions and triggers raise no table access events, so the schema rules do not see them; documented with the mitigation, and a check shows the behavior. test-mcp-guard.sh: 102 checks on 8.4.11, 9.7.2, 26.10.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ docs/MCP_COMPONENTS.md | 7 ++++++- scripts/test-mcp-guard.sh | 43 +++++++++++++++++++++++++++++++++++++++ sql/mcp/README.md | 2 ++ sql/mcp/guard-parity.tsv | 42 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 sql/mcp/guard-parity.tsv diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cccacd..ce5fc1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`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. + - **Parity table:** `sql/mcp/guard-parity.tsv` (34 statements) has the shared verdicts of mysql-mcp-server's `run_query` validator and the guard, with the reason for each difference. The guard column is checked here; mysql-mcp-server checks the other. + - **Known limitation, documented:** reads inside stored functions and triggers aren't seen by the schema rules, because the server raises no table access events for tables the calling statement locked. Writes inside them are still refused. `docs/MCP_COMPONENTS.md` (Limits) gives the mitigation (grants), and a test check shows the behavior ([#247](https://github.com/askdba/myvector/issues/247)). - **`mysql-mcp` Docker images** (Phase 3 of RFC 0001). `ghcr.io/askdba/mysql-mcp:<8.4|9.7|26.10>` (also tagged with the exact release) is the official MySQL image of the release the component is built for, with `component_mcp_guard` and the `mysql_mcp` tool pack installed on first start. `MYSQL_MCP_USER`/`MYSQL_MCP_PASSWORD` create an MCP account (`MCP_USER`, tool pack, read access to `MYSQL_MCP_DATABASES`), and `MYSQL_MCP_ALLOWED_SCHEMAS`/`MYSQL_MCP_MAX_EXECUTION_TIME_MS` persist guard settings. The images are a separate family from the MyVector images. They are published by `docker-publish.yml` with releases and tested in CI by `scripts/test-mysql-mcp-image.sh` (22 checks on 8.4.11, 9.7.2 and 26.10.0). - **`component_mcp_guard` tool calls and audit messages** (Phase 3 of RFC 0001, server side). - **`performance_schema.mcp_tool_calls`:** one row per mysql-mcp-server tool call (account, tool, request id, statements, errors, refusals, latency, first and last seen). The guard groups the MCP session statements that start with the label `/* mcp:tool=... req=... */`, keeping the last `mcp.tool_calls_history_size` calls (new read-only variable, default 1000). diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 443b74c..9e1b803 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -166,7 +166,12 @@ The option is `mcp.read_only`, `mcp.deny_system_schemas`, `mcp.allowed_schemas` - `mcp.allowed_schemas` limits **table access**, not catalog metadata: `information_schema` and `SHOW` still list what the account's grants allow. - With `mcp.deny_system_schemas = ON`, mysql-mcp-server's opt-in `slow_query_log` tool (which reads `mysql.slow_log`) is denied. +- The schema rules apply to a statement's **base tables**. A view in an allowed schema over a table in another schema (or in `mysql`) is denied, nested views included. A view in another schema whose base tables are all allowed is allowed. +- **Stored functions and triggers: their reads are not checked by the schema rules.** Their statements run on tables the calling statement has already locked, so the server raises no table access event for them. For example, with `mcp.allowed_schemas = 'd1'`, `SELECT d1.f()` returns data from `d2` if `d1.f()` reads `d2`. Writes inside them are still refused by `mcp.read_only` (a nested statement check), and stored procedures are not affected (`CALL` is limited to `mysql_mcp` under `mcp.read_only`, and a procedure's statements raise their own events). Control this with grants: don't give MCP accounts `EXECUTE` on schemas with functions that read data they must not see. A `SQL SECURITY INVOKER` function runs with the account's own grants, but `mcp.allowed_schemas` doesn't limit it. `scripts/test-mcp-guard.sh` keeps a check that shows this, so a server change is noticed ([#247](https://github.com/askdba/myvector/issues/247)). +- A multi-statement packet is checked statement by statement: each statement in it raises its own events. ### Tests -`scripts/test-mcp-guard.sh ` runs install, restart, persistence, uninstall and the enforcement cases, the tool-call table and its labels against the official image (90 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. +`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 and the parity table below against the official image (102 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`](../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`). diff --git a/scripts/test-mcp-guard.sh b/scripts/test-mcp-guard.sh index 36ba33e..3b5e918 100755 --- a/scripts/test-mcp-guard.sh +++ b/scripts/test-mcp-guard.sh @@ -136,6 +136,49 @@ sql "SET GLOBAL mcp.deny_system_schemas = OFF" >/dev/null expect "mcp: mysql schema with deny_system_schemas OFF" "$(mcp "SELECT COUNT(*) > 0 FROM mysql.user")" "^1$" sql "SET GLOBAL mcp.deny_system_schemas = ON" >/dev/null +# --- views, stored functions, multi-statement packets (RFC 0001 §5.2 item 7, Q3) -------------- +# The schema rules see the base tables of a statement, through views too. Statements of a +# stored function or trigger run on tables the calling statement already locked, which raise +# no table access events: their reads are not seen (known limitation, docs/MCP_COMPONENTS.md). +out=$(sql " +CREATE SQL SECURITY DEFINER VIEW d1.v_d2 AS SELECT i FROM d2.t; +CREATE SQL SECURITY DEFINER VIEW d1.v_nested AS SELECT i FROM d1.v_d2; +CREATE SQL SECURITY DEFINER VIEW d1.v_sys AS SELECT user FROM mysql.user; +CREATE SQL SECURITY DEFINER VIEW d2.v_d1 AS SELECT i FROM d1.t; +INSERT INTO d2.t VALUES (42);") +out="$out$(printf 'DELIMITER $$\nCREATE FUNCTION d1.f_read_d2() RETURNS INT READS SQL DATA SQL SECURITY DEFINER BEGIN RETURN (SELECT MAX(i) FROM d2.t); END$$\n' | + docker exec -i "$CNAME" mysql -h127.0.0.1 -uroot -ptestpass 2>&1 | grep -v "Using a password")" +expect "view fixtures" "$out" "^$" +# packet SQL: run SQL as one COM_QUERY packet (a multi-statement packet if it has several). +packet() { printf 'DELIMITER ~~\n%s~~\n' "$1" | docker exec -i "$CNAME" mysql -h127.0.0.1 -umcp -pmcppass1 -N --batch 2>&1 | grep -v "Using a password"; } +denied "view in d1 over mysql.user (deny_system_schemas)" "$(mcp "SELECT COUNT(*) FROM d1.v_sys")" "mcp.deny_system_schemas" +sql "SET GLOBAL mcp.allowed_schemas = 'd1'" >/dev/null +denied "view in an allowed schema over another schema" "$(mcp "SELECT * FROM d1.v_d2")" "mcp.allowed_schemas" +denied "nested view over another schema" "$(mcp "SELECT * FROM d1.v_nested")" "mcp.allowed_schemas" +expect "view in another schema over allowed tables (rules apply to base tables)" "$(mcp "SELECT COUNT(*) FROM d2.v_d1")" "^[0-9]+$" +denied "multi-statement packet: disallowed read in the second statement" "$(packet "SELECT 1; SELECT * FROM d2.t")" "mcp.allowed_schemas" +expect "KNOWN LIMITATION: a stored function's reads are not seen by the schema rules" "$(mcp "SELECT d1.f_read_d2()")" "^42$" +sql "SET GLOBAL mcp.allowed_schemas = ''" >/dev/null +denied "multi-statement packet: write in the second statement" "$(packet "SELECT 1; INSERT INTO d1.t VALUES (5)")" "mcp.read_only" +denied "multi-statement packet: write after a session SET" "$(packet "SET @a = 1; DELETE FROM d1.t")" "mcp.read_only" +expect "multi-statement packets wrote nothing" "$(sql "SELECT COUNT(*) FROM d1.t")" "^1$" + +# --- parity with mysql-mcp-server's run_query validator (sql/mcp/guard-parity.tsv) ----------- +# The guard column of the shared table; mysql-mcp-server checks the go column. Each statement +# is sent as one packet under the default settings; "deny" means refused with error 1290. +sql "GRANT SELECT ON sys.* TO mcp@'%'; GRANT ALTER ON d1.* TO mcp@'%'" >/dev/null +parity_rows=0 +parity_bad="" +while IFS=$'\t' read -r _go guard stmt _note; do + case "$_go" in '#'* | '') continue ;; esac + parity_rows=$((parity_rows + 1)) + got=allow + packet "$stmt" | grep -q "ERROR 1290" && got=deny + [ "$got" = "$guard" ] || parity_bad="$parity_bad [$stmt: $got, table says $guard]" +done <"$REPO/sql/mcp/guard-parity.tsv" +expect "parity table: guard verdicts ($parity_rows statements)" "${parity_bad:-ok}" "^ok$" +expect "parity table: nothing was written" "$(sql "SELECT COUNT(*) FROM d1.t; SHOW TABLES FROM d1 LIKE 'x'")" "^1$" + sql "SET GLOBAL mcp.enabled = OFF" >/dev/null denied "mcp.enabled OFF refuses MCP connections" "$(mcp "SELECT 1")" "mcp.enabled" expect "mcp.enabled OFF leaves other accounts alone" "$(as app apppass1 "SELECT 1")" "^1$" diff --git a/sql/mcp/README.md b/sql/mcp/README.md index ee2367a..e25e408 100644 --- a/sql/mcp/README.md +++ b/sql/mcp/README.md @@ -16,6 +16,8 @@ are standalone scripts that are not linked into any component, and mysql-mcp-ser `MCP_ADMIN` and unregisters them on `UNINSTALL COMPONENT`. MySQL's privilege registry has no owner, so other components must not register these names. +**Parity table:** [`guard-parity.tsv`](guard-parity.tsv) holds the shared verdicts of mysql-mcp-server's `run_query` validator and `component_mcp_guard` for the same statements. It is test data that both repositories check (see [`docs/MCP_COMPONENTS.md`](../../docs/MCP_COMPONENTS.md), Tests). + ## Version 1.0.0 (Phase 1) [`mysql_mcp.sql`](mysql_mcp.sql) installs the schema `mysql_mcp`. It has one procedure per diff --git a/sql/mcp/guard-parity.tsv b/sql/mcp/guard-parity.tsv new file mode 100644 index 0000000..21af34a --- /dev/null +++ b/sql/mcp/guard-parity.tsv @@ -0,0 +1,42 @@ +# Shared verdicts for run_query input: mysql-mcp-server's validator (go) and +# component_mcp_guard (guard), RFC 0001 §5.2 item 7. Tab-separated: go, guard, statement, note. +# go: what run_query accepts (default settings). guard: deny = refused with error 1290 by an +# MCP session under the default mcp.* settings; allow = not refused by the guard (grants may +# still refuse it). Every row where the two differ says why in the note. +# Fixtures assumed: database d1 with table t (column i) and the function d1.f_write() that +# inserts into d1.t; the MCP account has write grants on d1, so the guard, not grants, decides. +# Tested by scripts/test-mcp-guard.sh (guard) and mysql-mcp-server (go, vendored copy). +allow allow SELECT * FROM d1.t +allow allow SHOW TABLES FROM d1 +allow allow DESCRIBE d1.t +allow allow EXPLAIN SELECT * FROM d1.t +allow allow SELECT COUNT(*) FROM d1.t WHERE i > 0 LIMIT 10 +allow allow SELECT 'a;b' FROM d1.t +allow allow SHOW GRANTS +deny deny INSERT INTO d1.t VALUES (1) +deny deny UPDATE d1.t SET i = 2 +deny deny DELETE FROM d1.t +deny deny REPLACE INTO d1.t VALUES (1) +deny deny CREATE TABLE d1.x (i INT) +deny deny DROP TABLE d1.t +deny deny TRUNCATE TABLE d1.t +deny deny ALTER TABLE d1.t ADD COLUMN j INT +deny deny SELECT * FROM d1.t FOR UPDATE +deny deny SELECT * FROM d1.t FOR SHARE +deny deny SELECT * FROM d1.t LOCK IN SHARE MODE +deny deny SELECT * FROM d1.t /*!50000 FOR UPDATE */ +deny deny SELECT * FROM d1.t INTO OUTFILE '/tmp/parity.out' +deny deny SELECT * FROM d1.t INTO DUMPFILE '/tmp/parity.dump' +deny deny LOAD DATA INFILE '/tmp/parity.in' INTO TABLE d1.t +deny deny SELECT * FROM mysql.user +deny deny SELECT * FROM sys.sys_config +deny deny SET GLOBAL max_connections = 100 +deny deny LOCK TABLES d1.t READ +deny deny GRANT SELECT ON d1.* TO mcp +deny deny SELECT 1; DELETE FROM d1.t go: one statement per call; guard: checks each statement of a multi-statement packet +deny allow SELECT * FROM information_schema.tables LIMIT 1 go blocks system schemas unless MYSQL_MCP_ALLOW_SYSTEM_SCHEMAS; the guard leaves information_schema to grants +deny allow SELECT * FROM performance_schema.global_status LIMIT 1 same as information_schema +deny allow SET SESSION sql_mode = '' go accepts only reads; the guard allows session settings (they change nothing outside the session) +deny allow CALL mysql_mcp.version() go runs the tool pack itself, not through run_query; the guard allows CALL of mysql_mcp routines +deny allow SELECT SLEEP(0) go blocks SLEEP; the guard bounds run time with mcp.max_execution_time_ms instead +allow deny SELECT d1.f_write() go cannot see what a stored function does; the guard refuses the write inside it From 1696d1a6612da2bc0f35792b0a0e3af0d3069612 Mon Sep 17 00:00:00 2001 From: Alkin Tezuysal Date: Sat, 31 Oct 2026 21:19:17 +0000 Subject: [PATCH 11/11] =?UTF-8?q?fix(test):=20review=20of=20#248=20?= =?UTF-8?q?=E2=80=94=20trigger=20mitigation,=20docs=20link,=20stricter=20c?= =?UTF-8?q?hecks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docs: triggers need no EXECUTE and run with their definer's privileges when a statement writes their table; under mcp.read_only no trigger runs for MCP sessions; with it off, limit write grants and review trigger definers. - Docs: link the parity table by repository URL (mkdocs --strict failed on a link outside docs/). - test-mcp-guard.sh: fail if the parity table is unreadable or has < 30 rows; "nothing was written" checks d1.t's rows, its columns and d1's table list. Co-Authored-By: Claude Opus 5.5 --- docs/MCP_COMPONENTS.md | 4 ++-- scripts/test-mcp-guard.sh | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 9e1b803..b7ef66f 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -167,11 +167,11 @@ The option is `mcp.read_only`, `mcp.deny_system_schemas`, `mcp.allowed_schemas` - `mcp.allowed_schemas` limits **table access**, not catalog metadata: `information_schema` and `SHOW` still list what the account's grants allow. - With `mcp.deny_system_schemas = ON`, mysql-mcp-server's opt-in `slow_query_log` tool (which reads `mysql.slow_log`) is denied. - The schema rules apply to a statement's **base tables**. A view in an allowed schema over a table in another schema (or in `mysql`) is denied, nested views included. A view in another schema whose base tables are all allowed is allowed. -- **Stored functions and triggers: their reads are not checked by the schema rules.** Their statements run on tables the calling statement has already locked, so the server raises no table access event for them. For example, with `mcp.allowed_schemas = 'd1'`, `SELECT d1.f()` returns data from `d2` if `d1.f()` reads `d2`. Writes inside them are still refused by `mcp.read_only` (a nested statement check), and stored procedures are not affected (`CALL` is limited to `mysql_mcp` under `mcp.read_only`, and a procedure's statements raise their own events). Control this with grants: don't give MCP accounts `EXECUTE` on schemas with functions that read data they must not see. A `SQL SECURITY INVOKER` function runs with the account's own grants, but `mcp.allowed_schemas` doesn't limit it. `scripts/test-mcp-guard.sh` keeps a check that shows this, so a server change is noticed ([#247](https://github.com/askdba/myvector/issues/247)). +- **Stored functions and triggers: their reads are not checked by the schema rules.** Their statements run on tables the calling statement has already locked, so the server raises no table access event for them. For example, with `mcp.allowed_schemas = 'd1'`, `SELECT d1.f()` returns data from `d2` if `d1.f()` reads `d2`. Writes inside them are still refused by `mcp.read_only` (a nested statement check), and stored procedures are not affected (`CALL` is limited to `mysql_mcp` under `mcp.read_only`, and a procedure's statements raise their own events). Control this with grants: don't give MCP accounts `EXECUTE` on schemas with functions that read data they must not see. A `SQL SECURITY INVOKER` function runs with the account's own grants, but `mcp.allowed_schemas` doesn't limit it. Triggers need no `EXECUTE`: they run, with their definer's privileges, when a statement writes their table. Under `mcp.read_only` (the default) MCP sessions cannot write, so no trigger runs for them. If you turn `mcp.read_only` off, don't give MCP accounts write grants on tables whose triggers read data outside the account's allowed schemas, and review trigger definers. `scripts/test-mcp-guard.sh` keeps a check that shows this, so a server change is noticed ([#247](https://github.com/askdba/myvector/issues/247)). - A multi-statement packet is checked statement by statement: each statement in it raises its own events. ### Tests `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 and the parity table below against the official image (102 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`](../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`). +**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`). diff --git a/scripts/test-mcp-guard.sh b/scripts/test-mcp-guard.sh index 3b5e918..c25842b 100755 --- a/scripts/test-mcp-guard.sh +++ b/scripts/test-mcp-guard.sh @@ -161,7 +161,7 @@ expect "KNOWN LIMITATION: a stored function's reads are not seen by the schema r sql "SET GLOBAL mcp.allowed_schemas = ''" >/dev/null denied "multi-statement packet: write in the second statement" "$(packet "SELECT 1; INSERT INTO d1.t VALUES (5)")" "mcp.read_only" denied "multi-statement packet: write after a session SET" "$(packet "SET @a = 1; DELETE FROM d1.t")" "mcp.read_only" -expect "multi-statement packets wrote nothing" "$(sql "SELECT COUNT(*) FROM d1.t")" "^1$" +expect "multi-statement packets wrote nothing" "$(sql "SELECT GROUP_CONCAT(i ORDER BY i) FROM d1.t")" "^1$" # --- parity with mysql-mcp-server's run_query validator (sql/mcp/guard-parity.tsv) ----------- # The guard column of the shared table; mysql-mcp-server checks the go column. Each statement @@ -169,6 +169,7 @@ expect "multi-statement packets wrote nothing" "$(sql "SELECT COUNT(*) FROM d1.t sql "GRANT SELECT ON sys.* TO mcp@'%'; GRANT ALTER ON d1.* TO mcp@'%'" >/dev/null parity_rows=0 parity_bad="" +[ -s "$REPO/sql/mcp/guard-parity.tsv" ] || parity_bad="cannot read $REPO/sql/mcp/guard-parity.tsv" while IFS=$'\t' read -r _go guard stmt _note; do case "$_go" in '#'* | '') continue ;; esac parity_rows=$((parity_rows + 1)) @@ -176,8 +177,13 @@ while IFS=$'\t' read -r _go guard stmt _note; do packet "$stmt" | grep -q "ERROR 1290" && got=deny [ "$got" = "$guard" ] || parity_bad="$parity_bad [$stmt: $got, table says $guard]" done <"$REPO/sql/mcp/guard-parity.tsv" +[ "$parity_rows" -ge 30 ] || parity_bad="$parity_bad [only $parity_rows rows read]" expect "parity table: guard verdicts ($parity_rows statements)" "${parity_bad:-ok}" "^ok$" -expect "parity table: nothing was written" "$(sql "SELECT COUNT(*) FROM d1.t; SHOW TABLES FROM d1 LIKE 'x'")" "^1$" +# d1.t still holds exactly its row (1) and one column, d1.x was not created, d1 has no new table. +d1_state() { sql "SELECT CONCAT_WS(',', (SELECT GROUP_CONCAT(i ORDER BY i) FROM d1.t), + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'd1' AND TABLE_NAME = 't'), + (SELECT GROUP_CONCAT(TABLE_NAME ORDER BY TABLE_NAME) FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'd1'))"; } +expect "parity table: nothing was written" "$(d1_state)" "^1,1,t,v_d2,v_nested,v_sys$" sql "SET GLOBAL mcp.enabled = OFF" >/dev/null denied "mcp.enabled OFF refuses MCP connections" "$(mcp "SELECT 1")" "mcp.enabled"