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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **`component_mcp` validates `Origin`** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). Against DNS rebinding, a request with an `Origin` header that `mcp_http.allowed_origins` (new, read-only, default none) does not list gets 403; requests without `Origin` are not affected. For a listed origin it handles CORS (preflight answered, replies carry `Access-Control-Allow-Origin`), so browser clients such as MCP Inspector connect directly. `scripts/test-mcp-endpoint.sh` checks refusal, allowed origins, case and prefix matching, and the CORS preflight and headers.
- **`component_mcp` serves calls concurrently, with session limits** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `mcp_http.threads` (default 4) I/O workers each serve one call at a time; the spike had one. `mcp_http.max_sessions_per_account` (default 4) caps an account's concurrent endpoint sessions (error 1203), since `max_user_connections` does not apply to them, and the sessions are now subject to `max_connections` (error 1040) instead of bypassing it. `scripts/test-mcp-endpoint.sh` checks overlap of 4 calls, the cap and `max_connections`.
- **`component_mcp` refuses locked accounts** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). Impersonated sessions skip the login checks, so the endpoint reads `account_locked` from the session's security context and fails the call with error 3118 before any statement runs. `scripts/test-mcp-endpoint.sh` checks lock, both tools and unlock.
- **`component_mcp` bounded row consumer** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `run_query` sessions use the component's own row and value consumer services, which stop retaining rows past 1001 rows or 4 MiB of values while a statement runs, so a large explicit `LIMIT` or wide rows no longer buffer the whole result in `mysqld`. The budget counts values plus the per-row and per-cell overhead of the default consumer, NULL cells included. `scripts/test-mcp-endpoint.sh` now has 41 checks, including peak RSS for `LIMIT 1000000` and for 1 MB rows, 8000 NULL columns, DOUBLE widths, and errors raised after some rows.
Expand Down
1 change: 1 addition & 0 deletions docs/MCP_COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ INSTALL COMPONENT 'file://component_mcp'
| `mcp_http.max_sessions_per_account` | Concurrent endpoint sessions per account (read-only, default 4, 1 to 1024). A call over the cap fails with error 1203. The server's `max_user_connections` does not apply to these sessions |
| `mcp_http.account` | The account (`user@host`) every request runs as (read-only) |
| `mcp_http.token_sha256` | Hex SHA-256 of the bearer token (read-only, not persisted, sensitive: readable only with `SENSITIVE_VARIABLES_OBSERVER`). Requests without the matching `Authorization: Bearer <token>` get 401 |
| `mcp_http.allowed_origins` | Comma-separated `Origin` values accepted (read-only, default none; exact match, ASCII case-insensitive, spaces around commas ignored). Against DNS rebinding, as the MCP transport spec requires: a request that has an `Origin` header not in the list gets 403, before the token is checked. Requests without `Origin` (non-browser clients) are not affected. Add a browser-based client's origin here, e.g. `http://localhost:6274` for MCP Inspector. For a listed origin the endpoint also handles CORS: it answers the browser's `OPTIONS` preflight (204, allowing `POST` with `Authorization`, `Content-Type`, `Mcp-Session-Id` and `MCP-Protocol-Version`, cached 10 minutes) without the token, and every reply carries `Access-Control-Allow-Origin` for it, so the page can connect directly |

### How a tool call runs

Expand Down
26 changes: 25 additions & 1 deletion scripts/test-mcp-endpoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ sql() { docker exec -i "$C" mysql -h127.0.0.1 -uroot -ptestpass -N -e "$1" 2>&1
# rpc TOKEN JSON: POST to the endpoint from a sidecar in the server's network namespace.
rpc() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -w '\n%{http_code}' -X POST \
http://127.0.0.1:33480/mcp -H 'Content-Type: application/json' ${1:+-H "Authorization: Bearer $1"} -d "$2"; }
# rpc_origin ORIGIN JSON: as rpc with the token, sending an Origin header (as a browser does).
rpc_origin() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -w '\n%{http_code}' -X POST \
http://127.0.0.1:33480/mcp -H 'Content-Type: application/json' -H 'Authorization: Bearer tok-123' \
-H "Origin: $1" -d "$2"; }
# curl_i ARGS...: a request from a sidecar, printing the status line and headers (CR stripped).
curl_i() { docker run --rm --network "container:$C" "$CURL_IMAGE" -s -m 20 -o /dev/null -D - "$@" \
http://127.0.0.1:33480/mcp | tr -d '\r'; }
call() { rpc tok-123 "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}"; }
# par N SQL: N concurrent run_query calls from one sidecar; prints the replies (they can
# interleave, so count matches with grep -o), then the wall time in seconds as "elapsed=S".
Expand Down Expand Up @@ -56,6 +63,9 @@ expect "install component_mcp" "$(sql "INSTALL COMPONENT 'file://component_mcp'
expect "the token hash is not readable by other accounts" "$(docker exec -i "$C" mysql -h127.0.0.1 -umcp -pp -N -e "SELECT @@mcp_http.token_sha256" 2>&1 | grep -v "Using a password")" "ERROR.*SENSITIVE_VARIABLES_OBSERVER"
expect "no token: 401" "$(rpc '' '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^401$"
expect "wrong token: 401" "$(rpc nope '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^401$"
# DNS rebinding: a browser request carries its page's Origin; none is allowed by default.
expect "a browser Origin is refused: 403, even with the token" "$(rpc_origin http://evil.example '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$"
expect "a localhost Origin is refused too unless allowed" "$(rpc_origin http://localhost:6274 '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$"
expect "initialize" "$(rpc tok-123 '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}')" '"serverInfo":\{"name":"component_mcp"'
expect "notification: 202" "$(rpc tok-123 '{"jsonrpc":"2.0","method":"notifications/initialized"}')" "^202$"
expect "tools/list" "$(rpc tok-123 '{"jsonrpc":"2.0","id":2,"method":"tools/list"}')" '"name":"list_databases".*"name":"run_query"'
Expand Down Expand Up @@ -147,7 +157,21 @@ expect "no MCP session left after concurrent calls" "$(sql "SHOW GLOBAL STATUS L

# An account without MCP_USER is not treated as an MCP session (the token maps to app).
sql "UNINSTALL COMPONENT 'file://component_mcp'" >/dev/null
expect "reinstall mapped to app" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'app@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX'")" "^$"
expect "reinstall mapped to app, with allowed origins" "$(sql "INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'app@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX', GLOBAL mcp_http.allowed_origins = 'http://localhost:6274, https://Inspector.example'")" "^$"
expect "an allowed Origin passes" "$(rpc_origin http://localhost:6274 '{"jsonrpc":"2.0","id":1,"method":"ping"}')" '"result":\{\}'
expect "allowed origins match case-insensitively, spaces trimmed" "$(rpc_origin https://inspector.example '{"jsonrpc":"2.0","id":1,"method":"ping"}')" '"result":\{\}'
# CORS for an allowed browser origin: the preflight is answered and replies name the origin.
preflight=$(curl_i -X OPTIONS -H 'Origin: http://localhost:6274' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization, content-type')
expect "preflight from an allowed Origin: 204" "$(printf '%s\n' "$preflight" | head -1)" "^HTTP/1.1 204"
expect "preflight allows POST with Authorization" "$(printf '%s\n' "$preflight" | grep -i '^access-control-' | tr 'A-Z' 'a-z' | sort | tr '\n' ' ')" "access-control-allow-headers: authorization, content-type.*access-control-allow-methods: post.*access-control-allow-origin: http://localhost:6274"
expect "a POST reply names the allowed Origin" "$(curl_i -X POST -H 'Origin: http://localhost:6274' -H 'Authorization: Bearer tok-123' -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' | grep -i '^access-control-allow-origin')" "^[Aa]ccess-[Cc]ontrol-[Aa]llow-[Oo]rigin: http://localhost:6274$"
expect "a 401 for an allowed Origin is readable by the page" "$(curl_i -X POST -H 'Origin: http://localhost:6274' -H 'Content-Type: application/json' -d '{}' | grep -ci '^access-control-allow-origin: http://localhost:6274')" "^1$"
foreign=$(curl_i -X OPTIONS -H 'Origin: http://evil.example' -H 'Access-Control-Request-Method: POST')
expect "preflight from another Origin: 403" "$(printf '%s\n' "$foreign" | head -1)" "^HTTP/1.1 403"
expect "preflight from another Origin: no CORS headers" "$(printf '%s\n' "$foreign" | grep -ci '^access-control-')" "^0$"
expect "OPTIONS without Origin: 405" "$(curl_i -X OPTIONS | head -1)" "^HTTP/1.1 405"
expect "a prefix of an allowed Origin is refused" "$(rpc_origin http://localhost:62 '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$"
expect "another Origin is still refused" "$(rpc_origin http://evil.example '{"jsonrpc":"2.0","id":1,"method":"ping"}')" "^403$"
expect "guard: an account without MCP_USER is not restricted" "$(call run_query '{"sql":"INSERT INTO d1.t VALUES (7)"}')" '"isError":false'
expect "the guard cannot be uninstalled while the endpoint is installed" "$(sql "UNINSTALL COMPONENT 'file://component_mcp_guard'")" "ERROR"
expect "uninstall component_mcp" "$(sql "UNINSTALL COMPONENT 'file://component_mcp'")" "^$"
Expand Down
59 changes: 58 additions & 1 deletion src/mcp/endpoint/component_mcp.cc
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
#include <openssl/crypto.h>
#include <openssl/evp.h>

#include <algorithm>
#include <atomic>
#include <cctype>
#include <cstring>
#include <map>
#include <memory>
Expand Down Expand Up @@ -108,8 +110,9 @@ unsigned long opt_threads = 4; // I/O workers: calls served at
unsigned long opt_max_sessions_per_account = 4; // concurrent calls per account
char *opt_account = nullptr; // user@host the token maps to
char *opt_token_sha256 = nullptr; // hex SHA-256 of the bearer token
char *opt_allowed_origins = nullptr; // comma-separated Origin values browsers may send
bool port_registered = false, threads_registered = false, max_sessions_registered = false,
account_registered = false, token_registered = false;
account_registered = false, token_registered = false, origins_registered = false;

bool register_sysvars() {
INTEGRAL_CHECK_ARG(ulong) port_arg;
Expand Down Expand Up @@ -172,6 +175,18 @@ bool register_sysvars() {
return true;
token_registered = true;
}
{
STR_CHECK_ARG(str) origins_arg;
origins_arg.def_val = nullptr;
if (mysql_service_component_sys_variable_register->register_variable(
"mcp_http", "allowed_origins",
PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY,
"Comma-separated Origin values accepted from browsers (exact match); a request "
"with any other Origin gets 403",
nullptr, nullptr, &origins_arg, &opt_allowed_origins))
return true;
origins_registered = true;
}
return false;
}

Expand All @@ -184,6 +199,7 @@ bool unregister_sysvars() {
else
registered = false;
};
drop(origins_registered, "allowed_origins");
drop(token_registered, "token_sha256");
drop(account_registered, "account");
drop(max_sessions_registered, "max_sessions_per_account");
Expand Down Expand Up @@ -224,6 +240,27 @@ bool token_ok(const std::string *authorization) {
CRYPTO_memcmp(got.data(), want.data(), got.size()) == 0;
}

// DNS rebinding: a web page can make a browser send requests to 127.0.0.1, but the browser
// then sends the page's Origin. Requests without one (non-browser clients) pass; one with an
// Origin passes only if mcp_http.allowed_origins lists it (ASCII case-insensitive).
bool origin_ok(const std::string *origin) {
if (origin == nullptr) return true;
if (opt_allowed_origins == nullptr) return false;
const auto same = [&](const char *b, const char *e) {
while (b < e && *b == ' ') ++b;
while (e > b && e[-1] == ' ') --e;
return static_cast<size_t>(e - b) == origin->size() &&
std::equal(b, e, origin->begin(), [](char x, char y) {
return std::tolower(static_cast<unsigned char>(x)) ==
std::tolower(static_cast<unsigned char>(y));
});
};
const char *p = opt_allowed_origins;
for (const char *comma; (comma = std::strchr(p, ',')) != nullptr; p = comma + 1)
if (same(p, comma)) return true;
return same(p, p + std::strlen(p));
}

// ----- SQL in an impersonated local session ----------------------------------------------

// Result bounds: the reply keeps at most kMaxRows rows and kMaxBytes of serialized rows (the
Expand Down Expand Up @@ -737,6 +774,26 @@ void rpc_error(Json &out, int code, const char *message) {
class McpHandler : public http::base::RequestHandler {
public:
void handle_request(http::base::Request &req) override {
const std::string *origin = req.get_input_headers().find("Origin");
if (!origin_ok(origin)) {
req.send_reply(HttpStatusCode::Forbidden);
return;
}
// CORS for an allowed browser origin: its replies name it, so the page may read them, and
// its preflight (OPTIONS, sent without credentials) is answered here.
if (origin != nullptr) {
req.get_output_headers().add("Access-Control-Allow-Origin", origin->c_str());
req.get_output_headers().add("Vary", "Origin");
}
if (req.get_method() == HttpMethod::Options && origin != nullptr) {
req.get_output_headers().add("Access-Control-Allow-Methods", "POST");
req.get_output_headers().add("Access-Control-Allow-Headers",
"Authorization, Content-Type, Mcp-Session-Id, "
"MCP-Protocol-Version");
req.get_output_headers().add("Access-Control-Max-Age", "600");
req.send_reply(HttpStatusCode::NoContent);
return;
}
if (req.get_method() != HttpMethod::Post) {
req.get_output_headers().add("Allow", "POST");
req.send_reply(HttpStatusCode::MethodNotAllowed);
Expand Down
Loading