diff --git a/include/MCP_Endpoint.h b/include/MCP_Endpoint.h index 7e7bd5f050..b1bd989486 100644 --- a/include/MCP_Endpoint.h +++ b/include/MCP_Endpoint.h @@ -61,12 +61,12 @@ class MCP_JSONRPC_Resource : public httpserver::http_resource { * @brief Create a JSON-RPC 2.0 success response * * @param result The result data to include - * @param id The request ID + * @param id The request ID (can be string, number, or null) * @return JSON string representing the response */ std::string create_jsonrpc_response( const std::string& result, - const std::string& id = "1" + const json& id = nullptr ); /** @@ -74,13 +74,13 @@ class MCP_JSONRPC_Resource : public httpserver::http_resource { * * @param code The error code (JSON-RPC standard or custom) * @param message The error message - * @param id The request ID + * @param id The request ID (can be string, number, or null) * @return JSON string representing the error response */ std::string create_jsonrpc_error( int code, const std::string& message, - const std::string& id = "" + const json& id = nullptr ); /** @@ -112,6 +112,24 @@ class MCP_JSONRPC_Resource : public httpserver::http_resource { */ json handle_tools_call(const json& req_json); + /** + * @brief Handle prompts/list method + * + * Returns an empty prompts array since ProxySQL doesn't support prompts. + * + * @return JSON with empty prompts array + */ + json handle_prompts_list(); + + /** + * @brief Handle resources/list method + * + * Returns an empty resources array since ProxySQL doesn't support resources. + * + * @return JSON with empty resources array + */ + json handle_resources_list(); + public: /** * @brief Constructor for MCP_JSONRPC_Resource @@ -127,6 +145,51 @@ class MCP_JSONRPC_Resource : public httpserver::http_resource { */ ~MCP_JSONRPC_Resource(); + /** + * @brief Handle GET requests + * + * Returns HTTP 405 Method Not Allowed for GET requests. + * + * According to the MCP specification 2025-06-18 (Streamable HTTP transport): + * "The server MUST either return Content-Type: text/event-stream in response to + * this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that + * the server does not offer an SSE stream at this endpoint." + * + * @param req The HTTP request + * @return HTTP 405 response with Allow: POST header + */ + const std::shared_ptr render_GET( + const httpserver::http_request& req + ) override; + + /** + * @brief Handle OPTIONS requests (CORS preflight) + * + * Returns CORS headers for OPTIONS preflight requests. + * + * @param req The HTTP request + * @return HTTP response with CORS headers + */ + const std::shared_ptr render_OPTIONS( + const httpserver::http_request& req + ) override; + + /** + * @brief Handle DELETE requests + * + * Returns HTTP 405 Method Not Allowed for DELETE requests. + * + * According to the MCP specification 2025-06-18 (Streamable HTTP transport): + * "The server MAY respond to this request with HTTP 405 Method Not Allowed, + * indicating that the server does not allow clients to terminate sessions." + * + * @param req The HTTP request + * @return HTTP 405 response with Allow header + */ + const std::shared_ptr render_DELETE( + const httpserver::http_request& req + ) override; + /** * @brief Handle POST requests * diff --git a/include/MCP_Thread.h b/include/MCP_Thread.h index bae5585f04..cf1eaef6d2 100644 --- a/include/MCP_Thread.h +++ b/include/MCP_Thread.h @@ -42,7 +42,8 @@ class MCP_Threads_Handler */ struct { bool mcp_enabled; ///< Enable/disable MCP server - int mcp_port; ///< HTTPS port for MCP server (default: 6071) + int mcp_port; ///< HTTP/HTTPS port for MCP server (default: 6071) + bool mcp_use_ssl; ///< Enable/disable SSL/TLS (default: true) char* mcp_config_endpoint_auth; ///< Authentication for /mcp/config endpoint char* mcp_observe_endpoint_auth; ///< Authentication for /mcp/observe endpoint char* mcp_query_endpoint_auth; ///< Authentication for /mcp/query endpoint @@ -68,9 +69,9 @@ class MCP_Threads_Handler } status_variables; /** - * @brief Pointer to the HTTPS server instance + * @brief Pointer to the HTTP/HTTPS server instance * - * This is managed by the MCP_Thread module and provides HTTPS + * This is managed by the MCP_Thread module and provides HTTP/HTTPS * endpoints for MCP protocol communication. */ ProxySQL_MCP_Server* mcp_server; @@ -138,7 +139,7 @@ class MCP_Threads_Handler * @brief Initialize the MCP module * * Sets up the module with default configuration values and starts - * the HTTPS server if enabled. Must be called before using any + * the HTTP/HTTPS server if enabled. Must be called before using any * other methods. */ void init(); diff --git a/include/ProxySQL_MCP_Server.hpp b/include/ProxySQL_MCP_Server.hpp index e4ed237db3..33df7a92a8 100644 --- a/include/ProxySQL_MCP_Server.hpp +++ b/include/ProxySQL_MCP_Server.hpp @@ -17,14 +17,16 @@ class MCP_Threads_Handler; /** * @brief ProxySQL MCP Server class * - * This class wraps an HTTPS server using libhttpserver to provide - * MCP (Model Context Protocol) endpoints. It supports multiple + * This class wraps an HTTP/HTTPS server using libhttpserver to provide + * MCP (Model Context Protocol) endpoints. Supports both HTTP and HTTPS + * modes based on mcp_use_ssl configuration. It supports multiple * MCP server endpoints with their own authentication. */ class ProxySQL_MCP_Server { private: std::unique_ptr ws; int port; + bool use_ssl; // SSL mode the server was started with pthread_t thread_id; // Endpoint resources @@ -36,7 +38,8 @@ class ProxySQL_MCP_Server { /** * @brief Constructor for ProxySQL_MCP_Server * - * Creates a new HTTPS server instance on the specified port. + * Creates a new HTTP/HTTPS server instance on the specified port. + * Uses HTTPS if mcp_use_ssl is true, otherwise uses HTTP. * * @param p The port number to listen on * @param h Pointer to the MCP_Threads_Handler instance @@ -51,18 +54,32 @@ class ProxySQL_MCP_Server { ~ProxySQL_MCP_Server(); /** - * @brief Start the HTTPS server + * @brief Start the HTTP/HTTPS server * * Starts the webserver in a dedicated thread. */ void start(); /** - * @brief Stop the HTTPS server + * @brief Stop the HTTP/HTTPS server * * Stops the webserver and waits for the thread to complete. */ void stop(); + + /** + * @brief Get the port the server is listening on + * + * @return int The port number + */ + int get_port() const { return port; } + + /** + * @brief Check if the server is using SSL/TLS + * + * @return true if server is using HTTPS, false if using HTTP + */ + bool is_using_ssl() const { return use_ssl; } }; #endif /* CLASS_PROXYSQL_MCP_SERVER_H */ diff --git a/lib/Admin_FlushVariables.cpp b/lib/Admin_FlushVariables.cpp index c9bf714849..546416860e 100644 --- a/lib/Admin_FlushVariables.cpp +++ b/lib/Admin_FlushVariables.cpp @@ -27,6 +27,11 @@ using json = nlohmann::json; #include "proxysql_restapi.h" #include "MCP_Thread.h" #include "MySQL_Tool_Handler.h" +#include "Query_Tool_Handler.h" +#include "Config_Tool_Handler.h" +#include "Admin_Tool_Handler.h" +#include "Cache_Tool_Handler.h" +#include "Observe_Tool_Handler.h" #include "ProxySQL_MCP_Server.hpp" #include "proxysql_utils.h" #include "prometheus_helpers.h" @@ -1380,12 +1385,27 @@ void ProxySQL_Admin::flush_mcp_variables___database_to_runtime(SQLite3DB* db, bo if (enabled) { // Start the server if not already running if (GloMCPH->mcp_server == NULL) { - // Check if SSL certificates are available - if (!GloVars.global.ssl_key_pem_mem || !GloVars.global.ssl_cert_pem_mem) { - proxy_error("MCP: Cannot start server - SSL certificates not loaded. Please configure ssl_key_fp and ssl_cert_fp.\n"); + // Only check SSL certificates if SSL mode is enabled + if (GloMCPH->variables.mcp_use_ssl) { + if (!GloVars.global.ssl_key_pem_mem || !GloVars.global.ssl_cert_pem_mem) { + proxy_error("MCP: Cannot start server in SSL mode - SSL certificates not loaded. " + "Please configure ssl_key_fp and ssl_cert_fp, or set mcp_use_ssl=false.\n"); + } else { + int port = GloMCPH->variables.mcp_port; + const char* mode = GloMCPH->variables.mcp_use_ssl ? "HTTPS" : "HTTP"; + proxy_info("MCP: Starting %s server on port %d\n", mode, port); + GloMCPH->mcp_server = new ProxySQL_MCP_Server(port, GloMCPH); + if (GloMCPH->mcp_server) { + GloMCPH->mcp_server->start(); + proxy_info("MCP: Server started successfully\n"); + } else { + proxy_error("MCP: Failed to create server instance\n"); + } + } } else { + // HTTP mode - start without SSL certificates int port = GloMCPH->variables.mcp_port; - proxy_info("MCP: Starting HTTPS server on port %d\n", port); + proxy_info("MCP: Starting HTTP server on port %d (unencrypted)\n", port); GloMCPH->mcp_server = new ProxySQL_MCP_Server(port, GloMCPH); if (GloMCPH->mcp_server) { GloMCPH->mcp_server->start(); @@ -1395,14 +1415,78 @@ void ProxySQL_Admin::flush_mcp_variables___database_to_runtime(SQLite3DB* db, bo } } } else { - proxy_info("MCP: Server already running, updating configuration...\n"); - // Server is already running - we could update port/restart if needed - // For now, just log that it's running + proxy_info("MCP: Server already running, checking if configuration changed...\n"); + + // Check if restart is needed due to configuration changes + bool needs_restart = false; + std::string restart_reason; + + // Check if port changed + int current_port = GloMCPH->variables.mcp_port; + int server_port = GloMCPH->mcp_server->get_port(); + if (current_port != server_port) { + needs_restart = true; + restart_reason += "port (" + std::to_string(server_port) + " -> " + std::to_string(current_port) + ") "; + } + + // Check if SSL mode changed + bool current_use_ssl = GloMCPH->variables.mcp_use_ssl; + bool server_use_ssl = GloMCPH->mcp_server->is_using_ssl(); + if (current_use_ssl != server_use_ssl) { + needs_restart = true; + restart_reason += "SSL mode (" + std::string(server_use_ssl ? "HTTPS" : "HTTP") + " -> " + std::string(current_use_ssl ? "HTTPS" : "HTTP") + ") "; + } + + if (needs_restart) { + proxy_info("MCP: Configuration changed (%s), restarting server...\n", restart_reason.c_str()); + + // Stop server with old configuration + const char* old_mode = server_use_ssl ? "HTTPS" : "HTTP"; + proxy_info("MCP: Stopping %s server on port %d\n", old_mode, server_port); + delete GloMCPH->mcp_server; + GloMCPH->mcp_server = NULL; + + // Start server with new configuration + int new_port = GloMCPH->variables.mcp_port; + bool new_use_ssl = GloMCPH->variables.mcp_use_ssl; + const char* new_mode = new_use_ssl ? "HTTPS" : "HTTP"; + + // Check SSL certificates if needed + if (new_use_ssl) { + if (!GloVars.global.ssl_key_pem_mem || !GloVars.global.ssl_cert_pem_mem) { + proxy_error("MCP: Cannot start server in SSL mode - SSL certificates not loaded. " + "Please configure ssl_key_fp and ssl_cert_fp, or set mcp_use_ssl=false.\n"); + // Leave server stopped + } else { + proxy_info("MCP: Starting %s server on port %d\n", new_mode, new_port); + GloMCPH->mcp_server = new ProxySQL_MCP_Server(new_port, GloMCPH); + if (GloMCPH->mcp_server) { + GloMCPH->mcp_server->start(); + proxy_info("MCP: Server restarted successfully\n"); + } else { + proxy_error("MCP: Failed to create server instance\n"); + } + } + } else { + // HTTP mode - no SSL certificates needed + proxy_info("MCP: Starting %s server on port %d (unencrypted)\n", new_mode, new_port); + GloMCPH->mcp_server = new ProxySQL_MCP_Server(new_port, GloMCPH); + if (GloMCPH->mcp_server) { + GloMCPH->mcp_server->start(); + proxy_info("MCP: Server restarted successfully\n"); + } else { + proxy_error("MCP: Failed to create server instance\n"); + } + } + } else { + proxy_info("MCP: Server already running, no configuration changes detected\n"); + } } } else { // Stop the server if running if (GloMCPH->mcp_server != NULL) { - proxy_info("MCP: Stopping HTTPS server\n"); + const char* mode = GloMCPH->variables.mcp_use_ssl ? "HTTPS" : "HTTP"; + proxy_info("MCP: Stopping %s server\n", mode); delete GloMCPH->mcp_server; GloMCPH->mcp_server = NULL; proxy_info("MCP: Server stopped successfully\n"); @@ -1512,12 +1596,27 @@ void ProxySQL_Admin::flush_mcp_variables___runtime_to_database(SQLite3DB* db, bo if (enabled) { // Start the server if not already running if (GloMCPH->mcp_server == NULL) { - // Check if SSL certificates are available - if (!GloVars.global.ssl_key_pem_mem || !GloVars.global.ssl_cert_pem_mem) { - proxy_error("MCP: Cannot start server - SSL certificates not loaded. Please configure ssl_key_fp and ssl_cert_fp.\n"); + // Only check SSL certificates if SSL mode is enabled + if (GloMCPH->variables.mcp_use_ssl) { + if (!GloVars.global.ssl_key_pem_mem || !GloVars.global.ssl_cert_pem_mem) { + proxy_error("MCP: Cannot start server in SSL mode - SSL certificates not loaded. " + "Please configure ssl_key_fp and ssl_cert_fp, or set mcp_use_ssl=false.\n"); + } else { + int port = GloMCPH->variables.mcp_port; + const char* mode = GloMCPH->variables.mcp_use_ssl ? "HTTPS" : "HTTP"; + proxy_info("MCP: Starting %s server on port %d\n", mode, port); + GloMCPH->mcp_server = new ProxySQL_MCP_Server(port, GloMCPH); + if (GloMCPH->mcp_server) { + GloMCPH->mcp_server->start(); + proxy_info("MCP: Server started successfully\n"); + } else { + proxy_error("MCP: Failed to create server instance\n"); + } + } } else { + // HTTP mode - start without SSL certificates int port = GloMCPH->variables.mcp_port; - proxy_info("MCP: Starting HTTPS server on port %d\n", port); + proxy_info("MCP: Starting HTTP server on port %d (unencrypted)\n", port); GloMCPH->mcp_server = new ProxySQL_MCP_Server(port, GloMCPH); if (GloMCPH->mcp_server) { GloMCPH->mcp_server->start(); @@ -1527,38 +1626,35 @@ void ProxySQL_Admin::flush_mcp_variables___runtime_to_database(SQLite3DB* db, bo } } } else { - // Server is already running - check if MySQL configuration changed - // and reinitialize the tool handler if needed - proxy_info("MCP: Server already running, checking MySQL tool handler reinitialization\n"); - if (GloMCPH->mysql_tool_handler) { - // Delete old handler - delete GloMCPH->mysql_tool_handler; - GloMCPH->mysql_tool_handler = NULL; - } + // Server is already running - need to stop, delete server, and recreate everything + proxy_info("MCP: Server already running, reinitializing\n"); - // Create new tool handler with current configuration - proxy_info("MCP: Reinitializing MySQL Tool Handler with current configuration\n"); - GloMCPH->mysql_tool_handler = new MySQL_Tool_Handler( - GloMCPH->variables.mcp_mysql_hosts ? GloMCPH->variables.mcp_mysql_hosts : "", - GloMCPH->variables.mcp_mysql_ports ? GloMCPH->variables.mcp_mysql_ports : "", - GloMCPH->variables.mcp_mysql_user ? GloMCPH->variables.mcp_mysql_user : "", - GloMCPH->variables.mcp_mysql_password ? GloMCPH->variables.mcp_mysql_password : "", - GloMCPH->variables.mcp_mysql_schema ? GloMCPH->variables.mcp_mysql_schema : "", - GloMCPH->variables.mcp_catalog_path ? GloMCPH->variables.mcp_catalog_path : "" - ); - - if (GloMCPH->mysql_tool_handler->init() != 0) { - proxy_error("MCP: Failed to reinitialize MySQL Tool Handler\n"); - delete GloMCPH->mysql_tool_handler; - GloMCPH->mysql_tool_handler = NULL; + // Delete the old server - its destructor will clean up all handlers + // (mysql_tool_handler, config_tool_handler, query_tool_handler, + // admin_tool_handler, cache_tool_handler, observe_tool_handler) + proxy_info("MCP: Stopping and deleting old server\n"); + delete GloMCPH->mcp_server; + GloMCPH->mcp_server = NULL; + // All handlers are now deleted and set to NULL by the destructor + proxy_info("MCP: Old server deleted\n"); + + // Create and start new server with current configuration + // The server constructor will recreate all handlers with updated settings + proxy_info("MCP: Creating and starting new server\n"); + int port = GloMCPH->variables.mcp_port; + GloMCPH->mcp_server = new ProxySQL_MCP_Server(port, GloMCPH); + if (GloMCPH->mcp_server) { + GloMCPH->mcp_server->start(); + proxy_info("MCP: New server created and started successfully\n"); } else { - proxy_info("MCP: MySQL Tool Handler reinitialized successfully\n"); + proxy_error("MCP: Failed to create new server instance\n"); } } } else { // Stop the server if running if (GloMCPH->mcp_server != NULL) { - proxy_info("MCP: Stopping HTTPS server\n"); + const char* mode = GloMCPH->variables.mcp_use_ssl ? "HTTPS" : "HTTP"; + proxy_info("MCP: Stopping %s server\n", mode); delete GloMCPH->mcp_server; GloMCPH->mcp_server = NULL; proxy_info("MCP: Server stopped successfully\n"); diff --git a/lib/MCP_Endpoint.cpp b/lib/MCP_Endpoint.cpp index dd4430d0c7..983978b84a 100644 --- a/lib/MCP_Endpoint.cpp +++ b/lib/MCP_Endpoint.cpp @@ -98,29 +98,105 @@ bool MCP_JSONRPC_Resource::authenticate_request(const httpserver::http_request& return authenticated; } +const std::shared_ptr MCP_JSONRPC_Resource::render_GET( + const httpserver::http_request& req +) { + std::string req_path = req.get_path(); + proxy_debug(PROXY_DEBUG_GENERIC, 2, "Received MCP GET request on %s - returning 405 Method Not Allowed\n", req_path.c_str()); + + // According to the MCP specification (Streamable HTTP transport): + // "The server MUST either return Content-Type: text/event-stream in response to + // this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that + // the server does not offer an SSE stream at this endpoint." + // + // This server does not currently support SSE streaming, so we return 405. + auto response = std::shared_ptr(new string_response( + "", + http::http_utils::http_method_not_allowed // 405 + )); + response->with_header("Allow", "POST"); // Tell client what IS allowed + + if (handler) { + handler->status_variables.total_requests++; + } + + return response; +} + +const std::shared_ptr MCP_JSONRPC_Resource::render_OPTIONS( + const httpserver::http_request& req +) { + std::string req_path = req.get_path(); + proxy_debug(PROXY_DEBUG_GENERIC, 2, "Received MCP OPTIONS request on %s\n", req_path.c_str()); + + // Handle CORS preflight requests for MCP HTTP transport + // Return 200 OK with appropriate CORS headers + auto response = std::shared_ptr(new string_response( + "", + http::http_utils::http_ok + )); + response->with_header("Content-Type", "application/json"); + response->with_header("Access-Control-Allow-Origin", "*"); + response->with_header("Access-Control-Allow-Methods", "POST, OPTIONS"); + response->with_header("Access-Control-Allow-Headers", "Content-Type, Authorization"); + + if (handler) { + handler->status_variables.total_requests++; + } + + return response; +} + +const std::shared_ptr MCP_JSONRPC_Resource::render_DELETE( + const httpserver::http_request& req +) { + std::string req_path = req.get_path(); + proxy_debug(PROXY_DEBUG_GENERIC, 2, "Received MCP DELETE request on %s - returning 405 Method Not Allowed\n", req_path.c_str()); + + // ProxySQL doesn't support session termination + // Return 405 Method Not Allowed with Allow header indicating supported methods + auto response = std::shared_ptr(new string_response( + "", + http::http_utils::http_method_not_allowed // 405 + )); + response->with_header("Allow", "POST, OPTIONS"); // Tell client what IS allowed + + if (handler) { + handler->status_variables.total_requests++; + } + + return response; +} + std::string MCP_JSONRPC_Resource::create_jsonrpc_response( const std::string& result, - const std::string& id + const json& id ) { - json j; + nlohmann::ordered_json j; // Use ordered_json to preserve field order j["jsonrpc"] = "2.0"; + // Only include id if it's not null (per JSON-RPC 2.0 and MCP spec) + if (!id.is_null()) { + j["id"] = id; + } j["result"] = json::parse(result); - j["id"] = id; return j.dump(); } std::string MCP_JSONRPC_Resource::create_jsonrpc_error( int code, const std::string& message, - const std::string& id + const json& id ) { - json j; + nlohmann::ordered_json j; // Use ordered_json to preserve field order j["jsonrpc"] = "2.0"; json error; error["code"] = code; error["message"] = message; j["error"] = error; - j["id"] = id; + // Only include id if it's not null (per JSON-RPC 2.0 and MCP spec) + if (!id.is_null()) { + j["id"] = id; + } return j.dump(); } @@ -148,13 +224,20 @@ std::shared_ptr MCP_JSONRPC_Resource::handle_jsonrpc_request( handler->status_variables.failed_requests++; } auto response = std::shared_ptr(new string_response( - create_jsonrpc_error(-32700, "Parse error", ""), + create_jsonrpc_error(-32700, "Parse error", nullptr), http::http_utils::http_bad_request )); response->with_header("Content-Type", "application/json"); return response; } + // Extract request ID immediately after parsing (JSON-RPC 2.0 spec) + // This must be done BEFORE validation so we can include the ID in error responses + json req_id = nullptr; + if (req_json.contains("id")) { + req_id = req_json["id"]; + } + // Validate JSON-RPC 2.0 basic structure if (!req_json.contains("jsonrpc") || req_json["jsonrpc"] != "2.0") { proxy_error("MCP request on %s: Missing or invalid jsonrpc version\n", req_path.c_str()); @@ -162,7 +245,7 @@ std::shared_ptr MCP_JSONRPC_Resource::handle_jsonrpc_request( handler->status_variables.failed_requests++; } auto response = std::shared_ptr(new string_response( - create_jsonrpc_error(-32600, "Invalid Request", ""), + create_jsonrpc_error(-32600, "Invalid Request", req_id), http::http_utils::http_bad_request )); response->with_header("Content-Type", "application/json"); @@ -174,24 +257,16 @@ std::shared_ptr MCP_JSONRPC_Resource::handle_jsonrpc_request( if (handler) { handler->status_variables.failed_requests++; } + // Use -32601 "Method not found" for compatibility with MCP clients + // (even though -32600 "Invalid Request" is technically correct per JSON-RPC spec) auto response = std::shared_ptr(new string_response( - create_jsonrpc_error(-32600, "Invalid Request", ""), + create_jsonrpc_error(-32601, "Method not found", req_id), http::http_utils::http_bad_request )); response->with_header("Content-Type", "application/json"); return response; } - // Get request ID (optional but recommended) - std::string req_id = ""; - if (req_json.contains("id")) { - if (req_json["id"].is_string()) { - req_id = req_json["id"].get(); - } else if (req_json["id"].is_number()) { - req_id = std::to_string(req_json["id"].get()); - } - } - // Get method name std::string method = req_json["method"].get(); proxy_debug(PROXY_DEBUG_GENERIC, 2, "MCP method '%s' requested on endpoint '%s'\n", method.c_str(), endpoint_name.c_str()); @@ -211,7 +286,7 @@ std::shared_ptr MCP_JSONRPC_Resource::handle_jsonrpc_request( http::http_utils::http_internal_server_error )); response->with_header("Content-Type", "application/json"); - return response; + return response; } // Route to appropriate tool handler method @@ -222,24 +297,37 @@ std::shared_ptr MCP_JSONRPC_Resource::handle_jsonrpc_request( } else if (method == "tools/call") { result = handle_tools_call(req_json); } - } else if (method == "initialize" || method == "ping") { + } else if (method == "prompts/list") { + result = handle_prompts_list(); + } else if (method == "resources/list") { + result = handle_resources_list(); + } else if (method == "initialize") { // Handle MCP protocol methods - if (method == "initialize") { - result["protocolVersion"] = "2024-11-05"; - result["capabilities"] = json::object(); - result["serverInfo"] = { - {"name", "proxysql-mcp-mysql-tools"}, - {"version", MCP_THREAD_VERSION} - }; - } else if (method == "ping") { - result["status"] = "ok"; - } + result["protocolVersion"] = "2025-06-18"; + result["capabilities"]["tools"] = json::object(); // Explicitly declare tools support + result["serverInfo"] = { + {"name", "proxysql-mcp-mcp-mysql-tools"}, + {"version", MCP_THREAD_VERSION} + }; + } else if (method == "ping") { + result["status"] = "ok"; + } else if (method.compare(0, strlen("notifications/"), "notifications/") == 0) { + // Handle notifications sent by the client + // notifications/initialized + // - https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle#initialization + // notifications/cancelled + // - https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation#cancellation-flow + + proxy_debug(PROXY_DEBUG_GENERIC, 2, "MCP notification '%s' received on endpoint '%s'\n", method.c_str(), endpoint_name.c_str()); + // simple acknowledgement with HTTP 202 Accepted (no response body) + return std::shared_ptr(new string_response("",http::http_utils::http_accepted)); } else { // Unknown method proxy_info("MCP: Unknown method '%s' on endpoint '%s'\n", method.c_str(), endpoint_name.c_str()); + // Return HTTP 200 OK with JSON-RPC error (not HTTP 404) for compatibility with MCP clients auto response = std::shared_ptr(new string_response( create_jsonrpc_error(-32601, "Method not found", req_id), - http::http_utils::http_not_found + http::http_utils::http_ok )); response->with_header("Content-Type", "application/json"); return response; @@ -268,8 +356,9 @@ const std::shared_ptr MCP_JSONRPC_Resource::render_POST( if (handler) { handler->status_variables.failed_requests++; } + // Use nullptr for ID since we haven't parsed JSON yet (JSON-RPC 2.0 spec) auto response = std::shared_ptr(new string_response( - create_jsonrpc_error(-32600, "Invalid Request: Content-Type must be application/json", ""), + create_jsonrpc_error(-32600, "Invalid Request: Content-Type must be application/json", nullptr), http::http_utils::http_unsupported_media_type )); response->with_header("Content-Type", "application/json"); @@ -282,8 +371,9 @@ const std::shared_ptr MCP_JSONRPC_Resource::render_POST( if (handler) { handler->status_variables.failed_requests++; } + // Use nullptr for ID since we haven't parsed JSON yet (JSON-RPC 2.0 spec) auto response = std::shared_ptr(new string_response( - create_jsonrpc_error(-32001, "Unauthorized", ""), + create_jsonrpc_error(-32001, "Unauthorized", nullptr), http::http_utils::http_unauthorized )); response->with_header("Content-Type", "application/json"); @@ -376,6 +466,25 @@ json MCP_JSONRPC_Resource::handle_tools_call(const json& req_json) { } mcp_result["content"] = json::array({text_content}); - mcp_result["isError"] = false; + // Note: Per MCP spec, only include isError when true (error case) + // For success responses, omit the isError field entirely return mcp_result; } + +// Helper method to handle prompts/list +json MCP_JSONRPC_Resource::handle_prompts_list() { + proxy_debug(PROXY_DEBUG_GENERIC, 3, "MCP: prompts/list called\n"); + // Returns an empty prompts array since ProxySQL doesn't support prompts + json result; + result["prompts"] = json::array(); + return result; +} + +// Helper method to handle resources/list +json MCP_JSONRPC_Resource::handle_resources_list() { + proxy_debug(PROXY_DEBUG_GENERIC, 3, "MCP: resources/list called\n"); + // Returns an empty resources array since ProxySQL doesn't support resources + json result; + result["resources"] = json::array(); + return result; +} diff --git a/lib/MCP_Thread.cpp b/lib/MCP_Thread.cpp index 9d8a578608..5a61f23851 100644 --- a/lib/MCP_Thread.cpp +++ b/lib/MCP_Thread.cpp @@ -17,6 +17,7 @@ static const char* mcp_thread_variables_names[] = { "enabled", "port", + "use_ssl", "config_endpoint_auth", "observe_endpoint_auth", "query_endpoint_auth", @@ -42,6 +43,7 @@ MCP_Threads_Handler::MCP_Threads_Handler() { // Initialize variables with default values variables.mcp_enabled = false; variables.mcp_port = 6071; + variables.mcp_use_ssl = true; // Default to true for security variables.mcp_config_endpoint_auth = strdup(""); variables.mcp_observe_endpoint_auth = strdup(""); variables.mcp_query_endpoint_auth = strdup(""); @@ -135,7 +137,7 @@ MCP_Threads_Handler::~MCP_Threads_Handler() { void MCP_Threads_Handler::init() { proxy_info("Initializing MCP Threads Handler\n"); // For now, this is a simple initialization - // The HTTPS server will be started when mcp_enabled is set to true + // The HTTP/HTTPS server will be started when mcp_enabled is set to true // and will be managed through ProxySQL_Admin print_version(); } @@ -144,7 +146,7 @@ void MCP_Threads_Handler::shutdown() { proxy_info("Shutting down MCP Threads Handler\n"); shutdown_ = 1; - // Stop the HTTPS server if it's running + // Stop the HTTP/HTTPS server if it's running if (mcp_server) { delete mcp_server; mcp_server = NULL; @@ -171,6 +173,10 @@ int MCP_Threads_Handler::get_variable(const char* name, char* val) { sprintf(val, "%d", variables.mcp_port); return 0; } + if (!strcmp(name, "use_ssl")) { + sprintf(val, "%s", variables.mcp_use_ssl ? "true" : "false"); + return 0; + } if (!strcmp(name, "config_endpoint_auth")) { sprintf(val, "%s", variables.mcp_config_endpoint_auth ? variables.mcp_config_endpoint_auth : ""); return 0; @@ -247,6 +253,17 @@ int MCP_Threads_Handler::set_variable(const char* name, const char* value) { } return -1; } + if (!strcmp(name, "use_ssl")) { + if (strcasecmp(value, "true") == 0 || strcasecmp(value, "1") == 0) { + variables.mcp_use_ssl = true; + return 0; + } + if (strcasecmp(value, "false") == 0 || strcasecmp(value, "0") == 0) { + variables.mcp_use_ssl = false; + return 0; + } + return -1; + } if (!strcmp(name, "config_endpoint_auth")) { if (variables.mcp_config_endpoint_auth) free(variables.mcp_config_endpoint_auth); diff --git a/lib/MySQL_Catalog.cpp b/lib/MySQL_Catalog.cpp index e3a0aef72c..45304dca0e 100644 --- a/lib/MySQL_Catalog.cpp +++ b/lib/MySQL_Catalog.cpp @@ -189,69 +189,108 @@ std::string MySQL_Catalog::search( int limit, int offset ) { + // FTS5 search requires a query + if (query.empty()) { + proxy_error("Catalog search requires a query parameter\n"); + return "[]"; + } + + // Helper lambda to escape single quotes for SQLite SQL literals + auto escape_sql = [](const std::string& str) -> std::string { + std::string result; + result.reserve(str.length() * 2); // Reserve space for potential escaping + for (char c : str) { + if (c == '\'') { + result += '\''; // Escape single quote by doubling it + } + result += c; + } + return result; + }; + + // Escape query for use in FTS5 MATCH (MATCH doesn't support parameter binding) + std::string escaped_query = escape_sql(query); + + // Build SQL query with placeholders for parameters std::ostringstream sql; - sql << "SELECT kind, key, document, tags, links FROM catalog WHERE 1=1"; + sql << "SELECT c.kind, c.key, c.document, c.tags, c.links " + << "FROM catalog c " + << "INNER JOIN catalog_fts f ON c.id = f.rowid " + << "WHERE catalog_fts MATCH '" << escaped_query << "'"; - // Add kind filter + int param_count = 1; // Track parameter binding position + + // Add kind filter with parameter placeholder if (!kind.empty()) { - sql << " AND kind = '" << kind << "'"; + sql << " AND c.kind = ?"; } - // Add tags filter + // Add tags filter with parameter placeholder if (!tags.empty()) { - sql << " AND tags LIKE '%" << tags << "%'"; - } - - // Add search query - if (!query.empty()) { - sql << " AND (key LIKE '%" << query << "%' " - << "OR document LIKE '%" << query << "%' " - << "OR tags LIKE '%" << query << "%')"; + sql << " AND c.tags LIKE ?"; } - sql << " ORDER BY updated_at DESC LIMIT " << limit << " OFFSET " << offset; + // Order by relevance (BM25) and recency + sql << " ORDER BY bm25(f) ASC, c.updated_at DESC LIMIT ? OFFSET ?"; - char* error = NULL; - int cols = 0, affected = 0; - SQLite3_result* resultset = NULL; - - db->execute_statement(sql.str().c_str(), &error, &cols, &affected, &resultset); - if (error) { - proxy_error("Catalog search error: %s\n", error); + // Prepare the statement + sqlite3_stmt* stmt = NULL; + int rc = db->prepare_v2(sql.str().c_str(), &stmt); + if (rc != SQLITE_OK) { + proxy_error("Catalog search: Failed to prepare statement: %d\n", rc); return "[]"; } - // Build JSON result using nlohmann::json + // Bind parameters + param_count = 1; + if (!kind.empty()) { + (*proxy_sqlite3_bind_text)(stmt, param_count++, kind.c_str(), -1, SQLITE_TRANSIENT); + } + if (!tags.empty()) { + // Add wildcards for LIKE search + std::string tags_pattern = "%" + tags + "%"; + (*proxy_sqlite3_bind_text)(stmt, param_count++, tags_pattern.c_str(), -1, SQLITE_TRANSIENT); + } + (*proxy_sqlite3_bind_int)(stmt, param_count++, limit); + (*proxy_sqlite3_bind_int)(stmt, param_count, offset); + + // Build JSON result using nlohmann::json (consistent with list() function) nlohmann::json results = nlohmann::json::array(); - if (resultset) { - for (std::vector::iterator it = resultset->rows.begin(); - it != resultset->rows.end(); ++it) { - SQLite3_row* row = *it; + while ((rc = (*proxy_sqlite3_step)(stmt)) == SQLITE_ROW) { + nlohmann::json entry; + + const char* kind_val = (const char*)(*proxy_sqlite3_column_text)(stmt, 0); + const char* key_val = (const char*)(*proxy_sqlite3_column_text)(stmt, 1); + const char* doc_val = (const char*)(*proxy_sqlite3_column_text)(stmt, 2); + const char* tags_val = (const char*)(*proxy_sqlite3_column_text)(stmt, 3); + const char* links_val = (const char*)(*proxy_sqlite3_column_text)(stmt, 4); + + entry["kind"] = std::string(kind_val ? kind_val : ""); + entry["key"] = std::string(key_val ? key_val : ""); + + // Parse the stored JSON document - nlohmann::json handles escaping + if (doc_val) { + try { + entry["document"] = nlohmann::json::parse(doc_val); + } catch (const nlohmann::json::parse_error& e) { + // If document is not valid JSON, store as string + entry["document"] = std::string(doc_val); + } + } else { + entry["document"] = nullptr; + } - nlohmann::json entry; - entry["kind"] = std::string(row->fields[0] ? row->fields[0] : ""); - entry["key"] = std::string(row->fields[1] ? row->fields[1] : ""); + entry["tags"] = std::string(tags_val ? tags_val : ""); + entry["links"] = std::string(links_val ? links_val : ""); - // Parse the stored JSON document - nlohmann::json handles escaping - const char* doc_str = row->fields[2]; - if (doc_str) { - try { - entry["document"] = nlohmann::json::parse(doc_str); - } catch (const nlohmann::json::parse_error& e) { - // If document is not valid JSON, store as string - entry["document"] = std::string(doc_str); - } - } else { - entry["document"] = nullptr; - } + results.push_back(entry); + } - entry["tags"] = std::string(row->fields[3] ? row->fields[3] : ""); - entry["links"] = std::string(row->fields[4] ? row->fields[4] : ""); + (*proxy_sqlite3_finalize)(stmt); - results.push_back(entry); - } - delete resultset; + if (rc != SQLITE_DONE && rc != SQLITE_ROW) { + proxy_error("Catalog search: Error executing query: %d\n", rc); } return results.dump(); diff --git a/lib/ProxySQL_MCP_Server.cpp b/lib/ProxySQL_MCP_Server.cpp index 6c3ea9347a..7e4579e5db 100644 --- a/lib/ProxySQL_MCP_Server.cpp +++ b/lib/ProxySQL_MCP_Server.cpp @@ -36,31 +36,44 @@ static void *mcp_server_thread(void *arg) { } ProxySQL_MCP_Server::ProxySQL_MCP_Server(int p, MCP_Threads_Handler* h) - : port(p), handler(h), thread_id(0) + : port(p), handler(h), thread_id(0), use_ssl(h->variables.mcp_use_ssl) { - proxy_info("Creating ProxySQL MCP Server on port %d\n", port); - - // Get SSL certificates from ProxySQL - char* ssl_key = NULL; - char* ssl_cert = NULL; - GloVars.get_SSL_pem_mem(&ssl_key, &ssl_cert); + proxy_info("Creating ProxySQL MCP Server on port %d (SSL: %s)\n", + port, use_ssl ? "enabled" : "disabled"); + + // Create webserver - conditionally use SSL + if (handler->variables.mcp_use_ssl) { + // HTTPS mode: Get SSL certificates from ProxySQL + char* ssl_key = NULL; + char* ssl_cert = NULL; + GloVars.get_SSL_pem_mem(&ssl_key, &ssl_cert); + + // Check if SSL certificates are available + if (!ssl_key || !ssl_cert) { + proxy_error("Cannot start MCP server in SSL mode: SSL certificates not loaded. " + "Please configure ssl_key_fp and ssl_cert_fp, or set mcp_use_ssl=false.\n"); + return; + } - // Check if SSL certificates are available - if (!ssl_key || !ssl_cert) { - proxy_error("Cannot start MCP server: SSL certificates not loaded. Please configure ssl_key_fp and ssl_cert_fp.\n"); - return; + // Create HTTPS webserver using ProxySQL TLS certificates + ws = std::unique_ptr(new webserver( + create_webserver(port) + .use_ssl() + .raw_https_mem_key(std::string(ssl_key)) + .raw_https_mem_cert(std::string(ssl_cert)) + .no_post_process() + )); + proxy_info("MCP server configured for HTTPS\n"); + } else { + // HTTP mode: No SSL certificates required + ws = std::unique_ptr(new webserver( + create_webserver(port) + .no_ssl() // Explicitly disable SSL + .no_post_process() + )); + proxy_info("MCP server configured for HTTP (unencrypted)\n"); } - // Create HTTPS webserver using existing ProxySQL TLS certificates - // Use raw_https_mem_key/raw_https_mem_cert to pass in-memory PEM buffers - ws = std::unique_ptr(new webserver( - create_webserver(port) - .use_ssl() - .raw_https_mem_key(std::string(ssl_key)) - .raw_https_mem_cert(std::string(ssl_cert)) - .no_post_process() - )); - // Initialize tool handlers for each endpoint proxy_info("Initializing MCP tool handlers...\n"); @@ -179,21 +192,56 @@ ProxySQL_MCP_Server::ProxySQL_MCP_Server(int p, MCP_Threads_Handler* h) ProxySQL_MCP_Server::~ProxySQL_MCP_Server() { stop(); - // Clean up tool handlers + // Clean up all tool handlers stored in the handler object if (handler) { - // Clean up AI Tool Handler (uses shared components, don't delete them) - if (handler->ai_tool_handler) { - proxy_info("Cleaning up AI Tool Handler...\n"); - delete handler->ai_tool_handler; - handler->ai_tool_handler = NULL; - } - // Clean up MySQL Tool Handler if (handler->mysql_tool_handler) { proxy_info("Cleaning up MySQL Tool Handler...\n"); delete handler->mysql_tool_handler; handler->mysql_tool_handler = NULL; } + + // Clean up Config Tool Handler + if (handler->config_tool_handler) { + proxy_info("Cleaning up Config Tool Handler...\n"); + delete handler->config_tool_handler; + handler->config_tool_handler = NULL; + } + + // Clean up Query Tool Handler + if (handler->query_tool_handler) { + proxy_info("Cleaning up Query Tool Handler...\n"); + delete handler->query_tool_handler; + handler->query_tool_handler = NULL; + } + + // Clean up Admin Tool Handler + if (handler->admin_tool_handler) { + proxy_info("Cleaning up Admin Tool Handler...\n"); + delete handler->admin_tool_handler; + handler->admin_tool_handler = NULL; + } + + // Clean up Cache Tool Handler + if (handler->cache_tool_handler) { + proxy_info("Cleaning up Cache Tool Handler...\n"); + delete handler->cache_tool_handler; + handler->cache_tool_handler = NULL; + } + + // Clean up Observe Tool Handler + if (handler->observe_tool_handler) { + proxy_info("Cleaning up Observe Tool Handler...\n"); + delete handler->observe_tool_handler; + handler->observe_tool_handler = NULL; + } + + // Clean up AI Tool Handler (uses shared components, don't delete them) + if (handler->ai_tool_handler) { + proxy_info("Cleaning up AI Tool Handler...\n"); + delete handler->ai_tool_handler; + handler->ai_tool_handler = NULL; + } } } @@ -203,7 +251,8 @@ void ProxySQL_MCP_Server::start() { return; } - proxy_info("Starting MCP HTTPS server on port %d\n", port); + const char* mode = handler->variables.mcp_use_ssl ? "HTTPS" : "HTTP"; + proxy_info("Starting MCP %s server on port %d\n", mode, port); // Start the server in a dedicated thread if (pthread_create(&thread_id, NULL, mcp_server_thread, ws.get()) != 0) { @@ -211,12 +260,13 @@ void ProxySQL_MCP_Server::start() { return; } - proxy_info("MCP HTTPS server started successfully\n"); + proxy_info("MCP %s server started successfully\n", mode); } void ProxySQL_MCP_Server::stop() { if (ws) { - proxy_info("Stopping MCP HTTPS server\n"); + const char* mode = handler->variables.mcp_use_ssl ? "HTTPS" : "HTTP"; + proxy_info("Stopping MCP %s server\n", mode); ws->stop(); if (thread_id) { @@ -224,6 +274,6 @@ void ProxySQL_MCP_Server::stop() { thread_id = 0; } - proxy_info("MCP HTTPS server stopped\n"); + proxy_info("MCP %s server stopped\n", mode); } } diff --git a/scripts/mcp/configure_mcp.sh b/scripts/mcp/configure_mcp.sh index 3cfcd6a549..f11482326d 100755 --- a/scripts/mcp/configure_mcp.sh +++ b/scripts/mcp/configure_mcp.sh @@ -27,6 +27,7 @@ MYSQL_PASSWORD="${MYSQL_PASSWORD=test123}" # Use = instead of :- to allow empty MYSQL_DATABASE="${TEST_DB_NAME:-testdb}" MCP_PORT="${MCP_PORT:-6071}" MCP_ENABLED="false" +MCP_USE_SSL="true" # Default to true for security # ProxySQL admin configuration PROXYSQL_ADMIN_HOST="${PROXYSQL_ADMIN_HOST:-127.0.0.1}" @@ -115,6 +116,7 @@ configure_mcp() { exec_admin_silent "SET mcp-mysql_schema='${MYSQL_DATABASE}';" || { log_error "Failed to set mcp-mysql_schema"; errors=$((errors + 1)); } exec_admin_silent "SET mcp-catalog_path='mcp_catalog.db';" || { log_error "Failed to set mcp-catalog_path"; errors=$((errors + 1)); } exec_admin_silent "SET mcp-port='${MCP_PORT}';" || { log_error "Failed to set mcp-port"; errors=$((errors + 1)); } + exec_admin_silent "SET mcp-use_ssl='${MCP_USE_SSL}';" || { log_error "Failed to set mcp-use_ssl"; errors=$((errors + 1)); } exec_admin_silent "SET mcp-enabled='${enable}';" || { log_error "Failed to set mcp-enabled"; errors=$((errors + 1)); } if [ $errors -gt 0 ]; then @@ -130,6 +132,7 @@ configure_mcp() { echo " mcp-mysql_schema = ${MYSQL_DATABASE}" echo " mcp-catalog_path = mcp_catalog.db (relative to datadir)" echo " mcp-port = ${MCP_PORT}" + echo " mcp-use_ssl = ${MCP_USE_SSL}" echo " mcp-enabled = ${enable}" } @@ -159,9 +162,15 @@ test_mcp_server() { # Wait a moment for server to start sleep 2 + # Determine protocol based on SSL setting + local proto="https" + if [ "${MCP_USE_SSL}" = "false" ]; then + proto="http" + fi + # Test ping endpoint local response - response=$(curl -k -s -X POST "https://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/config" \ + response=$(curl -s -X POST "${proto}://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/config" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"ping","id":1}' 2>/dev/null || echo "") @@ -201,6 +210,14 @@ parse_args() { MCP_PORT="$2" shift 2 ;; + --use-ssl) + MCP_USE_SSL="true" + shift + ;; + --no-ssl) + MCP_USE_SSL="false" + shift + ;; --enable) MCP_ENABLED="true" shift @@ -234,6 +251,8 @@ Options: -p, --password PASS MySQL password (default: test123) -d, --database DB MySQL database (default: testdb) --mcp-port PORT MCP server port (default: 6071) + --use-ssl Enable SSL/TLS for MCP server (HTTPS mode) + --no-ssl Disable SSL/TLS for MCP server (HTTP mode) --enable Enable MCP server --disable Disable MCP server --status Show current MCP configuration @@ -245,15 +264,19 @@ Environment Variables: MYSQL_PASSWORD MySQL password (default: test123) TEST_DB_NAME MySQL database (default: testdb) MCP_PORT MCP server port (default: 6071) + MCP_USE_SSL MCP SSL mode (default: true) PROXYSQL_ADMIN_HOST ProxySQL admin host (default: 127.0.0.1) PROXYSQL_ADMIN_PORT ProxySQL admin port (default: 6032) PROXYSQL_ADMIN_USER ProxySQL admin user (default: admin) PROXYSQL_ADMIN_PASSWORD ProxySQL admin password (default: admin) Examples: - # Configure with test MySQL on port 3307 and enable MCP + # Configure with test MySQL on port 3307 and enable MCP (HTTPS mode) $0 --host 127.0.0.1 --port 3307 --enable + # Configure with HTTP mode (no SSL) for development + $0 --no-ssl --enable + # Disable MCP server $0 --disable @@ -266,6 +289,7 @@ Examples: export MYSQL_USER=myuser export MYSQL_PASSWORD=mypass export TEST_DB_NAME=production + export MCP_USE_SSL=false $0 --enable EOF } @@ -285,7 +309,7 @@ main() { echo "" # Print environment variables if set - if [ -n "${MYSQL_HOST}" ] || [ -n "${MYSQL_PORT}" ] || [ -n "${MYSQL_USER}" ] || [ -n "${MYSQL_PASSWORD}" ] || [ -n "${TEST_DB_NAME}" ] || [ -n "${MCP_PORT}" ]; then + if [ -n "${MYSQL_HOST}" ] || [ -n "${MYSQL_PORT}" ] || [ -n "${MYSQL_USER}" ] || [ -n "${MYSQL_PASSWORD}" ] || [ -n "${TEST_DB_NAME}" ] || [ -n "${MCP_PORT}" ] || [ -n "${MCP_USE_SSL}" ]; then log_info "Environment Variables:" [ -n "${MYSQL_HOST}" ] && echo " MYSQL_HOST=${MYSQL_HOST}" [ -n "${MYSQL_PORT}" ] && echo " MYSQL_PORT=${MYSQL_PORT}" @@ -293,6 +317,7 @@ main() { [ -n "${MYSQL_PASSWORD}" ] && echo " MYSQL_PASSWORD=${MYSQL_PASSWORD}" [ -n "${TEST_DB_NAME}" ] && echo " TEST_DB_NAME=${TEST_DB_NAME}" [ -n "${MCP_PORT}" ] && echo " MCP_PORT=${MCP_PORT}" + [ -n "${MCP_USE_SSL}" ] && echo " MCP_USE_SSL=${MCP_USE_SSL}" echo "" fi @@ -334,12 +359,18 @@ main() { log_info "Configuration complete!" if [ "${MCP_ENABLED}" = "true" ]; then echo "" - echo "MCP server is now enabled and accessible at:" - echo " https://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/config (config endpoint)" - echo " https://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/observe (observe endpoint)" - echo " https://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/query (query endpoint)" - echo " https://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/admin (admin endpoint)" - echo " https://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/cache (cache endpoint)" + if [ "${MCP_USE_SSL}" = "true" ]; then + local proto="https" + echo "MCP server is now enabled (HTTPS mode) and accessible at:" + else + local proto="http" + echo "MCP server is now enabled (HTTP mode - unencrypted) and accessible at:" + fi + echo " ${proto}://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/config (config endpoint)" + echo " ${proto}://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/observe (observe endpoint)" + echo " ${proto}://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/query (query endpoint)" + echo " ${proto}://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/admin (admin endpoint)" + echo " ${proto}://${PROXYSQL_ADMIN_HOST}:${MCP_PORT}/mcp/cache (cache endpoint)" echo "" echo "Run './test_mcp_tools.sh' to test MCP tools" fi diff --git a/scripts/mcp/test_catalog.sh b/scripts/mcp/test_catalog.sh index c572a16efd..6a2acc4d4d 100755 --- a/scripts/mcp/test_catalog.sh +++ b/scripts/mcp/test_catalog.sh @@ -7,6 +7,8 @@ # # Options: # -v, --verbose Show verbose output +# -s, --ssl Use HTTPS (SSL/TLS) for MCP connection (default: auto-detect) +# --no-ssl Use HTTP (no SSL) for MCP connection # -h, --help Show help # @@ -15,10 +17,12 @@ set -e # Configuration MCP_HOST="${MCP_HOST:-127.0.0.1}" MCP_PORT="${MCP_PORT:-6071}" -MCP_URL="https://${MCP_HOST}:${MCP_PORT}/mcp/query" +MCP_USE_SSL="${MCP_USE_SSL:-auto}" +MCP_URL="" # Will be set by setup_connection() # Test options VERBOSE=false +USE_SSL="" # Colors RED='\033[0;31m' @@ -39,14 +43,50 @@ log_test() { echo -e "${BLUE}[TEST]${NC} $1" } +# Determine URL and curl options based on SSL setting +setup_connection() { + local ssl_mode="${MCP_USE_SSL}" + + # Auto-detect: try HTTPS first, fall back to HTTP + if [ "$ssl_mode" = "auto" ]; then + # Try HTTPS first + if curl -k -s -m 2 "https://${MCP_HOST}:${MCP_PORT}" >/dev/null 2>&1; then + USE_SSL=true + MCP_URL="https://${MCP_HOST}:${MCP_PORT}/mcp/query" + log_info "Auto-detected: Using HTTPS (SSL)" + elif curl -s -m 2 "http://${MCP_HOST}:${MCP_PORT}" >/dev/null 2>&1; then + USE_SSL=false + MCP_URL="http://${MCP_HOST}:${MCP_PORT}/mcp/query" + log_info "Auto-detected: Using HTTP (no SSL)" + else + # Default to HTTPS if can't detect + USE_SSL=true + MCP_URL="https://${MCP_HOST}:${MCP_PORT}/mcp/query" + log_info "Auto-detect failed, defaulting to HTTPS" + fi + elif [ "$ssl_mode" = "true" ] || [ "$ssl_mode" = "1" ]; then + USE_SSL=true + MCP_URL="https://${MCP_HOST}:${MCP_PORT}/mcp/query" + else + USE_SSL=false + MCP_URL="http://${MCP_HOST}:${MCP_PORT}/mcp/query" + fi +} + # Execute MCP request and unwrap response mcp_request() { local payload="$1" local response - response=$(curl -k -s -X POST "${MCP_URL}" \ - -H "Content-Type: application/json" \ - -d "${payload}" 2>/dev/null) + if [ "$USE_SSL" = "true" ]; then + response=$(curl -k -s -X POST "${MCP_URL}" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null) + else + response=$(curl -s -X POST "${MCP_URL}" \ + -H "Content-Type: application/json" \ + -d "${payload}" 2>/dev/null) + fi # Extract content from MCP protocol wrapper if present # MCP format: {"result":{"content":[{"text":"..."}]}} @@ -95,6 +135,9 @@ run_catalog_tests() { echo "Catalog (LLM Memory) Test Suite" echo "======================================" echo "" + echo "MCP Server: ${MCP_URL}" + echo "SSL Mode: ${USE_SSL:-detecting...}" + echo "" echo "Testing catalog operations for LLM memory persistence" echo "" @@ -435,6 +478,330 @@ run_catalog_tests() { failed=$((failed + 1)) fi + echo "" + echo "======================================" + echo "FTS5 Enhanced Tests" + echo "======================================" + + # Setup: Add multiple entries for FTS5 testing + log_test "Setup: Adding test data for FTS5 tests" + + local setup_payload1='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_upsert", + "arguments": { + "kind": "table", + "key": "fts_test.users", + "document": "{\"table\": \"users\", \"description\": \"User accounts table with authentication data\", \"columns\": [\"id\", \"username\", \"email\", \"password_hash\"]}", + "tags": "authentication,users,security", + "links": "" + } + }, + "id": 1001 +}' + + local setup_payload2='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_upsert", + "arguments": { + "kind": "table", + "key": "fts_test.products", + "document": "{\"table\": \"products\", \"description\": \"Product catalog with pricing and inventory\", \"columns\": [\"id\", \"name\", \"price\", \"stock\"]}", + "tags": "ecommerce,products,catalog", + "links": "" + } + }, + "id": 1002 +}' + + local setup_payload3='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_upsert", + "arguments": { + "kind": "domain", + "key": "user_authentication", + "document": "{\"description\": \"User authentication and authorization domain\", \"flows\": [\"login\", \"logout\", \"password_reset\"], \"policies\": [\"MFA\", \"password_complexity\"]}", + "tags": "security,authentication", + "links": "" + } + }, + "id": 1003 +}' + + local setup_payload4='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_upsert", + "arguments": { + "kind": "domain", + "key": "product_management", + "document": "{\"description\": \"Product inventory and catalog management\", \"features\": [\"bulk_import\", \"pricing_rules\", \"stock_alerts\"]}", + "tags": "ecommerce,inventory", + "links": "" + } + }, + "id": 1004 +}' + + # Run setup + mcp_request "${setup_payload1}" > /dev/null + mcp_request "${setup_payload2}" > /dev/null + mcp_request "${setup_payload3}" > /dev/null + mcp_request "${setup_payload4}" > /dev/null + + log_info "Setup complete: Added 4 test entries for FTS5 testing" + + # Test CAT013: FTS5 multi-term search (AND logic) + local payload13='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "authentication user", + "limit": 10 + } + }, + "id": 13 +}' + + if test_catalog "CAT013" "FTS5 multi-term search (AND)" "${payload13}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT014: FTS5 phrase search with quotes + local payload14='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "\"user authentication\"", + "limit": 10 + } + }, + "id": 14 +}' + + if test_catalog "CAT014" "FTS5 phrase search" "${payload14}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT015: FTS5 OR search + local payload15='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "authentication OR inventory", + "limit": 10 + } + }, + "id": 15 +}' + + if test_catalog "CAT015" "FTS5 OR search" "${payload15}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT016: FTS5 NOT search + local payload16='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "authentication NOT domain", + "limit": 10 + } + }, + "id": 16 +}' + + if test_catalog "CAT016" "FTS5 NOT search" "${payload16}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT017: FTS5 search with kind filter + local payload17='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "user", + "kind": "table", + "limit": 10 + } + }, + "id": 17 +}' + + if test_catalog "CAT017" "FTS5 search with kind filter" "${payload17}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT018: FTS5 prefix search (ends with *) + local payload18='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "auth*", + "limit": 10 + } + }, + "id": 18 +}' + + if test_catalog "CAT018" "FTS5 prefix search" "${payload18}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT019: FTS5 relevance ranking (search for common term, check results exist) + local payload19='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "table", + "limit": 5 + } + }, + "id": 19 +}' + + if test_catalog "CAT019" "FTS5 relevance ranking" "${payload19}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT020: FTS5 search with tags filter + local payload20='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "user", + "tags": "security", + "limit": 10 + } + }, + "id": 20 +}' + + if test_catalog "CAT020" "FTS5 search with tags filter" "${payload20}" '"results"'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test CAT021: Empty query should return empty results (FTS5 requires query) + local payload21='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_search", + "arguments": { + "query": "", + "limit": 10 + } + }, + "id": 21 +}' + + if test_catalog "CAT021" "Empty query returns empty array" "${payload21}" '"results"[[:space:]]*:[[:space:]]*\[\]'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Cleanup: Remove FTS5 test entries + log_test "Cleanup: Removing FTS5 test entries" + + local cleanup_payload1='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_delete", + "arguments": { + "kind": "table", + "key": "fts_test.users" + } + }, + "id": 2001 +}' + + local cleanup_payload2='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_delete", + "arguments": { + "kind": "table", + "key": "fts_test.products" + } + }, + "id": 2002 +}' + + local cleanup_payload3='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_delete", + "arguments": { + "kind": "domain", + "key": "user_authentication" + } + }, + "id": 2003 +}' + + local cleanup_payload4='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_delete", + "arguments": { + "kind": "domain", + "key": "product_management" + } + }, + "id": 2004 +}' + + mcp_request "${cleanup_payload1}" > /dev/null + mcp_request "${cleanup_payload2}" > /dev/null + mcp_request "${cleanup_payload3}" > /dev/null + mcp_request "${cleanup_payload4}" > /dev/null + + log_info "Cleanup complete: Removed FTS5 test entries" + # Print summary echo "" echo "======================================" @@ -462,6 +829,14 @@ parse_args() { VERBOSE=true shift ;; + -s|--ssl) + MCP_USE_SSL=true + shift + ;; + --no-ssl) + MCP_USE_SSL=false + shift + ;; -h|--help) cat <