diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7b149c..30d6d048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`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). diff --git a/docs/MCP_COMPONENTS.md b/docs/MCP_COMPONENTS.md index 4b112f96..cc3500c9 100644 --- a/docs/MCP_COMPONENTS.md +++ b/docs/MCP_COMPONENTS.md @@ -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. @@ -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 ` get 401 | diff --git a/scripts/test-mcp-endpoint.sh b/scripts/test-mcp-endpoint.sh index db9b1049..79872719 100755 --- a/scripts/test-mcp-endpoint.sh +++ b/scripts/test-mcp-endpoint.sh @@ -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" diff --git a/src/mcp/endpoint/component_mcp.cc b/src/mcp/endpoint/component_mcp.cc index 1854d2da..09a3c7ab 100644 --- a/src/mcp/endpoint/component_mcp.cc +++ b/src/mcp/endpoint/component_mcp.cc @@ -41,6 +41,8 @@ #include #include +#include +#include #include #include @@ -49,6 +51,7 @@ #include #include #include +#include #include #include "http/base/request.h" @@ -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" @@ -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; @@ -126,6 +134,36 @@ bool register_sysvars() { return true; token_registered = true; } + { + STR_CHECK_ARG(str) bind_arg; + bind_arg.def_val = const_cast("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; } @@ -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"); @@ -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) { @@ -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. @@ -540,20 +620,28 @@ bool start_server() { if (!io.init(1, IoBackend::preferred())) return true; io_initialized = true; try { - server = std::make_shared( - &io.io_context(), &io.io_threads(), "127.0.0.1", static_cast(opt_port), - /*max_http_connections=*/64, /*max_request_body_size=*/1 << 20, - /*max_response_body_size=*/16 << 20); + const auto port = static_cast(opt_port); + constexpr uint64_t kMaxConnections = 64, kMaxRequest = 1 << 20, kMaxResponse = 16 << 20; + if (tls) + server = std::make_shared( + &io.io_context(), &io.io_threads(), make_tls_context(), address, port, kMaxConnections, + kMaxRequest, kMaxResponse); + else + server = std::make_shared(&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()); workguard = std::make_unique(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; }