Add full-text search (FTS) tools to MCP query server

Implement BM25-ranked full-text search capability for MySQL/MariaDB tables using SQLite-based external FTS index.

Changes:
- Add MySQL_FTS class for managing SQLite FTS indexes
- Add FTS tools: fts_index_table, fts_search, fts_reindex, fts_delete_index, fts_list_indexes, fts_rebuild_all
- Add thread-safe FTS lifecycle management with fts_lock mutex
- Add reset_fts_path() for runtime FTS database path configuration
- Add comprehensive FTS test scripts (test_mcp_fts.sh, test_mcp_fts_detailed.sh)
pull/5310/head
Rahim Kanji 1 month ago
parent 91ea6f5e49
commit 0d56918744

@ -56,6 +56,7 @@ public:
char* mcp_mysql_password; ///< MySQL password for tool connections
char* mcp_mysql_schema; ///< Default schema/database
char* mcp_catalog_path; ///< Path to catalog SQLite database
char* mcp_fts_path; ///< Path to FTS SQLite database
} variables;
/**

@ -2,6 +2,7 @@
#define CLASS_MYSQL_TOOL_HANDLER_H
#include "MySQL_Catalog.h"
#include "MySQL_FTS.h"
#include "cpp.h"
#include <string>
#include <memory>
@ -51,6 +52,10 @@ private:
// Catalog for LLM memory
MySQL_Catalog* catalog; ///< SQLite catalog for LLM discoveries
// FTS for fast data discovery
MySQL_FTS* fts; ///< SQLite FTS for full-text search
pthread_mutex_t fts_lock; ///< Mutex protecting FTS lifecycle/usage
// Query guardrails
int max_rows; ///< Maximum rows to return (default 200)
int timeout_ms; ///< Query timeout in milliseconds (default 2000)
@ -74,13 +79,6 @@ private:
*/
void return_connection(MYSQL* mysql);
/**
* @brief Execute a query and return results as JSON
* @param query SQL query to execute
* @return JSON with results or error
*/
std::string execute_query(const std::string& query);
/**
* @brief Validate SQL is read-only
* @param query SQL to validate
@ -111,6 +109,7 @@ public:
* @param password MySQL password
* @param schema Default schema/database
* @param catalog_path Path to catalog database
* @param fts_path Path to FTS database
*/
MySQL_Tool_Handler(
const std::string& hosts,
@ -118,9 +117,17 @@ public:
const std::string& user,
const std::string& password,
const std::string& schema,
const std::string& catalog_path
const std::string& catalog_path,
const std::string& fts_path = ""
);
/**
* @brief Reset FTS database path at runtime
* @param path New SQLite FTS database path
* @return true on success, false on error
*/
bool reset_fts_path(const std::string& path);
/**
* @brief Destructor
*/
@ -137,6 +144,13 @@ public:
*/
void close();
/**
* @brief Execute a query and return results as JSON
* @param query SQL query to execute
* @return JSON with results or error
*/
std::string execute_query(const std::string& query);
// ========== Inventory Tools ==========
/**
@ -389,6 +403,77 @@ public:
* @return JSON result
*/
std::string catalog_delete(const std::string& kind, const std::string& key);
// ========== FTS Tools (Full Text Search) ==========
/**
* @brief Create and populate an FTS index for a MySQL table
* @param schema Schema name
* @param table Table name
* @param columns JSON array of column names to index
* @param primary_key Primary key column name
* @param where_clause Optional WHERE clause for filtering
* @return JSON result with success status and metadata
*/
std::string fts_index_table(
const std::string& schema,
const std::string& table,
const std::string& columns,
const std::string& primary_key,
const std::string& where_clause = ""
);
/**
* @brief Search indexed data using FTS5
* @param query FTS5 search query
* @param schema Optional schema filter
* @param table Optional table filter
* @param limit Max results (default 100)
* @param offset Pagination offset (default 0)
* @return JSON result with matches and snippets
*/
std::string fts_search(
const std::string& query,
const std::string& schema = "",
const std::string& table = "",
int limit = 100,
int offset = 0
);
/**
* @brief List all FTS indexes with metadata
* @return JSON array of indexes
*/
std::string fts_list_indexes();
/**
* @brief Remove an FTS index
* @param schema Schema name
* @param table Table name
* @return JSON result
*/
std::string fts_delete_index(const std::string& schema, const std::string& table);
/**
* @brief Refresh an index with fresh data (full rebuild)
* @param schema Schema name
* @param table Table name
* @return JSON result
*/
std::string fts_reindex(const std::string& schema, const std::string& table);
/**
* @brief Rebuild ALL FTS indexes with fresh data
* @return JSON result with summary
*/
std::string fts_rebuild_all();
/**
* @brief Reinitialize FTS handler with a new database path
* @param fts_path New path to FTS database
* @return 0 on success, -1 on error
*/
int reinit_fts(const std::string& fts_path);
};
#endif /* CLASS_MYSQL_TOOL_HANDLER_H */

@ -30,6 +30,7 @@ static const char* mcp_thread_variables_names[] = {
"mysql_password",
"mysql_schema",
"catalog_path",
"fts_path",
NULL
};
@ -55,6 +56,7 @@ MCP_Threads_Handler::MCP_Threads_Handler() {
variables.mcp_mysql_password = strdup("");
variables.mcp_mysql_schema = strdup("");
variables.mcp_catalog_path = strdup("mcp_catalog.db");
variables.mcp_fts_path = strdup("mcp_fts.db");
status_variables.total_requests = 0;
status_variables.failed_requests = 0;
@ -95,6 +97,8 @@ MCP_Threads_Handler::~MCP_Threads_Handler() {
free(variables.mcp_mysql_schema);
if (variables.mcp_catalog_path)
free(variables.mcp_catalog_path);
if (variables.mcp_fts_path)
free(variables.mcp_fts_path);
if (mcp_server) {
delete mcp_server;
@ -220,6 +224,10 @@ int MCP_Threads_Handler::get_variable(const char* name, char* val) {
sprintf(val, "%s", variables.mcp_catalog_path ? variables.mcp_catalog_path : "");
return 0;
}
if (!strcmp(name, "fts_path")) {
sprintf(val, "%s", variables.mcp_fts_path ? variables.mcp_fts_path : "");
return 0;
}
return -1;
}
@ -322,6 +330,21 @@ int MCP_Threads_Handler::set_variable(const char* name, const char* value) {
variables.mcp_catalog_path = strdup(value);
return 0;
}
if (!strcmp(name, "fts_path")) {
if (variables.mcp_fts_path)
free(variables.mcp_fts_path);
variables.mcp_fts_path = strdup(value);
// Apply at runtime by resetting FTS in the existing handler
if (mysql_tool_handler) {
proxy_info("MCP: Applying new fts_path at runtime: %s\n", value);
if (!mysql_tool_handler->reset_fts_path(value)) {
proxy_error("Failed to reset FTS path at runtime\n");
return -1;
}
}
return 0;
}
return -1;
}

@ -82,7 +82,7 @@ _OBJ_CXX := ProxySQL_GloVars.oo network.oo debug.oo configfile.oo Query_Cache.oo
PgSQL_PreparedStatement.oo PgSQL_Extended_Query_Message.oo \
pgsql_tokenizer.oo \
MCP_Thread.oo ProxySQL_MCP_Server.oo MCP_Endpoint.oo \
MySQL_Catalog.oo MySQL_Tool_Handler.oo \
MySQL_Catalog.oo MySQL_Tool_Handler.oo MySQL_FTS.oo \
Config_Tool_Handler.oo Query_Tool_Handler.oo \
Admin_Tool_Handler.oo Cache_Tool_Handler.oo Observe_Tool_Handler.oo \
AI_Features_Manager.oo LLM_Bridge.oo LLM_Clients.oo Anomaly_Detector.oo AI_Vector_Storage.oo AI_Tool_Handler.oo

@ -5,6 +5,7 @@
#include <algorithm>
#include <regex>
#include <cstring>
#include <sys/stat.h>
// MySQL client library
#include <mysql.h>
@ -20,9 +21,11 @@ MySQL_Tool_Handler::MySQL_Tool_Handler(
const std::string& user,
const std::string& password,
const std::string& schema,
const std::string& catalog_path
const std::string& catalog_path,
const std::string& fts_path
)
: catalog(NULL),
fts(NULL),
max_rows(200),
timeout_ms(2000),
allow_select_star(false),
@ -30,6 +33,8 @@ MySQL_Tool_Handler::MySQL_Tool_Handler(
{
// Initialize the pool mutex
pthread_mutex_init(&pool_lock, NULL);
// Initialize the FTS mutex
pthread_mutex_init(&fts_lock, NULL);
// Parse hosts
std::istringstream h(hosts);
@ -65,6 +70,11 @@ MySQL_Tool_Handler::MySQL_Tool_Handler(
// Create catalog
catalog = new MySQL_Catalog(catalog_path);
// Create FTS if path is provided
if (!fts_path.empty()) {
fts = new MySQL_FTS(fts_path);
}
}
MySQL_Tool_Handler::~MySQL_Tool_Handler() {
@ -72,8 +82,13 @@ MySQL_Tool_Handler::~MySQL_Tool_Handler() {
if (catalog) {
delete catalog;
}
if (fts) {
delete fts;
}
// Destroy the pool mutex
pthread_mutex_destroy(&pool_lock);
// Destroy the FTS mutex
pthread_mutex_destroy(&fts_lock);
}
int MySQL_Tool_Handler::init() {
@ -82,6 +97,14 @@ int MySQL_Tool_Handler::init() {
return -1;
}
// Initialize FTS if configured
if (fts && fts->init()) {
proxy_error("Failed to initialize FTS, continuing without FTS\n");
// Continue without FTS - it's optional
delete fts;
fts = NULL;
}
// Initialize connection pool
if (init_connection_pool()) {
return -1;
@ -91,6 +114,29 @@ int MySQL_Tool_Handler::init() {
return 0;
}
bool MySQL_Tool_Handler::reset_fts_path(const std::string& path) {
pthread_mutex_lock(&fts_lock);
if (fts) {
delete fts;
fts = NULL;
}
if (!path.empty()) {
fts = new MySQL_FTS(path);
if (fts->init()) {
proxy_error("Failed to initialize FTS with new path: %s\n", path.c_str());
delete fts;
fts = NULL;
pthread_mutex_unlock(&fts_lock);
return false;
}
}
pthread_mutex_unlock(&fts_lock);
return true;
}
/**
* @brief Close all MySQL connections and cleanup resources
*
@ -988,3 +1034,145 @@ std::string MySQL_Tool_Handler::catalog_delete(const std::string& kind, const st
return result.dump();
}
// ========== FTS Tools (Full Text Search) ==========
std::string MySQL_Tool_Handler::fts_index_table(
const std::string& schema,
const std::string& table,
const std::string& columns,
const std::string& primary_key,
const std::string& where_clause
) {
pthread_mutex_lock(&fts_lock);
if (!fts) {
json result;
result["success"] = false;
result["error"] = "FTS not initialized";
pthread_mutex_unlock(&fts_lock);
return result.dump();
}
std::string out = fts->index_table(schema, table, columns, primary_key, where_clause, this);
pthread_mutex_unlock(&fts_lock);
return out;
}
std::string MySQL_Tool_Handler::fts_search(
const std::string& query,
const std::string& schema,
const std::string& table,
int limit,
int offset
) {
pthread_mutex_lock(&fts_lock);
if (!fts) {
json result;
result["success"] = false;
result["error"] = "FTS not initialized";
pthread_mutex_unlock(&fts_lock);
return result.dump();
}
std::string out = fts->search(query, schema, table, limit, offset);
pthread_mutex_unlock(&fts_lock);
return out;
}
std::string MySQL_Tool_Handler::fts_list_indexes() {
pthread_mutex_lock(&fts_lock);
if (!fts) {
json result;
result["success"] = false;
result["error"] = "FTS not initialized";
pthread_mutex_unlock(&fts_lock);
return result.dump();
}
std::string out = fts->list_indexes();
pthread_mutex_unlock(&fts_lock);
return out;
}
std::string MySQL_Tool_Handler::fts_delete_index(const std::string& schema, const std::string& table) {
pthread_mutex_lock(&fts_lock);
if (!fts) {
json result;
result["success"] = false;
result["error"] = "FTS not initialized";
pthread_mutex_unlock(&fts_lock);
return result.dump();
}
std::string out = fts->delete_index(schema, table);
pthread_mutex_unlock(&fts_lock);
return out;
}
std::string MySQL_Tool_Handler::fts_reindex(const std::string& schema, const std::string& table) {
pthread_mutex_lock(&fts_lock);
if (!fts) {
json result;
result["success"] = false;
result["error"] = "FTS not initialized";
pthread_mutex_unlock(&fts_lock);
return result.dump();
}
std::string out = fts->reindex(schema, table, this);
pthread_mutex_unlock(&fts_lock);
return out;
}
std::string MySQL_Tool_Handler::fts_rebuild_all() {
pthread_mutex_lock(&fts_lock);
if (!fts) {
json result;
result["success"] = false;
result["error"] = "FTS not initialized";
pthread_mutex_unlock(&fts_lock);
return result.dump();
}
std::string out = fts->rebuild_all(this);
pthread_mutex_unlock(&fts_lock);
return out;
}
int MySQL_Tool_Handler::reinit_fts(const std::string& fts_path) {
proxy_info("MySQL_Tool_Handler: Reinitializing FTS with path: %s\n", fts_path.c_str());
// Check if directory exists (SQLite can't create directories)
std::string::size_type last_slash = fts_path.find_last_of("/");
if (last_slash != std::string::npos && last_slash > 0) {
std::string dir = fts_path.substr(0, last_slash);
struct stat st;
if (stat(dir.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) {
proxy_error("MySQL_Tool_Handler: Directory does not exist for path '%s' (directory: '%s')\n",
fts_path.c_str(), dir.c_str());
return -1;
}
}
// First, test if we can open the new database
MySQL_FTS* new_fts = new MySQL_FTS(fts_path);
if (!new_fts) {
proxy_error("MySQL_Tool_Handler: Failed to create new FTS handler\n");
return -1;
}
if (new_fts->init() != 0) {
proxy_error("MySQL_Tool_Handler: Failed to initialize FTS at %s\n", fts_path.c_str());
delete new_fts;
return -1; // Return error WITHOUT closing old FTS
}
// Success! Now close old and replace with new
if (fts) {
delete fts;
}
fts = new_fts;
proxy_info("MySQL_Tool_Handler: FTS reinitialized successfully at %s\n", fts_path.c_str());
return 0;
}

@ -83,7 +83,8 @@ ProxySQL_MCP_Server::ProxySQL_MCP_Server(int p, MCP_Threads_Handler* h)
handler->variables.mcp_mysql_user ? handler->variables.mcp_mysql_user : "",
handler->variables.mcp_mysql_password ? handler->variables.mcp_mysql_password : "",
handler->variables.mcp_mysql_schema ? handler->variables.mcp_mysql_schema : "",
handler->variables.mcp_catalog_path ? handler->variables.mcp_catalog_path : ""
handler->variables.mcp_catalog_path ? handler->variables.mcp_catalog_path : "",
handler->variables.mcp_fts_path ? handler->variables.mcp_fts_path : ""
);
if (handler->mysql_tool_handler->init() != 0) {

@ -217,6 +217,49 @@ json Query_Tool_Handler::get_tool_list() {
{}
));
// FTS tools (Full Text Search)
tools.push_back(create_tool_schema(
"fts_index_table",
"Create and populate a full-text search index for a MySQL table",
{"schema", "table", "columns", "primary_key"},
{{"where_clause", "string"}}
));
tools.push_back(create_tool_schema(
"fts_search",
"Search indexed data using full-text search with BM25 ranking",
{"query"},
{{"schema", "string"}, {"table", "string"}, {"limit", "integer"}, {"offset", "integer"}}
));
tools.push_back(create_tool_schema(
"fts_list_indexes",
"List all full-text search indexes with metadata",
{},
{}
));
tools.push_back(create_tool_schema(
"fts_delete_index",
"Remove a full-text search index",
{"schema", "table"},
{}
));
tools.push_back(create_tool_schema(
"fts_reindex",
"Refresh an index with fresh data (full rebuild)",
{"schema", "table"},
{}
));
tools.push_back(create_tool_schema(
"fts_rebuild_all",
"Rebuild all full-text search indexes with fresh data",
{},
{}
));
json result;
result["tools"] = tools;
return result;
@ -396,6 +439,39 @@ json Query_Tool_Handler::execute_tool(const std::string& tool_name, const json&
std::string key = get_json_string(arguments, "key");
result_str = mysql_handler->catalog_delete(kind, key);
}
// FTS tools
else if (tool_name == "fts_index_table") {
std::string schema = get_json_string(arguments, "schema");
std::string table = get_json_string(arguments, "table");
std::string columns = get_json_string(arguments, "columns");
std::string primary_key = get_json_string(arguments, "primary_key");
std::string where_clause = get_json_string(arguments, "where_clause");
result_str = mysql_handler->fts_index_table(schema, table, columns, primary_key, where_clause);
}
else if (tool_name == "fts_search") {
std::string query = get_json_string(arguments, "query");
std::string schema = get_json_string(arguments, "schema");
std::string table = get_json_string(arguments, "table");
int limit = get_json_int(arguments, "limit", 100);
int offset = get_json_int(arguments, "offset", 0);
result_str = mysql_handler->fts_search(query, schema, table, limit, offset);
}
else if (tool_name == "fts_list_indexes") {
result_str = mysql_handler->fts_list_indexes();
}
else if (tool_name == "fts_delete_index") {
std::string schema = get_json_string(arguments, "schema");
std::string table = get_json_string(arguments, "table");
result_str = mysql_handler->fts_delete_index(schema, table);
}
else if (tool_name == "fts_reindex") {
std::string schema = get_json_string(arguments, "schema");
std::string table = get_json_string(arguments, "table");
result_str = mysql_handler->fts_reindex(schema, table);
}
else if (tool_name == "fts_rebuild_all") {
result_str = mysql_handler->fts_rebuild_all();
}
else {
return create_error_response("Unknown tool: " + tool_name);
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,264 @@
#!/bin/bash
#
# test_mcp_fts_detailed.sh - Detailed FTS validation for MCP /mcp/query endpoint
#
# Tests:
# - tools/list exposes FTS tools
# - runtime fts_path change + stress toggling
# - index/reindex on testdb tables
# - search returns hits and snippets
# - list_indexes columns is JSON array
# - empty query returns error
# - delete index and verify removal
# - rebuild all indexes and verify success
#
# Usage:
# ./test_mcp_fts_detailed.sh [--cleanup]
#
# Env:
# MCP_HOST (default 127.0.0.1)
# MCP_PORT (default 6071)
# USE_SSL (default false)
# MYSQL_HOST (default 127.0.0.1)
# MYSQL_PORT (default 6033)
# MYSQL_USER (default root)
# MYSQL_PASSWORD (default root)
# CREATE_SAMPLE_DATA (default true)
#
set -euo pipefail
MCP_HOST="${MCP_HOST:-127.0.0.1}"
MCP_PORT="${MCP_PORT:-6071}"
USE_SSL="${USE_SSL:-false}"
MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
MYSQL_PORT="${MYSQL_PORT:-6033}"
MYSQL_USER="${MYSQL_USER:-root}"
MYSQL_PASSWORD="${MYSQL_PASSWORD:-root}"
CREATE_SAMPLE_DATA="${CREATE_SAMPLE_DATA:-true}"
if [ "${USE_SSL}" = "true" ]; then
PROTO="https"
CURL_OPTS="-k"
else
PROTO="http"
CURL_OPTS=""
fi
MCP_ENDPOINT="${PROTO}://${MCP_HOST}:${MCP_PORT}/mcp/query"
MCP_CONFIG_ENDPOINT="${PROTO}://${MCP_HOST}:${MCP_PORT}/mcp/config"
CLEANUP=false
if [ "${1:-}" = "--cleanup" ]; then
CLEANUP=true
fi
if ! command -v jq >/dev/null 2>&1; then
echo "jq is required for this test script." >&2
exit 1
fi
if [ "${CREATE_SAMPLE_DATA}" = "true" ] && ! command -v mysql >/dev/null 2>&1; then
echo "mysql client is required for CREATE_SAMPLE_DATA=true" >&2
exit 1
fi
log() {
echo "[FTS] $1"
}
mysql_exec() {
local sql="$1"
mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" -p"${MYSQL_PASSWORD}" -e "${sql}"
}
setup_sample_data() {
log "Setting up sample MySQL data for CI"
mysql_exec "CREATE DATABASE IF NOT EXISTS fts_test;"
mysql_exec "DROP TABLE IF EXISTS fts_test.customers;"
mysql_exec "CREATE TABLE fts_test.customers (id INT PRIMARY KEY, name VARCHAR(100), email VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"
mysql_exec "INSERT INTO fts_test.customers (id, name, email) VALUES (1, 'Alice Johnson', 'alice@example.com'), (2, 'Bob Smith', 'bob@example.com'), (3, 'Charlie Brown', 'charlie@example.com');"
mysql_exec "DROP TABLE IF EXISTS fts_test.orders;"
mysql_exec "CREATE TABLE fts_test.orders (id INT PRIMARY KEY, customer_id INT, order_date DATE, total DECIMAL(10,2), status VARCHAR(20), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"
mysql_exec "INSERT INTO fts_test.orders (id, customer_id, order_date, total, status) VALUES (1, 1, '2026-01-01', 100.00, 'open'), (2, 2, '2026-01-02', 200.00, 'closed');"
mysql_exec "DROP TABLE IF EXISTS fts_test.products;"
mysql_exec "CREATE TABLE fts_test.products (id INT PRIMARY KEY, name VARCHAR(100), category VARCHAR(50), price DECIMAL(10,2), stock INT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);"
mysql_exec "INSERT INTO fts_test.products (id, name, category, price, stock) VALUES (1, 'Laptop Pro', 'electronics', 999.99, 10), (2, 'Coffee Mug', 'kitchen', 12.99, 200), (3, 'Desk Lamp', 'home', 29.99, 50);"
}
cleanup_sample_data() {
if [ "${CREATE_SAMPLE_DATA}" = "true" ]; then
log "Cleaning up sample MySQL data"
mysql_exec "DROP DATABASE IF EXISTS fts_test;"
fi
}
mcp_request() {
local payload="$1"
curl ${CURL_OPTS} -s -X POST "${MCP_ENDPOINT}" \
-H "Content-Type: application/json" \
-d "${payload}"
}
config_request() {
local payload="$1"
curl ${CURL_OPTS} -s -X POST "${MCP_CONFIG_ENDPOINT}" \
-H "Content-Type: application/json" \
-d "${payload}"
}
tool_call() {
local name="$1"
local args="$2"
mcp_request "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"${name}\",\"arguments\":${args}}}"
}
extract_tool_result() {
local resp="$1"
local text
text=$(echo "${resp}" | jq -r '.result.content[0].text // empty')
if [ -n "${text}" ] && [ "${text}" != "null" ]; then
echo "${text}"
return 0
fi
echo "${resp}" | jq -c '.result.result // .result'
}
config_call() {
local name="$1"
local args="$2"
config_request "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"${name}\",\"arguments\":${args}}}"
}
ensure_index() {
local schema="$1"
local table="$2"
local columns="$3"
local pk="$4"
local list_json
list_json=$(tool_call "fts_list_indexes" "{}")
list_json=$(extract_tool_result "${list_json}")
local exists
exists=$(echo "${list_json}" | jq -r --arg s "${schema}" --arg t "${table}" \
'.indexes[]? | select(.schema==$s and .table==$t) | .table' | head -n1)
if [ -n "${exists}" ]; then
log "Reindexing ${schema}.${table}"
local reindex_resp
reindex_resp=$(tool_call "fts_reindex" "{\"schema\":\"${schema}\",\"table\":\"${table}\"}")
reindex_resp=$(extract_tool_result "${reindex_resp}")
echo "${reindex_resp}" | jq -e '.success == true' >/dev/null
else
log "Indexing ${schema}.${table}"
local index_resp
index_resp=$(tool_call "fts_index_table" "{\"schema\":\"${schema}\",\"table\":\"${table}\",\"columns\":${columns},\"primary_key\":\"${pk}\"}")
index_resp=$(extract_tool_result "${index_resp}")
echo "${index_resp}" | jq -e '.success == true' >/dev/null
fi
}
if [ "${CREATE_SAMPLE_DATA}" = "true" ]; then
setup_sample_data
fi
log "Checking tools/list contains FTS tools"
tools_json=$(mcp_request '{"jsonrpc":"2.0","id":1,"method":"tools/list"}')
for tool in fts_index_table fts_search fts_list_indexes fts_delete_index fts_reindex fts_rebuild_all; do
echo "${tools_json}" | jq -e --arg t "${tool}" '.result.tools[]? | select(.name==$t)' >/dev/null
log "Found tool: ${tool}"
done
log "Testing runtime fts_path change"
orig_cfg=$(config_call "get_config" '{"variable_name":"fts_path"}')
orig_cfg=$(extract_tool_result "${orig_cfg}")
orig_path=$(echo "${orig_cfg}" | jq -r '.value')
alt_path="${ALT_FTS_PATH:-/tmp/mcp_fts_runtime_test.db}"
set_resp=$(config_call "set_config" "{\"variable_name\":\"fts_path\",\"value\":\"${alt_path}\"}")
set_resp=$(extract_tool_result "${set_resp}")
echo "${set_resp}" | jq -e '.variable_name == "fts_path" and .value == "'"${alt_path}"'"' >/dev/null
new_cfg=$(config_call "get_config" '{"variable_name":"fts_path"}')
new_cfg=$(extract_tool_result "${new_cfg}")
echo "${new_cfg}" | jq -e --arg v "${alt_path}" '.value == $v' >/dev/null
log "Stress test: toggling fts_path values"
TOGGLE_ITERATIONS="${TOGGLE_ITERATIONS:-10}"
for i in $(seq 1 "${TOGGLE_ITERATIONS}"); do
tmp_path="/tmp/mcp_fts_runtime_test_${i}.db"
toggle_resp=$(config_call "set_config" "{\"variable_name\":\"fts_path\",\"value\":\"${tmp_path}\"}")
toggle_resp=$(extract_tool_result "${toggle_resp}")
echo "${toggle_resp}" | jq -e '.variable_name == "fts_path" and .value == "'"${tmp_path}"'"' >/dev/null
verify_resp=$(config_call "get_config" '{"variable_name":"fts_path"}')
verify_resp=$(extract_tool_result "${verify_resp}")
echo "${verify_resp}" | jq -e --arg v "${tmp_path}" '.value == $v' >/dev/null
done
log "Restoring original fts_path"
restore_resp=$(config_call "set_config" "{\"variable_name\":\"fts_path\",\"value\":\"${orig_path}\"}")
restore_resp=$(extract_tool_result "${restore_resp}")
echo "${restore_resp}" | jq -e '.variable_name == "fts_path" and .value == "'"${orig_path}"'"' >/dev/null
ensure_index "fts_test" "customers" '["name","email","created_at"]' "id"
ensure_index "fts_test" "orders" '["customer_id","order_date","total","status","created_at"]' "id"
log "Validating list_indexes columns is JSON array"
list_json=$(tool_call "fts_list_indexes" "{}")
list_json=$(extract_tool_result "${list_json}")
echo "${list_json}" | jq -e '.indexes[]? | select(.schema=="fts_test" and .table=="customers") | (.columns|type=="array")' >/dev/null
log "Searching for 'Alice' in fts_test.customers"
search_json=$(tool_call "fts_search" '{"query":"Alice","schema":"fts_test","table":"customers","limit":5,"offset":0}')
search_json=$(extract_tool_result "${search_json}")
echo "${search_json}" | jq -e '.total_matches > 0' >/dev/null
echo "${search_json}" | jq -e '.results[0].snippet | contains("<mark>")' >/dev/null
log "Searching for 'order' across fts_test"
search_json=$(tool_call "fts_search" '{"query":"order","schema":"fts_test","limit":5,"offset":0}')
search_json=$(extract_tool_result "${search_json}")
echo "${search_json}" | jq -e '.total_matches >= 0' >/dev/null
log "Empty query should return error"
empty_json=$(tool_call "fts_search" '{"query":"","schema":"fts_test","limit":5,"offset":0}')
empty_json=$(extract_tool_result "${empty_json}")
echo "${empty_json}" | jq -e '.success == false' >/dev/null
log "Deleting and verifying index removal for fts_test.orders"
delete_resp=$(tool_call "fts_delete_index" '{"schema":"fts_test","table":"orders"}')
delete_resp=$(extract_tool_result "${delete_resp}")
echo "${delete_resp}" | jq -e '.success == true' >/dev/null
list_json=$(tool_call "fts_list_indexes" "{}")
list_json=$(extract_tool_result "${list_json}")
echo "${list_json}" | jq -e '(.indexes | map(select(.schema=="fts_test" and .table=="orders")) | length) == 0' >/dev/null
log "Rebuild all indexes and verify success"
rebuild_resp=$(tool_call "fts_rebuild_all" "{}")
rebuild_resp=$(extract_tool_result "${rebuild_resp}")
echo "${rebuild_resp}" | jq -e '.success == true' >/dev/null
echo "${rebuild_resp}" | jq -e '.total_indexes >= 0' >/dev/null
if [ "${CLEANUP}" = "true" ]; then
log "Cleanup: deleting fts_test.customers and fts_test.orders indexes"
delete_resp=$(tool_call "fts_delete_index" '{"schema":"fts_test","table":"customers"}')
delete_resp=$(extract_tool_result "${delete_resp}")
echo "${delete_resp}" | jq -e '.success == true' >/dev/null
delete_resp=$(tool_call "fts_delete_index" '{"schema":"fts_test","table":"orders"}')
delete_resp=$(extract_tool_result "${delete_resp}")
echo "${delete_resp}" | jq -e '.success == true' >/dev/null
fi
cleanup_sample_data
log "Detailed FTS tests completed successfully"
Loading…
Cancel
Save