Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 68 additions & 12 deletions lib/Discovery_Schema.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand 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":"<digest_of_sql>","timeout":"?"}
//
// Input: {"filters": {"status": "active", "age": 25}}
// Output: {"filters":{"?":"?","?":"?"}}
Expand All @@ -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;
Expand All @@ -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<std::string>();
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
Comment on lines +3041 to +3048
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mysql_query_digest_and_first_comment expects a non-null char** fst_cmnt (it dereferences it in the tokenizer). Passing NULL here can segfault when the SQL contains a comment. Use a local char* first_comment=nullptr; and pass &first_comment (and free it if allocated) or switch to an API that doesn't require the first-comment output.

Copilot uses AI. Check for mistakes.
);
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];
Comment on lines +3053 to +3065

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The manual JSON string escaping is incomplete and could lead to malformed JSON if the digest contains special characters other than \ and ", such as newlines or tabs. It's safer to use the nlohmann::json library, which is already included in this file, to handle JSON serialization and escaping correctly. This will ensure the generated JSON is always valid and make the code more robust.

                                        // Use nlohmann::json to correctly escape the digest for JSON.
						if (digest) {
							result += nlohmann::json(digest).dump();
							free(digest);
						} else {
							result += "\"\""; // Represent NULL digest as an empty string.
						}

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;
}
}
5 changes: 4 additions & 1 deletion lib/ProxySQL_Admin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
115 changes: 88 additions & 27 deletions lib/ProxySQL_Admin_Stats.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<SQLite3_row*>::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;
}
Expand Down
Loading