From f8529003652ec3bb89e575fbe12e095a75b3d00f Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 13 Jan 2026 23:56:45 +0000 Subject: [PATCH] Fix: Correct MCP catalog JSON parsing to handle special characters The catalog_search() and catalog_list() methods in MySQL_Catalog.cpp were manually building JSON strings by concatenating raw TEXT from SQLite without proper escaping. This caused parse errors when stored JSON contained quotes, backslashes, or newlines. Changes: - MySQL_Catalog.cpp: Use nlohmann::json to build proper nested JSON in search() and list() methods instead of manual concatenation - MySQL_Tool_Handler.cpp: Add try-catch for JSON parsing in catalog_get() - test_catalog.sh: Fix MCP URL path, add jq extraction for MCP protocol responses, add 3 special character tests (CAT013-CAT015) Test Results: All 15 catalog tests pass, including new tests that verify special characters (quotes, backslashes) are preserved. --- lib/MySQL_Catalog.cpp | 85 +++++++++++++++++++++++-------------- lib/MySQL_Tool_Handler.cpp | 8 +++- scripts/mcp/test_catalog.sh | 81 +++++++++++++++++++++++++++++++++-- 3 files changed, 138 insertions(+), 36 deletions(-) diff --git a/lib/MySQL_Catalog.cpp b/lib/MySQL_Catalog.cpp index 86f085c60..e3a0aef72 100644 --- a/lib/MySQL_Catalog.cpp +++ b/lib/MySQL_Catalog.cpp @@ -3,6 +3,7 @@ #include "proxysql.h" #include #include +#include "../deps/json/json.hpp" MySQL_Catalog::MySQL_Catalog(const std::string& path) : db(NULL), db_path(path) @@ -220,31 +221,40 @@ std::string MySQL_Catalog::search( return "[]"; } - // Build JSON result - std::ostringstream json; - json << "["; - bool first = true; + // Build JSON result using nlohmann::json + 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; - if (!first) json << ","; - first = false; - - json << "{" - << "\"kind\":\"" << (row->fields[0] ? row->fields[0] : "") << "\"," - << "\"key\":\"" << (row->fields[1] ? row->fields[1] : "") << "\"," - << "\"document\":" << (row->fields[2] ? row->fields[2] : "null") << "," - << "\"tags\":\"" << (row->fields[3] ? row->fields[3] : "") << "\"," - << "\"links\":\"" << (row->fields[4] ? row->fields[4] : "") << "\"" - << "}"; + + nlohmann::json entry; + entry["kind"] = std::string(row->fields[0] ? row->fields[0] : ""); + entry["key"] = std::string(row->fields[1] ? row->fields[1] : ""); + + // 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; + } + + entry["tags"] = std::string(row->fields[3] ? row->fields[3] : ""); + entry["links"] = std::string(row->fields[4] ? row->fields[4] : ""); + + results.push_back(entry); } delete resultset; } - json << "]"; - return json.str(); + return results.dump(); } std::string MySQL_Catalog::list( @@ -282,31 +292,42 @@ std::string MySQL_Catalog::list( resultset = NULL; db->execute_statement(sql.str().c_str(), &error, &cols, &affected, &resultset); - // Build JSON result with total count - std::ostringstream json; - json << "{\"total\":" << total << ",\"results\":["; + // Build JSON result using nlohmann::json + nlohmann::json result; + result["total"] = total; + nlohmann::json results = nlohmann::json::array(); - bool first = true; if (resultset) { for (std::vector::iterator it = resultset->rows.begin(); it != resultset->rows.end(); ++it) { SQLite3_row* row = *it; - if (!first) json << ","; - first = false; - - json << "{" - << "\"kind\":\"" << (row->fields[0] ? row->fields[0] : "") << "\"," - << "\"key\":\"" << (row->fields[1] ? row->fields[1] : "") << "\"," - << "\"document\":" << (row->fields[2] ? row->fields[2] : "null") << "," - << "\"tags\":\"" << (row->fields[3] ? row->fields[3] : "") << "\"," - << "\"links\":\"" << (row->fields[4] ? row->fields[4] : "") << "\"" - << "}"; + + nlohmann::json entry; + entry["kind"] = std::string(row->fields[0] ? row->fields[0] : ""); + entry["key"] = std::string(row->fields[1] ? row->fields[1] : ""); + + // Parse the stored JSON document + 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) { + entry["document"] = std::string(doc_str); + } + } else { + entry["document"] = nullptr; + } + + entry["tags"] = std::string(row->fields[3] ? row->fields[3] : ""); + entry["links"] = std::string(row->fields[4] ? row->fields[4] : ""); + + results.push_back(entry); } delete resultset; } - json << "]}"; - return json.str(); + result["results"] = results; + return result.dump(); } int MySQL_Catalog::merge( diff --git a/lib/MySQL_Tool_Handler.cpp b/lib/MySQL_Tool_Handler.cpp index b7132b09d..5c4354db8 100644 --- a/lib/MySQL_Tool_Handler.cpp +++ b/lib/MySQL_Tool_Handler.cpp @@ -910,7 +910,13 @@ std::string MySQL_Tool_Handler::catalog_get(const std::string& kind, const std:: if (rc == 0) { result["kind"] = kind; result["key"] = key; - result["document"] = json::parse(document); + // Parse as raw JSON value to preserve nested structure + try { + result["document"] = json::parse(document); + } catch (const json::parse_error& e) { + // If not valid JSON, store as string + result["document"] = document; + } } else { result["error"] = "Entry not found"; } diff --git a/scripts/mcp/test_catalog.sh b/scripts/mcp/test_catalog.sh index 0f983cbf9..c572a16ef 100755 --- a/scripts/mcp/test_catalog.sh +++ b/scripts/mcp/test_catalog.sh @@ -15,7 +15,7 @@ set -e # Configuration MCP_HOST="${MCP_HOST:-127.0.0.1}" MCP_PORT="${MCP_PORT:-6071}" -MCP_URL="https://${MCP_HOST}:${MCP_PORT}/query" +MCP_URL="https://${MCP_HOST}:${MCP_PORT}/mcp/query" # Test options VERBOSE=false @@ -39,7 +39,7 @@ log_test() { echo -e "${BLUE}[TEST]${NC} $1" } -# Execute MCP request +# Execute MCP request and unwrap response mcp_request() { local payload="$1" @@ -48,7 +48,16 @@ mcp_request() { -H "Content-Type: application/json" \ -d "${payload}" 2>/dev/null) - echo "${response}" + # Extract content from MCP protocol wrapper if present + # MCP format: {"result":{"content":[{"text":"..."}]}} + local extracted + extracted=$(echo "${response}" | jq -r 'if .result.content[0].text then .result.content[0].text else . end' 2>/dev/null) + + if [ -n "${extracted}" ] && [ "${extracted}" != "null" ]; then + echo "${extracted}" + else + echo "${response}" + fi } # Test catalog operations @@ -290,6 +299,72 @@ run_catalog_tests() { failed=$((failed + 1)) fi + # Test 13: Special characters in document (JSON parsing bug test) + local payload13 + payload13='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_upsert", + "arguments": { + "kind": "test", + "key": "special_chars", + "document": "{\"description\": \"Test with \\\"quotes\\\" and \\\\backslashes\\\\\"}", + "tags": "test,special", + "links": "" + } + }, + "id": 13 +}' + + if test_catalog "CAT013" "Upsert special characters" "${payload13}" '"success"[[:space:]]*:[[:space:]]*true'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test 14: Verify special characters can be read back + local payload14 + payload14='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_get", + "arguments": { + "kind": "test", + "key": "special_chars" + } + }, + "id": 14 +}' + + if test_catalog "CAT014" "Get special chars entry" "${payload14}" 'quotes'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + + # Test 15: Cleanup special chars entry + local payload15 + payload15='{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "catalog_delete", + "arguments": { + "kind": "test", + "key": "special_chars" + } + }, + "id": 15 +}' + + if test_catalog "CAT015" "Cleanup special chars" "${payload15}" '"success"[[:space:]]*:[[:space:]]*true'; then + passed=$((passed + 1)) + else + failed=$((failed + 1)) + fi + # Test 10: Delete entry local payload10 payload10='{