diff --git a/.github/workflows/benchmark-issue79.yml b/.github/workflows/benchmark-issue79.yml index ebf16267..022d430a 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 6bd7d463..9398ba02 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,16 +214,19 @@ 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 # 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 @@ -226,18 +240,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.13' + mysql-release: '8.4.13' 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.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.4-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 @@ -251,6 +274,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,13 +288,15 @@ 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 - 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 +304,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 +335,43 @@ 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-release }} + + # Like the component image, the mysql-mcp image is based on the exact release the + # guard was built against (mysql-release). + - name: Test mysql-mcp image + env: + MYSQL_VERSION: ${{ matrix.mysql-release }} + 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 }} + ghcr.io/${{ github.repository_owner }}/mysql-mcp:${{ matrix.mysql-release }} build-args: | - MYSQL_VERSION=${{ matrix.mysql-version }} + MYSQL_VERSION=${{ matrix.mysql-release }} diff --git a/.github/workflows/integration-scripts.yml b/.github/workflows/integration-scripts.yml index e533a51a..65e1c0eb 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 cf9f45b5..ce5fc1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ 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). - **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. @@ -129,6 +135,8 @@ 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 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 ea26434d..f6c2e0d5 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.13: a component built for another patch release can crash +# the server. +ARG MYSQL_VERSION FROM mysql:${MYSQL_VERSION} ARG TARGETARCH diff --git a/Dockerfile.oraclelinux8 b/Dockerfile.oraclelinux8 index 6e3e0473..1e05d7a1 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 ad5d605e..a90227aa 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/docker/mysql-mcp/Dockerfile b/docker/mysql-mcp/Dockerfile new file mode 100644 index 00000000..e2584177 --- /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.13: 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 00000000..4865e753 --- /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 00000000..579bc96c --- /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.13`) 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. @@ -144,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. 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 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`](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/examples/movie-finder/README.md b/examples/movie-finder/README.md index 717ddc17..2f52415e 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 diff --git a/scripts/build-component-9.7-docker.sh b/scripts/build-component-9.7-docker.sh index 00de2ee2..a3ac677b 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" diff --git a/scripts/build-docker-local.sh b/scripts/build-docker-local.sh index 2224d1ea..643d9273 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 "" diff --git a/scripts/test-mcp-guard.sh b/scripts/test-mcp-guard.sh index 36ba33e4..c25842b2 100755 --- a/scripts/test-mcp-guard.sh +++ b/scripts/test-mcp-guard.sh @@ -136,6 +136,55 @@ 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 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 +# 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="" +[ -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)) + 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" +[ "$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$" +# 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" expect "mcp.enabled OFF leaves other accounts alone" "$(as app apppass1 "SELECT 1")" "^1$" diff --git a/scripts/test-mysql-mcp-image.sh b/scripts/test-mysql-mcp-image.sh new file mode 100755 index 00000000..9929fd92 --- /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 ] diff --git a/sql/mcp/README.md b/sql/mcp/README.md index ee2367a5..e25e4087 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 00000000..21af34a9 --- /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