Skip to content
Open
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` TLS and bind address** (RFC 0001 Phase 5, [#256](https://github.com/askdba/myvector/issues/256)). `mcp_http.bind_address` (default `127.0.0.1`), `mcp_http.ssl_cert` and `mcp_http.ssl_key` (all read-only). With both files set the endpoint serves HTTPS; plain HTTP is allowed only on loopback, so a non-loopback address without TLS fails the install (RFC §5.3). `scripts/test-mcp-endpoint.sh` checks both refusals, HTTPS on loopback and from another container, and plain HTTP to the TLS port.
- **`component_mcp` (RFC 0001 Phase 4 spike, MySQL 26.10).** An MCP Streamable HTTP endpoint inside `mysqld` on MySQL Router's HTTP server (the libraries `component_mysql_rest_service` links), built in the server tree with `MYVECTOR_BUILD_MCP_ENDPOINT=ON ./scripts/build-component-26.10-docker.sh`. It is not for production.
- **Endpoint:** loopback, plain HTTP, one bearer token (`mcp_http.token_sha256`) mapped to one account (`mcp_http.account`).
- **Tools:** `list_databases` (tool pack) and `run_query` (one statement).
Expand Down
6 changes: 4 additions & 2 deletions docs/MCP_COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ The option is `mcp.read_only`, `mcp.deny_system_schemas`, `mcp.allowed_schemas`

An MCP **Streamable HTTP** endpoint (JSON-RPC 2.0 over `POST /mcp`) inside `mysqld`. It runs on MySQL Router's HTTP server, the libraries `component_mysql_rest_service` links (RFC 0001 §5.3). This is the Phase 4 build spike and is **not for production**. It is limited to the following:

- **Network and auth:** loopback only (`127.0.0.1`) and plain HTTP, with no TLS yet. One bearer token, mapped to one account.
- **Network and auth:** `127.0.0.1` by default. Plain HTTP is allowed only on loopback: with `mcp_http.bind_address` set to any other address the install fails unless `mcp_http.ssl_cert` and `mcp_http.ssl_key` are set, and then the endpoint serves HTTPS (TLS 1.2+, Router's TLS context). One bearer token, mapped to one account.
- **Tools:** `list_databases` (through the `mysql_mcp` tool pack) and `run_query` (one statement).
- **Results:** `run_query` returns at most 1000 rows and 4 MB of serialized rows (escapes counted), as `{"rows": [{"col": value, ...}], "truncated": true|false}`. A value is a JSON string, `null`, or `{"base64": "..."}` when its bytes are not valid UTF-8 (binary data). The session's `sql_select_limit` stops the server at 1001 rows for statements without their own `LIMIT`.
- **Known spike limit:** the command service's default consumer collects every row while a statement runs, so a statement with a large explicit `LIMIT` is buffered in full in `mysqld` before the reply is cut. Bounding that needs a custom row consumer (`mysql_command_consumer` services), which is Phase 5 work.
Expand Down Expand Up @@ -222,7 +222,9 @@ INSTALL COMPONENT 'file://component_mcp'

| Variable | Meaning |
|---|---|
| `mcp_http.port` | TCP port on `127.0.0.1` (read-only, default 33480) |
| `mcp_http.port` | TCP port (read-only, default 33480) |
| `mcp_http.bind_address` | Listen address (read-only, default `127.0.0.1`). Other than a loopback address (`127.0.0.0/8`, `::1`, `localhost`), it requires TLS: the install fails without `mcp_http.ssl_cert` and `mcp_http.ssl_key` |
| `mcp_http.ssl_cert`, `mcp_http.ssl_key` | PEM certificate and private key files, readable by `mysqld` (read-only). Set both (or neither) to serve HTTPS; the server's own `server-cert.pem` / `server-key.pem` work |
| `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 |

Expand Down
14 changes: 14 additions & 0 deletions scripts/test-mcp-endpoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ expect "reinstall mapped to app" "$(sql "INSTALL COMPONENT 'file://component_mcp
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'")" "^$"

# TLS (RFC 0001 §5.3): plain HTTP only on loopback. The server's auto-generated certificate
# in the datadir serves as the endpoint's.
INSTALL_MCP="INSTALL COMPONENT 'file://component_mcp' SET GLOBAL mcp_http.port = 33480, GLOBAL mcp_http.account = 'mcp@%', GLOBAL mcp_http.token_sha256 = '$TOKEN_HEX'"
expect "a non-loopback address without TLS is refused" "$(sql "$INSTALL_MCP, GLOBAL mcp_http.bind_address = '0.0.0.0'")" "ERROR"
expect "a certificate without a key is refused" "$(sql "$INSTALL_MCP, GLOBAL mcp_http.ssl_cert = '/var/lib/mysql/server-cert.pem'")" "ERROR"
expect "install on 0.0.0.0 with TLS" "$(sql "$INSTALL_MCP, GLOBAL mcp_http.bind_address = '0.0.0.0', GLOBAL mcp_http.ssl_cert = '/var/lib/mysql/server-cert.pem', GLOBAL mcp_http.ssl_key = '/var/lib/mysql/server-key.pem'")" "^$"
https_ping() { docker run --rm ${2:+--network "$2"} "$CURL_IMAGE" -sk -m 20 -w '\n%{http_code}' -X POST "$1" \
-H 'Content-Type: application/json' -H 'Authorization: Bearer tok-123' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}'; }
expect "HTTPS on loopback" "$(https_ping https://127.0.0.1:33480/mcp "container:$C")" '"result":\{\}'
expect "plain HTTP to the TLS port gets no reply" "$(https_ping http://127.0.0.1:33480/mcp "container:$C" | tail -1)" "^(000|400)$"
IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$C")
expect "HTTPS from another host (container $IP)" "$(https_ping "https://$IP:33480/mcp")" '"result":\{\}'
expect "uninstall the TLS endpoint" "$(sql "UNINSTALL COMPONENT 'file://component_mcp'")" "^$"
expect "then the guard can be uninstalled" "$(sql "UNINSTALL COMPONENT 'file://component_mcp_guard'")" "^$"
expect "server alive at the end" "$(alive)" "^up$"
echo "== $IMAGE: $PASS passed, $FAIL failed"
Expand Down
102 changes: 95 additions & 7 deletions src/mcp/endpoint/component_mcp.cc
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
#include <mysql/components/services/event_tracking_query_service.h>
#include <mysqld_error.h>

#include <arpa/inet.h>
#include <netinet/in.h>
#include <openssl/crypto.h>
#include <openssl/evp.h>

Expand All @@ -49,6 +51,7 @@
#include <memory>
#include <optional>
#include <string>
#include <system_error>
#include <thread>

#include "http/base/request.h"
Expand All @@ -58,6 +61,7 @@
#include "dim.h"
#include "mysql/harness/logging/logging.h"
#include "mysql/harness/logging/registry.h"
#include "mysql/harness/tls_server_context.h"
#include "mysqlrouter/io_component.h"
// In-tree, as component_mysql_rest_service: the session THD behind a command handle.
#include "sql/server_component/mysql_command_services_imp.h"
Expand Down Expand Up @@ -90,7 +94,11 @@ namespace {
unsigned long opt_port = 33480;
char *opt_account = nullptr; // user@host the token maps to
char *opt_token_sha256 = nullptr; // hex SHA-256 of the bearer token
bool port_registered = false, account_registered = false, token_registered = false;
char *opt_bind_address = nullptr; // listen address; null means 127.0.0.1
char *opt_ssl_cert = nullptr; // PEM certificate file; with opt_ssl_key, serve HTTPS
char *opt_ssl_key = nullptr; // PEM private key file
bool port_registered = false, account_registered = false, token_registered = false,
bind_registered = false, cert_registered = false, key_registered = false;

bool register_sysvars() {
INTEGRAL_CHECK_ARG(ulong) port_arg;
Expand Down Expand Up @@ -126,6 +134,36 @@ bool register_sysvars() {
return true;
token_registered = true;
}
{
STR_CHECK_ARG(str) bind_arg;
bind_arg.def_val = const_cast<char *>("127.0.0.1");
if (mysql_service_component_sys_variable_register->register_variable(
"mcp_http", "bind_address", PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY,
"Address the endpoint listens on; other than loopback, TLS is required", nullptr,
nullptr, &bind_arg, &opt_bind_address))
return true;
bind_registered = true;
}
{
STR_CHECK_ARG(str) cert_arg;
cert_arg.def_val = nullptr;
if (mysql_service_component_sys_variable_register->register_variable(
"mcp_http", "ssl_cert", PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY,
"PEM certificate file; with mcp_http.ssl_key, the endpoint serves HTTPS", nullptr,
nullptr, &cert_arg, &opt_ssl_cert))
return true;
cert_registered = true;
}
{
STR_CHECK_ARG(str) key_arg;
key_arg.def_val = nullptr;
if (mysql_service_component_sys_variable_register->register_variable(
"mcp_http", "ssl_key", PLUGIN_VAR_STR | PLUGIN_VAR_MEMALLOC | PLUGIN_VAR_READONLY,
"PEM private key file of mcp_http.ssl_cert", nullptr, nullptr, &key_arg,
&opt_ssl_key))
return true;
key_registered = true;
}
return false;
}

Expand All @@ -138,6 +176,9 @@ bool unregister_sysvars() {
else
registered = false;
};
drop(key_registered, "ssl_key");
drop(cert_registered, "ssl_cert");
drop(bind_registered, "bind_address");
drop(token_registered, "token_sha256");
drop(account_registered, "account");
drop(port_registered, "port");
Expand All @@ -148,6 +189,10 @@ void log_info(const std::string &msg) {
LogComponentErr(INFORMATION_LEVEL, ER_LOG_PRINTF_MSG, ("component_mcp: " + msg).c_str());
}

void log_error(const std::string &msg) {
LogComponentErr(ERROR_LEVEL, ER_LOG_PRINTF_MSG, ("component_mcp: " + msg).c_str());
}

// ----- auth -------------------------------------------------------------------------------

std::string sha256_hex(const std::string &s) {
Expand Down Expand Up @@ -531,7 +576,42 @@ void init_router_logging() {
reg.set_ready();
}

bool nonempty(const char *s) { return s != nullptr && *s != '\0'; }

// Loopback: a literal IPv4 127.0.0.0/8 or IPv6 ::1 address, or "localhost".
bool loopback(const std::string &address) {
if (address == "localhost") return true;
in_addr v4{};
if (inet_pton(AF_INET, address.c_str(), &v4) == 1) return (ntohl(v4.s_addr) >> 24) == 127;
in6_addr v6{};
return inet_pton(AF_INET6, address.c_str(), &v6) == 1 && IN6_IS_ADDR_LOOPBACK(&v6);
}

TlsServerContext make_tls_context() {
TlsServerContext tls;
if (const auto loaded = tls.load_key_and_cert(opt_ssl_key, opt_ssl_cert); !loaded)
throw std::system_error(loaded.error(), std::string("loading mcp_http.ssl_key '") +
opt_ssl_key + "' or mcp_http.ssl_cert '" +
opt_ssl_cert + "'");
if (const auto dh = tls.init_tmp_dh(""); !dh)
throw std::system_error(dh.error(), "setting up TLS DH parameters");
return tls;
}

bool start_server() {
const std::string address = nonempty(opt_bind_address) ? opt_bind_address : "127.0.0.1";
const bool tls = nonempty(opt_ssl_cert) || nonempty(opt_ssl_key);
if (tls && !(nonempty(opt_ssl_cert) && nonempty(opt_ssl_key))) {
log_error("mcp_http.ssl_cert and mcp_http.ssl_key must be set together");
return true;
}
// RFC 0001 §5.3: plain HTTP only on loopback, so the bearer token never crosses a network
// in the clear.
if (!tls && !loopback(address)) {
log_error("mcp_http.bind_address '" + address +
"' is not loopback: set mcp_http.ssl_cert and mcp_http.ssl_key");
return true;
}
init_router_logging();
auto &io = IoComponent::get_instance();
// HTTP work runs on the io threads, which run SQL through the command services.
Expand All @@ -540,20 +620,28 @@ bool start_server() {
if (!io.init(1, IoBackend::preferred())) return true;
io_initialized = true;
try {
server = std::make_shared<http::HttpServerContext>(
&io.io_context(), &io.io_threads(), "127.0.0.1", static_cast<uint16_t>(opt_port),
/*max_http_connections=*/64, /*max_request_body_size=*/1 << 20,
/*max_response_body_size=*/16 << 20);
const auto port = static_cast<uint16_t>(opt_port);
constexpr uint64_t kMaxConnections = 64, kMaxRequest = 1 << 20, kMaxResponse = 16 << 20;
if (tls)
server = std::make_shared<http::HttpServerContext>(
&io.io_context(), &io.io_threads(), make_tls_context(), address, port, kMaxConnections,
kMaxRequest, kMaxResponse);
else
server = std::make_shared<http::HttpServerContext>(&io.io_context(), &io.io_threads(),
address, port, kMaxConnections,
kMaxRequest, kMaxResponse);
server->add_direct_match_route("", http::base::UriPathMatcher{"/mcp", false, false},
std::make_unique<McpHandler>());
workguard = std::make_unique<IoComponent::Workguard>(io);
io_thread = std::thread([] { IoComponent::get_instance().run(); });
server->start();
} catch (const std::exception &e) {
log_info(std::string("cannot start the endpoint: ") + e.what());
log_error(std::string("cannot start the endpoint: ") + e.what());
return true;
}
log_info("MCP endpoint on http://127.0.0.1:" + std::to_string(opt_port) + "/mcp");
const bool v6 = address.find(':') != std::string::npos;
log_info(std::string("MCP endpoint on ") + (tls ? "https" : "http") + "://" +
(v6 ? "[" + address + "]" : address) + ":" + std::to_string(opt_port) + "/mcp");
return false;
}

Expand Down
Loading