/** * @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 #include #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 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()); 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()); 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 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(); }