diff --git a/lib/Discovery_Schema.cpp b/lib/Discovery_Schema.cpp index 7f704d7c48..667fab95c8 100644 --- a/lib/Discovery_Schema.cpp +++ b/lib/Discovery_Schema.cpp @@ -2653,7 +2653,7 @@ MCP_Query_Processor_Output* Discovery_Schema::evaluate_mcp_query_rules( // Uses read lock on mcp_rules_lock // SQLite3_result* Discovery_Schema::get_mcp_query_rules() { - SQLite3_result* result = new SQLite3_result(); + SQLite3_result* result = new SQLite3_result(17); // Define columns (17 columns - same for mcp_query_rules and runtime_mcp_query_rules) result->add_column_definition(SQLITE_TEXT, "rule_id"); @@ -2726,7 +2726,7 @@ SQLite3_result* Discovery_Schema::get_mcp_query_rules() { // Uses read lock on mcp_rules_lock // SQLite3_result* Discovery_Schema::get_stats_mcp_query_rules() { - SQLite3_result* result = new SQLite3_result(); + SQLite3_result* result = new SQLite3_result(2); // Define columns result->add_column_definition(SQLITE_TEXT, "rule_id"); @@ -2860,7 +2860,7 @@ void Discovery_Schema::update_mcp_query_digest( // // Note: The caller is responsible for freeing the returned SQLite3_result. SQLite3_result* Discovery_Schema::get_mcp_query_digest(bool reset) { - SQLite3_result* result = new SQLite3_result(); + SQLite3_result* result = new SQLite3_result(10); // Define columns for MCP query digest statistics result->add_column_definition(SQLITE_TEXT, "tool_name"); @@ -2967,12 +2967,25 @@ uint64_t Discovery_Schema::compute_mcp_digest( std::string combined = tool_name + ":" + fingerprint; // Use SpookyHash to compute digest - uint64_t hash1, hash2; - SpookyHash::Hash128(combined.data(), combined.length(), &hash1, &hash2); + uint64_t hash1 = SpookyHash::Hash64(combined.data(), combined.length(), 0); return hash1; } +static options get_def_mysql_opts() { + options opts {}; + + opts.lowercase = false; + opts.replace_null = true; + opts.replace_number = false; + opts.grouping_limit = 3; + opts.groups_grouping_limit = 1; + opts.keep_comment = false; + opts.max_query_length = 65000; + + return opts; +} + // Generate a fingerprint of MCP tool arguments by replacing literals with placeholders. // // Converts a JSON arguments structure into a normalized form where all @@ -2995,7 +3008,7 @@ uint64_t Discovery_Schema::compute_mcp_digest( // // Example: // Input: {"sql": "SELECT * FROM users WHERE id = 123", "timeout": 5000} -// Output: {"sql":"?","timeout":"?"} +// Output: {"sql":"","timeout":"?"} // // Input: {"filters": {"status": "active", "age": 25}} // Output: {"filters":{"?":"?","?":"?"}} @@ -3004,6 +3017,11 @@ uint64_t Discovery_Schema::compute_mcp_digest( // This ensures that queries with different parameter structures produce different // fingerprints, while queries with the same structure but different values produce // the same fingerprint. +// +// SQL Handling: For arguments where key is "sql", the value is replaced by a +// digest generated using mysql_query_digest_and_first_comment instead of "?". +// This normalizes SQL queries (removes comments, extra whitespace, etc.) so that +// semantically equivalent queries produce the same fingerprint. std::string Discovery_Schema::fingerprint_mcp_args(const nlohmann::json& arguments) { // Serialize JSON with literals replaced by placeholders std::string result; @@ -3017,23 +3035,61 @@ std::string Discovery_Schema::fingerprint_mcp_args(const nlohmann::json& argumen result += "\"" + it.key() + "\":"; if (it.value().is_string()) { - result += "\"?\""; + // Special handling for "sql" key - generate digest instead of "?" + if (it.key() == "sql") { + std::string sql_value = it.value().get(); + const options def_opts { get_def_mysql_opts() }; + char* first_comment = nullptr; // Will be allocated by the function if needed + char* digest = mysql_query_digest_and_first_comment( + sql_value.c_str(), + sql_value.length(), + &first_comment, + NULL, // buffer - not needed + &def_opts + ); + if (first_comment) { + free(first_comment); + } + // Escape the digest for JSON and add it to result + result += "\""; + if (digest) { + // Full JSON escaping - handle all control characters + for (const char* p = digest; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '\\') result += "\\\\"; + else if (c == '"') result += "\\\""; + else if (c == '\n') result += "\\n"; + else if (c == '\r') result += "\\r"; + else if (c == '\t') result += "\\t"; + else if (c < 0x20) { + char buf[8]; + snprintf(buf, sizeof(buf), "\\u%04x", c); + result += buf; + } + else result += *p; + } + free(digest); + } + result += "\""; + } else { + result += "\"?\""; + } } else if (it.value().is_number() || it.value().is_boolean()) { - result += "?"; + result += "\"?\""; } else if (it.value().is_object()) { result += fingerprint_mcp_args(it.value()); } else if (it.value().is_array()) { - result += "[?]"; + result += "[\"?\"]"; } else { result += "null"; } } result += "}"; } else if (arguments.is_array()) { - result += "[?]"; + result += "[\"?\"]"; } else { - result += "?"; + result += "\"?\""; } return result; -} \ No newline at end of file +} diff --git a/lib/ProxySQL_Admin.cpp b/lib/ProxySQL_Admin.cpp index a9f45f0adf..fde1060451 100644 --- a/lib/ProxySQL_Admin.cpp +++ b/lib/ProxySQL_Admin.cpp @@ -7776,7 +7776,10 @@ char* ProxySQL_Admin::load_mcp_query_rules_to_runtime() { Discovery_Schema* catalog = qth->get_catalog(); if (!catalog) return (char*)"Discovery Schema catalog not initialized"; - char* query = (char*)"SELECT rule_id, active, username, schemaname, tool_name, match_pattern, negate_match_pattern, re_modifiers, flagIN, flagOUT, replace_pattern, timeout_ms, error_msg, OK_msg, log, apply, comment FROM main.mcp_query_rules ORDER BY rule_id"; + char* query = (char*)"SELECT rule_id, active, username, schemaname," + " tool_name, match_pattern, negate_match_pattern, re_modifiers, flagIN, flagOUT," + " replace_pattern, timeout_ms, error_msg, OK_msg, log, apply, comment FROM" + " main.mcp_query_rules WHERE active=1 ORDER BY rule_id"; SQLite3_result* resultset = NULL; admindb->execute_statement(query, &error, &cols, &affected_rows, &resultset); diff --git a/lib/ProxySQL_Admin_Stats.cpp b/lib/ProxySQL_Admin_Stats.cpp index f8e0bbfc19..d608bb7f79 100644 --- a/lib/ProxySQL_Admin_Stats.cpp +++ b/lib/ProxySQL_Admin_Stats.cpp @@ -2611,44 +2611,105 @@ void ProxySQL_Admin::stats___mcp_query_digest(bool reset) { statsdb->execute("BEGIN"); - if (reset) { - statsdb->execute("DELETE FROM stats_mcp_query_digest_reset"); - } else { - statsdb->execute("DELETE FROM stats_mcp_query_digest"); - } + const char* target_table = reset ? "stats_mcp_query_digest_reset" : "stats_mcp_query_digest"; + string query_delete = "DELETE FROM "; + query_delete += target_table; + statsdb->execute(query_delete.c_str()); - // Use prepared statement to prevent SQL injection + // Prepare INSERT statement with placeholders // Columns: tool_name, run_id, digest, digest_text, count_star, // first_seen, last_seen, sum_time, min_time, max_time - const char* query_str = reset - ? "INSERT INTO stats_mcp_query_digest_reset VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" - : "INSERT INTO stats_mcp_query_digest VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"; + const string q_insert { + "INSERT INTO " + string(target_table) + " VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" + }; - sqlite3_stmt* statement = NULL; - int rc = statsdb->prepare_v2(query_str, &statement); + int rc = 0; + stmt_unique_ptr u_stmt { nullptr }; + std::tie(rc, u_stmt) = statsdb->prepare_v2(q_insert.c_str()); ASSERT_SQLITE_OK(rc, statsdb); + sqlite3_stmt* const stmt { u_stmt.get() }; + // Insert each row from the resultset for (std::vector::iterator it = resultset->rows.begin(); it != resultset->rows.end(); ++it) { SQLite3_row* r = *it; - // Bind all 10 columns using positional parameters - rc = (*proxy_sqlite3_bind_text)(statement, 1, r->fields[0], -1, SQLITE_TRANSIENT); ASSERT_SQLITE_OK(rc, statsdb); // tool_name - rc = (*proxy_sqlite3_bind_text)(statement, 2, r->fields[1], -1, SQLITE_TRANSIENT); ASSERT_SQLITE_OK(rc, statsdb); // run_id - rc = (*proxy_sqlite3_bind_text)(statement, 3, r->fields[2], -1, SQLITE_TRANSIENT); ASSERT_SQLITE_OK(rc, statsdb); // digest - rc = (*proxy_sqlite3_bind_text)(statement, 4, r->fields[3], -1, SQLITE_TRANSIENT); ASSERT_SQLITE_OK(rc, statsdb); // digest_text - rc = (*proxy_sqlite3_bind_int64)(statement, 5, atoll(r->fields[4])); ASSERT_SQLITE_OK(rc, statsdb); // count_star - rc = (*proxy_sqlite3_bind_int64)(statement, 6, atoll(r->fields[5])); ASSERT_SQLITE_OK(rc, statsdb); // first_seen - rc = (*proxy_sqlite3_bind_int64)(statement, 7, atoll(r->fields[6])); ASSERT_SQLITE_OK(rc, statsdb); // last_seen - rc = (*proxy_sqlite3_bind_int64)(statement, 8, atoll(r->fields[7])); ASSERT_SQLITE_OK(rc, statsdb); // sum_time - rc = (*proxy_sqlite3_bind_int64)(statement, 9, atoll(r->fields[8])); ASSERT_SQLITE_OK(rc, statsdb); // min_time - rc = (*proxy_sqlite3_bind_int64)(statement, 10, atoll(r->fields[9])); ASSERT_SQLITE_OK(rc, statsdb); // max_time + // Bind text values + rc = (*proxy_sqlite3_bind_text)(stmt, 1, r->fields[0], -1, SQLITE_TRANSIENT); // tool_name + ASSERT_SQLITE_OK(rc, statsdb); + + // Bind run_id (may be NULL) + if (r->fields[1]) { + rc = (*proxy_sqlite3_bind_int64)(stmt, 2, atoll(r->fields[1])); // run_id + ASSERT_SQLITE_OK(rc, statsdb); + } else { + rc = (*proxy_sqlite3_bind_null)(stmt, 2); // run_id + ASSERT_SQLITE_OK(rc, statsdb); + } - SAFE_SQLITE3_STEP2(statement); - rc = (*proxy_sqlite3_clear_bindings)(statement); ASSERT_SQLITE_OK(rc, statsdb); - rc = (*proxy_sqlite3_reset)(statement); ASSERT_SQLITE_OK(rc, statsdb); - } + rc = (*proxy_sqlite3_bind_text)(stmt, 3, r->fields[2], -1, SQLITE_TRANSIENT); // digest + ASSERT_SQLITE_OK(rc, statsdb); - (*proxy_sqlite3_finalize)(statement); + rc = (*proxy_sqlite3_bind_text)(stmt, 4, r->fields[3], -1, SQLITE_TRANSIENT); // digest_text + ASSERT_SQLITE_OK(rc, statsdb); + + // Bind count_star (may be NULL) + if (r->fields[4]) { + rc = (*proxy_sqlite3_bind_int64)(stmt, 5, atoll(r->fields[4])); // count_star + ASSERT_SQLITE_OK(rc, statsdb); + } else { + rc = (*proxy_sqlite3_bind_null)(stmt, 5); // count_star + ASSERT_SQLITE_OK(rc, statsdb); + } + + // Bind first_seen (may be NULL) + if (r->fields[5]) { + rc = (*proxy_sqlite3_bind_int64)(stmt, 6, atoll(r->fields[5])); // first_seen + ASSERT_SQLITE_OK(rc, statsdb); + } else { + rc = (*proxy_sqlite3_bind_null)(stmt, 6); // first_seen + ASSERT_SQLITE_OK(rc, statsdb); + } + + // Bind last_seen (may be NULL) + if (r->fields[6]) { + rc = (*proxy_sqlite3_bind_int64)(stmt, 7, atoll(r->fields[6])); // last_seen + ASSERT_SQLITE_OK(rc, statsdb); + } else { + rc = (*proxy_sqlite3_bind_null)(stmt, 7); // last_seen + ASSERT_SQLITE_OK(rc, statsdb); + } + + // Bind sum_time (may be NULL) + if (r->fields[7]) { + rc = (*proxy_sqlite3_bind_int64)(stmt, 8, atoll(r->fields[7])); // sum_time + ASSERT_SQLITE_OK(rc, statsdb); + } else { + rc = (*proxy_sqlite3_bind_null)(stmt, 8); // sum_time + ASSERT_SQLITE_OK(rc, statsdb); + } + + // Bind min_time (may be NULL) + if (r->fields[8]) { + rc = (*proxy_sqlite3_bind_int64)(stmt, 9, atoll(r->fields[8])); // min_time + ASSERT_SQLITE_OK(rc, statsdb); + } else { + rc = (*proxy_sqlite3_bind_null)(stmt, 9); // min_time + ASSERT_SQLITE_OK(rc, statsdb); + } + + // Bind max_time (may be NULL) + if (r->fields[9]) { + rc = (*proxy_sqlite3_bind_int64)(stmt, 10, atoll(r->fields[9])); // max_time + ASSERT_SQLITE_OK(rc, statsdb); + } else { + rc = (*proxy_sqlite3_bind_null)(stmt, 10); // max_time + ASSERT_SQLITE_OK(rc, statsdb); + } + + SAFE_SQLITE3_STEP2(stmt); + rc = (*proxy_sqlite3_clear_bindings)(stmt); ASSERT_SQLITE_OK(rc, statsdb); + rc = (*proxy_sqlite3_reset)(stmt); ASSERT_SQLITE_OK(rc, statsdb); + } statsdb->execute("COMMIT"); delete resultset; } diff --git a/scripts/mcp_rules_testing/claude-test-plan.md b/scripts/mcp_rules_testing/claude-test-plan.md new file mode 100644 index 0000000000..0861b4fbf6 --- /dev/null +++ b/scripts/mcp_rules_testing/claude-test-plan.md @@ -0,0 +1,338 @@ +# MCP Query Rules Test Plan + +## Overview + +This test plan covers the MCP Query Rules feature added in the last 7 commits. The feature allows filtering and modifying MCP tool calls based on rule evaluation, similar to MySQL query rules. + +### Feature Design Summary + +Actions are inferred from rule properties (like MySQL/PostgreSQL query rules): +- `error_msg != NULL` → **block** +- `replace_pattern != NULL` → **rewrite** +- `timeout_ms > 0` → **timeout** +- `OK_msg != NULL` → return OK message +- otherwise → **allow** + +Actions are NOT mutually exclusive - a single rule can perform multiple actions simultaneously. + +### Tables Involved + +| Table | Purpose | +|-------|---------| +| `mcp_query_rules` | Admin table for defining rules | +| `runtime_mcp_query_rules` | In-memory state of active rules | +| `stats_mcp_query_rules` | Hit counters per rule | +| `stats_mcp_query_digest` | Query tracking statistics | + +### Existing Test Infrastructure + +1. **TAP Test**: `test/tap/tests/mcp_module-t.cpp` - Tests LOAD/SAVE commands for MCP variables +2. **Shell Test**: `scripts/mcp/test_mcp_query_rules_block.sh` - Tests block action +3. **SQL Rules**: `scripts/mcp/rules/block_rule.sql` - Sample block rules + +--- + +## Test Plan + +### Phase 1: Rule Management Tests (CREATE/READ/UPDATE/DELETE) + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T1.1 | Create a basic rule with match_pattern | Rule inserted into `mcp_query_rules` | +| T1.2 | Create rule with all action types | Rule with error_msg, replace_pattern, OK_msg, timeout_ms | +| T1.3 | Create rule with username filter | Rule filters by specific user | +| T1.4 | Create rule with schemaname filter | Rule filters by specific schema | +| T1.5 | Create rule with tool_name filter | Rule filters by specific tool | +| T1.6 | Update existing rule | Rule properties modified | +| T1.7 | Delete rule | Rule removed from table | +| T1.8 | Create rule with flagIN/flagOUT | Rule chaining setup | + +### Phase 2: LOAD/SAVE Commands Tests + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T2.1 | `LOAD MCP QUERY RULES TO MEMORY` | Rules loaded from disk to memory table | +| T2.2 | `LOAD MCP QUERY RULES FROM MEMORY` | Rules copied from memory to... | +| T2.3 | `LOAD MCP QUERY RULES TO RUNTIME` | Rules become active for evaluation | +| T2.4 | `SAVE MCP QUERY RULES TO DISK` | Rules persisted to disk | +| T2.5 | `SAVE MCP QUERY RULES TO MEMORY` | Rules saved to memory table | +| T2.6 | `SAVE MCP QUERY RULES FROM RUNTIME` | Runtime rules saved to memory | + +### Phase 3: Runtime Table Tests + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T3.1 | Query `runtime_mcp_query_rules` | Returns active rules from memory | +| T3.2 | Verify rules match runtime after LOAD | Runtime table reflects loaded rules | +| T3.3 | Verify active flag filtering | Only active=1 rules are in runtime | +| T3.4 | Check rule order in runtime | Rules ordered by rule_id | + +### Phase 4: Statistics Table Tests + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T4.1 | Query `stats_mcp_query_rules` | Returns rule_id and hits count | +| T4.2 | Verify hit counter increments on match | hits counter increases when rule matches | +| T4.3 | Verify hit counter persists across queries | Counter accumulates across multiple matches | +| T4.4 | Check hit counter for non-matching rule | Counter stays at 0 for unmatched rules | + +### Phase 5: Query Digest Tests + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T5.1 | Query `stats_mcp_query_digest` | Returns tool_name, digest, count_star, etc. | +| T5.2 | Verify query tracked in digest | New query appears in digest table | +| T5.3 | Verify count_star increments | Repeated queries increment counter | +| T5.4 | Verify digest_text contains SQL | SQL query text is stored | +| T5.5 | Test `stats_mcp_query_digest_reset` | Reset table clears and returns current stats | + +### Phase 6: Rule Evaluation Tests - Block Action + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T6.1 | Block query with error_msg | Query rejected, error returned | +| T6.2 | Block with case-sensitive match | Pattern matching respects re_modifiers | +| T6.3 | Block with negate_match_pattern=1 | Inverts the match logic | +| T6.4 | Block specific username | Only queries from user are blocked | +| T6.5 | Block specific schema | Only queries in schema are blocked | +| T6.6 | Block specific tool_name | Only calls to tool are blocked | + +### Phase 7: Rule Evaluation Tests - Rewrite Action + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T7.1 | Rewrite SQL with replace_pattern | SQL modified before execution | +| T7.2 | Rewrite with capture groups | Pattern substitution works | +| T7.3 | Rewrite with regex modifiers | CASELESS/EXTENDED modifiers work | + +### Phase 8: Rule Evaluation Tests - Timeout Action + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T8.1 | Query with timeout_ms | Query times out after specified ms | +| T8.2 | Verify timeout error message | Appropriate error returned | + +TODO: There is a limitation for testing this feature. MCP connection gets killed and becomes unusable after +'timeout' takes place. This should be fixed before continuing this testing phase. + +### Phase 9: Rule Evaluation Tests - OK Message Action + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T9.1 | Query with OK_msg | Query returns OK message without execution | +| T9.2 | Verify success response | Success response contains OK_msg | + +### Phase 10: Rule Chaining Tests (flagIN/flagOUT) + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T10.1 | Create rules with flagIN=0, flagOUT=100 | First rule sets flag to 100 | +| T10.2 | Create rule with flagIN=100 | Second rule only evaluates if flag=100 | +| T10.3 | Verify rule chaining order | Rules evaluated in flagIN/flagOUT order | +| T10.4 | Test multiple flagOUT values | Complex chaining scenarios | + +### Phase 11: Integration Tests + +| Test ID | Description | Expected Result | +|---------|-------------|-----------------| +| T11.1 | Multiple actions in single rule | Block + rewrite together | +| T11.2 | Multiple matching rules | First matching rule wins (or all?) | +| T11.3 | Load rules and verify immediately | Rules active after LOAD TO RUNTIME | +| T11.4 | Modify rule and reload | Updated behavior after reload | + +--- + +## Implementation Approach + +### Option A: Extend Existing Shell Test Script +Extend `scripts/mcp/test_mcp_query_rules_block.sh` to cover all test cases. + +**Pros:** +- Follows existing pattern +- Easy to run manually +- Good for end-to-end testing + +**Cons:** +- Shell scripting complexity +- Harder to maintain + +### Option B: Create New TAP Test +Create `test/tap/tests/mcp_query_rules-t.cpp` following the pattern of `mcp_module-t.cpp`. + +**Pros:** +- Consistent with existing test framework +- Better integration with CI +- Cleaner C++ code +- Better error reporting + +**Cons:** +- Requires rebuild +- Less accessible for manual testing + +### Option C: Hybrid Approach (Recommended) +1. **TAP Test** (`mcp_query_rules-t.cpp`): Core functionality tests + - LOAD/SAVE commands + - Table operations + - Statistics tracking + - Basic rule evaluation + +2. **Shell Script** (`test_mcp_query_rules_all.sh`): End-to-end integration tests + - Complex rule chaining + - Multiple action types + - Real MCP server interaction + +--- + +## Test File Structure + +### TAP Test Structure +```cpp +// test/tap/tests/mcp_query_rules-t.cpp + +int main() { + // Part 1: Rule CRUD operations + test_rule_create(); + test_rule_read(); + test_rule_update(); + test_rule_delete(); + + // Part 2: LOAD/SAVE commands + test_load_save_commands(); + + // Part 3: Runtime table + test_runtime_table(); + + // Part 4: Statistics table + test_stats_table(); + + // Part 5: Query digest + test_query_digest(); + + // Part 6: Rule evaluation + test_block_action(); + test_rewrite_action(); + test_timeout_action(); + test_okmsg_action(); + + // Part 7: Rule chaining + test_flag_chaining(); + + return exit_status(); +} +``` + +### Shell Test Structure +```bash +# scripts/mcp/test_mcp_query_rules_all.sh + +test_block_action() { ... } +test_rewrite_action() { ... } +test_timeout_action() { ... } +test_okmsg_action() { ... } +test_flag_chaining() { ... } +``` + +--- + +## SQL Rule Templates + +### Block Rule Template +```sql +INSERT INTO mcp_query_rules ( + rule_id, active, username, schemaname, tool_name, + match_pattern, negate_match_pattern, re_modifiers, + flagIN, flagOUT, error_msg, apply, comment +) VALUES ( + 100, 1, NULL, NULL, NULL, + 'DROP TABLE', 0, 'CASELESS', + 0, NULL, + 'Blocked by rule: DROP TABLE not allowed', + 1, 'Block DROP TABLE' +); +``` + +### Rewrite Rule Template +```sql +INSERT INTO mcp_query_rules ( + rule_id, active, username, schemaname, tool_name, + match_pattern, replace_pattern, re_modifiers, + flagIN, flagOUT, apply, comment +) VALUES ( + 200, 1, NULL, NULL, 'run_sql_readonly', + 'SELECT \* FROM (.*)', 'SELECT count(*) FROM \1', + 'EXTENDED', 0, NULL, + 1, 'Rewrite SELECT * to SELECT count(*)' +); +``` + +### Timeout Rule Template +```sql +INSERT INTO mcp_query_rules ( + rule_id, active, username, schemaname, tool_name, + match_pattern, timeout_ms, re_modifiers, + flagIN, flagOUT, apply, comment +) VALUES ( + 300, 1, NULL, NULL, NULL, + 'SELECT.*FROM.*large_table', 5000, + 'CASELESS', 0, NULL, + 1, 'Timeout queries on large_table' +); +``` + +### OK Message Rule Template +```sql +INSERT INTO mcp_query_rules ( + rule_id, active, username, schemaname, tool_name, + match_pattern, OK_msg, re_modifiers, + flagIN, flagOUT, apply, comment +) VALUES ( + 400, 1, NULL, NULL, NULL, + 'PING', 'PONG', 'CASELESS', + 0, NULL, 1, 'Return PONG for PING' +); +``` + +--- + +## Recommended Next Actions + +1. **Start with Phase 1-5**: Create TAP test for table operations and statistics + - These don't require MCP server interaction + - Can be tested through admin interface only + +2. **Create test SQL files**: Organize rule templates in `scripts/mcp/rules/` + - `block_rule.sql` (already exists) + - `rewrite_rule.sql` + - `timeout_rule.sql` + - `okmsg_rule.sql` + - `chaining_rule.sql` + +3. **Extend shell test**: Modify `test_mcp_query_rules_block.sh` to `test_mcp_query_rules_all.sh` + - Add rewrite, timeout, OK_msg tests + - Add flag chaining tests + +4. **Create TAP test**: New file `test/tap/tests/mcp_query_rules-t.cpp` + - Core functionality tests + - Statistics tracking tests + +5. **Integration tests**: End-to-end tests with actual MCP server + - Test through JSON-RPC interface + - Verify response contents + +--- + +## Test Dependencies + +- **ProxySQL**: Must be running with MCP module enabled +- **MySQL client**: For admin interface commands +- **curl**: For MCP JSON-RPC requests +- **jq**: For JSON parsing in shell tests +- **TAP library**: For C++ tests + +## Test Execution Order + +1. Start ProxySQL with MCP enabled +2. Run TAP tests (fast, no external dependencies) +3. Run shell tests (require MCP server) +4. Verify all tests pass +5. Clean up test rules diff --git a/scripts/mcp_rules_testing/rules/block_rule.sql b/scripts/mcp_rules_testing/rules/block_rule.sql new file mode 100644 index 0000000000..8313ea0735 --- /dev/null +++ b/scripts/mcp_rules_testing/rules/block_rule.sql @@ -0,0 +1,79 @@ +-- Test Block Rule for MCP Query Rules +-- This rule blocks queries matching DROP TABLE pattern +-- Rule ID 100: Block any query containing DROP TABLE +INSERT INTO mcp_query_rules ( + rule_id, + active, + username, + schemaname, + tool_name, + match_pattern, + negate_match_pattern, + re_modifiers, + flagIN, + flagOUT, + replace_pattern, + timeout_ms, + error_msg, + OK_msg, + log, + apply, + comment +) VALUES ( + 100, -- rule_id + 1, -- active + NULL, -- username (any user) + NULL, -- schemaname (any schema) + NULL, -- tool_name (any tool) + 'DROP TABLE', -- match_pattern + 0, -- negate_match_pattern + 'CASELESS', -- re_modifiers + 0, -- flagIN + NULL, -- flagOUT + NULL, -- replace_pattern + NULL, -- timeout_ms + 'Blocked by MCP query rule: DROP TABLE statements are not allowed', -- error_msg (BLOCK action) + NULL, -- OK_msg + 1, -- log + 1, -- apply + 'Test rule: Block DROP TABLE statements' -- comment +); + +-- Rule ID 101: Block SELECT queries on customers table (more specific pattern) +INSERT INTO mcp_query_rules ( + rule_id, + active, + username, + schemaname, + tool_name, + match_pattern, + negate_match_pattern, + re_modifiers, + flagIN, + flagOUT, + replace_pattern, + timeout_ms, + error_msg, + OK_msg, + log, + apply, + comment +) VALUES ( + 101, -- rule_id + 1, -- active + NULL, -- username (any user) + 'testdb', -- schemaname (only testdb) + 'run_sql_readonly', -- tool_name (only this tool) + 'SELECT.*FROM.*customers', -- match_pattern + 0, -- negate_match_pattern + 'CASELESS', -- re_modifiers + 0, -- flagIN + NULL, -- flagOUT + NULL, -- replace_pattern + NULL, -- timeout_ms + 'Blocked by MCP query rule: Direct access to customers table is restricted', -- error_msg + NULL, -- OK_msg + 1, -- log + 1, -- apply + 'Test rule: Block SELECT from customers table in testdb' -- comment +); diff --git a/scripts/mcp_rules_testing/test_mcp_query_rules_block.sh b/scripts/mcp_rules_testing/test_mcp_query_rules_block.sh new file mode 100755 index 0000000000..d583af983e --- /dev/null +++ b/scripts/mcp_rules_testing/test_mcp_query_rules_block.sh @@ -0,0 +1,502 @@ +#!/bin/bash +# +# test_mcp_query_rules_block.sh - Test MCP Query Rules Block Action +# +# This script tests the Block action of MCP query rules by: +# 1. Loading block rules via the admin interface +# 2. Executing MCP tool calls via curl +# 3. Verifying that matching queries are blocked with the error message +# +# Usage: +# ./test_mcp_query_rules_block.sh [options] +# +# Options: +# -v, --verbose Show verbose output +# -c, --clean Clean up test rules after testing +# -h, --help Show help + +set -e + +# Check prerequisites +if ! command -v jq >/dev/null 2>&1; then + echo "Error: 'jq' is required but not installed." + echo "Please install jq to run this script." + echo " - On Ubuntu/Debian: sudo apt-get install jq" + echo " - On RHEL/CentOS: sudo yum install jq" + echo " - On macOS: brew install jq" + exit 1 +fi + +# Default configuration (can be overridden by environment variables) +MCP_HOST="${MCP_HOST:-127.0.0.1}" +MCP_PORT="${MCP_PORT:-6071}" + +# ProxySQL admin configuration +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RULES_DIR="${SCRIPT_DIR}/rules" + +# Test options +VERBOSE=false +CLEAN_AFTER=false + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_step() { + echo -e "${BLUE}[STEP]${NC} $1" +} + +log_verbose() { + if [ "${VERBOSE}" = "true" ]; then + echo -e "${CYAN}[DEBUG]${NC} $1" + fi +} + +log_test() { + echo -e "${BLUE}[TEST]${NC} $1" +} + +# Execute MySQL command via ProxySQL admin +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command via ProxySQL admin (silent mode) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Execute SQL file via ProxySQL admin +exec_admin_file() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + < "$1" 2>&1 +} + +# Get endpoint URL +get_endpoint_url() { + local endpoint="$1" + echo "https://${MCP_HOST}:${MCP_PORT}/mcp/${endpoint}" +} + +# Execute MCP request via curl +mcp_request() { + local endpoint="$1" + local payload="$2" + + local response + response=$(curl -k -s -w "\n%{http_code}" -X POST "$(get_endpoint_url "${endpoint}")" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null) + + local body + body=$(echo "$response" | head -n -1) + local code + code=$(echo "$response" | tail -n 1) + + if [ "${VERBOSE}" = "true" ]; then + echo "Request: ${payload}" >&2 + echo "Response (${code}): ${body}" >&2 + fi + + echo "${body}" + return 0 +} + +# Check if ProxySQL admin is accessible +check_proxysql_admin() { + log_step "Checking ProxySQL admin connection..." + if exec_admin_silent "SELECT 1" >/dev/null 2>&1; then + log_info "Connected to ProxySQL admin at ${PROXYSQL_ADMIN_HOST}:${PROXYSQL_ADMIN_PORT}" + return 0 + else + log_error "Cannot connect to ProxySQL admin at ${PROXYSQL_ADMIN_HOST}:${PROXYSQL_ADMIN_PORT}" + log_error "Please ensure ProxySQL is running" + return 1 + fi +} + +# Check if MCP server is accessible +check_mcp_server() { + log_step "Checking MCP server accessibility..." + + local response + response=$(mcp_request "config" '{"jsonrpc":"2.0","method":"ping","id":1}') + + if echo "${response}" | grep -q "result"; then + log_info "MCP server is accessible at ${MCP_HOST}:${MCP_PORT}" + return 0 + else + log_error "MCP server is not accessible" + log_error "Response: ${response}" + return 1 + fi +} + +# Load block rules from SQL file +load_block_rules() { + log_step "Loading block rules from SQL file..." + + local sql_file="${RULES_DIR}/block_rule.sql" + + if [ ! -f "${sql_file}" ]; then + log_error "SQL file not found: ${sql_file}" + return 1 + fi + + if exec_admin_file "${sql_file}"; then + log_info "Block rules inserted successfully" + return 0 + else + log_error "Failed to insert block rules" + return 1 + fi +} + +# Load MCP query rules to runtime +load_rules_to_runtime() { + log_step "Loading MCP query rules to RUNTIME..." + + if exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1; then + log_info "MCP query rules loaded to RUNTIME" + return 0 + else + log_error "Failed to load MCP query rules to RUNTIME" + return 1 + fi +} + +# Display current rules in runtime table +display_runtime_rules() { + log_step "Current rules in runtime_mcp_query_rules:" + exec_admin "SELECT rule_id, active, username, schemaname, tool_name, match_pattern, error_msg, comment FROM runtime_mcp_query_rules;" +} + +# Get rule hit count from stats table +get_rule_hits() { + local rule_id="$1" + local hits + hits=$(exec_admin_silent "SELECT hits FROM stats_mcp_query_rules WHERE rule_id = ${rule_id};") + echo "${hits:-0}" +} + +# Test that a query is blocked by a rule +test_block_action() { + local test_name="$1" + local endpoint="$2" + local tool_name="$3" + local arguments="$4" + local expected_error_msg="$5" + local rule_id="$6" + + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + + log_test "Testing: ${test_name}" + + local payload + payload=$(cat </dev/null) + + log_verbose "Error message: ${error_msg}" + + # Check if expected error message is contained in response + if echo "${error_msg}" | grep -qi "${expected_error_msg}"; then + log_info "✓ ${test_name} - Query blocked as expected" + PASSED_TESTS=$((PASSED_TESTS + 1)) + + # Verify rule hit counter incremented + if [ -n "${rule_id}" ]; then + local hits + hits=$(get_rule_hits "${rule_id}") + log_verbose "Rule ${rule_id} hits: ${hits}" + if [ "${hits}" -gt 0 ]; then + log_info " Rule ${rule_id} hit counter incremented to ${hits}" + else + log_warn " Rule ${rule_id} hit counter not incremented" + fi + fi + return 0 + else + log_error "✗ ${test_name} - Error message mismatch" + log_error " Expected substring: ${expected_error_msg}" + log_error " Actual: ${error_msg}" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi + else + log_error "✗ ${test_name} - Query was not blocked (expected error)" + log_error " Response: ${response}" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Test that a query is allowed (not blocked) +test_allow_action() { + local test_name="$1" + local endpoint="$2" + local tool_name="$3" + local arguments="$4" + + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + + log_test "Testing: ${test_name}" + + local payload + payload=$(cat </dev/null 2>&1 + + log_info "Test rules cleaned up" +} + +# Parse command line arguments +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + -v|--verbose) + VERBOSE=true + shift + ;; + -c|--clean) + CLEAN_AFTER=true + shift + ;; + -h|--help) + cat </dev/null 2>&1 + + # Load block rules + if ! load_block_rules; then + exit 1 + fi + + # Load rules to runtime + if ! load_rules_to_runtime; then + exit 1 + fi + + # Display current rules + echo "" + display_runtime_rules + echo "" + + # Give rules a moment to take effect + sleep 1 + + echo "======================================" + echo "Running Block Rule Tests" + echo "======================================" + echo "" + + # Test 1: Block DROP TABLE statement (rule_id=100) + test_block_action \ + "Test 1: Block DROP TABLE statement" \ + "query" \ + "run_sql_readonly" \ + '{"sql": "DROP TABLE IF EXISTS test_table;"}' \ + "DROP TABLE statements are not allowed" \ + "100" + + # Test 2: Block SELECT from customers table in testdb (rule_id=101) + test_block_action \ + "Test 2: Block SELECT from customers table" \ + "query" \ + "run_sql_readonly" \ + '{"sql": "SELECT * FROM customers;"}' \ + "customers table is restricted" \ + "101" + + # Test 3: Allow SELECT from other tables (should not be blocked) + test_allow_action \ + "Test 3: Allow SELECT from other tables" \ + "query" \ + "run_sql_readonly" \ + '{"sql": "SELECT * FROM products;"}' + + # Display final stats + echo "" + log_step "Rule hit statistics:" + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id IN (100, 101);" + + # Print summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Clean up if requested + if [ "${CLEAN_AFTER}" = "true" ]; then + cleanup_test_rules + fi + + if [ ${FAILED_TESTS} -gt 0 ]; then + log_error "Some tests failed!" + exit 1 + else + log_info "All tests passed!" + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase1_crud.sh b/scripts/mcp_rules_testing/test_phase1_crud.sh new file mode 100755 index 0000000000..14b427a62d --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase1_crud.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# +# test_phase1_crud.sh - Test MCP Query Rules CRUD Operations +# +# Phase 1: Test CREATE, READ, UPDATE, DELETE operations on mcp_query_rules table +# + +set -e + +# Default configuration +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } + +# Execute MySQL command +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Check if table has rule +rule_exists() { + local rule_id="$1" + local count + count=$(exec_admin_silent "SELECT COUNT(*) FROM mcp_query_rules WHERE rule_id = ${rule_id};") + [ "${count}" -gt 0 ] +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +main() { + echo "======================================" + echo "Phase 1: MCP Query Rules CRUD Tests" + echo "======================================" + echo "" + + # Cleanup any existing test rules + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + + # Test 1.1: Create a basic rule with match_pattern + run_test "T1.1: Create basic rule with match_pattern" \ + exec_admin "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) \ + VALUES (100, 1, 'DROP TABLE', 'Blocked', 1);" + + # Test 1.2: Verify rule was created + run_test "T1.2: Verify rule exists in table" rule_exists 100 + + # Test 1.3: Read the rule back + run_test "T1.3: Read rule from table" \ + exec_admin "SELECT rule_id, active, match_pattern, error_msg FROM mcp_query_rules WHERE rule_id = 100;" >/dev/null + + # Test 1.4: Create rule with all action types + run_test "T1.4: Create rule with all action types" \ + exec_admin "INSERT INTO mcp_query_rules (rule_id, active, username, schemaname, tool_name, \ + match_pattern, replace_pattern, timeout_ms, error_msg, OK_msg, apply, comment) \ + VALUES (101, 1, 'testuser', 'testdb', 'run_sql_readonly', \ + 'SELECT.*FROM.*test', 'SELECT COUNT(*) FROM test', 5000, \ + 'Error msg', 'OK msg', 1, 'Full rule test');" + + # Test 1.5: Create rule with username filter + run_test "T1.5: Create rule with username filter" \ + exec_admin "INSERT INTO mcp_query_rules (rule_id, active, username, match_pattern, error_msg, apply) \ + VALUES (102, 1, 'adminuser', 'DELETE FROM', 'Blocked for admin', 1);" + + # Test 1.6: Create rule with schemaname filter + run_test "T1.6: Create rule with schemaname filter" \ + exec_admin "INSERT INTO mcp_query_rules (rule_id, active, schemaname, match_pattern, error_msg, apply) \ + VALUES (103, 1, 'proddb', 'TRUNCATE', 'Blocked in proddb', 1);" + + # Test 1.7: Create rule with tool_name filter + run_test "T1.7: Create rule with tool_name filter" \ + exec_admin "INSERT INTO mcp_query_rules (rule_id, active, tool_name, match_pattern, error_msg, apply) \ + VALUES (104, 1, 'run_sql_readonly', 'INSERT INTO', 'Blocked on readonly', 1);" + + # Test 1.8: Update existing rule + run_test "T1.8: Update rule error_msg" \ + exec_admin "UPDATE mcp_query_rules SET error_msg = 'Updated error message' WHERE rule_id = 100;" + + # Test 1.9: Verify update worked + RESULT=$(exec_admin_silent "SELECT error_msg FROM mcp_query_rules WHERE rule_id = 100;") + if [ "${RESULT}" = "Updated error message" ]; then + run_test "T1.9: Verify update succeeded" true + else + run_test "T1.9: Verify update succeeded" false + fi + + # Test 1.10: Update multiple fields + run_test "T1.10: Update multiple fields" \ + exec_admin "UPDATE mcp_query_rules SET active = 0, match_pattern = 'ALTER TABLE' WHERE rule_id = 101;" + + # Test 1.11: Create rule with flagIN/flagOUT + run_test "T1.11: Create rule with flagIN/flagOUT" \ + exec_admin "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, flagIN, flagOUT, apply, comment) \ + VALUES (105, 1, 'SELECT', 0, 100, 1, 'Flag chaining rule 1');" + + # Test 1.12: Create second rule for chaining (flagIN=100) + run_test "T1.12: Create chaining rule with flagIN=100" \ + exec_admin "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, flagIN, apply, comment) \ + VALUES (106, 1, '.*customers.*', 100, 1, 'Flag chaining rule 2');" + + # Test 1.13: Count all test rules + COUNT=$(exec_admin_silent "SELECT COUNT(*) FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;") + if [ "${COUNT}" -ge 7 ]; then + run_test "T1.13: Verify all rules created (count=${COUNT})" true + else + run_test "T1.13: Verify all rules created (count=${COUNT})" false + fi + + # Test 1.14: Delete a rule + run_test "T1.14: Delete rule" \ + exec_admin "DELETE FROM mcp_query_rules WHERE rule_id = 106;" + + # Test 1.15: Verify deletion + if ! rule_exists 106; then + run_test "T1.15: Verify rule deleted" true + else + run_test "T1.15: Verify rule deleted" false + fi + + # Test 1.16: Delete multiple rules + run_test "T1.16: Delete multiple rules" \ + exec_admin "DELETE FROM mcp_query_rules WHERE rule_id IN (104, 105);" + + # Display remaining test rules + echo "" + echo "Remaining test rules:" + exec_admin "SELECT rule_id, active, username, schemaname, tool_name, match_pattern FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Cleanup + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase2_load_save.sh b/scripts/mcp_rules_testing/test_phase2_load_save.sh new file mode 100755 index 0000000000..c3aef72fe6 --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase2_load_save.sh @@ -0,0 +1,174 @@ +#!/bin/bash +# +# test_phase2_load_save.sh - Test MCP Query Rules LOAD/SAVE Commands +# +# Phase 2: Test LOAD/SAVE commands across storage layers (memory, disk, runtime) +# + +set -e + +# Default configuration +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } + +# Execute MySQL command +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Count rules in table +count_rules() { + local table="$1" + exec_admin_silent "SELECT COUNT(*) FROM ${table} WHERE rule_id BETWEEN 100 AND 199;" +} + +main() { + echo "======================================" + echo "Phase 2: LOAD/SAVE Commands Tests" + echo "======================================" + echo "" + + # Cleanup any existing test rules + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "DELETE FROM runtime_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 || true + + # Create test rules + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (100, 1, 'TEST1', 'Error1', 1);" >/dev/null 2>&1 + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (101, 1, 'TEST2', 'Error2', 1);" >/dev/null 2>&1 + + # Test 2.1: LOAD MCP QUERY RULES TO MEMORY + run_test "T2.1: LOAD MCP QUERY RULES TO MEMORY" \ + exec_admin "LOAD MCP QUERY RULES TO MEMORY;" + + # Test 2.2: LOAD MCP QUERY RULES FROM MEMORY + run_test "T2.2: LOAD MCP QUERY RULES FROM MEMORY" \ + exec_admin "LOAD MCP QUERY RULES FROM MEMORY;" + + # Test 2.3: LOAD MCP QUERY RULES TO RUNTIME + run_test "T2.3: LOAD MCP QUERY RULES TO RUNTIME" \ + exec_admin "LOAD MCP QUERY RULES TO RUNTIME;" + + # Test 2.4: Verify rules are in runtime after LOAD TO RUNTIME + RUNTIME_COUNT=$(count_rules "runtime_mcp_query_rules") + if [ "${RUNTIME_COUNT}" -ge 2 ]; then + run_test "T2.4: Verify rules in runtime (count=${RUNTIME_COUNT})" true + else + run_test "T2.4: Verify rules in runtime (count=${RUNTIME_COUNT})" false + fi + + # Test 2.5: SAVE MCP QUERY RULES TO DISK + run_test "T2.5: SAVE MCP QUERY RULES TO DISK" \ + exec_admin "SAVE MCP QUERY RULES TO DISK;" + + # Test 2.6: SAVE MCP QUERY RULES TO MEMORY + run_test "T2.6: SAVE MCP QUERY RULES TO MEMORY" \ + exec_admin "SAVE MCP QUERY RULES TO MEMORY;" + + # Test 2.7: SAVE MCP QUERY RULES FROM RUNTIME + run_test "T2.7: SAVE MCP QUERY RULES FROM RUNTIME" \ + exec_admin "SAVE MCP QUERY RULES FROM RUNTIME;" + + # Test 2.8: Test persistence - modify a rule, save to disk, modify again, load from disk + exec_admin_silent "UPDATE mcp_query_rules SET error_msg = 'Modified' WHERE rule_id = 100;" >/dev/null 2>&1 + exec_admin_silent "SAVE MCP QUERY RULES TO DISK;" >/dev/null 2>&1 + exec_admin_silent "UPDATE mcp_query_rules SET error_msg = 'Modified Again' WHERE rule_id = 100;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES FROM DISK;" >/dev/null 2>&1 + RESULT=$(exec_admin_silent "SELECT error_msg FROM mcp_query_rules WHERE rule_id = 100;") + if [ "${RESULT}" = "Modified" ]; then + run_test "T2.8: SAVE TO DISK / LOAD FROM DISK persistence" true + else + run_test "T2.8: SAVE TO DISK / LOAD FROM DISK persistence" false + fi + + # Test 2.9: Test round-trip - memory -> runtime -> memory + exec_admin_silent "UPDATE mcp_query_rules SET error_msg = 'RoundTrip Test' WHERE rule_id = 100;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + exec_admin_silent "SAVE MCP QUERY RULES FROM RUNTIME;" >/dev/null 2>&1 + RESULT=$(exec_admin_silent "SELECT error_msg FROM mcp_query_rules WHERE rule_id = 100;") + if [ "${RESULT}" = "RoundTrip Test" ]; then + run_test "T2.9: Round-trip memory -> runtime -> memory" true + else + run_test "T2.9: Round-trip memory -> runtime -> memory" false + fi + + # Test 2.10: Add new rule and verify LOAD TO RUNTIME works + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (102, 1, 'NEWTEST', 'New Error', 1);" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + RUNTIME_COUNT=$(count_rules "runtime_mcp_query_rules") + if [ "${RUNTIME_COUNT}" -ge 3 ]; then + run_test "T2.10: New rule appears in runtime after LOAD" true + else + run_test "T2.10: New rule appears in runtime after LOAD" false + fi + + # Display current state + echo "" + echo "Current rules in mcp_query_rules:" + exec_admin "SELECT rule_id, active, match_pattern, error_msg FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + echo "" + echo "Current rules in runtime_mcp_query_rules:" + exec_admin "SELECT rule_id, active, match_pattern, error_msg FROM runtime_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Cleanup + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase3_runtime.sh b/scripts/mcp_rules_testing/test_phase3_runtime.sh new file mode 100755 index 0000000000..a5c3eaeed9 --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase3_runtime.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# +# test_phase3_runtime.sh - Test MCP Query Rules Runtime Table +# +# Phase 3: Test runtime_mcp_query_rules table behavior +# + +set -e + +# Default configuration +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } + +# Execute MySQL command +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Count rules in table +count_rules() { + local table="$1" + exec_admin_silent "SELECT COUNT(*) FROM ${table};" +} + +# Check if rule exists in runtime +runtime_rule_exists() { + local rule_id="$1" + local count + count=$(exec_admin_silent "SELECT COUNT(*) FROM runtime_mcp_query_rules WHERE rule_id = ${rule_id};") + [ "${count}" -gt 0 ] +} + +main() { + echo "======================================" + echo "Phase 3: Runtime Table Tests" + echo "======================================" + echo "" + + # Cleanup any existing test rules + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + # Test 3.1: Query runtime_mcp_query_rules table + run_test "T3.1: Query runtime_mcp_query_rules table" \ + exec_admin "SELECT * FROM runtime_mcp_query_rules LIMIT 5;" + + # Test 3.2: Insert active rule and verify it appears in runtime after LOAD + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (100, 1, 'TEST1', 'Error1', 1);" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + run_test "T3.2: Active rule appears in runtime after LOAD" runtime_rule_exists 100 + + # Test 3.3: Insert inactive rule and verify it does NOT appear in runtime + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (101, 0, 'TEST2', 'Error2', 1);" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + if runtime_rule_exists 101; then + run_test "T3.3: Inactive rule does NOT appear in runtime" false + else + run_test "T3.3: Inactive rule does NOT appear in runtime" true + fi + + # Test 3.4: Update rule from inactive to active and verify it appears + exec_admin_silent "UPDATE mcp_query_rules SET active = 1 WHERE rule_id = 101;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + run_test "T3.4: Inactive->Active rule appears in runtime after reload" runtime_rule_exists 101 + + # Test 3.5: Update rule from active to inactive and verify it disappears + exec_admin_silent "UPDATE mcp_query_rules SET active = 0 WHERE rule_id = 100;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + if runtime_rule_exists 100; then + run_test "T3.5: Active->Inactive rule disappears from runtime" false + else + run_test "T3.5: Active->Inactive rule disappears from runtime" true + fi + + # Test 3.6: Check rule order in runtime (should be ordered by rule_id) + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (102, 1, 'TEST3', 'Error3', 1);" >/dev/null 2>&1 + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (103, 1, 'TEST4', 'Error4', 1);" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + IDS=$(exec_admin_silent "SELECT rule_id FROM runtime_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;") + # Verify exact ordering: 101, 102, 103 + if [ "${IDS}" = "101 +102 +103" ]; then + run_test "T3.6: Rules ordered by rule_id in runtime" true + else + run_test "T3.6: Rules ordered by rule_id in runtime (got: ${IDS})" false + fi + + # Test 3.7: Delete rule from main table and verify it disappears from runtime + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id = 102;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + if runtime_rule_exists 102; then + run_test "T3.7: Deleted rule disappears from runtime" false + else + run_test "T3.7: Deleted rule disappears from runtime" true + fi + + # Test 3.8: Verify runtime table schema matches main table (check columns exist) + SCHEMA_CHECK=$(exec_admin "PRAGMA table_info(runtime_mcp_query_rules);" 2>/dev/null | wc -l) + if [ "${SCHEMA_CHECK}" -gt 10 ]; then + run_test "T3.8: Runtime table schema is valid" true + else + run_test "T3.8: Runtime table schema is valid" false + fi + + # Test 3.9: Compare counts between main table (active only) and runtime + ACTIVE_COUNT=$(exec_admin_silent "SELECT COUNT(*) FROM mcp_query_rules WHERE active = 1 AND rule_id > 100;") + RUNTIME_ACTIVE_COUNT=$(exec_admin_silent "SELECT COUNT(*) FROM runtime_mcp_query_rules WHERE rule_id > 100;") + # Note: counts might differ due to other rules, just check both are positive + if [ "${RUNTIME_ACTIVE_COUNT}" -gt 0 ]; then + run_test "T3.9: Runtime table contains active rules" true + else + run_test "T3.9: Runtime table contains active rules" false + fi + + # Display current state + echo "" + echo "Rules in mcp_query_rules (test range):" + exec_admin "SELECT rule_id, active, match_pattern, error_msg FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + echo "" + echo "Rules in runtime_mcp_query_rules (test range):" + exec_admin "SELECT rule_id, active, match_pattern, error_msg FROM runtime_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Cleanup + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase4_stats.sh b/scripts/mcp_rules_testing/test_phase4_stats.sh new file mode 100755 index 0000000000..f10631aa59 --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase4_stats.sh @@ -0,0 +1,293 @@ +#!/bin/bash +# +# test_phase4_stats.sh - Test MCP Query Rules Statistics Table +# +# Phase 4: Test stats_mcp_query_rules table behavior (hit counters) +# + +set -e + +# Default configuration +MCP_HOST="${MCP_HOST:-127.0.0.1}" +MCP_PORT="${MCP_PORT:-6071}" + +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } + +# Execute MySQL command +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Get endpoint URL +get_endpoint_url() { + local endpoint="$1" + echo "https://${MCP_HOST}:${MCP_PORT}/mcp/${endpoint}" +} + +# Execute MCP request via curl +mcp_request() { + local endpoint="$1" + local payload="$2" + + curl -k -s -X POST "$(get_endpoint_url "${endpoint}")" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null +} + +# Check if ProxySQL admin is accessible +check_proxysql_admin() { + if exec_admin_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Check if MCP server is accessible +check_mcp_server() { + local response + response=$(mcp_request "config" '{"jsonrpc":"2.0","method":"ping","id":1}') + if echo "${response}" | grep -q "result"; then + return 0 + else + return 1 + fi +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Get hit count for a rule +get_hits() { + local rule_id="$1" + exec_admin_silent "SELECT hits FROM stats_mcp_query_rules WHERE rule_id = ${rule_id};" +} + +main() { + echo "======================================" + echo "Phase 4: Statistics Table Tests" + echo "======================================" + echo "" + + # Check connections + if ! check_proxysql_admin; then + log_error "Cannot connect to ProxySQL admin at ${PROXYSQL_ADMIN_HOST}:${PROXYSQL_ADMIN_PORT}" + exit 1 + fi + log_info "Connected to ProxySQL admin" + + if ! check_mcp_server; then + log_error "MCP server not accessible at ${MCP_HOST}:${MCP_PORT}" + exit 1 + fi + log_info "MCP server is accessible" + + # Cleanup any existing test rules + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + # Test 4.1: Query stats_mcp_query_rules table + run_test "T4.1: Query stats_mcp_query_rules table" \ + exec_admin "SELECT * FROM stats_mcp_query_rules LIMIT 5;" + + # Create test rules + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (100, 1, 'SELECT.*FROM.*test_table', 'Error 100', 1);" >/dev/null 2>&1 + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (101, 1, 'DROP TABLE', 'Error 101', 1);" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + # Test 4.2: Check that rules exist in stats table with initial hits=0 + sleep 1 + HITS_100=$(get_hits 100) + HITS_101=$(get_hits 101) + if [ -n "${HITS_100}" ] && [ -n "${HITS_101}" ]; then + run_test "T4.2: Rules appear in stats table after load" true + else + run_test "T4.2: Rules appear in stats table after load" false + fi + + # Test 4.3: Verify initial hit count is 0 or non-negative + if [ "${HITS_100:-0}" -ge 0 ] && [ "${HITS_101:-0}" -ge 0 ]; then + run_test "T4.3: Initial hit counts are non-negative" true + else + run_test "T4.3: Initial hit counts are non-negative" false + fi + + # Test 4.4: Check stats table schema (rule_id, hits columns) + SCHEMA_INFO=$(exec_admin "PRAGMA table_info(stats_mcp_query_rules);" 2>/dev/null) + if echo "${SCHEMA_INFO}" | grep -q "rule_id" && echo "${SCHEMA_INFO}" | grep -q "hits"; then + run_test "T4.4: Stats table has rule_id and hits columns" true + else + run_test "T4.4: Stats table has rule_id and hits columns" false + fi + + # Test 4.5: Query stats for specific rule_id + run_test "T4.5: Query stats for specific rule_id" \ + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id = 100;" + + # Test 4.6: Query stats for multiple rule_ids using IN + run_test "T4.6: Query stats for multiple rules using IN" \ + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id IN (100, 101);" + + # Test 4.7: Query stats for rule_id range + run_test "T4.7: Query stats for rule_id range" \ + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Test 4.8: Check that non-existent rule returns NULL or empty + NO_HITS=$(exec_admin_silent "SELECT hits FROM stats_mcp_query_rules WHERE rule_id = 9999;") + if [ -z "${NO_HITS}" ]; then + run_test "T4.8: Non-existent rule returns empty result" true + else + run_test "T4.8: Non-existent rule returns empty result" false + fi + + # Test 4.9: Verify stats table is read-only (cannot directly insert) + exec_admin_silent "INSERT INTO stats_mcp_query_rules (rule_id, hits) VALUES (999, 100);" 2>/dev/null || true + INSERT_CHECK=$(exec_admin_silent "SELECT COUNT(*) FROM stats_mcp_query_rules WHERE rule_id = 999;") + if [ "${INSERT_CHECK:-0}" -eq 0 ]; then + run_test "T4.9: Stats table is read-only (insert ignored)" true + else + run_test "T4.9: Stats table is read-only (insert ignored)" false + fi + exec_admin_silent "DELETE FROM stats_mcp_query_rules WHERE rule_id = 999;" 2>/dev/null || true + + # Test 4.10: Test ORDER BY on hits column + run_test "T4.10: Query stats ordered by hits" \ + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id IN (100, 101) ORDER BY hits DESC;" + + # Test 4.11: Create additional rules and verify they appear in stats + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (102, 1, 'SELECT.*FROM.*products', 'Error 102', 1);" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + sleep 1 + HITS_102=$(get_hits 102) + if [ -n "${HITS_102}" ]; then + run_test "T4.11: New rule appears in stats after runtime load" true + else + run_test "T4.11: New rule appears in stats after runtime load" false + fi + + echo "" + echo "======================================" + echo "Testing Hit Counter Increments" + echo "======================================" + echo "" + + # Get initial hit counts + HITS_BEFORE_100=$(get_hits 100) + HITS_BEFORE_101=$(get_hits 101) + + # Test 4.12: Execute MCP query matching rule 100 and verify hit counter increments + log_info "Executing query matching rule 100..." + PAYLOAD_100='{"jsonrpc":"2.0","method":"tools/call","params":{"name":"run_sql_readonly","arguments":{"sql":"SELECT * FROM test_table"}},"id":1}' + mcp_request "query" "${PAYLOAD_100}" >/dev/null + sleep 1 + HITS_AFTER_100=$(get_hits 100) + if [ "${HITS_AFTER_100:-0}" -gt "${HITS_BEFORE_100:-0}" ]; then + run_test "T4.12: Hit counter incremented for rule 100 (from ${HITS_BEFORE_100:-0} to ${HITS_AFTER_100})" true + else + run_test "T4.12: Hit counter incremented for rule 100" false + fi + + # Test 4.13: Execute MCP query matching rule 101 and verify hit counter increments + log_info "Executing query matching rule 101..." + PAYLOAD_101='{"jsonrpc":"2.0","method":"tools/call","params":{"name":"run_sql_readonly","arguments":{"sql":"DROP TABLE IF EXISTS dummy_table"}},"id":2}' + mcp_request "query" "${PAYLOAD_101}" >/dev/null + sleep 1 + HITS_AFTER_101=$(get_hits 101) + if [ "${HITS_AFTER_101:-0}" -gt "${HITS_BEFORE_101:-0}" ]; then + run_test "T4.13: Hit counter incremented for rule 101 (from ${HITS_BEFORE_101:-0} to ${HITS_AFTER_101})" true + else + run_test "T4.13: Hit counter incremented for rule 101" false + fi + + # Test 4.14: Execute same query again and verify counter increments again + log_info "Executing same query for rule 100 again..." + mcp_request "query" "${PAYLOAD_100}" >/dev/null + sleep 1 + HITS_FINAL_100=$(get_hits 100) + if [ "${HITS_FINAL_100:-0}" -gt "${HITS_AFTER_100:-0}" ]; then + run_test "T4.14: Hit counter increments on repeated matches (from ${HITS_AFTER_100} to ${HITS_FINAL_100})" true + else + run_test "T4.14: Hit counter increments on repeated matches" false + fi + + # Test 4.15: Execute query NOT matching any rule and verify no test rule counter increments + log_info "Executing query NOT matching any test rule..." + PAYLOAD_NO_MATCH='{"jsonrpc":"2.0","method":"tools/call","params":{"name":"run_sql_readonly","arguments":{"sql":"SELECT * FROM other_table"}},"id":3}' + HITS_BEFORE_NO_MATCH_100=$(get_hits 100) + HITS_BEFORE_NO_MATCH_101=$(get_hits 101) + mcp_request "query" "${PAYLOAD_NO_MATCH}" >/dev/null + sleep 1 + HITS_AFTER_NO_MATCH_100=$(get_hits 100) + HITS_AFTER_NO_MATCH_101=$(get_hits 101) + if [ "${HITS_AFTER_NO_MATCH_100}" = "${HITS_BEFORE_NO_MATCH_100}" ] && [ "${HITS_AFTER_NO_MATCH_101}" = "${HITS_BEFORE_NO_MATCH_101}" ]; then + run_test "T4.15: Hit counters NOT incremented for non-matching query" true + else + run_test "T4.15: Hit counters NOT incremented for non-matching query" false + fi + + # Display current stats + echo "" + echo "Current stats for test rules:" + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Cleanup + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase5_digest.sh b/scripts/mcp_rules_testing/test_phase5_digest.sh new file mode 100755 index 0000000000..ef0acbcf8b --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase5_digest.sh @@ -0,0 +1,422 @@ +#!/bin/bash +# +# test_phase5_digest.sh - Test MCP Query Digest Statistics +# +# Phase 5: Test stats_mcp_query_digest table behavior +# + +set -e + +# Default configuration +MCP_HOST="${MCP_HOST:-127.0.0.1}" +MCP_PORT="${MCP_PORT:-6071}" + +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# MySQL backend configuration (the actual database where queries are executed) +MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-}" +MYSQL_DATABASE="${MYSQL_DATABASE:-testdb}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } +log_verbose() { echo -e "${YELLOW}[VERBOSE]${NC} $1"; } + +# Execute MySQL command via ProxySQL admin +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command via ProxySQL admin (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Execute MySQL command directly on backend MySQL server +exec_mysql() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>&1 +} + +# Execute MySQL command directly on backend MySQL server (silent) +exec_mysql_silent() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -B -N -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>/dev/null +} + +# Get endpoint URL +get_endpoint_url() { + local endpoint="$1" + echo "https://${MCP_HOST}:${MCP_PORT}/mcp/${endpoint}" +} + +# Execute MCP request via curl +mcp_request() { + local endpoint="$1" + local payload="$2" + + curl -k -s -X POST "$(get_endpoint_url "${endpoint}")" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null +} + +# Check if ProxySQL admin is accessible +check_proxysql_admin() { + if exec_admin_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Check if MCP server is accessible +check_mcp_server() { + local response + response=$(mcp_request "config" '{"jsonrpc":"2.0","method":"ping","id":1}') + if echo "${response}" | grep -q "result"; then + return 0 + else + return 1 + fi +} + +# Check if MySQL backend is accessible +check_mysql_backend() { + if exec_mysql_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Create test tables in MySQL database +create_test_tables() { + log_info "Creating test tables in MySQL backend..." + log_verbose "MySQL Host: ${MYSQL_HOST}:${MYSQL_PORT}" + log_verbose "MySQL User: ${MYSQL_USER}" + log_verbose "MySQL Database: ${MYSQL_DATABASE}" + + # Create database if it doesn't exist + log_verbose "Creating database '${MYSQL_DATABASE}' if not exists..." + exec_mysql "CREATE DATABASE IF NOT EXISTS ${MYSQL_DATABASE};" 2>/dev/null + + # Create test tables + log_verbose "Creating table 'test_phase5_table'..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.test_phase5_table (id INT PRIMARY KEY, name VARCHAR(100));" 2>/dev/null + + log_verbose "Creating table 'another_phase5_table'..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.another_phase5_table (id INT PRIMARY KEY, value VARCHAR(100));" 2>/dev/null + + # Insert some test data + log_verbose "Inserting test data into tables..." + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.test_phase5_table VALUES (1, 'test1'), (2, 'test2');" 2>/dev/null + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.another_phase5_table VALUES (1, 'value1'), (2, 'value2');" 2>/dev/null + + log_info "Test tables created successfully" +} + +# Drop test tables from MySQL database +drop_test_tables() { + log_info "Dropping test tables from MySQL backend..." + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.test_phase5_table;" 2>/dev/null + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.another_phase5_table;" 2>/dev/null + log_info "Test tables dropped" +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Get count_star for a specific tool_name and digest +get_count_star() { + local tool_name="$1" + local digest="$2" + exec_admin_silent "SELECT count_star FROM stats_mcp_query_digest WHERE tool_name = '${tool_name}' AND digest = '${digest}';" +} + +main() { + echo "======================================" + echo "Phase 5: Query Digest Tests" + echo "======================================" + echo "" + + # Check ProxySQL admin connection + if ! check_proxysql_admin; then + log_error "Cannot connect to ProxySQL admin at ${PROXYSQL_ADMIN_HOST}:${PROXYSQL_ADMIN_PORT}" + exit 1 + fi + log_info "Connected to ProxySQL admin" + + # Check MCP server connection + if ! check_mcp_server; then + log_error "MCP server not accessible at ${MCP_HOST}:${MCP_PORT}" + exit 1 + fi + log_info "MCP server is accessible" + + # Check MySQL backend connection + if ! check_mysql_backend; then + log_error "Cannot connect to MySQL backend at ${MYSQL_HOST}:${MYSQL_PORT}" + log_error "Please set MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE environment variables" + exit 1 + fi + log_info "Connected to MySQL backend at ${MYSQL_HOST}:${MYSQL_PORT}" + + echo "" + echo "======================================" + echo "Setting Up Test Tables" + echo "======================================" + echo "" + + # Create test tables in MySQL database + create_test_tables + + echo "" + echo "======================================" + echo "Running Digest Table Tests" + echo "======================================" + echo "" + + # Test 5.1: Query stats_mcp_query_digest table + run_test "T5.1: Query stats_mcp_query_digest table" \ + exec_admin "SELECT * FROM stats_mcp_query_digest LIMIT 5;" + + # Test 5.2: Check digest table schema + SCHEMA_INFO=$(exec_admin "PRAGMA table_info(stats_mcp_query_digest);" 2>/dev/null) + if echo "${SCHEMA_INFO}" | grep -q "tool_name" && echo "${SCHEMA_INFO}" | grep -q "digest" && echo "${SCHEMA_INFO}" | grep -q "count_star"; then + run_test "T5.2: Digest table has required columns" true + else + run_test "T5.2: Digest table has required columns" false + fi + + # Test 5.3: Query digest for specific tool_name + run_test "T5.3: Query digest for specific tool_name" \ + exec_admin "SELECT * FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' LIMIT 5;" + + # Test 5.4: Query digest ordered by count_star + run_test "T5.4: Query digest ordered by count_star DESC" \ + exec_admin "SELECT tool_name, digest, count_star FROM stats_mcp_query_digest ORDER BY count_star DESC LIMIT 5;" + + # Test 5.5: Query digest for specific digest pattern + run_test "T5.5: Query digest filtering by digest" \ + exec_admin "SELECT * FROM stats_mcp_query_digest WHERE digest IS NOT NULL LIMIT 5;" + + # Test 5.6: Query stats_mcp_query_digest_reset table + run_test "T5.6: Query stats_mcp_query_digest_reset table" \ + exec_admin "SELECT * FROM stats_mcp_query_digest_reset LIMIT 5;" + + # Test 5.7: Query digest with aggregate functions + run_test "T5.7: Query digest with SUM aggregate" \ + exec_admin "SELECT tool_name, SUM(count_star) as total_calls FROM stats_mcp_query_digest GROUP BY tool_name;" + + # Test 5.8: Query digest with WHERE clause on count_star + run_test "T5.8: Query digest filtering by count_star threshold" \ + exec_admin "SELECT tool_name, digest, count_star FROM stats_mcp_query_digest WHERE count_star > 0;" + + # Test 5.9: Check that digest_text column contains query text + run_test "T5.9: Query digest showing digest_text" \ + exec_admin "SELECT tool_name, digest, digest_text, count_star FROM stats_mcp_query_digest WHERE digest_text IS NOT NULL LIMIT 5;" + + # Test 5.10: Query digest with multiple conditions + run_test "T5.10: Query digest with tool_name and count_star filter" \ + exec_admin "SELECT * FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND count_star > 0 ORDER BY count_star DESC LIMIT 5;" + + # Test 5.11: Check timing columns (sum_time, min_time, max_time) + TIMING_COLS=$(exec_admin "SELECT sum_time, min_time, max_time FROM stats_mcp_query_digest WHERE count_star > 0 LIMIT 1;" 2>/dev/null) + if [ -n "${TIMING_COLS}" ]; then + run_test "T5.11: Timing columns (sum_time, min_time, max_time) are accessible" true + else + run_test "T5.11: Timing columns (sum_time, min_time, max_time) are accessible" false + fi + + # Test 5.12: Query digest grouped by tool_name + run_test "T5.12: Aggregate digest by tool_name" \ + exec_admin "SELECT tool_name, COUNT(*) as unique_digests, SUM(count_star) as total_calls FROM stats_mcp_query_digest GROUP BY tool_name;" + + # Test 5.13: Check for digest table size (number of entries) + DIGEST_COUNT=$(exec_admin_silent "SELECT COUNT(*) FROM stats_mcp_query_digest;") + if [ "${DIGEST_COUNT:-0}" -ge 0 ]; then + run_test "T5.13: Digest table contains ${DIGEST_COUNT:-0} entries" true + else + run_test "T5.13: Digest table contains entries" false + fi + + # Test 5.14: Query digest with LIKE pattern on tool_name + run_test "T5.14: Query digest with LIKE on tool_name" \ + exec_admin "SELECT tool_name, digest, count_star FROM stats_mcp_query_digest WHERE tool_name LIKE '%sql%' LIMIT 5;" + + # Test 5.15: Verify reset table has same schema as main table + RESET_SCHEMA=$(exec_admin "PRAGMA table_info(stats_mcp_query_digest_reset);" 2>/dev/null | wc -l) + MAIN_SCHEMA=$(exec_admin "PRAGMA table_info(stats_mcp_query_digest);" 2>/dev/null | wc -l) + if [ "${RESET_SCHEMA}" -eq "${MAIN_SCHEMA}" ] && [ "${RESET_SCHEMA}" -gt 0 ]; then + run_test "T5.15: Reset table schema matches main table" true + else + run_test "T5.15: Reset table schema matches main table" false + fi + + echo "" + echo "======================================" + echo "Testing Digest Population" + echo "======================================" + echo "" + + # Get initial digest count + DIGEST_COUNT_BEFORE=$(exec_admin_silent "SELECT COUNT(*) FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly';") + log_verbose "Initial digest count for run_sql_readonly: ${DIGEST_COUNT_BEFORE}" + + # Test 5.16: Execute a query and verify it appears in digest + log_info "Executing unique query: SELECT COUNT(*) FROM test_phase5_table" + PAYLOAD_1='{"jsonrpc":"2.0","method":"tools/call","params":{"name":"run_sql_readonly","arguments":{"sql":"SELECT COUNT(*) FROM test_phase5_table"}},"id":1}' + mcp_request "query" "${PAYLOAD_1}" >/dev/null + sleep 1 + DIGEST_COUNT_AFTER_1=$(exec_admin_silent "SELECT COUNT(*) FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly';") + log_verbose "Digest count after query 1: ${DIGEST_COUNT_AFTER_1}" + if [ "${DIGEST_COUNT_AFTER_1:-0}" -ge "${DIGEST_COUNT_BEFORE:-0}" ]; then + run_test "T5.16: Query tracked in digest (count: ${DIGEST_COUNT_BEFORE} -> ${DIGEST_COUNT_AFTER_1})" true + else + run_test "T5.16: Query tracked in digest" false + fi + + # Test 5.17: Execute same query again and verify count_star increments + log_info "Executing same query again to test count_star increment..." + COUNT_BEFORE=$(exec_admin_silent "SELECT count_star FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%test_phase5_table%' ORDER BY last_seen DESC LIMIT 1;") + log_verbose "count_star before repeat: ${COUNT_BEFORE}" + mcp_request "query" "${PAYLOAD_1}" >/dev/null + sleep 1 + COUNT_AFTER=$(exec_admin_silent "SELECT count_star FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%test_phase5_table%' ORDER BY last_seen DESC LIMIT 1;") + log_verbose "count_star after repeat: ${COUNT_AFTER}" + if [ "${COUNT_AFTER:-0}" -gt "${COUNT_BEFORE:-0}" ]; then + run_test "T5.17: count_star incremented on repeat (from ${COUNT_BEFORE} to ${COUNT_AFTER})" true + else + run_test "T5.17: count_star incremented on repeat" false + fi + + # Test 5.18: Execute different query and verify new digest entry + log_info "Executing different query: SELECT * FROM another_phase5_table LIMIT 10" + PAYLOAD_2='{"jsonrpc":"2.0","method":"tools/call","params":{"name":"run_sql_readonly","arguments":{"sql":"SELECT * FROM another_phase5_table LIMIT 10"}},"id":2}' + DIGEST_COUNT_BEFORE_2=$(exec_admin_silent "SELECT COUNT(*) FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly';") + log_verbose "Digest count before query 2: ${DIGEST_COUNT_BEFORE_2}" + mcp_request "query" "${PAYLOAD_2}" >/dev/null + sleep 1 + DIGEST_COUNT_AFTER_2=$(exec_admin_silent "SELECT COUNT(*) FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly';") + log_verbose "Digest count after query 2: ${DIGEST_COUNT_AFTER_2}" + if [ "${DIGEST_COUNT_AFTER_2:-0}" -ge "${DIGEST_COUNT_BEFORE_2:-0}" ]; then + run_test "T5.18: Different query creates new digest entry" true + else + run_test "T5.18: Different query creates new digest entry" false + fi + + # Test 5.19: Verify digest_text contains the actual SQL query + log_info "Checking digest_text content..." + DIGEST_TEXT_RESULT=$(exec_admin "SELECT digest_text FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%test_phase5_table%' ORDER BY last_seen DESC LIMIT 1;" 2>/dev/null) + log_verbose "Found digest_text: ${DIGEST_TEXT_RESULT}" + if echo "${DIGEST_TEXT_RESULT}" | grep -q "SELECT"; then + run_test "T5.19: digest_text contains actual SQL query" true + else + run_test "T5.19: digest_text contains actual SQL query" false + fi + + # Test 5.20: Verify timing information is captured (sum_time increases) + log_info "Checking timing information..." + SUM_TIME_BEFORE=$(exec_admin_silent "SELECT sum_time FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%test_phase5_table%' ORDER BY last_seen DESC LIMIT 1;") + log_verbose "sum_time before: ${SUM_TIME_BEFORE}" + mcp_request "query" "${PAYLOAD_1}" >/dev/null + sleep 1 + SUM_TIME_AFTER=$(exec_admin_silent "SELECT sum_time FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%test_phase5_table%' ORDER BY last_seen DESC LIMIT 1;") + log_verbose "sum_time after: ${SUM_TIME_AFTER}" + if [ "${SUM_TIME_AFTER:-0}" -ge "${SUM_TIME_BEFORE:-0}" ]; then + run_test "T5.20: sum_time tracked and increments" true + else + run_test "T5.20: sum_time tracked and increments" false + fi + + # Test 5.21: Verify last_seen timestamp updates + log_info "Checking timestamp tracking..." + FIRST_SEEN=$(exec_admin_silent "SELECT first_seen FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%test_phase5_table%' ORDER BY last_seen DESC LIMIT 1;") + LAST_SEEN=$(exec_admin_silent "SELECT last_seen FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%test_phase5_table%' ORDER BY last_seen DESC LIMIT 1;") + log_verbose "first_seen: ${FIRST_SEEN}, last_seen: ${LAST_SEEN}" + if [ -n "${FIRST_SEEN}" ] && [ -n "${LAST_SEEN}" ]; then + run_test "T5.21: first_seen and last_seen timestamps tracked" true + else + run_test "T5.21: first_seen and last_seen timestamps tracked" false + fi + + # Display sample digest data + echo "" + echo "Recent digest entries for run_sql_readonly (phase5 queries):" + exec_admin "SELECT tool_name, substr(digest_text, 1, 60) as query_snippet, count_star, sum_time FROM stats_mcp_query_digest WHERE tool_name = 'run_sql_readonly' AND digest_text LIKE '%phase5%' ORDER BY last_seen DESC LIMIT 5;" + + # Display summary by tool + echo "" + echo "Summary by tool:" + exec_admin "SELECT tool_name, COUNT(*) as unique_queries, SUM(count_star) as total_calls FROM stats_mcp_query_digest GROUP BY tool_name;" + + # Cleanup test tables + echo "" + echo "======================================" + echo "Cleaning Up" + echo "======================================" + echo "" + drop_test_tables + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase6_eval_block.sh b/scripts/mcp_rules_testing/test_phase6_eval_block.sh new file mode 100755 index 0000000000..762872a11e --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase6_eval_block.sh @@ -0,0 +1,385 @@ +#!/bin/bash +# +# test_phase6_eval_block.sh - Test MCP Query Rules Block Action Evaluation +# +# Phase 6: Test rule evaluation for Block action with various filters +# + +set -e + +# Default configuration +MCP_HOST="${MCP_HOST:-127.0.0.1}" +MCP_PORT="${MCP_PORT:-6071}" + +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# MySQL backend configuration (the actual database where queries are executed) +MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-}" +MYSQL_DATABASE="${MYSQL_DATABASE:-testdb}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } +log_verbose() { echo -e "${YELLOW}[VERBOSE]${NC} $1"; } + +# Execute MySQL command via ProxySQL admin +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command via ProxySQL admin (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Execute MySQL command directly on backend MySQL server +exec_mysql() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>&1 +} + +# Execute MySQL command directly on backend MySQL server (silent) +exec_mysql_silent() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -B -N -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>/dev/null +} + +# Get endpoint URL +get_endpoint_url() { + local endpoint="$1" + echo "https://${MCP_HOST}:${MCP_PORT}/mcp/${endpoint}" +} + +# Execute MCP request via curl +mcp_request() { + local endpoint="$1" + local payload="$2" + + curl -k -s -X POST "$(get_endpoint_url "${endpoint}")" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null +} + +# Check if ProxySQL admin is accessible +check_proxysql_admin() { + if exec_admin_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Check if MCP server is accessible +check_mcp_server() { + local response + response=$(mcp_request "config" '{"jsonrpc":"2.0","method":"ping","id":1}') + if echo "${response}" | grep -q "result"; then + return 0 + else + return 1 + fi +} + +# Check if MySQL backend is accessible +check_mysql_backend() { + if exec_mysql_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Create test tables in MySQL database +create_test_tables() { + log_info "Creating test tables in MySQL backend..." + log_verbose "MySQL Host: ${MYSQL_HOST}:${MYSQL_PORT}" + log_verbose "MySQL User: ${MYSQL_USER}" + log_verbose "MySQL Database: ${MYSQL_DATABASE}" + + # Create database if it doesn't exist + log_verbose "Creating database '${MYSQL_DATABASE}' if not exists..." + exec_mysql "CREATE DATABASE IF NOT EXISTS ${MYSQL_DATABASE};" 2>/dev/null + + # Create test tables with phase6 naming + log_verbose "Creating table 'fake_table' for phase6 tests..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.fake_table (id INT PRIMARY KEY, phase6_allowed_col VARCHAR(100), phase6_blocked_col VARCHAR(100));" 2>/dev/null + + log_verbose "Creating table 'phase6_test_table'..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.phase6_test_table (id INT PRIMARY KEY, name VARCHAR(100));" 2>/dev/null + + # Insert some test data + log_verbose "Inserting test data into tables..." + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.fake_table VALUES (1, 'allowed', 'blocked');" 2>/dev/null + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.phase6_test_table VALUES (1, 'test1'), (2, 'test2');" 2>/dev/null + + log_info "Test tables created successfully" +} + +# Drop test tables from MySQL database +drop_test_tables() { + log_info "Dropping test tables from MySQL backend..." + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.fake_table;" 2>/dev/null + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.phase6_test_table;" 2>/dev/null + log_info "Test tables dropped" +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Test that a query is blocked +test_is_blocked() { + local tool_name="$1" + local sql="$2" + local expected_error_substring="$3" + + local payload + payload=$(cat </dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + echo "" + echo "======================================" + echo "Setting Up Test Tables" + echo "======================================" + echo "" + + # Create test tables in MySQL database + create_test_tables + + echo "" + echo "======================================" + echo "Setting Up Test Rules" + echo "======================================" + echo "" + + # T6.1: Basic block rule with error_msg + log_info "Creating rule 100: Basic DROP TABLE block" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (100, 1, 'DROP TABLE', 'DROP TABLE statements are not allowed', 1);" >/dev/null 2>&1 + + # T6.2: Case-sensitive match (default, no CASELESS modifier) + log_info "Creating rule 101: Case-sensitive 'DROP TABLE' block (no CASELESS)" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, error_msg, apply) VALUES (101, 1, 'DROP TABLE', 'Case-sensitive match failed', 1);" >/dev/null 2>&1 + + # T6.3: Block with negate_match_pattern=1 (block everything EXCEPT pattern) + log_info "Creating rule 102: Negate pattern - block everything except specific query" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, negate_match_pattern, error_msg, apply) VALUES (102, 1, '^SELECT phase6_allowed_col FROM fake_table$', 1, 'Only specific query is allowed', 1);" >/dev/null 2>&1 + + # T6.4: Block specific username + log_info "Creating rule 103: Block for specific user 'testuser'" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, username, match_pattern, error_msg, apply) VALUES (103, 1, 'testuser', 'DROP', 'User testuser cannot DROP', 1);" >/dev/null 2>&1 + + # T6.5: Block specific schema + log_info "Creating rule 104: Block for specific schema 'testdb'" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, schemaname, match_pattern, error_msg, apply) VALUES (104, 1, 'testdb', 'DROP', 'DROP not allowed in testdb', 1);" >/dev/null 2>&1 + + # T6.6: Block specific tool_name + log_info "Creating rule 105: Block for specific tool 'run_sql_readonly'" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, tool_name, match_pattern, error_msg, apply) VALUES (105, 1, 'run_sql_readonly', 'TRUNCATE', 'TRUNCATE not allowed in readonly mode', 1);" >/dev/null 2>&1 + + # Load to runtime + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + sleep 1 + + echo "" + echo "======================================" + echo "Running Block Action Evaluation Tests" + echo "======================================" + echo "" + + # T6.1: Block query with error_msg + run_test "T6.1: Block DROP TABLE with error_msg" \ + test_is_blocked "run_sql_readonly" "DROP TABLE test_table;" "DROP TABLE statements are not allowed" + + # T6.2: Block with case-sensitive match (lowercase should NOT match if no CASELESS) + # Note: This test may vary based on regex implementation. Assuming default is case-sensitive. + run_test "T6.2: Case-sensitive match - exact case matches" \ + test_is_blocked "run_sql_readonly" "DROP TABLE test2;" "DROP" + + # T6.3: Block with negate_match_pattern=1 + # Rule 102: negate_match_pattern=1, pattern='^SELECT phase6_allowed_col FROM fake_table$', so blocks everything EXCEPT that specific query + run_test "T6.3: Negate pattern - other query should be blocked" \ + test_is_blocked "run_sql_readonly" "SELECT phase6_blocked_col FROM fake_table;" "Only specific query is allowed" + + run_test "T6.3: Negate pattern - exact pattern match should be allowed" \ + test_is_allowed "run_sql_readonly" "SELECT phase6_allowed_col FROM fake_table" + + # T6.4: Block specific username + # Note: This test depends on the user context. For now, we test that the rule exists. + # Actual username filtering requires authentication context. + log_info "T6.4: Username-based filtering (rule 103 created - requires auth context to fully test)" + run_test "T6.4: Username rule exists in runtime" \ + bash -c "[ $(exec_admin_silent 'SELECT COUNT(*) FROM runtime_mcp_query_rules WHERE rule_id = 103 AND username = "testuser"') -eq 1 ]" + + # T6.5: Block specific schema + log_info "T6.5: Schema-based filtering (rule 104 created for 'testdb')" + run_test "T6.5: Schema rule exists in runtime" \ + bash -c "[ $(exec_admin_silent 'SELECT COUNT(*) FROM runtime_mcp_query_rules WHERE rule_id = 104 AND schemaname = "testdb"') -eq 1 ]" + + # T6.6: Block specific tool_name + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id=102;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + run_test "T6.6: Block TRUNCATE in run_sql_readonly tool" \ + test_is_blocked "run_sql_readonly" "TRUNCATE TABLE test_table;" "TRUNCATE not allowed" + + # Display runtime rules + echo "" + echo "Runtime rules created:" + exec_admin "SELECT rule_id, username, schemaname, tool_name, match_pattern, negate_match_pattern, error_msg FROM runtime_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Display stats + echo "" + echo "Rule hit statistics:" + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Cleanup + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + log_info "Test rules cleaned up" + + # Drop test tables + echo "" + drop_test_tables + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase7_eval_rewrite.sh b/scripts/mcp_rules_testing/test_phase7_eval_rewrite.sh new file mode 100755 index 0000000000..1b9d4c4249 --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase7_eval_rewrite.sh @@ -0,0 +1,333 @@ +#!/bin/bash +# +# test_phase7_eval_rewrite.sh - Test MCP Query Rules Rewrite Action Evaluation +# +# Phase 7: Test rule evaluation for Rewrite action with various patterns +# + +set -e + +# Default configuration +MCP_HOST="${MCP_HOST:-127.0.0.1}" +MCP_PORT="${MCP_PORT:-6071}" + +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# MySQL backend configuration (the actual database where queries are executed) +MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-}" +MYSQL_DATABASE="${MYSQL_DATABASE:-testdb}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } +log_verbose() { echo -e "${YELLOW}[VERBOSE]${NC} $1"; } + +# Execute MySQL command via ProxySQL admin +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command via ProxySQL admin (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Execute MySQL command directly on backend MySQL server +exec_mysql() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>&1 +} + +# Execute MySQL command directly on backend MySQL server (silent) +exec_mysql_silent() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -B -N -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>/dev/null +} + +# Get endpoint URL +get_endpoint_url() { + local endpoint="$1" + echo "https://${MCP_HOST}:${MCP_PORT}/mcp/${endpoint}" +} + +# Execute MCP request via curl +mcp_request() { + local endpoint="$1" + local payload="$2" + + curl -k -s -X POST "$(get_endpoint_url "${endpoint}")" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null +} + +# Check if ProxySQL admin is accessible +check_proxysql_admin() { + if exec_admin_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Check if MCP server is accessible +check_mcp_server() { + local response + response=$(mcp_request "config" '{"jsonrpc":"2.0","method":"ping","id":1}') + if echo "${response}" | grep -q "result"; then + return 0 + else + return 1 + fi +} + +# Check if MySQL backend is accessible +check_mysql_backend() { + if exec_mysql_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Create test tables in MySQL database +create_test_tables() { + log_info "Creating test tables in MySQL backend..." + log_verbose "MySQL Host: ${MYSQL_HOST}:${MYSQL_PORT}" + log_verbose "MySQL User: ${MYSQL_USER}" + log_verbose "MySQL Database: ${MYSQL_DATABASE}" + + # Create database if it doesn't exist + log_verbose "Creating database '${MYSQL_DATABASE}' if not exists..." + exec_mysql "CREATE DATABASE IF NOT EXISTS ${MYSQL_DATABASE};" 2>/dev/null + + # Create test tables with phase7 naming + log_verbose "Creating table 'customers' for phase7 tests..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.customers_phase7 (id INT PRIMARY KEY, phase7_name VARCHAR(100), phase7_email VARCHAR(100));" 2>/dev/null + + log_verbose "Creating table 'orders'..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.orders_phase7 (id INT PRIMARY KEY, customer_id INT, amount DECIMAL(10,2));" 2>/dev/null + + log_verbose "Creating table 'products'..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.products_phase7 (id INT PRIMARY KEY, product_name VARCHAR(100), price DECIMAL(10,2));" 2>/dev/null + + # Insert some test data + log_verbose "Inserting test data into tables..." + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.customers_phase7 VALUES (1, 'Alice', 'alice@test.com'), (2, 'Bob', 'bob@test.com');" 2>/dev/null + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.orders_phase7 VALUES (1, 1, 100.00), (2, 2, 200.00);" 2>/dev/null + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.products_phase7 VALUES (1, 'Widget', 10.00), (2, 'Gadget', 20.00);" 2>/dev/null + + log_info "Test tables created successfully" +} + +# Drop test tables from MySQL database +drop_test_tables() { + log_info "Dropping test tables from MySQL backend..." + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.customers_phase7;" 2>/dev/null + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.orders_phase7;" 2>/dev/null + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.products_phase7;" 2>/dev/null + log_info "Test tables dropped" +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Test that a query is rewritten and returns results +test_is_rewritten() { + local tool_name="$1" + local original_sql="$2" + local expected_result_substring="$3" + + local payload + payload=$(cat </dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + echo "" + echo "======================================" + echo "Setting Up Test Tables" + echo "======================================" + echo "" + + # Create test tables in MySQL database + create_test_tables + + echo "" + echo "======================================" + echo "Setting Up Test Rules" + echo "======================================" + echo "" + + # T7.1: Rewrite SQL with replace_pattern - SELECT * to known string + log_info "Creating rule 100: Rewrite SELECT * FROM customers to known string" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, replace_pattern, apply) VALUES (100, 1, 'SELECT\s+\\*\s+FROM\s+customers', 'SELECT \"PHASE7_REWRITTEN\" AS result FROM (SELECT 0) t1', 1);" >/dev/null 2>&1 + + # T7.2: Rewrite with capture groups - Rewrite to known string with original table captured + log_info "Creating rule 101: Rewrite with capture groups - capture table name" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, replace_pattern, re_modifiers, apply) VALUES (101, 1, 'SELECT phase7_name FROM (\\w+)', 'SELECT \"PHASE7_CAPTURED\" AS result FROM (SELECT 0) t1', 'EXTENDED', 1);" >/dev/null 2>&1 + + # T7.3: Rewrite with CASELESS modifier + log_info "Creating rule 102: Rewrite with CASELESS - select * from products (any case)" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, replace_pattern, re_modifiers, apply) VALUES (102, 1, 'select \\* from products', 'SELECT \"PHASE7_CASELESS\" AS result FROM (SELECT 0) t1', 'CASELESS', 1);" >/dev/null 2>&1 + + # Load to runtime + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + sleep 1 + + echo "" + echo "======================================" + echo "Running Rewrite Action Evaluation Tests" + echo "======================================" + echo "" + + # T7.1: Rewrite SQL with replace_pattern + run_test "T7.1: Rewrite SELECT * FROM customers to known string" \ + test_is_rewritten "run_sql_readonly" "SELECT * FROM customers" "PHASE7_REWRITTEN" + + # T7.2: Rewrite with capture groups + run_test "T7.2: Rewrite with capture groups - captured table name" \ + test_is_rewritten "run_sql_readonly" "SELECT phase7_name FROM customers_phase7;" "PHASE7_CAPTURED" + + # T7.3: Rewrite with CASELESS modifier + run_test "T7.3: Rewrite with CASELESS - lowercase 'select * from products'" \ + test_is_rewritten "run_sql_readonly" "select * from products;" "PHASE7_CASELESS" + + # Display runtime rules + echo "" + echo "Runtime rules created:" + exec_admin "SELECT rule_id, match_pattern, replace_pattern, re_modifiers FROM runtime_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Display stats + echo "" + echo "Rule hit statistics:" + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Cleanup + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + log_info "Test rules cleaned up" + + # Drop test tables + echo "" + drop_test_tables + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@" diff --git a/scripts/mcp_rules_testing/test_phase8_eval_timeout.sh b/scripts/mcp_rules_testing/test_phase8_eval_timeout.sh new file mode 100755 index 0000000000..88917371f8 --- /dev/null +++ b/scripts/mcp_rules_testing/test_phase8_eval_timeout.sh @@ -0,0 +1,325 @@ +#!/bin/bash +# +# test_phase8_eval_timeout.sh - Test MCP Query Rules Timeout Action Evaluation +# +# Phase 8: Test rule evaluation for Timeout action +# + +set -e + +# Default configuration +MCP_HOST="${MCP_HOST:-127.0.0.1}" +MCP_PORT="${MCP_PORT:-6071}" + +PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" +PROXYSQL_ADMIN_PORT="${PROXYSQL_ADMIN_PORT:-6032}" +PROXYSQL_ADMIN_USER="${PROXYSQL_ADMIN_USER:-radmin}" +PROXYSQL_ADMIN_PASSWORD="${PROXYSQL_ADMIN_PASSWORD:-radmin}" + +# MySQL backend configuration (the actual database where queries are executed) +MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-}" +MYSQL_DATABASE="${MYSQL_DATABASE:-testdb}" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Statistics +TOTAL_TESTS=0 +PASSED_TESTS=0 +FAILED_TESTS=0 + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_test() { echo -e "${GREEN}[TEST]${NC} $1"; } +log_verbose() { echo -e "${YELLOW}[VERBOSE]${NC} $1"; } + +# Execute MySQL command via ProxySQL admin +exec_admin() { + mysql -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>&1 +} + +# Execute MySQL command via ProxySQL admin (silent) +exec_admin_silent() { + mysql -B -N -h "${PROXYSQL_ADMIN_HOST}" -P "${PROXYSQL_ADMIN_PORT}" \ + -u "${PROXYSQL_ADMIN_USER}" -p"${PROXYSQL_ADMIN_PASSWORD}" \ + -e "$1" 2>/dev/null +} + +# Execute MySQL command directly on backend MySQL server +exec_mysql() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>&1 +} + +# Execute MySQL command directly on backend MySQL server (silent) +exec_mysql_silent() { + local db_param="" + if [ -n "${MYSQL_DATABASE}" ]; then + db_param="-D ${MYSQL_DATABASE}" + fi + mysql -B -N -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" \ + -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" \ + ${db_param} -e "$1" 2>/dev/null +} + +# Get endpoint URL +get_endpoint_url() { + local endpoint="$1" + echo "https://${MCP_HOST}:${MCP_PORT}/mcp/${endpoint}" +} + +# Execute MCP request via curl +mcp_request() { + local endpoint="$1" + local payload="$2" + + curl -k -s -X POST "$(get_endpoint_url "${endpoint}")" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null +} + +# Check if ProxySQL admin is accessible +check_proxysql_admin() { + if exec_admin_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Check if MCP server is accessible +check_mcp_server() { + local response + response=$(mcp_request "config" '{"jsonrpc":"2.0","method":"ping","id":1}') + if echo "${response}" | grep -q "result"; then + return 0 + else + return 1 + fi +} + +# Check if MySQL backend is accessible +check_mysql_backend() { + if exec_mysql_silent "SELECT 1" >/dev/null 2>&1; then + return 0 + else + return 1 + fi +} + +# Create test tables in MySQL database +create_test_tables() { + log_info "Creating test tables in MySQL backend..." + log_verbose "MySQL Host: ${MYSQL_HOST}:${MYSQL_PORT}" + log_verbose "MySQL User: ${MYSQL_USER}" + log_verbose "MySQL Database: ${MYSQL_DATABASE}" + + # Create database if it doesn't exist + log_verbose "Creating database '${MYSQL_DATABASE}' if not exists..." + exec_mysql "CREATE DATABASE IF NOT EXISTS ${MYSQL_DATABASE};" 2>/dev/null + + # Create test tables with phase8 naming + log_verbose "Creating table 'slow_table' for phase8 timeout tests..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.slow_table (id INT PRIMARY KEY, phase8_data VARCHAR(100));" 2>/dev/null + + log_verbose "Creating table 'quick_table'..." + exec_mysql "CREATE TABLE IF NOT EXISTS ${MYSQL_DATABASE}.quick_table (id INT PRIMARY KEY, phase8_data VARCHAR(100));" 2>/dev/null + + # Insert some test data + log_verbose "Inserting test data into tables..." + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.slow_table VALUES (1, 'slow1'), (2, 'slow2');" 2>/dev/null + exec_mysql "INSERT IGNORE INTO ${MYSQL_DATABASE}.quick_table VALUES (1, 'quick1'), (2, 'quick2');" 2>/dev/null + + log_info "Test tables created successfully" +} + +# Drop test tables from MySQL database +drop_test_tables() { + log_info "Dropping test tables from MySQL backend..." + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.slow_table;" 2>/dev/null + exec_mysql "DROP TABLE IF EXISTS ${MYSQL_DATABASE}.quick_table;" 2>/dev/null + log_info "Test tables dropped" +} + +# Run test function +run_test() { + TOTAL_TESTS=$((TOTAL_TESTS + 1)) + log_test "$1" + shift + if "$@"; then + log_info "✓ Test $TOTAL_TESTS passed" + PASSED_TESTS=$((PASSED_TESTS + 1)) + return 0 + else + log_error "✗ Test $TOTAL_TESTS failed" + FAILED_TESTS=$((FAILED_TESTS + 1)) + return 1 + fi +} + +# Test that a query times out +test_is_timed_out() { + local tool_name="$1" + local sql="$2" + local expected_error_substring="$3" + local timeout_sec="$4" + + local payload + payload=$(cat </dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + + echo "" + echo "======================================" + echo "Setting Up Test Tables" + echo "======================================" + echo "" + + # Create test tables in MySQL database + create_test_tables + + echo "" + echo "======================================" + echo "Setting Up Test Rules" + echo "======================================" + echo "" + + # T8.1: Query with timeout_ms - Set a very short timeout for testing + log_info "Creating rule 100: Timeout queries matching pattern after 100ms" + exec_admin_silent "INSERT INTO mcp_query_rules (rule_id, active, match_pattern, timeout_ms, apply) VALUES (100, 1, 'SELECT SLEEP\\(', 100, 1);" >/dev/null 2>&1 + + # Load to runtime + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + sleep 1 + + echo "" + echo "======================================" + echo "Running Timeout Action Evaluation Tests" + echo "======================================" + echo "" + + # T8.1: Query with timeout_ms + # Use SLEEP() to simulate a long-running query that should timeout + log_info "T8.1: Testing timeout with SLEEP() query..." + run_test "T8.1: Query with timeout_ms - SLEEP() should timeout" \ + test_is_timed_out "run_sql_readonly" "SELECT SLEEP(5) FROM slow_table;" "Lost connection to server" "10" + + # T8.2: Verify timeout error message + # Check that the timeout rule exists and is configured correctly + log_info "T8.2: Verifying timeout rule configuration" + run_test "T8.2: Timeout rule exists with timeout_ms set" \ + bash -c "[ $(exec_admin_silent 'SELECT timeout_ms FROM runtime_mcp_query_rules WHERE rule_id = 100') -gt 0 ]" + + # Test that a quick query without timeout rule executes successfully + run_test "T8.3: Quick query without SLEEP executes successfully" \ + bash -c "timeout 5 curl -k -s -X POST 'https://${MCP_HOST}:${MCP_PORT}/mcp/query' -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"run_sql_readonly\",\"arguments\":{\"sql\":\"SELECT phase8_data FROM quick_table\"}},\"id\":1}' | grep -q 'phase8_data'" + + # Display runtime rules + echo "" + echo "Runtime rules created:" + exec_admin "SELECT rule_id, match_pattern, timeout_ms FROM runtime_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Display stats + echo "" + echo "Rule hit statistics:" + exec_admin "SELECT rule_id, hits FROM stats_mcp_query_rules WHERE rule_id BETWEEN 100 AND 199 ORDER BY rule_id;" + + # Summary + echo "" + echo "======================================" + echo "Test Summary" + echo "======================================" + echo "Total tests: ${TOTAL_TESTS}" + echo -e "Passed: ${GREEN}${PASSED_TESTS}${NC}" + echo -e "Failed: ${RED}${FAILED_TESTS}${NC}" + echo "" + + # Cleanup + exec_admin_silent "DELETE FROM mcp_query_rules WHERE rule_id BETWEEN 100 AND 199;" >/dev/null 2>&1 + exec_admin_silent "LOAD MCP QUERY RULES TO RUNTIME;" >/dev/null 2>&1 + log_info "Test rules cleaned up" + + # Drop test tables + echo "" + drop_test_tables + + if [ ${FAILED_TESTS} -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +main "$@"