diff --git a/doc/MCP/FTS_USER_GUIDE.md b/doc/MCP/FTS_USER_GUIDE.md
new file mode 100644
index 0000000000..91a979b562
--- /dev/null
+++ b/doc/MCP/FTS_USER_GUIDE.md
@@ -0,0 +1,854 @@
+# MCP Full-Text Search (FTS) - User Guide
+
+## Table of Contents
+
+1. [Overview](#overview)
+2. [Architecture](#architecture)
+3. [Configuration](#configuration)
+4. [FTS Tools Reference](#fts-tools-reference)
+5. [Usage Examples](#usage-examples)
+6. [API Endpoints](#api-endpoints)
+7. [Best Practices](#best-practices)
+8. [Troubleshooting](#troubleshooting)
+9. [Detailed Test Script](#detailed-test-script)
+
+---
+
+## Overview
+
+The MCP Full-Text Search (FTS) module provides fast, indexed search capabilities for MySQL table data. It uses SQLite's FTS5 extension with BM25 ranking, allowing AI agents to quickly find relevant data before making targeted queries to the MySQL backend.
+
+### Key Benefits
+
+- **Fast Discovery**: Search millions of rows in milliseconds
+- **BM25 Ranking**: Results ranked by relevance
+- **Snippet Highlighting**: Search terms highlighted in results
+- **Cross-Table Search**: Search across multiple indexed tables
+- **Selective Indexing**: Index specific columns with optional WHERE filters
+- **AI Agent Optimized**: Reduces LLM query overhead by finding relevant IDs first
+
+### How It Works
+
+```text
+Traditional Query Flow:
+LLM Agent → Full Table Scan → Millions of Rows → Slow Response
+
+FTS-Optimized Flow:
+LLM Agent → FTS Search (ms) → Top N IDs → Targeted MySQL Query → Fast Response
+```
+
+---
+
+## Architecture
+
+### Components
+
+```text
+┌─────────────────────────────────────────────────────────────┐
+│ MCP Query Endpoint │
+│ (JSON-RPC 2.0 over HTTPS) │
+└────────────────────────┬────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Query_Tool_Handler │
+│ - Routes tool calls to MySQL_Tool_Handler │
+│ - Provides 6 FTS tools via MCP protocol │
+└────────────────────────┬────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ MySQL_Tool_Handler │
+│ - Wraps MySQL_FTS class │
+│ - Provides execute_query() for MySQL access │
+└────────────────────────┬────────────────────────────────────┘
+ │
+ ┌───────────────┴───────────────┐
+ ▼ ▼
+┌─────────────────────┐ ┌─────────────────┐
+│ MySQL_FTS │ │ MySQL Backend │
+│ (SQLite FTS5) │ │ (Actual Data) │
+│ │ │ │
+│ ┌─────────────────┐ │ │ │
+│ │ fts_indexes │ │ │ │
+│ │ (metadata) │ │ │ │
+│ └─────────────────┘ │ │ │
+│ │ │ │
+│ ┌─────────────────┐ │ │ │
+│ │ fts_data_* │ │ │ │
+│ │ (content store) │ │ │ │
+│ └─────────────────┘ │ │ │
+│ │ │ │
+│ ┌─────────────────┐ │ │ │
+│ │ fts_search_* │ │ │ │
+│ │ (FTS5 virtual) │ │ │ │
+│ └─────────────────┘ │ │ │
+└─────────────────────┘ └─────────────────┘
+```
+
+### Data Flow
+
+1. **Index Creation**:
+ ```text
+ MySQL Table → SELECT → JSON Parse → SQLite Insert → FTS5 Index
+ ```
+
+2. **Search**:
+ ```text
+ Query → FTS5 MATCH → BM25 Ranking → Results + Snippets → JSON Response
+ ```
+
+---
+
+## Configuration
+
+### Admin Interface Variables
+
+Configure FTS via the ProxySQL admin interface (port 6032):
+
+```sql
+-- Enable/disable MCP module
+SET mcp-enabled = true;
+
+-- Configure FTS database path
+SET mcp-fts_path = '/var/lib/proxysql/mcp_fts.db';
+
+-- Configure MySQL backend for FTS indexing
+SET mcp-mysql_hosts = '127.0.0.1';
+SET mcp-mysql_ports = '3306';
+SET mcp-mysql_user = 'root';
+SET mcp-mysql_password = 'password';
+SET mcp-mysql_schema = 'mydb';
+
+-- Apply changes
+LOAD MCP VARIABLES TO RUNTIME;
+```
+
+### Configuration Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `mcp-fts_path` | `mcp_fts.db` | Path to SQLite FTS database |
+| `mcp-mysql_hosts` | `127.0.0.1` | Comma-separated MySQL hosts |
+| `mcp-mysql_ports` | `3306` | Comma-separated MySQL ports |
+| `mcp-mysql_user` | (empty) | MySQL username |
+| `mcp-mysql_password` | (empty) | MySQL password |
+| `mcp-mysql_schema` | (empty) | Default MySQL schema |
+
+### File System Requirements
+
+The FTS database file will be created at the configured path. Ensure:
+
+1. The directory exists and is writable by ProxySQL
+2. Sufficient disk space for indexes (typically 10-50% of source data size)
+3. Regular backups if data persistence is required
+
+---
+
+### Quick Start (End-to-End)
+
+1. Start ProxySQL with MCP enabled and a valid `mcp-fts_path`.
+2. Create an index on a table.
+3. Run a search and use returned IDs for a targeted SQL query.
+
+Example (JSON-RPC via curl):
+
+```bash
+curl -s -X POST http://127.0.0.1:6071/mcp/query \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {
+ "name": "fts_index_table",
+ "arguments": {
+ "schema": "testdb",
+ "table": "customers",
+ "columns": ["name", "email", "created_at"],
+ "primary_key": "id"
+ }
+ }
+ }'
+```
+
+Then search:
+
+```bash
+curl -s -X POST http://127.0.0.1:6071/mcp/query \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "id": 2,
+ "method": "tools/call",
+ "params": {
+ "name": "fts_search",
+ "arguments": {
+ "query": "Alice",
+ "schema": "testdb",
+ "table": "customers",
+ "limit": 5,
+ "offset": 0
+ }
+ }
+ }'
+```
+
+### Response Envelope (MCP JSON-RPC)
+
+The MCP endpoint returns tool results inside the JSON-RPC response. Depending on client/server configuration, the tool result may appear in:
+
+- `result.content[0].text` (stringified JSON), or
+- `result.result` (JSON object)
+
+If your client expects MCP “content blocks”, parse `result.content[0].text` as JSON.
+
+---
+
+## FTS Tools Reference
+
+### 1. fts_index_table
+
+Create and populate a full-text search index for a MySQL table.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `schema` | string | Yes | Schema name |
+| `table` | string | Yes | Table name |
+| `columns` | array (or JSON string) | Yes | Column names to index |
+| `primary_key` | string | Yes | Primary key column name |
+| `where_clause` | string | No | Optional WHERE clause for filtering |
+
+**Response:**
+```json
+{
+ "success": true,
+ "schema": "sales",
+ "table": "orders",
+ "row_count": 15000,
+ "indexed_at": 1736668800
+}
+```
+
+**Example:**
+```json
+{
+ "name": "fts_index_table",
+ "arguments": {
+ "schema": "sales",
+ "table": "orders",
+ "columns": ["order_id", "customer_name", "notes", "status"],
+ "primary_key": "order_id",
+ "where_clause": "created_at >= '2024-01-01'"
+ }
+}
+```
+
+**Notes:**
+- If an index already exists, the tool returns an error
+- Use `fts_reindex` to refresh an existing index
+- Column values are concatenated for full-text search
+- Original row data is stored as JSON metadata
+- The primary key is always fetched to populate `primary_key_value`
+
+---
+
+### 2. fts_search
+
+Search indexed data using FTS5 with BM25 ranking.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `query` | string | Yes | FTS5 search query |
+| `schema` | string | No | Filter by schema |
+| `table` | string | No | Filter by table |
+| `limit` | integer | No | Max results (default: 100) |
+| `offset` | integer | No | Pagination offset (default: 0) |
+
+**Response:**
+```json
+{
+ "success": true,
+ "query": "urgent customer",
+ "total_matches": 234,
+ "results": [
+ {
+ "schema": "sales",
+ "table": "orders",
+ "primary_key_value": "12345",
+ "snippet": "Customer has urgent customer complaint...",
+ "metadata": {"order_id":12345,"customer_name":"John Smith"}
+ }
+ ]
+}
+```
+
+**Example:**
+```json
+{
+ "name": "fts_search",
+ "arguments": {
+ "query": "urgent customer complaint",
+ "limit": 10
+ }
+}
+```
+
+**FTS5 Query Syntax:**
+- Simple terms: `urgent`
+- Phrases: `"customer complaint"`
+- Boolean: `urgent AND pending`
+- Wildcards: `cust*`
+- Prefix: `^urgent`
+
+**Notes:**
+- Results are ranked by BM25 relevance score
+- Snippets highlight matching terms with `` tags
+- Without schema/table filters, searches across all indexes
+
+---
+
+### 3. fts_list_indexes
+
+List all FTS indexes with metadata.
+
+**Parameters:**
+None
+
+**Response:**
+```json
+{
+ "success": true,
+ "indexes": [
+ {
+ "schema": "sales",
+ "table": "orders",
+ "columns": ["order_id","customer_name","notes"],
+ "primary_key": "order_id",
+ "where_clause": "created_at >= '2024-01-01'",
+ "row_count": 15000,
+ "indexed_at": 1736668800
+ }
+ ]
+}
+```
+
+**Example:**
+```json
+{
+ "name": "fts_list_indexes",
+ "arguments": {}
+}
+```
+
+---
+
+### 4. fts_delete_index
+
+Remove an FTS index and all associated data.
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `schema` | string | Yes | Schema name |
+| `table` | string | Yes | Table name |
+
+**Response:**
+```json
+{
+ "success": true,
+ "schema": "sales",
+ "table": "orders",
+ "message": "Index deleted successfully"
+}
+```
+
+**Example:**
+```json
+{
+ "name": "fts_delete_index",
+ "arguments": {
+ "schema": "sales",
+ "table": "orders"
+ }
+}
+```
+
+**Warning:**
+- This permanently removes the index and all search data
+- Does not affect the original MySQL table
+
+---
+
+### 5. fts_reindex
+
+Refresh an index with fresh data from MySQL (full rebuild).
+
+**Parameters:**
+
+| Name | Type | Required | Description |
+|------|------|----------|-------------|
+| `schema` | string | Yes | Schema name |
+| `table` | string | Yes | Table name |
+
+**Response:**
+```json
+{
+ "success": true,
+ "schema": "sales",
+ "table": "orders",
+ "row_count": 15200,
+ "indexed_at": 1736670000
+}
+```
+
+**Example:**
+```json
+{
+ "name": "fts_reindex",
+ "arguments": {
+ "schema": "sales",
+ "table": "orders"
+ }
+}
+```
+
+**Use Cases:**
+- Data has been added/modified in MySQL
+- Scheduled index refresh
+- Index corruption recovery
+
+---
+
+### 6. fts_rebuild_all
+
+Rebuild ALL FTS indexes with fresh data.
+
+**Parameters:**
+None
+
+**Response:**
+```json
+{
+ "success": true,
+ "rebuilt_count": 5,
+ "failed": [],
+ "total_indexes": 5,
+ "indexes": [
+ {
+ "schema": "sales",
+ "table": "orders",
+ "row_count": 15200,
+ "status": "success"
+ }
+ ]
+}
+```
+
+**Example:**
+```json
+{
+ "name": "fts_rebuild_all",
+ "arguments": {}
+}
+```
+
+**Use Cases:**
+- Scheduled maintenance
+- Bulk data updates
+- Index recovery after failures
+
+---
+
+## Usage Examples
+
+### Example 1: Basic Index Creation and Search
+
+```bash
+# Create index
+curl -k -X POST "https://127.0.0.1:6071/mcp/query" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "fts_index_table",
+ "arguments": {
+ "schema": "sales",
+ "table": "orders",
+ "columns": ["order_id", "customer_name", "notes"],
+ "primary_key": "order_id"
+ }
+ },
+ "id": 1
+ }'
+
+# Search
+curl -k -X POST "https://127.0.0.1:6071/mcp/query" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "fts_search",
+ "arguments": {
+ "query": "urgent",
+ "schema": "sales",
+ "table": "orders",
+ "limit": 10
+ }
+ },
+ "id": 2
+ }'
+```
+
+### Example 2: AI Agent Workflow
+
+```python
+# AI Agent using FTS for efficient data discovery
+
+# 1. Fast FTS search to find relevant orders
+fts_results = mcp_tool("fts_search", {
+ "query": "urgent customer complaint",
+ "limit": 10
+})
+
+# 2. Extract primary keys from FTS results
+order_ids = [r["primary_key_value"] for r in fts_results["results"]]
+
+# 3. Targeted MySQL query for full data
+full_orders = mcp_tool("run_sql_readonly", {
+ "sql": f"SELECT * FROM sales.orders WHERE order_id IN ({','.join(order_ids)})"
+})
+
+# Result: Fast discovery without scanning millions of rows
+```
+
+### Example 3: Cross-Table Search
+
+```bash
+# Search across all indexed tables
+curl -k -X POST "https://127.0.0.1:6071/mcp/query" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "fts_search",
+ "arguments": {
+ "query": "payment issue",
+ "limit": 20
+ }
+ },
+ "id": 3
+ }'
+```
+
+### Example 4: Scheduled Index Refresh
+
+```bash
+# Daily cron job to refresh all indexes
+#!/bin/bash
+curl -k -X POST "https://127.0.0.1:6071/mcp/query" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "fts_rebuild_all",
+ "arguments": {}
+ },
+ "id": 1
+ }'
+```
+
+---
+
+## API Endpoints
+
+### Base URL
+```text
+https://:6071/mcp/query
+```
+
+### Authentication
+
+Authentication is optional. If `mcp_query_endpoint_auth` is empty, requests are allowed without a token. When set, use Bearer token auth:
+
+```bash
+curl -k -X POST "https://127.0.0.1:6071/mcp/query" \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{...}'
+```
+
+### JSON-RPC 2.0 Format
+
+All requests follow JSON-RPC 2.0 specification:
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "tools/call",
+ "params": {
+ "name": "",
+ "arguments": { ... }
+ },
+ "id": 1
+}
+```
+
+### Response Format
+
+**Success (MCP content wrapper):**
+```json
+{
+ "jsonrpc": "2.0",
+ "result": {
+ "content": [
+ {
+ "type": "text",
+ "text": "{\n \"success\": true,\n ...\n}"
+ }
+ ]
+ },
+ "id": 1
+}
+```
+
+**Error (MCP content wrapper):**
+```json
+{
+ "jsonrpc": "2.0",
+ "result": {
+ "content": [
+ {
+ "type": "text",
+ "text": "Error message"
+ }
+ ],
+ "isError": true
+ },
+ "id": 1
+}
+```
+
+---
+
+## Best Practices
+
+### 1. Index Strategy
+
+**DO:**
+- Index columns frequently searched together (e.g., title + content)
+- Use WHERE clauses to index subsets of data
+- Index text-heavy columns (VARCHAR, TEXT)
+- Keep indexes focused on searchable content
+
+**DON'T:**
+- Index all columns unnecessarily
+- Index purely numeric/ID columns (use standard indexes)
+- Include large BLOB/JSON columns unless needed
+
+### 2. Query Patterns
+
+**Effective Queries:**
+```json
+{"query": "urgent"} // Single term
+{"query": "\"customer complaint\""} // Exact phrase
+{"query": "urgent AND pending"} // Boolean AND
+{"query": "error OR issue"} // Boolean OR
+{"query": "cust*"} // Wildcard prefix
+```
+
+**Ineffective Queries:**
+```json
+{"query": ""} // Empty - will fail
+{"query": "a OR b OR c OR d"} // Too broad - slow
+{"query": "NOT relevant"} // NOT queries - limited support
+```
+
+### 3. Performance Tips
+
+1. **Batch Indexing**: Index large tables in batches (automatic in current implementation)
+2. **Regular Refreshes**: Set up scheduled reindex for frequently changing data
+3. **Monitor Index Size**: FTS indexes can grow to 10-50% of source data size
+4. **Use Limits**: Always use `limit` parameter to control result size
+5. **Targeted Queries**: Combine FTS with targeted MySQL queries using returned IDs
+
+### 4. Maintenance
+
+```sql
+-- Check index metadata
+SELECT * FROM fts_indexes ORDER BY indexed_at DESC;
+
+-- Monitor index count (via SQLite)
+SELECT COUNT(*) FROM fts_indexes;
+
+-- Rebuild all indexes (via MCP)
+-- See Example 4 above
+```
+
+---
+
+## Troubleshooting
+
+### Common Issues
+
+#### Issue: "FTS not initialized"
+
+**Cause**: FTS database path not configured or inaccessible
+
+**Solution**:
+```sql
+SET mcp-fts_path = '/var/lib/proxysql/mcp_fts.db';
+LOAD MCP VARIABLES TO RUNTIME;
+```
+
+#### Issue: "Index already exists"
+
+**Cause**: Attempting to create duplicate index
+
+**Solution**: Use `fts_reindex` to refresh existing index
+
+#### Issue: "No matches found"
+
+**Cause**:
+- Index doesn't exist
+- Query doesn't match indexed content
+- Case sensitivity (FTS5 is case-insensitive for ASCII)
+
+**Solution**:
+```bash
+# List indexes
+fts_list_indexes
+
+# Try simpler query
+fts_search {"query": "single_word"}
+
+# Check if index exists
+```
+
+#### Issue: Search returns unexpected results
+
+**Cause**: FTS5 tokenization and ranking behavior
+
+**Solution**:
+- Use quotes for exact phrases: `"exact phrase"`
+- Check indexed columns (search only indexed content)
+- Verify WHERE clause filter (if used during indexing)
+
+#### Issue: Slow indexing
+
+**Cause**: Large table, MySQL latency
+
+**Solution**:
+- Use WHERE clause to index subset
+- Index during off-peak hours
+- Consider incremental indexing (future feature)
+
+### Debugging
+
+Enable verbose logging:
+
+```bash
+# With test script
+./scripts/mcp/test_mcp_fts.sh -v
+
+# Check ProxySQL logs
+tail -f /var/log/proxysql.log | grep FTS
+```
+
+---
+
+## Detailed Test Script
+
+For a full end-to-end validation of the FTS stack (tools/list, indexing, search/snippet, list_indexes structure, empty query handling), run:
+
+```bash
+scripts/mcp/test_mcp_fts_detailed.sh
+```
+
+Optional cleanup of created indexes:
+
+```bash
+scripts/mcp/test_mcp_fts_detailed.sh --cleanup
+```
+
+---
+
+## Appendix
+
+### FTS5 Query Syntax Reference
+
+| Syntax | Example | Description |
+|--------|---------|-------------|
+| Term | `urgent` | Match word |
+| Phrase | `"urgent order"` | Match exact phrase |
+| AND | `urgent AND pending` | Both terms |
+| OR | `urgent OR critical` | Either term |
+| NOT | `urgent NOT pending` | Exclude term |
+| Prefix | `urg*` | Words starting with prefix |
+| Column | `content:urgent` | Search in specific column |
+
+### BM25 Ranking
+
+FTS5 uses BM25 ranking algorithm:
+- Rewards term frequency in documents
+- Penalizes common terms across corpus
+- Results ordered by relevance (lower score = more relevant)
+
+### Database Schema
+
+```sql
+-- Metadata table
+CREATE TABLE fts_indexes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ schema_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ columns TEXT NOT NULL,
+ primary_key TEXT NOT NULL,
+ where_clause TEXT,
+ row_count INTEGER DEFAULT 0,
+ indexed_at INTEGER DEFAULT (strftime('%s', 'now')),
+ UNIQUE(schema_name, table_name)
+);
+
+-- Per-index tables (created dynamically)
+CREATE TABLE fts_data__ (
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
+ schema_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ primary_key_value TEXT NOT NULL,
+ content TEXT NOT NULL,
+ metadata TEXT
+);
+
+CREATE VIRTUAL TABLE fts_search__ USING fts5(
+ content, metadata,
+ content='fts_data__',
+ content_rowid='rowid',
+ tokenize='porter unicode61'
+);
+```
+
+---
+
+## Version History
+
+| Version | Date | Changes |
+|---------|------|---------|
+| 0.1.0 | 2025-01 | Initial implementation |
+
+---
+
+## Support
+
+For issues, questions, or contributions:
+- GitHub: [ProxySQL/proxysql-vec](https://github.com/ProxySQL/proxysql-vec)
+- Documentation: `/doc/MCP/` directory
diff --git a/include/MCP_Thread.h b/include/MCP_Thread.h
index cf1eaef6d2..dca5900406 100644
--- a/include/MCP_Thread.h
+++ b/include/MCP_Thread.h
@@ -57,6 +57,7 @@ class MCP_Threads_Handler
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;
/**
diff --git a/include/MySQL_FTS.h b/include/MySQL_FTS.h
new file mode 100644
index 0000000000..82edebfb69
--- /dev/null
+++ b/include/MySQL_FTS.h
@@ -0,0 +1,204 @@
+#ifndef CLASS_MYSQL_FTS_H
+#define CLASS_MYSQL_FTS_H
+
+#include "sqlite3db.h"
+#include
+#include
+
+// Forward declaration
+class MySQL_Tool_Handler;
+
+/**
+ * @brief MySQL Full Text Search (FTS) for Fast Data Discovery
+ *
+ * This class manages a dedicated SQLite database that provides:
+ * - Full-text search indexes for MySQL tables
+ * - Fast data discovery before querying the actual MySQL database
+ * - Cross-table search capabilities
+ * - BM25 ranking with FTS5
+ *
+ * The FTS system serves as a fast local cache for AI agents to quickly
+ * find relevant data before making targeted queries to MySQL backend.
+ */
+class MySQL_FTS {
+private:
+ SQLite3DB* db;
+ std::string db_path;
+
+ /**
+ * @brief Initialize FTS schema
+ * @return 0 on success, -1 on error
+ */
+ int init_schema();
+
+ /**
+ * @brief Create FTS metadata tables
+ * @return 0 on success, -1 on error
+ */
+ int create_tables();
+
+ /**
+ * @brief Create per-index tables (data and FTS5 virtual table)
+ * @param schema Schema name
+ * @param table Table name
+ * @return 0 on success, -1 on error
+ */
+ int create_index_tables(const std::string& schema, const std::string& table);
+
+ /**
+ * @brief Get sanitized data table name for a schema.table
+ * @param schema Schema name
+ * @param table Table name
+ * @return Sanitized table name
+ */
+ std::string get_data_table_name(const std::string& schema, const std::string& table);
+
+ /**
+ * @brief Get FTS search table name for a schema.table
+ * @param schema Schema name
+ * @param table Table name
+ * @return Sanitized FTS table name
+ */
+ std::string get_fts_table_name(const std::string& schema, const std::string& table);
+
+ /**
+ * @brief Sanitize a name for use as SQLite table name
+ * @param name Name to sanitize
+ * @return Sanitized name
+ */
+ std::string sanitize_name(const std::string& name);
+
+ /**
+ * @brief Escape single quotes for SQL
+ * @param str String to escape
+ * @return Escaped string
+ */
+ std::string escape_sql(const std::string& str);
+
+ /**
+ * @brief Escape identifier for SQLite (double backticks)
+ * @param identifier Identifier to escape
+ * @return Escaped identifier
+ */
+ std::string escape_identifier(const std::string& identifier);
+
+public:
+ /**
+ * @brief Constructor
+ * @param path Path to the FTS database file
+ */
+ MySQL_FTS(const std::string& path);
+
+ // Prevent copy and move (class owns raw pointer)
+ MySQL_FTS(const MySQL_FTS&) = delete;
+ MySQL_FTS& operator=(const MySQL_FTS&) = delete;
+ MySQL_FTS(MySQL_FTS&&) = delete;
+ MySQL_FTS& operator=(MySQL_FTS&&) = delete;
+
+ /**
+ * @brief Destructor
+ */
+ ~MySQL_FTS();
+
+ /**
+ * @brief Initialize the FTS database
+ * @return 0 on success, -1 on error
+ */
+ int init();
+
+ /**
+ * @brief Close the FTS database
+ */
+ void close();
+
+ /**
+ * @brief Check if an index exists for a schema.table
+ * @param schema Schema name
+ * @param table Table name
+ * @return true if exists, false otherwise
+ */
+ bool index_exists(const std::string& schema, const std::string& table);
+
+ /**
+ * @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
+ * @param mysql_handler Pointer to MySQL_Tool_Handler for executing queries
+ * @return JSON result with success status and metadata
+ */
+ std::string index_table(
+ const std::string& schema,
+ const std::string& table,
+ const std::string& columns,
+ const std::string& primary_key,
+ const std::string& where_clause,
+ MySQL_Tool_Handler* mysql_handler
+ );
+
+ /**
+ * @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 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 list_indexes();
+
+ /**
+ * @brief Remove an FTS index
+ *
+ * @param schema Schema name
+ * @param table Table name
+ * @return JSON result
+ */
+ std::string 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
+ * @param mysql_handler Pointer to MySQL_Tool_Handler for executing queries
+ * @return JSON result
+ */
+ std::string reindex(
+ const std::string& schema,
+ const std::string& table,
+ MySQL_Tool_Handler* mysql_handler
+ );
+
+ /**
+ * @brief Rebuild ALL FTS indexes with fresh data
+ *
+ * @param mysql_handler Pointer to MySQL_Tool_Handler for executing queries
+ * @return JSON result with summary
+ */
+ std::string rebuild_all(MySQL_Tool_Handler* mysql_handler);
+
+ /**
+ * @brief Get database handle for direct access
+ * @return SQLite3DB pointer
+ */
+ SQLite3DB* get_db() { return db; }
+};
+
+#endif /* CLASS_MYSQL_FTS_H */
diff --git a/include/MySQL_Tool_Handler.h b/include/MySQL_Tool_Handler.h
index fa42b91a50..bb2e010f9f 100644
--- a/include/MySQL_Tool_Handler.h
+++ b/include/MySQL_Tool_Handler.h
@@ -2,6 +2,7 @@
#define CLASS_MYSQL_TOOL_HANDLER_H
#include "MySQL_Catalog.h"
+#include "MySQL_FTS.h"
#include "cpp.h"
#include
#include
@@ -51,6 +52,10 @@ class MySQL_Tool_Handler {
// 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 @@ class MySQL_Tool_Handler {
*/
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 @@ class MySQL_Tool_Handler {
* @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 @@ class 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 = ""
);
+ /**
+ * @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 @@ class MySQL_Tool_Handler {
*/
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 @@ class MySQL_Tool_Handler {
* @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 */
diff --git a/lib/MCP_Thread.cpp b/lib/MCP_Thread.cpp
index 5a61f23851..d47feebad4 100644
--- a/lib/MCP_Thread.cpp
+++ b/lib/MCP_Thread.cpp
@@ -31,6 +31,7 @@ static const char* mcp_thread_variables_names[] = {
"mysql_password",
"mysql_schema",
"catalog_path",
+ "fts_path",
NULL
};
@@ -57,6 +58,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;
@@ -97,6 +99,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;
@@ -226,6 +230,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;
}
@@ -339,6 +347,31 @@ 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")) {
+ // Save old value to restore on failure
+ char* old_fts_path = variables.mcp_fts_path;
+ char* new_fts_path = strdup(value);
+ if (!new_fts_path) {
+ proxy_error("Failed to allocate memory for fts_path\n");
+ return -1;
+ }
+
+ // 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");
+ free(new_fts_path);
+ variables.mcp_fts_path = old_fts_path;
+ return -1;
+ }
+ }
+
+ // Success: commit the new path and free the old one
+ variables.mcp_fts_path = new_fts_path;
+ if (old_fts_path) free(old_fts_path);
+ return 0;
+ }
return -1;
}
diff --git a/lib/Makefile b/lib/Makefile
index 3e3283d0aa..3328f3e6a1 100644
--- a/lib/Makefile
+++ b/lib/Makefile
@@ -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
diff --git a/lib/MySQL_FTS.cpp b/lib/MySQL_FTS.cpp
new file mode 100644
index 0000000000..3a7eb58d34
--- /dev/null
+++ b/lib/MySQL_FTS.cpp
@@ -0,0 +1,842 @@
+#include "MySQL_FTS.h"
+#include "MySQL_Tool_Handler.h"
+#include "cpp.h"
+#include "proxysql.h"
+#include
+#include
+#include
+#include
+
+// JSON library
+#include "../deps/json/json.hpp"
+using json = nlohmann::json;
+#define PROXYJSON
+
+MySQL_FTS::MySQL_FTS(const std::string& path)
+ : db(NULL), db_path(path)
+{
+}
+
+MySQL_FTS::~MySQL_FTS() {
+ close();
+}
+
+int MySQL_FTS::init() {
+ // Initialize database connection
+ db = new SQLite3DB();
+ std::vector path_buf(db_path.size() + 1);
+ strcpy(path_buf.data(), db_path.c_str());
+ int rc = db->open(path_buf.data(), SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
+ if (rc != SQLITE_OK) {
+ proxy_error("Failed to open FTS database at %s: %d\n", db_path.c_str(), rc);
+ delete db;
+ db = NULL;
+ return -1;
+ }
+
+ // Initialize schema
+ return init_schema();
+}
+
+void MySQL_FTS::close() {
+ if (db) {
+ delete db;
+ db = NULL;
+ }
+}
+
+int MySQL_FTS::init_schema() {
+ // Enable foreign keys and optimize
+ db->execute("PRAGMA foreign_keys = ON");
+ db->execute("PRAGMA journal_mode = WAL");
+ db->execute("PRAGMA synchronous = NORMAL");
+
+ // Create tables
+ int rc = create_tables();
+ if (rc) {
+ proxy_error("Failed to create FTS tables\n");
+ return -1;
+ }
+
+ proxy_info("MySQL FTS database initialized at %s\n", db_path.c_str());
+ return 0;
+}
+
+int MySQL_FTS::create_tables() {
+ // Main metadata table for indexes
+ const char* create_indexes_table =
+ "CREATE TABLE IF NOT EXISTS fts_indexes ("
+ " id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ " schema_name TEXT NOT NULL,"
+ " table_name TEXT NOT NULL,"
+ " columns TEXT NOT NULL," // JSON array of column names
+ " primary_key TEXT NOT NULL,"
+ " where_clause TEXT,"
+ " row_count INTEGER DEFAULT 0,"
+ " indexed_at INTEGER DEFAULT (strftime('%s', 'now')),"
+ " UNIQUE(schema_name, table_name)"
+ ");";
+
+ if (!db->execute(create_indexes_table)) {
+ proxy_error("Failed to create fts_indexes table\n");
+ return -1;
+ }
+
+ // Indexes for faster lookups
+ db->execute("CREATE INDEX IF NOT EXISTS idx_fts_indexes_schema ON fts_indexes(schema_name)");
+ db->execute("CREATE INDEX IF NOT EXISTS idx_fts_indexes_table ON fts_indexes(table_name)");
+
+ return 0;
+}
+
+std::string MySQL_FTS::sanitize_name(const std::string& name) {
+ const size_t MAX_NAME_LEN = 100;
+ std::string sanitized;
+ // Allowlist: only ASCII letters, digits, underscore
+ for (char c : name) {
+ if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') || c == '_') {
+ sanitized.push_back(c);
+ }
+ }
+
+ // Return fallback with unique suffix if empty or would be too short
+ if (sanitized.empty()) {
+ // Create unique suffix from hash of original name
+ std::hash hasher;
+ size_t hash_value = hasher(name);
+ char hash_suffix[16];
+ snprintf(hash_suffix, sizeof(hash_suffix), "%08zx", hash_value & 0xFFFFFFFF);
+ sanitized = "_unnamed_";
+ sanitized += hash_suffix;
+ }
+
+ // Prevent leading digit (SQLite identifiers can't start with digit)
+ if (sanitized[0] >= '0' && sanitized[0] <= '9') {
+ sanitized.insert(sanitized.begin(), '_');
+ }
+ // Enforce maximum length
+ if (sanitized.length() > MAX_NAME_LEN) sanitized = sanitized.substr(0, MAX_NAME_LEN);
+ return sanitized;
+}
+
+std::string MySQL_FTS::escape_identifier(const std::string& identifier) {
+ std::string escaped;
+ escaped.reserve(identifier.length() * 2 + 2);
+ escaped.push_back('`');
+ for (char c : identifier) {
+ escaped.push_back(c);
+ if (c == '`') escaped.push_back('`'); // Double backticks
+ }
+ escaped.push_back('`');
+ return escaped;
+}
+
+// Helper for escaping MySQL identifiers (double backticks)
+static std::string escape_mysql_identifier(const std::string& id) {
+ std::string escaped;
+ escaped.reserve(id.length() * 2 + 2);
+ escaped.push_back('`');
+ for (char c : id) {
+ escaped.push_back(c);
+ if (c == '`') escaped.push_back('`');
+ }
+ escaped.push_back('`');
+ return escaped;
+}
+
+std::string MySQL_FTS::escape_sql(const std::string& str) {
+ std::string escaped;
+ for (size_t i = 0; i < str.length(); i++) {
+ if (str[i] == '\'') {
+ escaped += "''";
+ } else {
+ escaped += str[i];
+ }
+ }
+ return escaped;
+}
+
+std::string MySQL_FTS::get_data_table_name(const std::string& schema, const std::string& table) {
+ return "fts_data_" + sanitize_name(schema) + "_" + sanitize_name(table);
+}
+
+std::string MySQL_FTS::get_fts_table_name(const std::string& schema, const std::string& table) {
+ return "fts_search_" + sanitize_name(schema) + "_" + sanitize_name(table);
+}
+
+bool MySQL_FTS::index_exists(const std::string& schema, const std::string& table) {
+ sqlite3_stmt* stmt = NULL;
+
+ const char* check_sql =
+ "SELECT COUNT(*) FROM fts_indexes "
+ "WHERE schema_name = ?1 AND table_name = ?2";
+
+ int rc = db->prepare_v2(check_sql, &stmt);
+ if (rc != SQLITE_OK) {
+ proxy_error("Failed to prepare index check: %d\n", rc);
+ return false;
+ }
+
+ (*proxy_sqlite3_bind_text)(stmt, 1, schema.c_str(), -1, SQLITE_TRANSIENT);
+ (*proxy_sqlite3_bind_text)(stmt, 2, table.c_str(), -1, SQLITE_TRANSIENT);
+
+ rc = (*proxy_sqlite3_step)(stmt);
+ bool exists = false;
+ if (rc == SQLITE_ROW) {
+ int count = (*proxy_sqlite3_column_int)(stmt, 0);
+ exists = (count > 0);
+ }
+
+ (*proxy_sqlite3_finalize)(stmt);
+ return exists;
+}
+
+int MySQL_FTS::create_index_tables(const std::string& schema, const std::string& table) {
+ std::string data_table = get_data_table_name(schema, table);
+ std::string fts_table = get_fts_table_name(schema, table);
+ std::string escaped_data = escape_identifier(data_table);
+ std::string escaped_fts = escape_identifier(fts_table);
+
+ // Create data table
+ std::ostringstream create_data_sql;
+ create_data_sql << "CREATE TABLE IF NOT EXISTS " << escaped_data << " ("
+ " rowid INTEGER PRIMARY KEY AUTOINCREMENT,"
+ " schema_name TEXT NOT NULL,"
+ " table_name TEXT NOT NULL,"
+ " primary_key_value TEXT NOT NULL,"
+ " content TEXT NOT NULL,"
+ " metadata TEXT"
+ ");";
+
+ if (!db->execute(create_data_sql.str().c_str())) {
+ proxy_error("Failed to create data table %s\n", data_table.c_str());
+ return -1;
+ }
+
+ // Create FTS5 virtual table with external content
+ std::ostringstream create_fts_sql;
+ create_fts_sql << "CREATE VIRTUAL TABLE IF NOT EXISTS " << escaped_fts << " USING fts5("
+ " content, metadata,"
+ " content=" << escaped_data << ","
+ " content_rowid='rowid',"
+ " tokenize='porter unicode61'"
+ ");";
+
+ if (!db->execute(create_fts_sql.str().c_str())) {
+ proxy_error("Failed to create FTS table %s\n", fts_table.c_str());
+ return -1;
+ }
+
+ // Create triggers for automatic sync (populate the FTS table)
+ std::string base_name = sanitize_name(schema) + "_" + sanitize_name(table);
+ std::string escaped_base = escape_identifier(base_name);
+
+ // Drop existing triggers if any
+ db->execute(("DROP TRIGGER IF EXISTS " + escape_identifier("fts_ai_" + base_name)).c_str());
+ db->execute(("DROP TRIGGER IF EXISTS " + escape_identifier("fts_ad_" + base_name)).c_str());
+ db->execute(("DROP TRIGGER IF EXISTS " + escape_identifier("fts_au_" + base_name)).c_str());
+
+ // AFTER INSERT trigger
+ std::ostringstream ai_sql;
+ ai_sql << "CREATE TRIGGER IF NOT EXISTS " << escape_identifier("fts_ai_" + base_name)
+ << " AFTER INSERT ON " << escaped_data << " BEGIN"
+ << " INSERT INTO " << escaped_fts << "(rowid, content, metadata)"
+ << " VALUES (new.rowid, new.content, new.metadata);"
+ << "END;";
+ db->execute(ai_sql.str().c_str());
+
+ // AFTER DELETE trigger
+ std::ostringstream ad_sql;
+ ad_sql << "CREATE TRIGGER IF NOT EXISTS " << escape_identifier("fts_ad_" + base_name)
+ << " AFTER DELETE ON " << escaped_data << " BEGIN"
+ << " INSERT INTO " << escaped_fts << "(" << escaped_fts << ", rowid, content, metadata)"
+ << " VALUES ('delete', old.rowid, old.content, old.metadata);"
+ << "END;";
+ db->execute(ad_sql.str().c_str());
+
+ // AFTER UPDATE trigger
+ std::ostringstream au_sql;
+ au_sql << "CREATE TRIGGER IF NOT EXISTS " << escape_identifier("fts_au_" + base_name)
+ << " AFTER UPDATE ON " << escaped_data << " BEGIN"
+ << " INSERT INTO " << escaped_fts << "(" << escaped_fts << ", rowid, content, metadata)"
+ << " VALUES ('delete', old.rowid, old.content, old.metadata);"
+ << " INSERT INTO " << escaped_fts << "(rowid, content, metadata)"
+ << " VALUES (new.rowid, new.content, new.metadata);"
+ << "END;";
+ db->execute(au_sql.str().c_str());
+
+ return 0;
+}
+
+std::string MySQL_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,
+ MySQL_Tool_Handler* mysql_handler
+) {
+ json result;
+ result["success"] = false;
+
+ std::string primary_key_lower = primary_key;
+ std::transform(primary_key_lower.begin(), primary_key_lower.end(), primary_key_lower.begin(), ::tolower);
+
+ // Validate parameters
+ if (schema.empty() || table.empty() || columns.empty() || primary_key.empty()) {
+ result["error"] = "Missing required parameters: schema, table, columns, primary_key";
+ return result.dump();
+ }
+
+ if (!mysql_handler) {
+ result["error"] = "MySQL handler not provided";
+ return result.dump();
+ }
+
+ // Parse columns JSON
+ try {
+ json cols_json = json::parse(columns);
+ if (!cols_json.is_array()) {
+ result["error"] = "columns must be a JSON array";
+ return result.dump();
+ }
+ } catch (const json::exception& e) {
+ result["error"] = std::string("Invalid JSON in columns: ") + e.what();
+ return result.dump();
+ }
+
+ // Check if index already exists
+ if (index_exists(schema, table)) {
+ result["error"] = "Index already exists for " + schema + "." + table + ". Use fts_reindex to update.";
+ return result.dump();
+ }
+
+ // Create index tables
+ if (create_index_tables(schema, table) != 0) {
+ result["error"] = "Failed to create index tables";
+ return result.dump();
+ }
+
+ // Parse columns and build query (ensure primary key is selected)
+ std::vector indexed_cols;
+ std::vector selected_cols;
+ std::unordered_set seen;
+
+ try {
+ json cols_json = json::parse(columns);
+ if (!cols_json.is_array()) {
+ result["error"] = "columns must be a JSON array";
+ return result.dump();
+ }
+ for (const auto& col : cols_json) {
+ std::string col_name = col.get();
+ std::string col_lower = col_name;
+ std::transform(col_lower.begin(), col_lower.end(), col_lower.begin(), ::tolower);
+ indexed_cols.push_back(col_lower);
+ if (seen.insert(col_lower).second) {
+ selected_cols.push_back(col_name);
+ }
+ }
+ } catch (const json::exception& e) {
+ result["error"] = std::string("Failed to parse columns: ") + e.what();
+ return result.dump();
+ }
+
+ if (seen.find(primary_key_lower) == seen.end()) {
+ selected_cols.push_back(primary_key);
+ seen.insert(primary_key_lower);
+ }
+
+ // Build MySQL query to fetch data
+ std::ostringstream mysql_query;
+ mysql_query << "SELECT ";
+ for (size_t i = 0; i < selected_cols.size(); i++) {
+ if (i > 0) mysql_query << ", ";
+ mysql_query << escape_mysql_identifier(selected_cols[i]);
+ }
+
+ mysql_query << " FROM " << escape_mysql_identifier(schema) << "." << escape_mysql_identifier(table);
+
+ // Validate where_clause to prevent SQL injection
+ if (!where_clause.empty()) {
+ // Basic sanity check - reject obviously dangerous patterns
+ std::string upper_where = where_clause;
+ std::transform(upper_where.begin(), upper_where.end(), upper_where.begin(), ::toupper);
+ if (upper_where.find("INTO OUTFILE") != std::string::npos ||
+ upper_where.find("LOAD_FILE") != std::string::npos ||
+ upper_where.find("DROP TABLE") != std::string::npos ||
+ upper_where.find("DROP DATABASE") != std::string::npos ||
+ upper_where.find("TRUNCATE") != std::string::npos ||
+ upper_where.find("DELETE FROM") != std::string::npos ||
+ upper_where.find("INSERT INTO") != std::string::npos ||
+ upper_where.find("UPDATE ") != std::string::npos) {
+ result["error"] = "Dangerous pattern in where_clause - not allowed for security";
+ return result.dump();
+ }
+ mysql_query << " WHERE " << where_clause;
+ }
+
+ proxy_info("FTS indexing: %s.%s with query: %s\n", schema.c_str(), table.c_str(), mysql_query.str().c_str());
+
+ // Execute MySQL query
+ std::string query_result = mysql_handler->execute_query(mysql_query.str());
+ json query_json = json::parse(query_result);
+
+ if (!query_json["success"].get()) {
+ result["error"] = "MySQL query failed: " + query_json["error"].get();
+ return result.dump();
+ }
+
+ // Get data table name
+ std::string data_table = get_data_table_name(schema, table);
+ std::string escaped_data = escape_identifier(data_table);
+
+ // Insert data in batches
+ int row_count = 0;
+ int batch_size = 100;
+
+ db->wrlock();
+
+ try {
+ const json& rows = query_json["rows"];
+ const json& cols_array = query_json["columns"];
+ std::vector col_names;
+ for (const auto& c : cols_array) {
+ std::string c_name = c.get();
+ std::transform(c_name.begin(), c_name.end(), c_name.begin(), ::tolower);
+ col_names.push_back(c_name);
+ }
+
+ for (const auto& row : rows) {
+ // Build content by concatenating column values
+ std::ostringstream content;
+ json metadata = json::object();
+
+ for (size_t i = 0; i < col_names.size(); i++) {
+ std::string col_name = col_names[i];
+ if (row.contains(col_name) && !row[col_name].is_null()) {
+ std::string val = row[col_name].get();
+ metadata[col_name] = val;
+ if (std::find(indexed_cols.begin(), indexed_cols.end(), col_name) != indexed_cols.end()) {
+ content << val << " ";
+ }
+ }
+ }
+
+ // Get primary key value
+ std::string pk_value = "";
+ if (row.contains(primary_key_lower) && !row[primary_key_lower].is_null()) {
+ pk_value = row[primary_key_lower].get();
+ } else {
+ pk_value = std::to_string(row_count);
+ }
+
+ // Insert into data table (triggers will sync to FTS)
+ std::ostringstream insert_sql;
+ insert_sql << "INSERT INTO " << escaped_data
+ << " (schema_name, table_name, primary_key_value, content, metadata) "
+ << "VALUES ('" << escape_sql(schema) << "', '"
+ << escape_sql(table) << "', '"
+ << escape_sql(pk_value) << "', '"
+ << escape_sql(content.str()) << "', '"
+ << escape_sql(metadata.dump()) << "');";
+
+ if (!db->execute(insert_sql.str().c_str())) {
+ proxy_error("Failed to insert row into FTS: %s\n", insert_sql.str().c_str());
+ }
+
+ row_count++;
+
+ // Commit batch
+ if (row_count % batch_size == 0) {
+ proxy_debug(PROXY_DEBUG_GENERIC, 3, "FTS: Indexed %d rows so far\n", row_count);
+ }
+ }
+
+ // Update metadata
+ std::ostringstream metadata_sql;
+ metadata_sql << "INSERT INTO fts_indexes "
+ "(schema_name, table_name, columns, primary_key, where_clause, row_count, indexed_at) "
+ "VALUES ('" << escape_sql(schema) << "', '"
+ << escape_sql(table) << "', '"
+ << escape_sql(columns) << "', '"
+ << escape_sql(primary_key) << "', '"
+ << escape_sql(where_clause) << "', "
+ << row_count << ", strftime('%s', 'now'));";
+
+ db->execute(metadata_sql.str().c_str());
+
+ db->wrunlock();
+
+ result["success"] = true;
+ result["schema"] = schema;
+ result["table"] = table;
+ result["row_count"] = row_count;
+ result["indexed_at"] = (int)time(NULL);
+
+ proxy_info("FTS index created for %s.%s: %d rows indexed\n", schema.c_str(), table.c_str(), row_count);
+
+ } catch (const std::exception& e) {
+ db->wrunlock();
+ result["error"] = std::string("Exception during indexing: ") + e.what();
+ proxy_error("FTS indexing exception: %s\n", e.what());
+ }
+
+ return result.dump();
+}
+
+std::string MySQL_FTS::search(
+ const std::string& query,
+ const std::string& schema,
+ const std::string& table,
+ int limit,
+ int offset
+) {
+ json result;
+ result["success"] = false;
+
+ if (query.empty()) {
+ result["error"] = "Search query cannot be empty";
+ return result.dump();
+ }
+
+ // Get list of indexes to search
+ std::string index_filter = "";
+ if (!schema.empty() || !table.empty()) {
+ index_filter = " WHERE 1=1";
+ if (!schema.empty()) {
+ index_filter += " AND schema_name = '" + escape_sql(schema) + "'";
+ }
+ if (!table.empty()) {
+ index_filter += " AND table_name = '" + escape_sql(table) + "'";
+ }
+ }
+
+ std::ostringstream indexes_sql;
+ indexes_sql << "SELECT schema_name, table_name FROM fts_indexes" << index_filter;
+
+ char* error = NULL;
+ int cols = 0, affected = 0;
+ SQLite3_result* indexes_result = NULL;
+
+ db->rdlock();
+ indexes_result = db->execute_statement(indexes_sql.str().c_str(), &error, &cols, &affected);
+
+ if (!indexes_result || indexes_result->rows.empty()) {
+ db->rdunlock();
+ if (indexes_result) delete indexes_result;
+ result["success"] = true;
+ result["query"] = query;
+ result["total_matches"] = 0;
+ result["results"] = json::array();
+ return result.dump();
+ }
+
+ // Collect all results from each index
+ json all_results = json::array();
+ int total_matches = 0;
+
+ for (std::vector::iterator it = indexes_result->rows.begin();
+ it != indexes_result->rows.end(); ++it) {
+ SQLite3_row* row = *it;
+ const char* idx_schema = row->fields[0];
+ const char* idx_table = row->fields[1];
+
+ if (!idx_schema || !idx_table) continue;
+
+ std::string data_table = get_data_table_name(idx_schema, idx_table);
+ std::string fts_table = get_fts_table_name(idx_schema, idx_table);
+ std::string escaped_data = escape_identifier(data_table);
+ std::string escaped_fts = escape_identifier(fts_table);
+
+ // Escape query for FTS5 MATCH clause (wrap in double quotes, escape embedded quotes)
+ std::string fts_literal = "\"";
+ for (char c : query) {
+ fts_literal.push_back(c);
+ if (c == '"') fts_literal.push_back('"'); // Double quotes
+ }
+ fts_literal.push_back('"');
+
+ // Search query for this index (use table name for MATCH/bm25)
+ std::ostringstream search_sql;
+ search_sql << "SELECT d.schema_name, d.table_name, d.primary_key_value, "
+ << "snippet(" << escaped_fts << ", 0, '', '', '...', 30) AS snippet, "
+ << "d.metadata "
+ << "FROM " << escaped_fts << " "
+ << "JOIN " << escaped_data << " d ON " << escaped_fts << ".rowid = d.rowid "
+ << "WHERE " << escaped_fts << " MATCH " << fts_literal << " "
+ << "ORDER BY bm25(" << escaped_fts << ") ASC "
+ << "LIMIT " << limit;
+
+ SQLite3_result* idx_resultset = NULL;
+ error = NULL;
+ cols = 0;
+ affected = 0;
+
+ idx_resultset = db->execute_statement(search_sql.str().c_str(), &error, &cols, &affected);
+
+ if (error) {
+ proxy_error("FTS search error on %s.%s: %s\n", idx_schema, idx_table, error);
+ (*proxy_sqlite3_free)(error);
+ }
+
+ if (idx_resultset) {
+ for (std::vector::iterator row_it = idx_resultset->rows.begin();
+ row_it != idx_resultset->rows.end(); ++row_it) {
+ SQLite3_row* res_row = *row_it;
+
+ json match;
+ match["schema"] = res_row->fields[0] ? res_row->fields[0] : "";
+ match["table"] = res_row->fields[1] ? res_row->fields[1] : "";
+ match["primary_key_value"] = res_row->fields[2] ? res_row->fields[2] : "";
+
+ match["snippet"] = res_row->fields[3] ? res_row->fields[3] : "";
+
+ // Parse metadata JSON
+ try {
+ if (res_row->fields[4]) {
+ match["metadata"] = json::parse(res_row->fields[4]);
+ } else {
+ match["metadata"] = json::object();
+ }
+ } catch (const json::exception& e) {
+ match["metadata"] = res_row->fields[4] ? res_row->fields[4] : "";
+ }
+
+ all_results.push_back(match);
+ total_matches++;
+ }
+ delete idx_resultset;
+ }
+ }
+
+ delete indexes_result;
+ db->rdunlock();
+
+ // Apply pagination to collected results
+ int total_size = (int)all_results.size();
+ int start_idx = offset;
+ if (start_idx >= total_size) start_idx = total_size;
+ int end_idx = start_idx + limit;
+ if (end_idx > total_size) end_idx = total_size;
+
+ json paginated_results = json::array();
+ for (int i = start_idx; i < end_idx; i++) {
+ paginated_results.push_back(all_results[i]);
+ }
+
+ result["success"] = true;
+ result["query"] = query;
+ result["total_matches"] = total_matches;
+ result["results"] = paginated_results;
+
+ return result.dump();
+}
+
+std::string MySQL_FTS::list_indexes() {
+ json result;
+ result["success"] = false;
+
+ std::ostringstream sql;
+ sql << "SELECT schema_name, table_name, columns, primary_key, where_clause, row_count, indexed_at "
+ << "FROM fts_indexes ORDER BY schema_name, table_name";
+
+ db->rdlock();
+
+ char* error = NULL;
+ int cols = 0, affected = 0;
+ SQLite3_result* resultset = NULL;
+
+ resultset = db->execute_statement(sql.str().c_str(), &error, &cols, &affected);
+
+ db->rdunlock();
+
+ if (error) {
+ result["error"] = "Failed to list indexes: " + std::string(error);
+ (*proxy_sqlite3_free)(error);
+ return result.dump();
+ }
+
+ json indexes = json::array();
+
+ if (resultset) {
+ for (std::vector::iterator it = resultset->rows.begin();
+ it != resultset->rows.end(); ++it) {
+ SQLite3_row* row = *it;
+
+ json idx;
+ idx["schema"] = row->fields[0] ? row->fields[0] : "";
+ idx["table"] = row->fields[1] ? row->fields[1] : "";
+ if (row->fields[2]) {
+ try {
+ idx["columns"] = json::parse(row->fields[2]);
+ } catch (const json::exception&) {
+ idx["columns"] = row->fields[2];
+ }
+ } else {
+ idx["columns"] = json::array();
+ }
+ idx["primary_key"] = row->fields[3] ? row->fields[3] : "";
+ idx["where_clause"] = row->fields[4] ? row->fields[4] : "";
+ idx["row_count"] = row->fields[5] ? atoi(row->fields[5]) : 0;
+ idx["indexed_at"] = row->fields[6] ? atoi(row->fields[6]) : 0;
+
+ indexes.push_back(idx);
+ }
+ delete resultset;
+ }
+
+ result["success"] = true;
+ result["indexes"] = indexes;
+
+ return result.dump();
+}
+
+std::string MySQL_FTS::delete_index(const std::string& schema, const std::string& table) {
+ json result;
+ result["success"] = false;
+
+ if (!index_exists(schema, table)) {
+ result["error"] = "Index not found for " + schema + "." + table;
+ return result.dump();
+ }
+
+ std::string base_name = sanitize_name(schema) + "_" + sanitize_name(table);
+
+ db->wrlock();
+
+ // Drop triggers
+ db->execute(("DROP TRIGGER IF EXISTS " + escape_identifier("fts_ai_" + base_name)).c_str());
+ db->execute(("DROP TRIGGER IF EXISTS " + escape_identifier("fts_ad_" + base_name)).c_str());
+ db->execute(("DROP TRIGGER IF EXISTS " + escape_identifier("fts_au_" + base_name)).c_str());
+
+ // Drop FTS table
+ std::string fts_table = get_fts_table_name(schema, table);
+ db->execute(("DROP TABLE IF EXISTS " + escape_identifier(fts_table)).c_str());
+
+ // Drop data table
+ std::string data_table = get_data_table_name(schema, table);
+ db->execute(("DROP TABLE IF EXISTS " + escape_identifier(data_table)).c_str());
+
+ // Remove metadata
+ std::ostringstream metadata_sql;
+ metadata_sql << "DELETE FROM fts_indexes "
+ << "WHERE schema_name = '" << escape_sql(schema) << "' "
+ << "AND table_name = '" << escape_sql(table) << "'";
+
+ db->execute(metadata_sql.str().c_str());
+
+ db->wrunlock();
+
+ result["success"] = true;
+ result["schema"] = schema;
+ result["table"] = table;
+ result["message"] = "Index deleted successfully";
+
+ proxy_info("FTS index deleted for %s.%s\n", schema.c_str(), table.c_str());
+
+ return result.dump();
+}
+
+std::string MySQL_FTS::reindex(
+ const std::string& schema,
+ const std::string& table,
+ MySQL_Tool_Handler* mysql_handler
+) {
+ json result;
+ result["success"] = false;
+
+ if (!mysql_handler) {
+ result["error"] = "MySQL handler not provided";
+ return result.dump();
+ }
+
+ // Get existing index metadata
+ std::ostringstream metadata_sql;
+ metadata_sql << "SELECT columns, primary_key, where_clause FROM fts_indexes "
+ << "WHERE schema_name = '" << escape_sql(schema) << "' "
+ << "AND table_name = '" << escape_sql(table) << "'";
+
+ db->rdlock();
+
+ char* error = NULL;
+ int cols = 0, affected = 0;
+ SQLite3_result* resultset = NULL;
+
+ resultset = db->execute_statement(metadata_sql.str().c_str(), &error, &cols, &affected);
+
+ db->rdunlock();
+
+ if (error || !resultset || resultset->rows.empty()) {
+ result["error"] = "Index not found for " + schema + "." + table;
+ if (resultset) delete resultset;
+ return result.dump();
+ }
+
+ SQLite3_row* row = resultset->rows[0];
+ std::string columns = row->fields[0] ? row->fields[0] : "";
+ std::string primary_key = row->fields[1] ? row->fields[1] : "";
+ std::string where_clause = row->fields[2] ? row->fields[2] : "";
+
+ delete resultset;
+
+ // Delete existing index
+ delete_index(schema, table);
+
+ // Recreate index with stored metadata
+ return index_table(schema, table, columns, primary_key, where_clause, mysql_handler);
+}
+
+std::string MySQL_FTS::rebuild_all(MySQL_Tool_Handler* mysql_handler) {
+ json result;
+ result["success"] = false;
+
+ if (!mysql_handler) {
+ result["error"] = "MySQL handler not provided";
+ return result.dump();
+ }
+
+ // Get all indexes
+ std::string list_result = list_indexes();
+ json list_json = json::parse(list_result);
+
+ if (!list_json["success"].get()) {
+ result["error"] = "Failed to get index list";
+ return result.dump();
+ }
+
+ const json& indexes = list_json["indexes"];
+ int rebuilt_count = 0;
+ json failed = json::array();
+
+ for (const auto& idx : indexes) {
+ std::string schema = idx["schema"].get();
+ std::string table = idx["table"].get();
+
+ proxy_info("FTS: Rebuilding index for %s.%s\n", schema.c_str(), table.c_str());
+
+ std::string reindex_result = reindex(schema, table, mysql_handler);
+ json reindex_json = json::parse(reindex_result);
+
+ if (reindex_json["success"].get()) {
+ rebuilt_count++;
+ } else {
+ json failed_item;
+ failed_item["schema"] = schema;
+ failed_item["table"] = table;
+ failed_item["error"] = reindex_json.value("error", std::string("unknown error"));
+ failed.push_back(failed_item);
+ }
+ }
+
+ result["success"] = true;
+ result["rebuilt_count"] = rebuilt_count;
+ result["failed"] = failed;
+ result["total_indexes"] = (int)indexes.size();
+
+ proxy_info("FTS: Rebuild complete - %d succeeded, %d failed\n",
+ rebuilt_count, (int)failed.size());
+
+ return result.dump();
+}
diff --git a/lib/MySQL_Tool_Handler.cpp b/lib/MySQL_Tool_Handler.cpp
index 5c4354db88..585073536e 100644
--- a/lib/MySQL_Tool_Handler.cpp
+++ b/lib/MySQL_Tool_Handler.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
// MySQL client library
#include
@@ -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) {
+ MySQL_FTS* new_fts = NULL;
+
+ // Initialize new FTS outside lock (blocking I/O)
+ if (!path.empty()) {
+ new_fts = new MySQL_FTS(path);
+ if (new_fts->init()) {
+ proxy_error("Failed to initialize FTS with new path: %s\n", path.c_str());
+ delete new_fts;
+ return false;
+ }
+ }
+
+ // Swap pointer under lock (non-blocking)
+ pthread_mutex_lock(&fts_lock);
+ MySQL_FTS* old_fts = fts;
+ fts = new_fts;
+ pthread_mutex_unlock(&fts_lock);
+ if (old_fts) delete old_fts;
+
+ return true;
+}
+
/**
* @brief Close all MySQL connections and cleanup resources
*
@@ -254,13 +300,11 @@ void MySQL_Tool_Handler::return_connection(MYSQL* mysql) {
* - Failure: {"success":false, "error":"...", "sql_error":code}
*/
std::string MySQL_Tool_Handler::execute_query(const std::string& query) {
- fprintf(stderr, "DEBUG execute_query: Starting, query=%s\n", query.c_str());
json result;
result["success"] = false;
MYSQL* mysql = get_connection();
- fprintf(stderr, "DEBUG execute_query: Got connection\n");
if (!mysql) {
result["error"] = "No available database connection";
@@ -268,19 +312,15 @@ std::string MySQL_Tool_Handler::execute_query(const std::string& query) {
}
// Execute query
- fprintf(stderr, "DEBUG execute_query: About to call mysql_query\n");
if (mysql_query(mysql, query.c_str()) != 0) {
- fprintf(stderr, "DEBUG execute_query: mysql_query failed\n");
result["error"] = mysql_error(mysql);
result["sql_error"] = mysql_errno(mysql);
return_connection(mysql);
return result.dump();
}
- fprintf(stderr, "DEBUG execute_query: mysql_query succeeded\n");
// Store result
MYSQL_RES* res = mysql_store_result(mysql);
- fprintf(stderr, "DEBUG execute_query: Got result set\n");
if (!res) {
// No result set (e.g., INSERT, UPDATE, etc.)
@@ -294,11 +334,9 @@ std::string MySQL_Tool_Handler::execute_query(const std::string& query) {
json columns = json::array();
std::vector lowercase_columns;
MYSQL_FIELD* field;
- fprintf(stderr, "DEBUG execute_query: About to fetch fields\n");
int field_count = 0;
while ((field = mysql_fetch_field(res))) {
field_count++;
- fprintf(stderr, "DEBUG execute_query: Processing field %d, name=%p\n", field_count, (void*)field->name);
// Check if field name is null (can happen in edge cases)
// Use placeholder name to maintain column index alignment
std::string col_name = field->name ? field->name : "unknown_field";
@@ -307,7 +345,6 @@ std::string MySQL_Tool_Handler::execute_query(const std::string& query) {
columns.push_back(col_name);
lowercase_columns.push_back(col_name);
}
- fprintf(stderr, "DEBUG execute_query: Processed %d fields\n", field_count);
// Get rows
json rows = json::array();
@@ -352,7 +389,6 @@ std::string MySQL_Tool_Handler::sanitize_query(const std::string& query) {
bool MySQL_Tool_Handler::is_dangerous_query(const std::string& query) {
std::string upper = query;
std::transform(upper.begin(), upper.end(), upper.begin(), ::toupper);
- fprintf(stderr, "DEBUG is_dangerous_query: Checking query '%s'\n", upper.c_str());
// List of dangerous keywords
static const char* dangerous[] = {
@@ -364,13 +400,11 @@ bool MySQL_Tool_Handler::is_dangerous_query(const std::string& query) {
for (const char* word : dangerous) {
if (upper.find(word) != std::string::npos) {
- fprintf(stderr, "DEBUG is_dangerous_query: Found dangerous keyword '%s'\n", word);
proxy_debug(PROXY_DEBUG_GENERIC, 3, "Dangerous keyword found: %s\n", word);
return true;
}
}
- fprintf(stderr, "DEBUG is_dangerous_query: No dangerous keywords found\n");
return false;
}
@@ -444,10 +478,6 @@ std::string MySQL_Tool_Handler::list_tables(
int page_size,
const std::string& name_filter
) {
- fprintf(stderr, "DEBUG: list_tables called with schema='%s', page_token='%s', page_size=%d, name_filter='%s'\n",
- schema.c_str(), page_token.c_str(), page_size, name_filter.c_str());
- fprintf(stderr, "DEBUG: mysql_schema='%s'\n", mysql_schema.c_str());
-
// Build query to list tables with metadata
std::string sql =
"SELECT "
@@ -460,64 +490,44 @@ std::string MySQL_Tool_Handler::list_tables(
"FROM information_schema.tables t "
"WHERE t.table_schema = '" + (schema.empty() ? mysql_schema : schema) + "' ";
- fprintf(stderr, "DEBUG: Built WHERE clause\n");
-
if (!name_filter.empty()) {
sql += " AND t.table_name LIKE '%" + name_filter + "%'";
}
- fprintf(stderr, "DEBUG: Built name_filter clause\n");
sql += " ORDER BY t.table_name LIMIT " + std::to_string(page_size);
- fprintf(stderr, "DEBUG: Built SQL query: %s\n", sql.c_str());
proxy_debug(PROXY_DEBUG_GENERIC, 3, "list_tables query: %s\n", sql.c_str());
- fprintf(stderr, "DEBUG: About to call execute_query\n");
// Execute the query
std::string response = execute_query(sql);
- fprintf(stderr, "DEBUG: execute_query returned, response length=%zu\n", response.length());
// Debug: print raw response
proxy_debug(PROXY_DEBUG_GENERIC, 3, "list_tables raw response: %s\n", response.c_str());
- fprintf(stderr, "DEBUG: list_tables raw response: %s\n", response.c_str());
// Parse and format the response
json result;
try {
- fprintf(stderr, "DEBUG list_tables: About to parse response\n");
json query_result = json::parse(response);
- fprintf(stderr, "DEBUG list_tables: Parsed response successfully\n");
if (query_result["success"] == true) {
- fprintf(stderr, "DEBUG list_tables: Query successful, processing rows\n");
result = json::array();
for (const auto& row : query_result["rows"]) {
- fprintf(stderr, "DEBUG list_tables: Processing row\n");
json table_entry;
- fprintf(stderr, "DEBUG list_tables: About to access table_name\n");
table_entry["name"] = row["table_name"];
- fprintf(stderr, "DEBUG list_tables: About to access table_type\n");
table_entry["type"] = row["table_type"];
- fprintf(stderr, "DEBUG list_tables: About to access row_count\n");
table_entry["row_count"] = row["row_count"];
- fprintf(stderr, "DEBUG list_tables: About to access total_size\n");
table_entry["total_size"] = row["total_size"];
- fprintf(stderr, "DEBUG list_tables: About to access create_time\n");
table_entry["create_time"] = row["create_time"];
- fprintf(stderr, "DEBUG list_tables: About to access update_time (may be null)\n");
table_entry["update_time"] = row["update_time"];
- fprintf(stderr, "DEBUG list_tables: All fields accessed, pushing entry\n");
result.push_back(table_entry);
}
} else {
- fprintf(stderr, "DEBUG list_tables: Query failed, extracting error\n");
result["error"] = query_result["error"];
}
} catch (const std::exception& e) {
- fprintf(stderr, "DEBUG list_tables: Exception caught: %s\n", e.what());
result["error"] = std::string("Failed to parse query result: ") + e.what();
}
@@ -988,3 +998,151 @@ std::string MySQL_Tool_Handler::catalog_delete(const std::string& kind, const st
return result.dump();
}
+
+// ========== FTS Tools (Full Text Search) ==========
+// NOTE: The fts_lock is intentionally held during the entire FTS operation
+// to serialize all FTS operations for correctness. This prevents race conditions
+// where reset_fts_path() or reinit_fts() could delete the MySQL_FTS instance
+// while an operation is in progress, which would cause use-after-free.
+// If performance becomes an issue, consider reference counting instead.
+
+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 (outside lock)
+ 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 swap the pointer under lock
+ pthread_mutex_lock(&fts_lock);
+ MySQL_FTS* old_fts = fts;
+ fts = new_fts;
+ pthread_mutex_unlock(&fts_lock);
+ if (old_fts) delete old_fts;
+
+ proxy_info("MySQL_Tool_Handler: FTS reinitialized successfully at %s\n", fts_path.c_str());
+ return 0;
+}
diff --git a/lib/ProxySQL_MCP_Server.cpp b/lib/ProxySQL_MCP_Server.cpp
index 7e4579e5db..55c704f949 100644
--- a/lib/ProxySQL_MCP_Server.cpp
+++ b/lib/ProxySQL_MCP_Server.cpp
@@ -96,7 +96,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) {
diff --git a/lib/Query_Tool_Handler.cpp b/lib/Query_Tool_Handler.cpp
index d638b86fb4..f427a7ca6a 100644
--- a/lib/Query_Tool_Handler.cpp
+++ b/lib/Query_Tool_Handler.cpp
@@ -217,6 +217,62 @@ json Query_Tool_Handler::get_tool_list() {
{}
));
+ // FTS tools (Full Text Search)
+ {
+ // Custom schema for fts_index_table with columns as array
+ json schema = {
+ {"type", "object"},
+ {"properties", {
+ {"schema", {{"type", "string"}, {"description", "Schema name"}}},
+ {"table", {{"type", "string"}, {"description", "Table name"}}},
+ {"columns", {{"type", "array"}, {"items", {{"type", "string"}}}, {"description", "Columns to index"}}},
+ {"primary_key", {{"type", "string"}, {"description", "Primary key column"}}},
+ {"where_clause", {{"type", "string"}, {"description", "Optional WHERE clause"}}}
+ }},
+ {"required", {"schema", "table", "columns", "primary_key"}}
+ };
+ tools.push_back(create_tool_description(
+ "fts_index_table",
+ "Create and populate a full-text search index for a MySQL table",
+ schema
+ ));
+ }
+
+ 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;
@@ -235,24 +291,18 @@ json Query_Tool_Handler::get_tool_description(const std::string& tool_name) {
// Helper function to safely extract string value from JSON
// nlohmann::json value() handles missing keys, null values, and type conversion
static std::string get_json_string(const json& j, const std::string& key, const std::string& default_val = "") {
- fprintf(stderr, "DEBUG: get_json_string key=%s, default='%s'\n", key.c_str(), default_val.c_str());
if (j.contains(key)) {
const json& val = j[key];
- fprintf(stderr, "DEBUG: key exists, is_null=%d, is_string=%d\n", val.is_null(), val.is_string());
if (!val.is_null()) {
if (val.is_string()) {
std::string result = val.get();
- fprintf(stderr, "DEBUG: returning string: '%s'\n", result.c_str());
return result;
} else {
- fprintf(stderr, "DEBUG: value is not a string, trying dump\n");
std::string result = val.dump();
- fprintf(stderr, "DEBUG: returning dumped: '%s'\n", result.c_str());
return result;
}
}
}
- fprintf(stderr, "DEBUG: returning default: '%s'\n", default_val.c_str());
return default_val;
}
@@ -265,8 +315,6 @@ static int get_json_int(const json& j, const std::string& key, int default_val =
}
json Query_Tool_Handler::execute_tool(const std::string& tool_name, const json& arguments) {
- fprintf(stderr, "DEBUG: execute_tool tool_name=%s, arguments=%s\n", tool_name.c_str(), arguments.dump().c_str());
-
if (!mysql_handler) {
return create_error_response("MySQL handler not initialized");
}
@@ -396,6 +444,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);
}
diff --git a/scripts/mcp/test_mcp_fts.sh b/scripts/mcp/test_mcp_fts.sh
new file mode 100755
index 0000000000..522a254543
--- /dev/null
+++ b/scripts/mcp/test_mcp_fts.sh
@@ -0,0 +1,1322 @@
+#!/bin/bash
+#
+# test_mcp_fts.sh - Comprehensive test script for MCP FTS (Full Text Search) tools
+#
+# This script tests all 6 FTS tools via the MCP /mcp/query endpoint:
+# - fts_index_table : Create and populate an FTS index for a MySQL table
+# - fts_search : Search indexed data using FTS5 with BM25 ranking
+# - fts_list_indexes : List all FTS indexes with metadata
+# - fts_delete_index : Remove an FTS index
+# - fts_reindex : Refresh an index with fresh data (full rebuild)
+# - fts_rebuild_all : Rebuild ALL FTS indexes with fresh data
+#
+# Usage:
+# ./test_mcp_fts.sh [options]
+#
+# Options:
+# -v, --verbose Show verbose output (curl requests/responses)
+# -q, --quiet Suppress progress messages
+# --skip-cleanup Don't delete test data/indexes after testing
+# --test-schema SCHEMA Schema to use for testing (default: test_fts)
+# --test-table TABLE Table to use for testing (default: test_documents)
+# -h, --help Show help
+#
+# Environment Variables:
+# MCP_HOST MCP server host (default: 127.0.0.1)
+# MCP_PORT MCP server port (default: 6071)
+# MYSQL_HOST MySQL backend host (default: 127.0.0.1)
+# MYSQL_PORT MySQL backend port (default: 6033)
+# MYSQL_USER MySQL user (default: root)
+# MYSQL_PASSWORD MySQL password (default: root)
+#
+# Prerequisites:
+# - ProxySQL with MCP module enabled
+# - MySQL backend accessible
+# - curl, jq (optional but recommended)
+#
+
+set -e
+
+# ============================================================================
+# CONFIGURATION
+# ============================================================================
+
+# MCP Server Configuration
+MCP_HOST="${MCP_HOST:-127.0.0.1}"
+MCP_PORT="${MCP_PORT:-6071}"
+MCP_ENDPOINT="http://${MCP_HOST}:${MCP_PORT}/mcp/query"
+
+# MySQL Backend Configuration (for setup/teardown)
+MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
+MYSQL_PORT="${MYSQL_PORT:-6033}"
+MYSQL_USER="${MYSQL_USER:-root}"
+MYSQL_PASSWORD="${MYSQL_PASSWORD:-root}"
+
+# Test Configuration
+TEST_SCHEMA="${TEST_SCHEMA:-test_fts}"
+TEST_TABLE="${TEST_TABLE:-test_documents}"
+
+# Test Data
+TEST_DOCUMENTS=(
+ ["1"]="Customer John Smith reported urgent issue with order #12345. Status: pending. Priority: high."
+ ["2"]="Machine learning model training completed successfully. Accuracy: 95%. Dataset size: 1M records."
+ ["3"]="Database migration from MySQL to PostgreSQL failed due to foreign key constraints. Error code: FK001."
+ ["4"]="Urgent: Payment gateway timeout during Black Friday sale. Transactions affected: 1500."
+ ["5"]="AI-powered recommendation engine shows 40% improvement in click-through rates after optimization."
+ ["6"]="Security alert: Multiple failed login attempts detected from IP 192.168.1.100. Account locked."
+ ["7"]="Quarterly financial report shows revenue increase of 25% compared to previous year."
+ ["8"]="Customer feedback: Excellent product quality but delivery was delayed by 3 days."
+ ["9"]="System crash occurred at 2:30 AM UTC. Root cause: Out of memory error in cache service."
+ ["10"]="New feature request: Add dark mode support for mobile applications. Priority: medium."
+)
+
+# Test Options
+VERBOSE=false
+QUIET=false
+SKIP_CLEANUP=false
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+MAGENTA='\033[0;35m'
+NC='\033[0m'
+
+# Statistics
+TOTAL_TESTS=0
+PASSED_TESTS=0
+FAILED_TESTS=0
+SKIPPED_TESTS=0
+
+# Test results storage
+declare -a TEST_RESULTS
+declare -a TEST_NAMES
+
+# ============================================================================
+# LOGGING FUNCTIONS
+# ============================================================================
+
+log_info() {
+ if [ "${QUIET}" = "false" ]; then
+ echo -e "${GREEN}[INFO]${NC} $1"
+ fi
+}
+
+log_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+ echo -e "${RED}[ERROR]${NC} $1"
+}
+
+log_verbose() {
+ if [ "${VERBOSE}" = "true" ]; then
+ echo -e "${BLUE}[DEBUG]${NC} $1"
+ fi
+}
+
+log_test() {
+ if [ "${QUIET}" = "false" ]; then
+ echo -e "${CYAN}[TEST]${NC} $1"
+ fi
+}
+
+log_section() {
+ echo ""
+ echo -e "${MAGENTA}========================================${NC}"
+ echo -e "${MAGENTA}$1${NC}"
+ echo -e "${MAGENTA}========================================${NC}"
+}
+
+# Escape single quotes in SQL strings (prevent SQL injection)
+escape_sql() {
+ echo "$1" | sed "s/'/''/g"
+}
+
+# ============================================================================
+# MCP REQUEST FUNCTIONS
+# ============================================================================
+
+# Execute MCP request
+mcp_request() {
+ local payload="$1"
+
+ local response
+ response=$(curl -s --connect-timeout 5 --max-time 30 -w "\n%{http_code}" -X POST "${MCP_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 MCP server is accessible
+check_mcp_server() {
+ log_test "Checking MCP server accessibility..."
+
+ local response
+ response=$(mcp_request '{"jsonrpc":"2.0","method":"ping","id":1}')
+
+ if echo "${response}" | grep -q "result"; then
+ log_info "MCP server is accessible at ${MCP_ENDPOINT}"
+ return 0
+ else
+ log_error "MCP server is not accessible"
+ log_error "Response: ${response}"
+ return 1
+ fi
+}
+
+# Execute FTS tool
+fts_tool_call() {
+ local tool_name="$1"
+ local arguments="$2"
+
+ local payload
+ payload=$(cat </dev/null 2>&1; then
+ echo "${response}" | jq -r "${field}" 2>/dev/null || echo ""
+ else
+ # Fallback to grep/sed for basic JSON parsing
+ echo "${response}" | grep -o "\"${field}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | sed 's/.*: "\(.*\)"/\1/' || echo ""
+ fi
+}
+
+# Check JSON boolean field
+check_json_bool() {
+ local response="$1"
+ local field="$2"
+ local expected="$3"
+
+ # Extract inner result from double-nested structure
+ local inner_result
+ inner_result=$(extract_inner_result "${response}")
+
+ if command -v jq >/dev/null 2>&1; then
+ local actual
+ actual=$(echo "${inner_result}" | jq -r "${field}" 2>/dev/null)
+ [ "${actual}" = "${expected}" ]
+ else
+ # Fallback: check for true/false string
+ if [ "${expected}" = "true" ]; then
+ echo "${inner_result}" | grep -q "\"${field}\"[[:space:]]*:[[:space:]]*true"
+ else
+ echo "${inner_result}" | grep -q "\"${field}\"[[:space:]]*:[[:space:]]*false"
+ fi
+ fi
+}
+
+# Extract inner result from MCP response (handles double-nesting)
+extract_inner_result() {
+ local response="$1"
+
+ if command -v jq >/dev/null 2>&1; then
+ local text
+ text=$(echo "${response}" | jq -r '.result.content[0].text // empty' 2>/dev/null)
+ if [ -n "${text}" ] && [ "${text}" != "null" ]; then
+ echo "${text}"
+ return 0
+ fi
+
+ echo "${response}" | jq -r '.result.result // .result' 2>/dev/null || echo "${response}"
+ else
+ echo "${response}"
+ fi
+}
+
+# Extract field from inner result
+extract_inner_field() {
+ local response="$1"
+ local field="$2"
+
+ local inner_result
+ inner_result=$(extract_inner_result "${response}")
+
+ extract_json_field "${inner_result}" "${field}"
+}
+
+# ============================================================================
+# MYSQL HELPER FUNCTIONS
+# ============================================================================
+
+mysql_exec() {
+ local sql="$1"
+ MYSQL_PWD="${MYSQL_PASSWORD}" mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" \
+ -e "${sql}" 2>/dev/null
+}
+
+mysql_check_connection() {
+ log_test "Checking MySQL connection..."
+
+ if mysql_exec "SELECT 1" >/dev/null 2>&1; then
+ log_info "MySQL connection successful"
+ return 0
+ else
+ log_error "Cannot connect to MySQL backend"
+ log_error "Host: ${MYSQL_HOST}:${MYSQL_PORT}, User: ${MYSQL_USER}"
+ return 1
+ fi
+}
+
+setup_test_schema() {
+ log_info "Setting up test schema and table..."
+
+ # Create schema
+ mysql_exec "CREATE SCHEMA IF NOT EXISTS ${TEST_SCHEMA};" 2>/dev/null || true
+
+ # Create test table
+ mysql_exec "CREATE TABLE IF NOT EXISTS ${TEST_SCHEMA}.${TEST_TABLE} (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ title VARCHAR(200),
+ content TEXT,
+ category VARCHAR(50),
+ priority VARCHAR(20),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );" 2>/dev/null || true
+
+ # Clear existing data
+ mysql_exec "DELETE FROM ${TEST_SCHEMA}.${TEST_TABLE};" 2>/dev/null || true
+ mysql_exec "ALTER TABLE ${TEST_SCHEMA}.${TEST_TABLE} AUTO_INCREMENT = 1;" 2>/dev/null || true
+
+ # Insert test data
+ for doc_id in "${!TEST_DOCUMENTS[@]}"; do
+ local doc="${TEST_DOCUMENTS[$doc_id]}"
+ local title="Document ${doc_id}"
+
+ # Determine category and priority based on content
+ local category="general"
+ local priority="normal"
+ if echo "${doc}" | grep -iq "urgent"; then
+ category="support"
+ priority="high"
+ elif echo "${doc}" | grep -iq "error\|failed\|crash"; then
+ category="errors"
+ priority="high"
+ elif echo "${doc}" | grep -iq "customer"; then
+ category="support"
+ elif echo "${doc}" | grep -iq "security"; then
+ category="security"
+ priority="high"
+ elif echo "${doc}" | grep -iq "report\|financial"; then
+ category="reports"
+ fi
+
+ mysql_exec "INSERT INTO ${TEST_SCHEMA}.${TEST_TABLE} (title, content, category, priority) \
+ VALUES ('$(escape_sql "${title}")', '$(escape_sql "${doc}")', '$(escape_sql "${category}")', '$(escape_sql "${priority}")');" 2>/dev/null || true
+ done
+
+ log_info "Test data setup complete (10 documents inserted)"
+}
+
+teardown_test_schema() {
+ if [ "${SKIP_CLEANUP}" = "true" ]; then
+ log_info "Skipping cleanup (--skip-cleanup specified)"
+ return 0
+ fi
+
+ log_info "Cleaning up test schema..."
+
+ # Drop FTS index if exists
+ fts_tool_call "fts_delete_index" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}\"}" >/dev/null
+
+ # Drop test table and schema
+ mysql_exec "DROP TABLE IF EXISTS ${TEST_SCHEMA}.${TEST_SCHEMA}__${TEST_TABLE};" 2>/dev/null || true
+ mysql_exec "DROP TABLE IF EXISTS ${TEST_SCHEMA}.${TEST_TABLE};" 2>/dev/null || true
+ mysql_exec "DROP SCHEMA IF EXISTS ${TEST_SCHEMA};" 2>/dev/null || true
+
+ log_info "Cleanup complete"
+}
+
+# ============================================================================
+# TEST FUNCTIONS
+# ============================================================================
+
+# Run a test
+run_test() {
+ local test_name="$1"
+ local test_func="$2"
+
+ TOTAL_TESTS=$((TOTAL_TESTS + 1))
+ TEST_NAMES+=("${test_name}")
+
+ log_test "${test_name}"
+
+ local output
+ local result
+ if output=$(${test_func} 2>&1); then
+ result="PASS"
+ PASSED_TESTS=$((PASSED_TESTS + 1))
+ log_info " ✓ ${test_name}"
+ else
+ result="FAIL"
+ FAILED_TESTS=$((FAILED_TESTS + 1))
+ log_error " ✗ ${test_name}"
+ if [ "${VERBOSE}" = "true" ]; then
+ echo " Output: ${output}"
+ fi
+ fi
+
+ TEST_RESULTS+=("${result}")
+
+ return 0
+}
+
+# ============================================================================
+# FTS TOOL TESTS
+# ============================================================================
+
+# Test 1: fts_list_indexes (initially empty)
+test_fts_list_indexes_initial() {
+ local response
+ response=$(fts_tool_call "fts_list_indexes" "{}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_list_indexes failed: ${response}"
+ return 1
+ fi
+
+ # Check that indexes array exists (should be empty)
+ local index_count
+ index_count=$(extract_inner_field "${response}" ".indexes | length")
+ log_verbose "Initial index count: ${index_count}"
+
+ log_info " Initial indexes listed successfully"
+ return 0
+}
+
+# Test 2: fts_index_table
+test_fts_index_table() {
+ local response
+ response=$(fts_tool_call "fts_index_table" \
+ "{\"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"columns\": [\"title\", \"content\", \"category\", \"priority\"], \
+ \"primary_key\": \"id\"}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_index_table failed: ${response}"
+ return 1
+ fi
+
+ # Verify row count
+ local row_count
+ row_count=$(extract_inner_field "${response}" ".row_count")
+ if [ "${row_count}" -lt 10 ]; then
+ log_error "Expected at least 10 rows indexed, got: ${row_count}"
+ return 1
+ fi
+
+ log_info " Index created with ${row_count} rows"
+ return 0
+}
+
+# Test 3: fts_list_indexes (after index creation)
+test_fts_list_indexes_after_creation() {
+ local response
+ response=$(fts_tool_call "fts_list_indexes" "{}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_list_indexes failed: ${response}"
+ return 1
+ fi
+
+ # Verify index exists - search for our specific index
+ local index_count
+ index_count=$(extract_inner_field "${response}" ".indexes | length")
+ if [ "${index_count}" -lt 1 ]; then
+ log_error "Expected at least 1 index, got: ${index_count}"
+ return 1
+ fi
+
+ # Find the test_documents index
+ local found=false
+ local i=0
+ while [ $i -lt ${index_count} ]; do
+ local schema
+ local table
+ schema=$(extract_inner_field "${response}" ".indexes[$i].schema")
+ table=$(extract_inner_field "${response}" ".indexes[$i].table")
+
+ if [ "${schema}" = "${TEST_SCHEMA}" ] && [ "${table}" = "${TEST_TABLE}" ]; then
+ found=true
+ break
+ fi
+ i=$((i + 1))
+ done
+
+ if [ "${found}" != "true" ]; then
+ log_error "test_documents index not found in index list"
+ return 1
+ fi
+
+ log_info " test_documents index found in index list"
+ return 0
+}
+
+# Test 4: fts_search (simple query)
+test_fts_search_simple() {
+ local query="urgent"
+ local response
+ response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"${query}\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"limit\": 10}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_search failed: ${response}"
+ return 1
+ fi
+
+ # Check results
+ local total_matches
+ local result_count
+ total_matches=$(extract_json_field "${response}" ".total_matches")
+ result_count=$(extract_json_field "${response}" ".results | length")
+
+ if [ "${total_matches}" -lt 1 ]; then
+ log_error "Expected at least 1 match for '${query}', got: ${total_matches}"
+ return 1
+ fi
+
+ log_info " Search '${query}': ${total_matches} total matches, ${result_count} returned"
+ return 0
+}
+
+# Test 5: fts_search (phrase query)
+test_fts_search_phrase() {
+ local query="payment gateway"
+ local response
+ response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"${query}\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"limit\": 10}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_search failed: ${response}"
+ return 1
+ fi
+
+ # Check results
+ local total_matches
+ total_matches=$(extract_json_field "${response}" ".total_matches")
+
+ if [ "${total_matches}" -lt 1 ]; then
+ log_error "Expected at least 1 match for '${query}', got: ${total_matches}"
+ return 1
+ fi
+
+ log_info " Phrase search '${query}': ${total_matches} matches"
+ return 0
+}
+
+# Test 6: fts_search (cross-table - no schema filter)
+test_fts_search_cross_table() {
+ local query="customer"
+ local response
+ response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"${query}\", \
+ \"limit\": 10}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_search failed: ${response}"
+ return 1
+ fi
+
+ # Check results
+ local total_matches
+ total_matches=$(extract_json_field "${response}" ".total_matches")
+
+ if [ "${total_matches}" -lt 1 ]; then
+ log_error "Expected at least 1 match for '${query}', got: ${total_matches}"
+ return 1
+ fi
+
+ log_info " Cross-table search '${query}': ${total_matches} matches"
+ return 0
+}
+
+# Test 7: fts_search (BM25 ranking test)
+test_fts_search_bm25() {
+ local query="error issue"
+ local response
+ response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"${query}\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"limit\": 5}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_search failed: ${response}"
+ return 1
+ fi
+
+ # Check that results are ranked
+ local total_matches
+ total_matches=$(extract_json_field "${response}" ".total_matches")
+
+ log_info " BM25 ranking test for '${query}': ${total_matches} matches"
+ return 0
+}
+
+# Test 8: fts_search (pagination)
+test_fts_search_pagination() {
+ local query="customer"
+ local limit=3
+ local offset=0
+
+ # First page
+ local response1
+ response1=$(fts_tool_call "fts_search" \
+ "{\"query\": \"${query}\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"limit\": ${limit}, \
+ \"offset\": ${offset}}")
+
+ # Second page
+ local response2
+ response2=$(fts_tool_call "fts_search" \
+ "{\"query\": \"${query}\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"limit\": ${limit}, \
+ \"offset\": $((limit + offset))}")
+
+ # Check for success
+ if ! check_json_bool "${response1}" ".success" "true" || \
+ ! check_json_bool "${response2}" ".success" "true"; then
+ log_error "fts_search pagination failed"
+ return 1
+ fi
+
+ log_info " Pagination test passed"
+ return 0
+}
+
+# Test 9: fts_search (empty query should fail)
+test_fts_search_empty_query() {
+ local response
+ response=$(fts_tool_call "fts_search" "{\"query\": \"\"}")
+
+ # Should return error
+ if check_json_bool "${response}" ".success" "true"; then
+ log_error "Empty query should fail but succeeded"
+ return 1
+ fi
+
+ log_info " Empty query correctly rejected"
+ return 0
+}
+
+# Test 10: fts_reindex (refresh existing index)
+test_fts_reindex() {
+ # First, add a new document to MySQL
+ mysql_exec "INSERT INTO ${TEST_SCHEMA}.${TEST_TABLE} (title, content, category, priority) \
+ VALUES ('New Document', 'This is a new urgent document for testing reindex', 'support', 'high');" 2>/dev/null || true
+
+ # Reindex
+ local response
+ response=$(fts_tool_call "fts_reindex" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}\"}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_reindex failed: ${response}"
+ return 1
+ fi
+
+ # Verify updated row count
+ local row_count
+ row_count=$(extract_json_field "${response}" ".row_count")
+ if [ "${row_count}" -lt 11 ]; then
+ log_error "Expected at least 11 rows after reindex, got: ${row_count}"
+ return 1
+ fi
+
+ log_info " Reindex successful with ${row_count} rows"
+ return 0
+}
+
+# Test 11: fts_delete_index
+test_fts_delete_index() {
+ local response
+ response=$(fts_tool_call "fts_delete_index" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}\"}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_delete_index failed: ${response}"
+ return 1
+ fi
+
+ # Verify index is deleted
+ local list_response
+ list_response=$(fts_tool_call "fts_list_indexes" "{}")
+ local index_count
+ index_count=$(extract_json_field "${list_response}" ".indexes | length")
+
+ # Filter out our index
+ local our_index_count
+ our_index_count=$(extract_json_field "${list_response}" \
+ ".indexes[] | select(.schema==\"${TEST_SCHEMA}\" and .table==\"${TEST_TABLE}\") | length")
+
+ if [ "${our_index_count}" != "0" ] && [ "${our_index_count}" != "" ]; then
+ log_error "Index still exists after deletion"
+ return 1
+ fi
+
+ log_info " Index deleted successfully"
+ return 0
+}
+
+# Test 12: fts_search after deletion (should fail gracefully)
+test_fts_search_after_deletion() {
+ local response
+ response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"urgent\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\"}")
+
+ # Should return no results (index doesn't exist)
+ local total_matches
+ total_matches=$(extract_inner_field "${response}" ".total_matches")
+
+ if [ "${total_matches}" != "0" ]; then
+ log_error "Expected 0 matches after index deletion, got: ${total_matches}"
+ return 1
+ fi
+
+ log_info " Search after deletion returns 0 matches (expected)"
+ return 0
+}
+
+# Test 13: fts_rebuild_all (no indexes)
+test_fts_rebuild_all_empty() {
+ local response
+ response=$(fts_tool_call "fts_rebuild_all" "{}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_rebuild_all failed: ${response}"
+ return 1
+ fi
+
+ log_info " fts_rebuild_all with no indexes succeeded"
+ return 0
+}
+
+# Test 14: fts_index_table with WHERE clause
+test_fts_index_table_with_where() {
+ # First, create the index without WHERE clause
+ fts_tool_call "fts_index_table" \
+ "{\"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"columns\": [\"title\", \"content\"], \
+ \"primary_key\": \"id\"}" >/dev/null
+
+ # Delete it
+ fts_tool_call "fts_delete_index" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}\"}" >/dev/null
+
+ # Now create with WHERE clause
+ local response
+ response=$(fts_tool_call "fts_index_table" \
+ "{\"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"columns\": [\"title\", \"content\", \"priority\"], \
+ \"primary_key\": \"id\", \
+ \"where_clause\": \"priority = 'high'\"}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_index_table with WHERE clause failed: ${response}"
+ return 1
+ fi
+
+ # Verify row count (should be less than total)
+ local row_count
+ row_count=$(extract_json_field "${response}" ".row_count")
+
+ if [ "${row_count}" -lt 1 ]; then
+ log_error "Expected at least 1 row with WHERE clause, got: ${row_count}"
+ return 1
+ fi
+
+ log_info " Index with WHERE clause created: ${row_count} high-priority rows"
+ return 0
+}
+
+# Test 15: Multiple indexes
+test_fts_multiple_indexes() {
+ # Create a second table
+ mysql_exec "CREATE TABLE IF NOT EXISTS ${TEST_SCHEMA}.logs (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ message TEXT,
+ level VARCHAR(20),
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );" 2>/dev/null || true
+
+ mysql_exec "INSERT IGNORE INTO ${TEST_SCHEMA}.logs (message, level) VALUES \
+ ('Error in module A', 'error'), \
+ ('Warning in module B', 'warning'), \
+ ('Info message', 'info');" 2>/dev/null || true
+
+ # Delete logs index if exists (cleanup from previous runs)
+ fts_tool_call "fts_delete_index" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"logs\"}" >/dev/null 2>&1
+
+ # Create index for logs table
+ local response
+ response=$(fts_tool_call "fts_index_table" \
+ "{\"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"logs\", \
+ \"columns\": [\"message\", \"level\"], \
+ \"primary_key\": \"id\"}")
+
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "Failed to create second index: ${response}"
+ return 1
+ fi
+
+ # List indexes
+ local list_response
+ list_response=$(fts_tool_call "fts_list_indexes" "{}")
+ local index_count
+ index_count=$(extract_inner_field "${list_response}" ".indexes | length")
+
+ if [ "${index_count}" -lt 2 ]; then
+ log_error "Expected at least 2 indexes, got: ${index_count}"
+ return 1
+ fi
+
+ log_info " Multiple indexes: ${index_count} indexes exist"
+
+ # Search across all tables
+ local search_response
+ search_response=$(fts_tool_call "fts_search" "{\"query\": \"error\", \"limit\": 10}")
+ local total_matches
+ total_matches=$(extract_inner_field "${search_response}" ".total_matches")
+
+ log_info " Cross-table search 'error': ${total_matches} matches across all indexes"
+
+ return 0
+}
+
+# Test 16: fts_rebuild_all (with indexes)
+test_fts_rebuild_all_with_indexes() {
+ local response
+ response=$(fts_tool_call "fts_rebuild_all" "{}")
+
+ # Check for success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_rebuild_all failed: ${response}"
+ return 1
+ fi
+
+ local rebuilt_count
+ rebuilt_count=$(extract_json_field "${response}" ".rebuilt_count")
+
+ if [ "${rebuilt_count}" -lt 1 ]; then
+ log_error "Expected at least 1 rebuilt index, got: ${rebuilt_count}"
+ return 1
+ fi
+
+ log_info " Rebuilt ${rebuilt_count} indexes"
+ return 0
+}
+
+# Test 17: Index already exists error handling
+test_fts_index_already_exists() {
+ local response
+ response=$(fts_tool_call "fts_index_table" \
+ "{\"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"columns\": [\"title\", \"content\"], \
+ \"primary_key\": \"id\"}")
+
+ # Should fail with "already exists" error
+ if check_json_bool "${response}" ".success" "true"; then
+ log_error "Creating duplicate index should fail but succeeded"
+ return 1
+ fi
+
+ local error_msg
+ error_msg=$(extract_inner_field "${response}" ".error")
+
+ if ! echo "${error_msg}" | grep -iq "already exists"; then
+ log_error "Expected 'already exists' error, got: ${error_msg}"
+ return 1
+ fi
+
+ log_info " Duplicate index correctly rejected"
+ return 0
+}
+
+# Test 18: Delete non-existent index
+test_fts_delete_nonexistent_index() {
+ # First delete the index
+ fts_tool_call "fts_delete_index" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}\"}" >/dev/null
+
+ # Try to delete again
+ local response
+ response=$(fts_tool_call "fts_delete_index" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}\"}")
+
+ # Should fail gracefully
+ if check_json_bool "${response}" ".success" "true"; then
+ log_error "Deleting non-existent index should fail but succeeded"
+ return 1
+ fi
+
+ log_info " Non-existent index deletion correctly failed"
+ return 0
+}
+
+# Test 19: Complex search with special characters
+test_fts_search_special_chars() {
+ # Create a document with special characters
+ mysql_exec "INSERT INTO ${TEST_SCHEMA}.${TEST_TABLE} (title, content, category, priority) \
+ VALUES ('Special Chars', 'Test with @ # $ % ^ & * ( ) - _ = + [ ] { } | \\ : ; \" \" < > ? / ~', 'test', 'normal');" 2>/dev/null || true
+
+ # Reindex
+ fts_tool_call "fts_reindex" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}\"}" >/dev/null
+
+ # Search for "special"
+ local response
+ response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"special\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"limit\": 10}")
+
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "Search with special chars failed: ${response}"
+ return 1
+ fi
+
+ local total_matches
+ total_matches=$(extract_json_field "${response}" ".total_matches")
+
+ log_info " Special characters search: ${total_matches} matches"
+ return 0
+}
+
+# Test 20: Verify FTS5 features (snippet highlighting)
+test_fts_snippet_highlighting() {
+ local response
+ response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"urgent\", \
+ \"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}\", \
+ \"limit\": 3}")
+
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "fts_search for snippet test failed"
+ return 1
+ fi
+
+ # Check if snippet is present in results
+ local has_snippet
+ if command -v jq >/dev/null 2>&1; then
+ has_snippet=$(echo "${response}" | jq -r '.results[0].snippet // empty' | grep -c "mark" || echo "0")
+ else
+ has_snippet=$(echo "${response}" | grep -o "mark" | wc -l)
+ fi
+
+ if [ "${has_snippet}" -lt 1 ]; then
+ log_warn "No snippet highlighting found (may be expected if no matches)"
+ else
+ log_info " Snippet highlighting present: tags found"
+ fi
+
+ return 0
+}
+
+# Test 21: Test custom FTS database path configuration
+test_fts_custom_database_path() {
+ log_test "Testing custom FTS database path configuration..."
+
+ # Note: This test verifies that mcp_fts_path changes are properly applied
+ # via the admin interface with LOAD MCP VARIABLES TO RUNTIME.
+ # This specifically tests the bug fix in Admin_FlushVariables.cpp
+
+ local custom_path="/tmp/test_fts_$$.db"
+
+ # Remove old test file if exists
+ rm -f "${custom_path}"
+
+ # Verify we can query the current FTS path setting
+ local current_path
+ current_path=$(MYSQL_PWD="${MYSQL_PASSWORD}" mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" \
+ -e "SELECT @@mcp-fts_path" -s -N 2>/dev/null | tr -d '\r')
+
+ if [ -z "${current_path}" ]; then
+ log_warn "Could not query current FTS path - admin interface may not be available"
+ current_path="mcp_fts.db" # Default value
+ fi
+
+ log_verbose "Current FTS database path: ${current_path}"
+
+ # Test 1: Verify we can set a custom path via admin interface
+ log_verbose "Setting custom FTS path to: ${custom_path}"
+ local set_result
+ set_result=$(MYSQL_PWD="${MYSQL_PASSWORD}" mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" \
+ -e "SET mcp-fts_path = '${custom_path}'" 2>&1)
+
+ if [ $? -ne 0 ]; then
+ log_warn "Could not set mcp-fts_path via admin interface (this may be expected if admin access is limited)"
+ log_warn "Error: ${set_result}"
+ log_info " FTS system is working with current configuration"
+ log_info " Note: Custom path configuration requires admin interface access"
+ return 0 # Not a failure - FTS still works, just can't test admin config
+ fi
+
+ # Verify the value was set
+ local new_path
+ new_path=$(MYSQL_PWD="${MYSQL_PASSWORD}" mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" \
+ -e "SELECT @@mcp-fts_path" -s -N 2>/dev/null | tr -d '\r')
+
+ if [ "${new_path}" != "${custom_path}" ]; then
+ log_error "Failed to set mcp_fts_path. Expected '${custom_path}', got '${new_path}'"
+ return 1
+ fi
+
+ # Test 2: Load configuration to runtime - this is where the bug was
+ log_verbose "Loading MCP variables to runtime..."
+ local load_result
+ load_result=$(MYSQL_PWD="${MYSQL_PASSWORD}" mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" \
+ -e "LOAD MCP VARIABLES TO RUNTIME" 2>&1)
+
+ if [ $? -ne 0 ]; then
+ log_error "LOAD MCP VARIABLES TO RUNTIME failed: ${load_result}"
+ return 1
+ fi
+
+ # Give the system a moment to reinitialize
+ sleep 2
+
+ # Test 3: Create a test index with the new path
+ log_verbose "Creating FTS index to test new database path..."
+ local response
+ response=$(fts_tool_call "fts_index_table" \
+ "{\"schema\": \"${TEST_SCHEMA}\", \
+ \"table\": \"${TEST_TABLE}_path_test\", \
+ \"columns\": [\"title\", \"content\"], \
+ \"primary_key\": \"id\"}")
+
+ if [ "${VERBOSE}" = "true" ]; then
+ echo "Index creation response: ${response}" >&2
+ fi
+
+ # Verify success
+ if ! check_json_bool "${response}" ".success" "true"; then
+ log_error "Index creation failed with new path: ${response}"
+ # This might not be an error - the path change may require full MCP restart
+ log_warn "FTS index creation may require MCP server restart for path changes"
+ fi
+
+ # Test 4: Verify the database file was created at the custom path
+ if [ -f "${custom_path}" ]; then
+ log_info " ✓ FTS database file created at custom path: ${custom_path}"
+ log_info " ✓ Configuration reload mechanism is working correctly"
+ else
+ log_warn " ⚠ FTS database file not found at ${custom_path}"
+ log_info " Note: FTS path changes may require full ProxySQL restart in some configurations"
+ # This is not a failure - different configurations handle path changes differently
+ fi
+
+ # Test 5: Verify search functionality still works
+ log_verbose "Testing search functionality with new configuration..."
+ local search_response
+ search_response=$(fts_tool_call "fts_search" \
+ "{\"query\": \"test\", \
+ \"limit\": 1}")
+
+ if [ "${VERBOSE}" = "true" ]; then
+ echo "Search response: ${search_response}" >&2
+ fi
+
+ if check_json_bool "${search_response}" ".success" "true"; then
+ log_info " ✓ FTS search functionality working after configuration reload"
+ else
+ log_warn " ⚠ Search may have issues: ${search_response}"
+ fi
+
+ # Test 6: Restore original path
+ log_verbose "Restoring original FTS path: ${current_path}"
+ MYSQL_PWD="${MYSQL_PASSWORD}" mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" \
+ -e "SET mcp-fts_path = '${current_path}'" 2>/dev/null
+ MYSQL_PWD="${MYSQL_PASSWORD}" mysql -h "${MYSQL_HOST}" -P "${MYSQL_PORT}" -u "${MYSQL_USER}" \
+ -e "LOAD MCP VARIABLES TO RUNTIME" 2>/dev/null
+
+ log_info " FTS custom path configuration test completed"
+
+ # Cleanup
+ log_verbose "Cleaning up test index and database file..."
+ fts_tool_call "fts_delete_index" "{\"schema\": \"${TEST_SCHEMA}\", \"table\": \"${TEST_TABLE}_path_test\"}" >/dev/null 2>&1
+ rm -f "${custom_path}"
+
+ return 0
+}
+
+# ============================================================================
+# TEST SUITE DEFINITION
+# ============================================================================
+
+declare -a TEST_SUITE=(
+ "test_fts_list_indexes_initial"
+ "test_fts_index_table"
+ "test_fts_list_indexes_after_creation"
+ "test_fts_search_simple"
+ "test_fts_search_phrase"
+ "test_fts_search_cross_table"
+ "test_fts_search_bm25"
+ "test_fts_search_pagination"
+ "test_fts_search_empty_query"
+ "test_fts_reindex"
+ "test_fts_delete_index"
+ "test_fts_search_after_deletion"
+ "test_fts_rebuild_all_empty"
+ "test_fts_index_table_with_where"
+ "test_fts_multiple_indexes"
+ "test_fts_rebuild_all_with_indexes"
+ "test_fts_index_already_exists"
+ "test_fts_delete_nonexistent_index"
+ "test_fts_search_special_chars"
+ "test_fts_snippet_highlighting"
+ "test_fts_custom_database_path"
+)
+
+# ============================================================================
+# RESULTS REPORTING
+# ============================================================================
+
+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 "Skipped: ${SKIPPED_TESTS}"
+ echo ""
+
+ if [ ${FAILED_TESTS} -gt 0 ]; then
+ echo "Failed tests:"
+ for i in "${!TEST_NAMES[@]}"; do
+ if [ "${TEST_RESULTS[$i]}" = "FAIL" ]; then
+ echo " - ${TEST_NAMES[$i]}"
+ fi
+ done
+ echo ""
+ fi
+
+ if [ ${PASSED_TESTS} -eq ${TOTAL_TESTS} ]; then
+ echo -e "${GREEN}All tests passed!${NC}"
+ return 0
+ else
+ echo -e "${RED}Some tests failed!${NC}"
+ return 1
+ fi
+}
+
+print_test_info() {
+ echo ""
+ echo "========================================"
+ echo "MCP FTS Test Suite"
+ echo "========================================"
+ echo "MCP Endpoint: ${MCP_ENDPOINT}"
+ echo "Test Schema: ${TEST_SCHEMA}"
+ echo "Test Table: ${TEST_TABLE}"
+ echo "MySQL Backend: ${MYSQL_HOST}:${MYSQL_PORT}"
+ echo ""
+ echo "Test Configuration:"
+ echo " - Verbose: ${VERBOSE}"
+ echo " - Skip Cleanup: ${SKIP_CLEANUP}"
+ echo ""
+}
+
+# ============================================================================
+# PARSE ARGUMENTS
+# ============================================================================
+
+parse_args() {
+ while [[ $# -gt 0 ]]; do
+ case $1 in
+ -v|--verbose)
+ VERBOSE=true
+ shift
+ ;;
+ -q|--quiet)
+ QUIET=true
+ shift
+ ;;
+ --skip-cleanup)
+ SKIP_CLEANUP=true
+ shift
+ ;;
+ --test-schema)
+ TEST_SCHEMA="$2"
+ shift 2
+ ;;
+ --test-table)
+ TEST_TABLE="$2"
+ shift 2
+ ;;
+ -h|--help)
+ cat </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:+"${CURL_OPTS}"} -s -X POST "${MCP_ENDPOINT}" \
+ -H "Content-Type: application/json" \
+ -d "${payload}"
+}
+
+config_request() {
+ local payload="$1"
+ curl ${CURL_OPTS:+"${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("")' >/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 indexes (ignore if not found)"
+ 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 || log "Note: customers index may not exist"
+
+ 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 || log "Note: orders index may not exist"
+fi
+
+cleanup_sample_data
+
+log "Detailed FTS tests completed successfully"
diff --git a/test/tap/tests/Makefile b/test/tap/tests/Makefile
index 9c1a47717d..f140e51506 100644
--- a/test/tap/tests/Makefile
+++ b/test/tap/tests/Makefile
@@ -168,6 +168,9 @@ sh-%:
cp $(patsubst sh-%,%,$@) $(patsubst sh-%.sh,%,$@)
chmod +x $(patsubst sh-%.sh,%,$@)
+anomaly_detection-t: anomaly_detection-t.cpp $(TAP_LDIR)/libtap.so
+ $(CXX) -DEXCLUDE_TRACKING_VARIABLES $< ../tap/SQLite3_Server.cpp -I$(CLICKHOUSE_CPP_IDIR) $(IDIRS) $(LDIRS) -L$(CLICKHOUSE_CPP_LDIR) -L$(LZ4_LDIR) $(OPT) $(OBJ) $(MYLIBSJEMALLOC) $(MYLIBS) $(STATIC_LIBS) $(CLICKHOUSE_CPP_LDIR)/libclickhouse-cpp-lib.a $(CLICKHOUSE_CPP_PATH)/contrib/zstd/zstd/libzstdstatic.a $(LZ4_LDIR)/liblz4.a $(SQLITE3_LDIR)/../libsqlite_rembed.a -lscram -lusual -Wl,--allow-multiple-definition -o $@
+
%-t: %-t.cpp $(TAP_LDIR)/libtap.so
$(CXX) $< $(IDIRS) $(LDIRS) $(OPT) $(MYLIBS) $(STATIC_LIBS) -o $@
diff --git a/test/tap/tests/ai_llm_retry_scenarios-t.cpp b/test/tap/tests/ai_llm_retry_scenarios-t.cpp
index 175e74668b..211586e194 100644
--- a/test/tap/tests/ai_llm_retry_scenarios-t.cpp
+++ b/test/tap/tests/ai_llm_retry_scenarios-t.cpp
@@ -14,6 +14,7 @@
*/
#include "tap.h"
+#include
#include
#include
#include
diff --git a/test/tap/tests/anomaly_detection-t.cpp b/test/tap/tests/anomaly_detection-t.cpp
index 28092a8ce9..bd73ae896a 100644
--- a/test/tap/tests/anomaly_detection-t.cpp
+++ b/test/tap/tests/anomaly_detection-t.cpp
@@ -50,6 +50,17 @@ MYSQL* g_admin = NULL;
class AI_Features_Manager;
extern AI_Features_Manager *GloAI;
+// Forward declarations
+class MySQL_Session;
+typedef struct _PtrSize_t PtrSize_t;
+
+// Stub for SQLite3_Server_session_handler - required by SQLite3_Server.cpp
+// This test uses admin MySQL connection, so this is just a placeholder
+void SQLite3_Server_session_handler(MySQL_Session* sess, void* _pa, PtrSize_t* pkt) {
+ // This is a stub - the actual test uses MySQL admin connection
+ // The SQLite3_Server.cpp sets this as a handler but we don't use it
+}
+
// ============================================================================
// Helper Functions
// ============================================================================
diff --git a/test/tap/tests/vector_db_performance-t.cpp b/test/tap/tests/vector_db_performance-t.cpp
index d5e5678dcf..71bbf7fd50 100644
--- a/test/tap/tests/vector_db_performance-t.cpp
+++ b/test/tap/tests/vector_db_performance-t.cpp
@@ -14,9 +14,11 @@
*/
#include "tap.h"
+#include
#include
#include
#include
+#include
#include
#include
#include
@@ -320,7 +322,7 @@ void test_large_dataset_handling() {
auto insert_duration = std::chrono::duration_cast(end_insert - start_insert);
ok(db.size() == large_size, "Large dataset (%zu entries) inserted successfully", large_size);
- diag("Time to insert %zu entries: %lld ms", large_size, insert_duration.count());
+ diag("Time to insert %zu entries: %lld ms", large_size, (long long)insert_duration.count());
// Test search performance in large dataset
auto search_result = db.lookup_entry("Large dataset query 5000");
@@ -376,7 +378,7 @@ void test_concurrent_access() {
long long avg_time = total_time / num_operations;
diag("Average time per concurrent operation: %lld microseconds", avg_time);
- diag("Total time for %d operations: %lld microseconds", num_operations, total_duration.count());
+ diag("Total time for %d operations: %lld microseconds", num_operations, (long long)total_duration.count());
// Operations should be reasonably fast
ok(avg_time < 50000, "Average concurrent operation time reasonable (< 50ms)");