Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ 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_mcp_guard`: sessions opened by components were never guarded** (RFC 0001 Phase 4, open question 4). A component's `"local"` command-service session that impersonates an MCP account raises no connection event, so the guard never marked it as an MCP session. On 9.7.2 such a session could write, read `mysql.user` and run without the time limit.
- The guard now classifies an unclassified session from its privileges at its first statement. A "not MCP" marker keeps the cost at one grant check per session.
- `mcp.sessions_connected` is decremented when the session's THD ends, which also covers sessions without a disconnect event.
- These sessions also start **unaudited** (no events at all), so a component must emit connection audit events for them. This is documented in `docs/MCP_COMPONENTS.md`, and `component_mcp` will do it.
- `scripts/test-mcp-guard.sh`: 123 checks on 8.4.11, 9.7.2 and 26.10.0, with the test-only `component_mcp_session_probe`. The unfixed guard fails the local-session checks.
- **`component_mcp_guard`: table reads the schema rules could not see** (found reviewing the code behind #247). The server raises table access events only for `SELECT`, data changes, `ANALYZE` and `HANDLER READ`. An MCP session could therefore read any table, `mysql.user` included, past `mcp.allowed_schemas` and `mcp.deny_system_schemas`:
- directly, with a subquery in `SET` or `DO` (`SET @x = (SELECT … FROM mysql.user); SELECT @x`), also through `PREPARE`/`EXECUTE`;
- as a yes/no oracle, with a subquery in `SHOW … WHERE` or in a `CALL` argument;
Expand Down
12 changes: 12 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@ if(MYVECTOR_BUILD_MCP)
install(FILES src/mcp/guard/mcp_guard.json
DESTINATION share/mysql/component
)

# Test only, not installed: runs SQL in impersonated command-service sessions, as
# component_mcp does, for scripts/test-mcp-guard.sh.
find_package(Threads REQUIRED)
add_library(component_mcp_session_probe SHARED src/mcp/guard/test/component_mcp_session_probe.cc)
target_include_directories(component_mcp_session_probe PRIVATE ${MYVECTOR_INCLUDE_DIRS})
target_link_libraries(component_mcp_session_probe PRIVATE Threads::Threads)
set_target_properties(component_mcp_session_probe PROPERTIES PREFIX "")
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
set_property(TARGET component_mcp_session_probe APPEND_STRING PROPERTY LINK_FLAGS
" -static-libstdc++ -static-libgcc")
endif()
endif()

# Install: shared library and component manifest
Expand Down
9 changes: 7 additions & 2 deletions docs/MCP_COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ The images are built and published with MyVector releases (`docker-publish.yml`)
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.

- `MCP_ADMIN` holders are never guarded. After the component is installed, `GRANT ALL ON *.*` includes both privileges, so an administrator granted that way is not put under the guard.
- **Privilege changes take effect at the next connection.** Sessions already connected when the component is installed, or when `MCP_USER` / `MCP_ADMIN` is granted or revoked, keep their previous status. Disconnect them (`KILL CONNECTION`) or have the client reconnect after such a change.
- **Privilege changes take effect at the next connection.** A session's status is decided once (at connect, at `COM_CHANGE_USER`, or at its first statement if neither was seen) and kept. Sessions connected when `MCP_USER` / `MCP_ADMIN` is granted or revoked keep their previous status; disconnect them (`KILL CONNECTION`) or have the client reconnect after such a change. Sessions already connected when the component is installed are classified at their next statement.
- Grant `MCP_USER` to the account itself; a role activated later with `SET ROLE` is not considered.
- **Sessions opened by components.** A component can run SQL as an account through the command services: a `"local"` session that impersonates the account, as `component_mcp` (RFC 0001 Phase 4) and `component_mysql_rest_service` do. Such a session raises no connection event the guard can use, and it starts **unaudited**: the guard receives no events for it until the component emits `MYSQL_AUDIT_CONNECTION_CONNECT` for the session's THD, which turns auditing on. The guard then classifies the session from its privileges at its first statement. (The CONNECT callback runs on the component's thread, where the session is not current, so the guard does not classify it there.)
- The server starts these sessions **unaudited**: no statement or connection event reaches any audit consumer, the guard included.
- A component that runs SQL for MCP accounts must therefore emit `MYSQL_AUDIT_CONNECTION_CONNECT` for the session's THD after connecting (`mysql_audit_api_connection`), and `..._DISCONNECT` before closing. The CONNECT event turns auditing on.
- `component_mcp` does this. A component that doesn't runs its SQL unseen by the guard and by audit log plugins.
- `scripts/test-mcp-guard.sh` checks both cases with a test-only component (`src/mcp/guard/test/component_mcp_session_probe.cc`).

### Settings

Expand Down Expand Up @@ -174,6 +179,6 @@ The option is `mcp.read_only`, `mcp.deny_system_schemas`, `mcp.allowed_schemas`

### Tests

`scripts/test-mcp-guard.sh <mysql-tag> <artifact-dir>` runs install, restart, persistence, uninstall and the enforcement cases, the tool-call table and its labels, views, multi-statement packets, statements without table access events and the parity table below against the official image (110 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 <mysql-tag> <artifact-dir>` runs install, restart, persistence, uninstall and the enforcement cases, the tool-call table and its labels, views, multi-statement packets, statements without table access events, sessions opened by components and the parity table below against the official image (123 checks, including a `lower_case_table_names=1` server), and `src/mcp/guard/test_mcp_guard_policy.cc` unit-tests the classification and the SQL scanner. CI (`integration-scripts.yml`, job `mcp-guard`) runs both on MySQL 8.4.11, 9.7.2 and 26.10.0.

**Parity with mysql-mcp-server** (RFC 0001 §5.2 item 7): [`sql/mcp/guard-parity.tsv`](https://github.com/askdba/myvector/blob/main/sql/mcp/guard-parity.tsv) lists statements a `run_query` call might send, with the verdict of mysql-mcp-server's validator (`go`) and of the guard under the default settings (`guard`). Every row where they differ says why. The test checks the guard column, sending each statement as one packet. mysql-mcp-server vendors the file and checks the go column. Both reject every write and locking read. The guard allows a few things the validator does not (session `SET`, `information_schema`, `CALL mysql_mcp.*`) and catches what text checks cannot (a write inside a function called from `SELECT`).
4 changes: 3 additions & 1 deletion scripts/build-component-26.10-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,15 @@ docker run --rm \
cp src/component_src/myvector.json "/workspace/$OUTPUT_DIR/"
# MCP guard component (RFC 0001), built when MYVECTOR_BUILD_MCP=ON. Clear any
# copy from an earlier build first, so an OFF build never ships a stale guard.
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json"
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json" "/workspace/$OUTPUT_DIR/component_mcp_session_probe.so"
if [ "$MYVECTOR_BUILD_MCP" = ON ]; then
if [ ! -f "$COMPONENT_BUILD/component_mcp_guard.so" ]; then
echo "ERROR: MYVECTOR_BUILD_MCP=ON but component_mcp_guard.so was not built" >&2
exit 1
fi
cp "$COMPONENT_BUILD/component_mcp_guard.so" src/mcp/guard/mcp_guard.json "/workspace/$OUTPUT_DIR/"
# Test-only component for scripts/test-mcp-guard.sh (not packaged).
cp "$COMPONENT_BUILD/component_mcp_session_probe.so" "/workspace/$OUTPUT_DIR/"
echo "==> Built: /workspace/$OUTPUT_DIR/component_mcp_guard.so"
fi
echo "==> Built: /workspace/$OUTPUT_DIR/libmyvector_component.so"
Expand Down
4 changes: 3 additions & 1 deletion scripts/build-component-26.7-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,15 @@ docker run --rm \
cp src/component_src/myvector.json "/workspace/$OUTPUT_DIR/"
# MCP guard component (RFC 0001), built when MYVECTOR_BUILD_MCP=ON. Clear any
# copy from an earlier build first, so an OFF build never ships a stale guard.
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json"
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json" "/workspace/$OUTPUT_DIR/component_mcp_session_probe.so"
if [ "$MYVECTOR_BUILD_MCP" = ON ]; then
if [ ! -f "$COMPONENT_BUILD/component_mcp_guard.so" ]; then
echo "ERROR: MYVECTOR_BUILD_MCP=ON but component_mcp_guard.so was not built" >&2
exit 1
fi
cp "$COMPONENT_BUILD/component_mcp_guard.so" src/mcp/guard/mcp_guard.json "/workspace/$OUTPUT_DIR/"
# Test-only component for scripts/test-mcp-guard.sh (not packaged).
cp "$COMPONENT_BUILD/component_mcp_session_probe.so" "/workspace/$OUTPUT_DIR/"
echo "==> Built: /workspace/$OUTPUT_DIR/component_mcp_guard.so"
fi
echo "==> Built: /workspace/$OUTPUT_DIR/libmyvector_component.so"
Expand Down
4 changes: 3 additions & 1 deletion scripts/build-component-8.4-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,15 @@ docker run --rm \
cp src/component_src/myvector.json "/workspace/$OUTPUT_DIR/"
# MCP guard component (RFC 0001), built when MYVECTOR_BUILD_MCP=ON. Clear any
# copy from an earlier build first, so an OFF build never ships a stale guard.
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json"
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json" "/workspace/$OUTPUT_DIR/component_mcp_session_probe.so"
if [ "$MYVECTOR_BUILD_MCP" = ON ]; then
if [ ! -f "$COMPONENT_BUILD/component_mcp_guard.so" ]; then
echo "ERROR: MYVECTOR_BUILD_MCP=ON but component_mcp_guard.so was not built" >&2
exit 1
fi
cp "$COMPONENT_BUILD/component_mcp_guard.so" src/mcp/guard/mcp_guard.json "/workspace/$OUTPUT_DIR/"
# Test-only component for scripts/test-mcp-guard.sh (not packaged).
cp "$COMPONENT_BUILD/component_mcp_session_probe.so" "/workspace/$OUTPUT_DIR/"
echo "==> Built: /workspace/$OUTPUT_DIR/component_mcp_guard.so"
fi
echo "==> Built: /workspace/$OUTPUT_DIR/libmyvector_component.so"
Expand Down
4 changes: 3 additions & 1 deletion scripts/build-component-9.7-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,15 @@ docker run --rm \
cp src/component_src/myvector.json "/workspace/$OUTPUT_DIR/"
# MCP guard component (RFC 0001), built when MYVECTOR_BUILD_MCP=ON. Clear any
# copy from an earlier build first, so an OFF build never ships a stale guard.
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json"
rm -f "/workspace/$OUTPUT_DIR/component_mcp_guard.so" "/workspace/$OUTPUT_DIR/mcp_guard.json" "/workspace/$OUTPUT_DIR/component_mcp_session_probe.so"
if [ "$MYVECTOR_BUILD_MCP" = ON ]; then
if [ ! -f "$COMPONENT_BUILD/component_mcp_guard.so" ]; then
echo "ERROR: MYVECTOR_BUILD_MCP=ON but component_mcp_guard.so was not built" >&2
exit 1
fi
cp "$COMPONENT_BUILD/component_mcp_guard.so" src/mcp/guard/mcp_guard.json "/workspace/$OUTPUT_DIR/"
# Test-only component for scripts/test-mcp-guard.sh (not packaged).
cp "$COMPONENT_BUILD/component_mcp_session_probe.so" "/workspace/$OUTPUT_DIR/"
echo "==> Built: /workspace/$OUTPUT_DIR/component_mcp_guard.so"
fi
echo "==> Built: /workspace/$OUTPUT_DIR/libmyvector_component.so"
Expand Down
49 changes: 49 additions & 0 deletions scripts/test-mcp-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
#
# Usage: scripts/test-mcp-guard.sh <mysql-image-tag> <artifact-dir>
# e.g. scripts/test-mcp-guard.sh 9.7 dist/mcp-guard-9.7.2
# The artifact dir holds component_mcp_guard.so and the test-only component_mcp_session_probe.so
# (both built with MYVECTOR_BUILD_MCP=ON).
# Requires Docker. bash 3.2+.
set -u

TAG=${1:?mysql image tag, e.g. 9.7}
ART=${2:?artifact dir containing component_mcp_guard.so}
SO=$ART/component_mcp_guard.so
[ -f "$SO" ] || { echo "missing $SO"; exit 1; }
# Test-only component that opens command-service sessions as component_mcp does.
PROBE=$ART/component_mcp_session_probe.so
[ -f "$PROBE" ] || { echo "missing $PROBE"; exit 1; }
REPO=$(cd "$(dirname "$0")/.." && pwd)
CNAME=mcp-guard-test-$(printf '%s' "$TAG" | tr -c 'a-zA-Z0-9.-' '-')-$$
PASS=0
Expand Down Expand Up @@ -47,6 +52,8 @@ echo " server: $(sql 'select version()')"
PLUGIN_DIR=$(sql 'select @@plugin_dir')
docker cp "$SO" "$CNAME:$PLUGIN_DIR/component_mcp_guard.so" &&
docker exec -u 0 "$CNAME" chmod 0644 "$PLUGIN_DIR/component_mcp_guard.so"
docker cp "$PROBE" "$CNAME:$PLUGIN_DIR/component_mcp_session_probe.so" &&
docker exec -u 0 "$CNAME" chmod 0644 "$PLUGIN_DIR/component_mcp_session_probe.so"
out=$(docker exec -i "$CNAME" mysql -h127.0.0.1 -uroot -ptestpass <"$REPO/sql/mcp/mysql_mcp.sql" 2>&1 | grep -v "Using a password")
[ -z "$out" ] && [ "$(sql "CALL mysql_mcp.version()")" = "1.0.0" ] || { echo "tool pack import failed: $out"; exit 1; }

Expand Down Expand Up @@ -194,6 +201,31 @@ d1_state() { sql "SELECT CONCAT_WS(',', (SELECT GROUP_CONCAT(i ORDER BY i) FROM
(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$"

# --- sessions opened by components (command services), as component_mcp opens them -----------
# A component's "local" session that impersonates an MCP account raises no connection event;
# the guard classifies it at its first statement. The component must emit connection audit
# events for it (Srv_session starts unaudited), as component_mcp will.
expect "probe component installed" "$(sql "INSTALL COMPONENT 'file://component_mcp_session_probe'")" "^$"
local_session() { sql "SELECT mcp_test_local_session($1)"; }
# Refused by the guard: its message, with errno 1290 (8.4 reports errno 0 through the
# command service for an error raised by an event consumer; the message is the same).
denied_local() { expect "$1" "$2" "^err:query:(1290|0):The MySQL server is running with the mcp\."; }
denied_local "local MCP session: write refused" "$(local_session "'mcp', '%', 'INSERT INTO d1.t VALUES (2)'")"
denied_local "local MCP session: mysql schema refused" "$(local_session "'mcp', '%', 'SELECT COUNT(*) FROM mysql.user'")"
denied_local "local MCP session: SET subquery refused" "$(local_session "'mcp', '%', 'SET @x = (SELECT COUNT(*) FROM mysql.user)'")"
expect "local MCP session: reads allowed" "$(local_session "'mcp', '%', 'SELECT COUNT(*) FROM d1.t'")" "^ok:1$"
sql "SET GLOBAL mcp.max_execution_time_ms = 1500" >/dev/null
expect "local MCP session: time limit applied" "$(local_session "'mcp', '%', 'SELECT @@max_execution_time'")" "^ok:1500$"
sql "SET GLOBAL mcp.max_execution_time_ms = 0" >/dev/null
expect "local session of another account: not restricted" "$(local_session "'app', '%', 'INSERT INTO d1.t VALUES (9)'")" "^ok:no-result$"
sql "DELETE FROM d1.t WHERE i = 9" >/dev/null
# Without the connection event the session is unaudited: no event reaches the guard. This is
# why component_mcp must emit it (checked here so that a server change is noticed).
expect "REQUIREMENT: an unaudited local session is not seen (components must emit connection events)" "$(local_session "'mcp', '%', 'INSERT INTO d1.t VALUES (8)', 'unaudited'")" "^ok:no-result$"
sql "DELETE FROM d1.t WHERE i = 8" >/dev/null
expect "local sessions: nothing written, no MCP session left" "$(sql "SELECT GROUP_CONCAT(i ORDER BY i) FROM d1.t; SHOW GLOBAL STATUS LIKE 'mcp.sessions_connected'" | tr '\n' ' ')" "^1 mcp.sessions_connected 0 $"
expect "probe component uninstalled" "$(sql "UNINSTALL COMPONENT 'file://component_mcp_session_probe'")" "^$"

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$"
Expand Down Expand Up @@ -284,6 +316,23 @@ expect "MCP_ADMIN unknown after uninstall" "$(sql "GRANT MCP_ADMIN ON *.* TO app
expect "mcp.* variables gone after uninstall" "$(sql "SHOW VARIABLES LIKE 'mcp.%'")" "^$"
expect "performance_schema.mcp_tool_calls gone after uninstall" "$(sql "SELECT COUNT(*) FROM performance_schema.mcp_tool_calls")" "ERROR 1146"
expect "no enforcement after uninstall" "$(mcp "INSERT INTO d1.t VALUES (5); SELECT COUNT(*) FROM d1.t")" "^2$"
# A session connected before INSTALL COMPONENT is classified at its first statement after it.
# The client is connected and idle (no statement running) through the install; its INSERT is
# then sent over the same connection, through a FIFO.
early_dir=$(mktemp -d)
mkfifo "$early_dir/in"
docker exec -i "$CNAME" mysql -h127.0.0.1 -umcp -pmcppass1 -N <"$early_dir/in" >"$early_dir/out" 2>&1 &
early=$!
exec 3>"$early_dir/in"
idle_mcp() { sql "SELECT COUNT(*) FROM performance_schema.processlist WHERE USER = 'mcp' AND COMMAND = 'Sleep'"; }
for _ in $(seq 1 40); do [ "$(idle_mcp)" = 1 ] && break; sleep 0.25; done
expect "the MCP session is connected and idle before the install" "$(idle_mcp)" "^1$"
expect "reinstalled while an MCP session is connected" "$(sql "INSTALL COMPONENT 'file://component_mcp_guard'")" "^$"
printf 'INSERT INTO d1.t VALUES (6);\n' >&3
exec 3>&-
wait "$early"
denied "a session connected before INSTALL is guarded at its first statement after it" "$(grep -v "Using a password" "$early_dir/out")" "mcp.read_only"
rm -rf "$early_dir"

# --- lower_case_table_names = 1: allowed_schemas compares case-insensitively ---------------------
docker run -d --name "$CNAME_LCTN" -e MYSQL_ROOT_PASSWORD=testpass "mysql:$TAG" --lower-case-table-names=1 >/dev/null
Expand Down
Loading
Loading