diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6bd7d46..515a226 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,45 @@ jobs: tags: ghcr.io/${{ github.repository }}:${{ matrix.image-tag }} 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_TAG: ${{ matrix.mysql-tag }} + run: | + MYSQL_VERSION=${MYSQL_TAG#mysql-} + 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: 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: + 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 }} + ghcr.io/${{ github.repository_owner }}/mysql-mcp:${{ steps.mcp-version.outputs.version }} + build-args: | + MYSQL_VERSION=${{ steps.mcp-version.outputs.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 cf9f45b..a826305 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>` (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). - **Audit messages:** each refusal is emitted through `mysql_audit_api_message` (component `mcp_guard`, message `refused`, with the option, connection id, redacted statement and the tool call), so audit log consumers record refusals. diff --git a/docker/mysql-mcp/Dockerfile b/docker/mysql-mcp/Dockerfile new file mode 100644 index 0000000..90907fb --- /dev/null +++ b/docker/mysql-mcp/Dockerfile @@ -0,0 +1,28 @@ +# 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. +# 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 + +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 +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 +# 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/docker/mysql-mcp/mysql-mcp-init.sh b/docker/mysql-mcp/mysql-mcp-init.sh new file mode 100644 index 0000000..579bc96 --- /dev/null +++ b/docker/mysql-mcp/mysql-mcp-init.sh @@ -0,0 +1,70 @@ +#!/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. + +# 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//\'/\'\'}" +} +_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; 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 | +|---|---| +| `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..9929fd9 --- /dev/null +++ b/scripts/test-mysql-mcp-image.sh @@ -0,0 +1,96 @@ +#!/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 +# 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 -P"${PORT:-3306}" -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$" + +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 +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 +docker rm -f -v "$CNAME_PLAIN" >/dev/null 2>&1 + +# 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 --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 + bad "NO_BACKSLASH_ESCAPES container" "did not start" +fi + +echo "== $IMAGE: $PASS passed, $FAIL failed" +[ "$FAIL" = 0 ]