You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
proxysql/test/tap/tests/mcp_stats_refresh-t.cpp

374 lines
12 KiB

/**
* @file mcp_stats_refresh-t.cpp
* @brief TAP integration test for MCP stats endpoint functionality.
*
* This test validates that the MCP stats endpoint can query runtime metrics
* and that the metrics reflect actual ProxySQL state.
*
* Test strategy:
* 1. Query Client_Connections_connected via MCP stats endpoint
* 2. Create several new MySQL connections to generate traffic
* 3. Query the same metric again
* 4. Verify the connection count has increased
*/
#include <string>
#include <vector>
#include "mysql.h"
#include "tap.h"
#include "command_line.h"
#include "utils.h"
#include "mcp_client.h"
using json = nlohmann::json;
namespace {
/**
* @brief Execute an admin SQL statement and report success/failure.
*
* @param admin Open admin connection.
* @param query SQL statement to execute.
* @param context Human-readable label used in diagnostics.
* @return true on success, false on failure.
*/
bool run_admin_stmt(MYSQL* admin, const std::string& query, const char* context) {
if (!admin) {
diag("%s: admin connection is null", context);
return false;
}
if (run_q(admin, query.c_str()) != 0) {
diag("%s failed: %s", context, mysql_error(admin));
return false;
}
return true;
}
/**
* @brief Configure MCP runtime variables required by this test.
*
* @param admin Open admin connection.
* @param cl TAP command-line environment with target MCP port.
* @return true if all configuration statements succeeded.
*/
bool configure_mcp_stats_endpoint(MYSQL* admin, const CommandLine& cl) {
const std::vector<std::string> statements = {
"SET mcp-port=" + std::to_string(cl.mcp_port),
"SET mcp-use_ssl=false",
"SET mcp-enabled=true",
"SET mcp-stats_endpoint_auth=''",
"LOAD MCP VARIABLES TO RUNTIME"
};
for (const auto& stmt : statements) {
if (!run_admin_stmt(admin, stmt, "MCP stats config")) {
return false;
}
}
return true;
}
/**
* @brief Parse and validate the payload returned by MCP `show_status`.
*
* Expected payload shape (after MCPClient extracts from content[0].text):
* `{ "db_type": "mysql", "variables": [...] }`
*
* @param response MCP response object.
* @param variables Output JSON array of variables.
* @param error Output error text on failure.
* @return true when payload structure is valid.
*/
bool extract_show_status_variables(const MCPResponse& response, json& variables, std::string& error) {
if (!response.is_success()) {
error = response.get_error_message();
return false;
}
const json& payload = response.get_result();
if (!payload.is_object()) {
error = "show_status payload is not a JSON object";
return false;
}
// Check for variables array directly (new format)
if (payload.contains("variables") && payload["variables"].is_array()) {
variables = payload["variables"];
return true;
}
// Check for legacy format with success/result wrapper
if (!payload.value("success", false)) {
error = payload.value("error", std::string("show_status returned tool error"));
return false;
}
if (!payload.contains("result") || !payload["result"].is_object()) {
error = "show_status payload missing object field 'result'";
return false;
}
const json& result_obj = payload["result"];
if (!result_obj.contains("variables") || !result_obj["variables"].is_array()) {
error = "show_status payload missing array field 'result.variables'";
return false;
}
variables = result_obj["variables"];
return true;
}
/**
* @brief Extract a numeric metric value from show_status variables array.
*
* @param variables JSON array of variable objects (each with variable_name/value or Variable_Name/Variable_Value).
* @param var_name Name of the variable to find.
* @param value Output value if found.
* @return true if variable was found and parsed as integer.
*/
bool get_metric_value(const json& variables, const std::string& var_name, long& value) {
for (const auto& var : variables) {
// Try lowercase field names (new format)
if (var.contains("variable_name") && var["variable_name"] == var_name) {
if (var.contains("value")) {
try {
value = std::stol(var["value"].get<std::string>());
return true;
} catch (...) {
return false;
}
}
}
// Try uppercase field names (legacy format)
if (var.contains("Variable_Name") && var["Variable_Name"] == var_name) {
if (var.contains("Variable_Value")) {
try {
value = std::stol(var["Variable_Value"].get<std::string>());
return true;
} catch (...) {
return false;
}
}
}
}
return false;
}
} // namespace
int main(int argc, char** argv) {
(void)argc;
(void)argv;
plan(10);
CommandLine cl;
if (cl.getEnv()) {
diag("Failed to read TAP environment");
return exit_status();
}
diag("=== MCP Stats Refresh Test ===");
diag("This test validates that the MCP stats endpoint can query runtime metrics");
diag("and that the metrics reflect actual ProxySQL state.");
diag("Test strategy:");
diag(" 1. Query Client_Connections_connected via MCP stats endpoint");
diag(" 2. Create several new MySQL connections to generate traffic");
diag(" 3. Query the same metric again");
diag(" 4. Verify the connection count has increased");
diag("===============================");
MYSQL* admin = nullptr;
MCPClient* mcp = nullptr;
bool can_continue = true;
admin = init_mysql_conn(cl.admin_host, cl.admin_port, cl.admin_username, cl.admin_password);
ok(admin != nullptr, "Admin connection established");
if (!admin) {
skip(9, "Cannot continue without admin connection");
can_continue = false;
}
bool configured = false;
if (can_continue) {
configured = configure_mcp_stats_endpoint(admin, cl);
ok(configured, "Configured and loaded MCP runtime variables for /mcp/stats");
if (!configured) {
skip(8, "Cannot continue without MCP runtime configuration");
can_continue = false;
}
}
if (can_continue) {
mcp = new MCPClient(cl.admin_host, cl.mcp_port);
if (strlen(cl.mcp_auth_token) > 0) {
mcp->set_auth_token(cl.mcp_auth_token);
}
}
bool mcp_reachable = false;
bool using_ssl = false;
if (can_continue) {
// Retry loop: MCP server may need a moment to start after LOAD MCP VARIABLES TO RUNTIME
const int k_max_retries = 30; // 30 retries * 100ms = 3 seconds max wait
const int k_retry_delay_ms = 100;
int retry_count = 0;
// First try HTTP, then HTTPS if HTTP fails
for (int ssl_attempt = 0; ssl_attempt <= 1 && !mcp_reachable; ssl_attempt++) {
bool try_ssl = (ssl_attempt == 1);
mcp->set_use_ssl(try_ssl);
if (try_ssl) {
diag("HTTP failed, trying HTTPS...");
}
retry_count = 0;
while (!mcp_reachable && retry_count < k_max_retries) {
usleep(k_retry_delay_ms * 1000);
mcp_reachable = mcp->check_server();
retry_count++;
}
if (mcp_reachable) {
using_ssl = try_ssl;
diag("MCP server reachable via %s after %d retries (%dms)",
try_ssl ? "HTTPS" : "HTTP", retry_count, retry_count * k_retry_delay_ms);
}
}
ok(mcp_reachable, "MCP server reachable at %s (%s)", mcp->get_connection_info().c_str(),
using_ssl ? "HTTPS" : "HTTP");
if (!mcp_reachable) {
skip(7, "Cannot continue without MCP connectivity");
can_continue = false;
}
}
// Variables needed across multiple blocks
json initial_vars = json::array();
long initial_count = 0;
// Test: Query Client_Connections_connected, create connections, verify count increases
if (can_continue) {
diag("Step 1: Querying initial Client_Connections_connected via MCP stats endpoint");
// Step 1: Get initial connection count via MCP stats
const MCPResponse initial_resp = mcp->call_tool(
"stats",
"show_status",
json{{"db_type", "mysql"}, {"variable_name", "Client_Connections_connected"}}
);
ok(initial_resp.is_success(), "MCP call stats.show_status(Client_Connections_connected) transport success");
// Debug: print raw response
diag("Raw HTTP response code: %ld", initial_resp.get_http_code());
if (!initial_resp.is_success()) {
diag("Transport/protocol error: %s", initial_resp.get_error_message().c_str());
}
std::string initial_err;
bool initial_payload_ok = extract_show_status_variables(initial_resp, initial_vars, initial_err);
if (!initial_payload_ok) {
diag("Payload extraction failed: %s", initial_err.c_str());
diag("Raw response body: %s", initial_resp.get_http_response().substr(0, 500).c_str());
}
ok(initial_payload_ok, "Initial show_status payload valid%s%s",
initial_payload_ok ? "" : ": ", initial_payload_ok ? "" : initial_err.c_str());
if (initial_payload_ok) {
diag("Received %zu variables in response", initial_vars.size());
bool found = get_metric_value(initial_vars, "Client_Connections_connected", initial_count);
if (found) {
diag("Client_Connections_connected initial value: %ld", initial_count);
}
ok(found, "Found Client_Connections_connected in initial response (value=%ld)", initial_count);
if (!found) {
skip(4, "Cannot continue without initial connection count");
can_continue = false;
}
} else {
skip(5, "Cannot continue without valid initial payload");
can_continue = false;
}
}
// Create additional connections and verify count increases
if (can_continue) {
// Step 2: Create several new MySQL connections to the frontend port
const int k_new_connections = 5;
std::vector<MYSQL*> new_conns;
diag("Step 2: Creating %d new MySQL connections to %s:%d to generate traffic",
k_new_connections, cl.host, cl.port);
for (int i = 0; i < k_new_connections; i++) {
MYSQL* conn = init_mysql_conn(cl.host, cl.port, cl.username, cl.password);
if (conn) {
new_conns.push_back(conn);
diag(" Created connection %d", i + 1);
} else {
diag(" Failed to create connection %d", i + 1);
}
}
diag("Successfully created %zu new connections", new_conns.size());
ok(new_conns.size() >= 1, "Created at least 1 new connection (created %zu)", new_conns.size());
// Step 3: Query connection count again
diag("Step 3: Querying Client_Connections_connected again via MCP stats endpoint");
const MCPResponse updated_resp = mcp->call_tool(
"stats",
"show_status",
json{{"db_type", "mysql"}, {"variable_name", "Client_Connections_connected"}}
);
ok(updated_resp.is_success(), "MCP call stats.show_status after connections transport success");
json updated_vars = json::array();
std::string updated_err;
bool updated_payload_ok = extract_show_status_variables(updated_resp, updated_vars, updated_err);
if (!updated_payload_ok) {
diag("Payload extraction failed: %s", updated_err.c_str());
diag("Raw response body: %s", updated_resp.get_http_response().substr(0, 500).c_str());
}
ok(updated_payload_ok, "Updated show_status payload valid%s%s",
updated_payload_ok ? "" : ": ", updated_payload_ok ? "" : updated_err.c_str());
// Step 4: Verify count increased
if (updated_payload_ok) {
long updated_count = 0;
bool found = get_metric_value(updated_vars, "Client_Connections_connected", updated_count);
if (found) {
diag("Client_Connections_connected updated value: %ld (initial was: %ld)", updated_count, initial_count);
ok(updated_count > initial_count,
"Connection count increased after creating connections (before=%ld, after=%ld, diff=%ld)",
initial_count, updated_count, updated_count - initial_count);
} else {
diag("Client_Connections_connected not found in response");
diag("Available variables: %s", updated_vars.dump().substr(0, 500).c_str());
ok(false, "Client_Connections_connected not found in updated response");
}
} else {
skip(1, "Cannot verify count without valid updated payload");
}
// Cleanup: close the connections we created
diag("Cleanup: Closing %zu test connections", new_conns.size());
for (MYSQL* conn : new_conns) {
mysql_close(conn);
}
}
if (admin) {
run_q(admin, "SET mcp-stats_endpoint_auth=''");
run_q(admin, "SET mcp-enabled=false");
run_q(admin, "LOAD MCP VARIABLES TO RUNTIME");
mysql_close(admin);
}
if (mcp) {
delete mcp;
}
return exit_status();
}