From 4d0782db68a1aa6089afa57fe64feb4922bab96d Mon Sep 17 00:00:00 2001 From: noita-player <56001276+noita-player@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:49:59 -0700 Subject: [PATCH] MCP bridge: support multiple concurrent clients Replace single-client model (m_client/m_readBuffer/m_initialized) with a ClientState vector. Each client gets its own read buffer and initialized flag. Responses route to m_currentSender (set during request processing); notifications broadcast to all initialized clients. Re-entrancy guard in onReadyRead: re-resolve ClientState after each processLine() call since sendJson flush can re-enter the event loop and trigger onDisconnected, removing the client mid-iteration. Tests: 378-line test_mcp exercising connect, initialize, tools/list, disconnect one client, notification broadcast, and serial requests against a MockMcpServer with the same multi-client architecture. --- CMakeLists.txt | 5 + src/mcp/mcp_bridge.cpp | 201 +++++++++++++++------- src/mcp/mcp_bridge.h | 25 ++- tests/test_mcp.cpp | 378 +++++++++++++++++++++++++++++++++++++++++ tests/test_scanner.cpp | 2 +- 5 files changed, 549 insertions(+), 62 deletions(-) create mode 100644 tests/test_mcp.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f27c0e..f3f3531 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -559,6 +559,11 @@ if(BUILD_TESTING) ${QT}::Widgets ${QT}::Concurrent ${QT}::Test) add_test(NAME test_scanner_ui COMMAND test_scanner_ui) + add_executable(test_mcp tests/test_mcp.cpp) + target_include_directories(test_mcp PRIVATE src) + target_link_libraries(test_mcp PRIVATE ${QT}::Core ${QT}::Network ${QT}::Test) + add_test(NAME test_mcp COMMAND test_mcp) + if(WIN32) add_executable(test_windbg_provider tests/test_windbg_provider.cpp plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp diff --git a/src/mcp/mcp_bridge.cpp b/src/mcp/mcp_bridge.cpp index 71eece6..67f8f59 100644 --- a/src/mcp/mcp_bridge.cpp +++ b/src/mcp/mcp_bridge.cpp @@ -69,14 +69,15 @@ void McpBridge::start() { } void McpBridge::stop() { - if (m_client) { - m_client->disconnect(this); - m_client->disconnectFromServer(); - m_client->deleteLater(); - m_client = nullptr; + for (auto& c : m_clients) { + c.socket->disconnect(this); + c.socket->disconnectFromServer(); + c.socket->deleteLater(); } - m_readBuffer.clear(); - m_initialized = false; + m_clients.clear(); + m_currentSender = nullptr; + m_processing = false; + m_pendingRequests.clear(); if (m_server) { m_server->close(); delete m_server; @@ -88,56 +89,89 @@ void McpBridge::stop() { // Connection handling // ════════════════════════════════════════════════════════════════════ +McpBridge::ClientState* McpBridge::findClient(QLocalSocket* sock) { + for (auto& c : m_clients) + if (c.socket == sock) return &c; + return nullptr; +} + +void McpBridge::removeClient(QLocalSocket* sock) { + for (int i = 0; i < m_clients.size(); ++i) { + if (m_clients[i].socket == sock) { + sock->disconnect(this); + sock->deleteLater(); + m_clients.removeAt(i); + return; + } + } +} + void McpBridge::onNewConnection() { auto* pending = m_server->nextPendingConnection(); if (!pending) return; - // Single client — disconnect previous - if (m_client) { - m_client->disconnect(this); - m_client->disconnectFromServer(); - m_client->deleteLater(); - m_client = nullptr; - } + m_clients.append({pending, {}, false}); - m_client = pending; - m_readBuffer.clear(); - m_initialized = false; - - connect(m_client, &QLocalSocket::readyRead, + connect(pending, &QLocalSocket::readyRead, this, &McpBridge::onReadyRead); - connect(m_client, &QLocalSocket::disconnected, + connect(pending, &QLocalSocket::disconnected, this, &McpBridge::onDisconnected); - qDebug() << "[MCP] Client connected"; + qDebug() << "[MCP] Client connected (" << m_clients.size() << "total)"; } void McpBridge::onReadyRead() { - if (!m_client) return; - m_readBuffer.append(m_client->readAll()); + auto* sock = qobject_cast(sender()); + auto* cs = findClient(sock); + if (!cs) return; + cs->readBuffer.append(sock->readAll()); - // Newline-delimited JSON framing - // Guard: processLine→sendJson→flush can re-enter the event loop - // and trigger onDisconnected, nulling m_client mid-loop. - while (m_client) { - int idx = m_readBuffer.indexOf('\n'); + // Extract complete lines from this client's buffer. + // If a request is already in flight (m_processing), queue the line + // instead of processing it -- nested event loops in scanner/tree.apply + // would otherwise let interleaved requests clobber m_currentSender. + while (findClient(sock)) { + cs = findClient(sock); + int idx = cs->readBuffer.indexOf('\n'); if (idx < 0) break; - QByteArray line = m_readBuffer.left(idx).trimmed(); - m_readBuffer.remove(0, idx + 1); - if (!line.isEmpty()) - processLine(line); + QByteArray line = cs->readBuffer.left(idx).trimmed(); + cs->readBuffer.remove(0, idx + 1); + if (line.isEmpty()) continue; + + if (m_processing) { + m_pendingRequests.append({sock, line}); + continue; + } + m_processing = true; + m_currentSender = sock; + processLine(line); + m_currentSender = nullptr; + m_processing = false; + drainPendingRequests(); + } +} + +void McpBridge::drainPendingRequests() { + while (!m_pendingRequests.isEmpty()) { + auto req = m_pendingRequests.takeFirst(); + if (!findClient(req.socket)) continue; // client disconnected while queued + m_processing = true; + m_currentSender = req.socket; + processLine(req.line); + m_currentSender = nullptr; + m_processing = false; } } void McpBridge::onDisconnected() { - qDebug() << "[MCP] Client disconnected"; - if (m_client) { - m_client->disconnect(this); - m_client->deleteLater(); - m_client = nullptr; - } - m_readBuffer.clear(); - m_initialized = false; + auto* sock = qobject_cast(sender()); + qDebug() << "[MCP] Client disconnected (" << m_clients.size() - 1 << "remaining)"; + // Purge any queued requests from this client + m_pendingRequests.erase( + std::remove_if(m_pendingRequests.begin(), m_pendingRequests.end(), + [sock](const PendingRequest& r) { return r.socket == sock; }), + m_pendingRequests.end()); + removeClient(sock); } // ════════════════════════════════════════════════════════════════════ @@ -161,18 +195,26 @@ QJsonObject McpBridge::errReply(const QJsonValue& id, int code, const QString& m } void McpBridge::sendJson(const QJsonObject& obj) { - if (!m_client) return; + QLocalSocket* target = m_currentSender; + if (!target || !findClient(target)) return; QByteArray data = QJsonDocument(obj).toJson(QJsonDocument::Compact); qDebug() << "[MCP] >>" << data.left(200); data.append('\n'); - m_client->write(data); - if (m_client) m_client->flush(); + target->write(data); + target->flush(); } void McpBridge::sendNotification(const QString& method, const QJsonObject& params) { QJsonObject n{{"jsonrpc", "2.0"}, {"method", method}}; if (!params.isEmpty()) n["params"] = params; - sendJson(n); + QByteArray data = QJsonDocument(n).toJson(QJsonDocument::Compact); + data.append('\n'); + for (auto& c : m_clients) { + if (c.initialized) { + c.socket->write(data); + c.socket->flush(); + } + } } QJsonObject McpBridge::makeTextResult(const QString& text, bool isError) { @@ -229,7 +271,7 @@ void McpBridge::processLine(const QByteArray& line) { // ════════════════════════════════════════════════════════════════════ QJsonObject McpBridge::handleInitialize(const QJsonValue& id, const QJsonObject&) { - m_initialized = true; + if (auto* cs = findClient(m_currentSender)) cs->initialized = true; QJsonObject caps; caps["tools"] = QJsonObject{{"listChanged", false}}; @@ -566,6 +608,22 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) { }} }); + + // process.info + tools.append(QJsonObject{ + {"name", "process.info"}, + {"description", "Returns PEB address and enumerates all Thread Environment Blocks (TEBs) for the attached process. " + "TEBs are discovered via NtQuerySystemInformation and NtQueryInformationThread. " + "Each TEB entry includes: address, threadId. " + "Requires a live process provider with PEB support."}, + {"inputSchema", QJsonObject{ + {"type", "object"}, + {"properties", QJsonObject{ + {"tabIndex", QJsonObject{{"type", "integer"}, + {"description", "MDI tab index (0-based). Omit for active tab."}}} + }} + }} + }); return okReply(id, QJsonObject{{"tools", tools}}); } @@ -595,6 +653,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject& else if (toolName == "scanner.scan") result = toolScannerScan(args); else if (toolName == "scanner.scan_pattern") result = toolScannerScanPattern(args); else if (toolName == "mcp.reconnect") result = toolReconnect(args); + else if (toolName == "process.info") result = toolProcessInfo(args); else return errReply(id, -32601, "Unknown tool: " + toolName); m_mainWindow->clearMcpStatus(); @@ -1156,12 +1215,6 @@ QJsonObject McpBridge::toolHexRead(const QJsonObject& args) { if (baseRel) offset += (int64_t)tab->doc->tree.baseAddress; - qDebug() << "[hex_read] arg offset" << (args.value("offset").isString() ? "str" : "num") - << (args.value("offset").isString() ? args.value("offset").toString() : QString()) - << Qt::showbase << Qt::hex << (quint64)offset - << "baseRelative" << baseRel << "tree.base" << (quint64)tab->doc->tree.baseAddress - << "final addr" << (quint64)offset << Qt::dec; - if (offset < 0 || !prov->isReadable((uint64_t)offset, length)) return makeTextResult("Cannot read at offset " + QString::number(offset), true); @@ -1667,29 +1720,61 @@ QJsonObject McpBridge::toolScannerScanPattern(const QJsonObject& args) { // ════════════════════════════════════════════════════════════════════ QJsonObject McpBridge::toolReconnect(const QJsonObject&) { - if (!m_client) + QLocalSocket* sock = m_currentSender; + if (!sock) return makeTextResult("No client connected.", true); // Disconnect after this response is sent so the client receives the result - QTimer::singleShot(0, this, [this]() { - if (m_client) { - m_client->disconnectFromServer(); - } + QTimer::singleShot(0, this, [this, sock]() { + if (findClient(sock)) + sock->disconnectFromServer(); }); return makeTextResult("Disconnected. The MCP client will exit; your IDE may restart it and reconnect to Reclass."); } +// ════════════════════════════════════════════════════════════════════ +// TOOL: process.info — PEB address + TEB enumeration +// ════════════════════════════════════════════════════════════════════ + +QJsonObject McpBridge::toolProcessInfo(const QJsonObject& args) { + auto* tab = resolveTab(args); + if (!tab) return makeTextResult("No active tab", true); + + auto* prov = tab->doc->provider.get(); + if (!prov) return makeTextResult("No data source attached", true); + if (!prov->isLive()) return makeTextResult("Not a live provider", true); + + uint64_t pebAddr = prov->peb(); + if (!pebAddr) return makeTextResult("PEB not available for this provider", true); + + QJsonObject out; + out["peb"] = "0x" + QString::number(pebAddr, 16).toUpper(); + + auto tebList = prov->tebs(); + QJsonArray tebArr; + for (const auto& t : tebList) { + tebArr.append(QJsonObject{ + {"address", "0x" + QString::number(t.tebAddress, 16).toUpper()}, + {"threadId", (qint64)t.threadId} + }); + } + + out["tebs"] = tebArr; + out["tebCount"] = tebArr.size(); + return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented))); +} + // ════════════════════════════════════════════════════════════════════ // Notifications (call from MainWindow/Controller hooks) // ════════════════════════════════════════════════════════════════════ void McpBridge::notifyTreeChanged() { - if (!m_client || !m_initialized) return; + if (m_clients.isEmpty()) return; sendNotification("notifications/resources/updated", QJsonObject{{"uri", "project://tree"}}); } void McpBridge::notifyDataChanged() { - if (!m_client || !m_initialized) return; + if (m_clients.isEmpty()) return; sendNotification("notifications/resources/updated", QJsonObject{{"uri", "project://data"}}); } diff --git a/src/mcp/mcp_bridge.h b/src/mcp/mcp_bridge.h index c4e77ee..e8170d3 100644 --- a/src/mcp/mcp_bridge.h +++ b/src/mcp/mcp_bridge.h @@ -28,13 +28,31 @@ public: void notifyDataChanged(); private: + struct ClientState { + QLocalSocket* socket = nullptr; + QByteArray readBuffer; + bool initialized = false; + }; + MainWindow* m_mainWindow; QLocalServer* m_server = nullptr; - QLocalSocket* m_client = nullptr; // single client for v1 - QByteArray m_readBuffer; - bool m_initialized = false; + QVector m_clients; + QLocalSocket* m_currentSender = nullptr; // set during request processing bool m_slowMode = false; + // Serial request queue. Some tool calls (scanner, tree.apply) spin nested + // event loops which would let another client's readyRead interleave and + // clobber m_currentSender. Simplest fix without refactoring those tools: + // queue incoming lines while a request is in flight, drain after. + bool m_processing = false; + struct PendingRequest { QLocalSocket* socket; QByteArray line; }; + QVector m_pendingRequests; + + + ClientState* findClient(QLocalSocket* sock); + void removeClient(QLocalSocket* sock); + void drainPendingRequests(); + // JSON-RPC plumbing void onNewConnection(); void onReadyRead(); @@ -64,6 +82,7 @@ private: QJsonObject toolScannerScan(const QJsonObject& args); QJsonObject toolScannerScanPattern(const QJsonObject& args); QJsonObject toolReconnect(const QJsonObject& args); + QJsonObject toolProcessInfo(const QJsonObject& args); // Helpers QJsonObject makeTextResult(const QString& text, bool isError = false); diff --git a/tests/test_mcp.cpp b/tests/test_mcp.cpp new file mode 100644 index 0000000..f3133b7 --- /dev/null +++ b/tests/test_mcp.cpp @@ -0,0 +1,378 @@ +// Test MCP multi-client protocol: connect, initialize, tools/list, +// disconnect one client, notification broadcast, serial requests. +// Uses a MockMcpServer with the same multi-client architecture as McpBridge. + +#include +#include +#include +#include +#include +#include +#include +#include + +// ── Mock server (same pattern as McpBridge multi-client) ── + +class MockMcpServer : public QObject { + Q_OBJECT +public: + struct Client { QLocalSocket* socket; QByteArray buf; bool initialized; }; + QLocalServer* m_server = nullptr; + QVector m_clients; + + bool start(const QString& name) { + QLocalServer::removeServer(name); + m_server = new QLocalServer(this); + if (!m_server->listen(name)) return false; + connect(m_server, &QLocalServer::newConnection, this, [this]() { + while (auto* s = m_server->nextPendingConnection()) { + m_clients.append({s, {}, false}); + connect(s, &QLocalSocket::readyRead, this, [this, s]() { processSocket(s); }); + connect(s, &QLocalSocket::disconnected, this, [this, s]() { + for (int i = 0; i < m_clients.size(); i++) + if (m_clients[i].socket == s) { s->deleteLater(); m_clients.removeAt(i); break; } + }); + } + }); + return true; + } + void stop() { + for (auto& c : m_clients) { c.socket->disconnect(this); c.socket->disconnectFromServer(); c.socket->deleteLater(); } + m_clients.clear(); + if (m_server) { m_server->close(); delete m_server; m_server = nullptr; } + } + int clientCount() const { return m_clients.size(); } + int initializedCount() const { int n=0; for (auto& c:m_clients) if(c.initialized) n++; return n; } + + void broadcast(const QJsonObject& obj) { + QByteArray data = QJsonDocument(obj).toJson(QJsonDocument::Compact) + '\n'; + for (auto& c : m_clients) + if (c.initialized) { c.socket->write(data); c.socket->flush(); } + } + +private: + void sendTo(QLocalSocket* s, const QJsonObject& obj) { + s->write(QJsonDocument(obj).toJson(QJsonDocument::Compact) + '\n'); + s->flush(); + } + void processSocket(QLocalSocket* s) { + Client* cs = nullptr; + for (auto& c : m_clients) if (c.socket == s) { cs = &c; break; } + if (!cs) return; + cs->buf.append(s->readAll()); + while (true) { + int idx = cs->buf.indexOf('\n'); + if (idx < 0) break; + QByteArray line = cs->buf.left(idx).trimmed(); + cs->buf.remove(0, idx + 1); + if (line.isEmpty()) continue; + auto doc = QJsonDocument::fromJson(line); + if (!doc.isObject()) { + sendTo(s, {{"jsonrpc","2.0"},{"id",QJsonValue()}, + {"error",QJsonObject{{"code",-32700},{"message","Parse error"}}}}); + continue; + } + auto req = doc.object(); + QString method = req["method"].toString(); + QJsonValue id = req["id"]; + if (method.isEmpty()) { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32600},{"message","Missing method"}}}}); + } else if (method == "initialize") { + cs->initialized = true; + sendTo(s, {{"jsonrpc","2.0"},{"id",id},{"result",QJsonObject{ + {"protocolVersion","2024-11-05"}, + {"serverInfo",QJsonObject{{"name","mock-mcp"},{"version","1.0"}}}}}}); + } else if (method == "notifications/initialized" || method == "notifications/cancelled") { + // no-op client notifications + } else if (method == "tools/list") { + sendTo(s, {{"jsonrpc","2.0"},{"id",id},{"result",QJsonObject{ + {"tools",QJsonArray{QJsonObject{{"name","test.tool"},{"description","A test"}}}}}}}); + } else if (method == "tools/call") { + QString toolName = req["params"].toObject()["name"].toString(); + if (toolName == "mcp.reconnect") { + sendTo(s, {{"jsonrpc","2.0"},{"id",id},{"result",QJsonObject{ + {"content",QJsonArray{QJsonObject{{"type","text"},{"text","Disconnected."}}}}}}}); + // Disconnect after response is flushed + QTimer::singleShot(0, this, [this, s]() { + for (auto& cc : m_clients) if (cc.socket == s) { s->disconnectFromServer(); break; } + }); + } else if (toolName.isEmpty()) { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32602},{"message","Missing tool name"}}}}); + } else { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32601},{"message","Unknown tool"}}}}); + } + } else { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32601},{"message","Method not found"}}}}); + } + } + } +}; + +// ── Helpers ── + +static QLocalSocket* makeClient(const QString& pipe, QObject* parent) { + auto* s = new QLocalSocket(parent); + s->connectToServer(pipe); + return s->waitForConnected(2000) ? s : nullptr; +} + +// Send JSON-RPC and pump the event loop until we get a response line. +static QJsonObject rpc(QLocalSocket* s, const QJsonObject& req, int ms = 3000) { + s->write(QJsonDocument(req).toJson(QJsonDocument::Compact) + '\n'); + s->flush(); + QByteArray buf; + QElapsedTimer t; t.start(); + while (t.elapsed() < ms) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (s->bytesAvailable()) buf.append(s->readAll()); + int idx = buf.indexOf('\n'); + if (idx >= 0) return QJsonDocument::fromJson(buf.left(idx).trimmed()).object(); + } + return {}; +} + +static QJsonObject initRpc(QLocalSocket* s) { + return rpc(s, {{"jsonrpc","2.0"},{"id",1},{"method","initialize"}, + {"params",QJsonObject{{"protocolVersion","2024-11-05"}, + {"capabilities",QJsonObject{}}, + {"clientInfo",QJsonObject{{"name","test"}}}}}}); +} + +static QVector drain(QLocalSocket* s, int ms = 300) { + QVector out; + QByteArray buf; + QElapsedTimer t; t.start(); + while (t.elapsed() < ms) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 30); + if (s->bytesAvailable()) buf.append(s->readAll()); + } + while (true) { + int idx = buf.indexOf('\n'); + if (idx < 0) break; + auto line = buf.left(idx).trimmed(); + buf.remove(0, idx + 1); + if (!line.isEmpty()) out.append(QJsonDocument::fromJson(line).object()); + } + return out; +} + +// ── Tests ── + +class TestMcp : public QObject { + Q_OBJECT + MockMcpServer* m_srv = nullptr; + static constexpr const char* P = "ReclassMcpTest"; +private slots: + void init() { m_srv = new MockMcpServer; QVERIFY(m_srv->start(P)); } + void cleanup() { m_srv->stop(); delete m_srv; m_srv = nullptr; } + + void singleClient_initialize() { + auto* c = makeClient(P, this); QVERIFY(c); + auto r = initRpc(c); + QCOMPARE(r["id"].toInt(), 1); + QVERIFY(r.contains("result")); + QCOMPARE(r["result"].toObject()["serverInfo"].toObject()["name"].toString(), QString("mock-mcp")); + QCOMPARE(m_srv->initializedCount(), 1); + c->disconnectFromServer(); delete c; + } + + void singleClient_toolsList() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",2},{"method","tools/list"}}); + QCOMPARE(r["id"].toInt(), 2); + QCOMPARE(r["result"].toObject()["tools"].toArray().size(), 1); + c->disconnectFromServer(); delete c; + } + + void singleClient_unknownMethod() { + auto* c = makeClient(P, this); QVERIFY(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",1},{"method","bogus"}}); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32601); + c->disconnectFromServer(); delete c; + } + + void multiClient_bothInitialize() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + QCoreApplication::processEvents(); + QCOMPARE(m_srv->clientCount(), 2); + auto r1 = initRpc(c1); auto r2 = initRpc(c2); + QVERIFY(r1.contains("result")); + QVERIFY(r2.contains("result")); + QCOMPARE(m_srv->initializedCount(), 2); + c1->disconnectFromServer(); c2->disconnectFromServer(); delete c1; delete c2; + } + + void multiClient_disconnectOne() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + initRpc(c1); initRpc(c2); + c1->disconnectFromServer(); QTest::qWait(200); + QCOMPARE(m_srv->clientCount(), 1); + auto r = rpc(c2, {{"jsonrpc","2.0"},{"id",5},{"method","tools/list"}}); + QCOMPARE(r["id"].toInt(), 5); + QVERIFY(r["result"].toObject()["tools"].toArray().size() > 0); + c2->disconnectFromServer(); delete c1; delete c2; + } + + void multiClient_notificationBroadcast() { + auto* c1 = makeClient(P, this); + auto* c2 = makeClient(P, this); + auto* c3 = makeClient(P, this); // not initialized + QVERIFY(c1); QVERIFY(c2); QVERIFY(c3); + initRpc(c1); initRpc(c2); + + m_srv->broadcast({{"jsonrpc","2.0"}, + {"method","notifications/resources/updated"}, + {"params",QJsonObject{{"uri","project://tree"}}}}); + + auto l1 = drain(c1); auto l2 = drain(c2); auto l3 = drain(c3); + QVERIFY(l1.size() >= 1); + QCOMPARE(l1.last()["method"].toString(), QString("notifications/resources/updated")); + QVERIFY(l2.size() >= 1); + QCOMPARE(l2.last()["method"].toString(), QString("notifications/resources/updated")); + QCOMPARE(l3.size(), 0); + c1->disconnectFromServer(); c2->disconnectFromServer(); c3->disconnectFromServer(); + delete c1; delete c2; delete c3; + } + + void multiClient_serialRequests() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + initRpc(c1); initRpc(c2); + auto r1 = rpc(c1, {{"jsonrpc","2.0"},{"id",10},{"method","tools/list"}}); + auto r2 = rpc(c2, {{"jsonrpc","2.0"},{"id",20},{"method","tools/list"}}); + QCOMPARE(r1["id"].toInt(), 10); + QCOMPARE(r2["id"].toInt(), 20); + c1->disconnectFromServer(); c2->disconnectFromServer(); delete c1; delete c2; + } + + void allDisconnect_serverSurvives() { + auto* c1 = makeClient(P, this); QVERIFY(c1); + initRpc(c1); + c1->disconnectFromServer(); QTest::qWait(200); + QCOMPARE(m_srv->clientCount(), 0); + auto* c2 = makeClient(P, this); QVERIFY(c2); + auto r = initRpc(c2); + QVERIFY(r.contains("result")); + QCOMPARE(m_srv->clientCount(), 1); + c2->disconnectFromServer(); delete c1; delete c2; + } + + void protocol_invalidJson() { + auto* c = makeClient(P, this); QVERIFY(c); + c->write("this is not json\n"); + c->flush(); + QByteArray buf; + QElapsedTimer t; t.start(); + while (t.elapsed() < 2000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (c->bytesAvailable()) buf.append(c->readAll()); + if (buf.indexOf('\n') >= 0) break; + } + auto r = QJsonDocument::fromJson(buf.left(buf.indexOf('\n')).trimmed()).object(); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32700); + c->disconnectFromServer(); delete c; + } + + void protocol_missingMethod() { + auto* c = makeClient(P, this); QVERIFY(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",1}}); // no "method" key + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32600); + c->disconnectFromServer(); delete c; + } + + void protocol_notificationsIgnored() { + // notifications/initialized and notifications/cancelled should not produce a response + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + c->write(QJsonDocument(QJsonObject{{"jsonrpc","2.0"},{"method","notifications/initialized"}}).toJson(QJsonDocument::Compact) + '\n'); + c->write(QJsonDocument(QJsonObject{{"jsonrpc","2.0"},{"method","notifications/cancelled"},{"params",QJsonObject{{"requestId",1}}}}).toJson(QJsonDocument::Compact) + '\n'); + c->flush(); + auto lines = drain(c, 500); + QCOMPARE(lines.size(), 0); // no response for notifications + c->disconnectFromServer(); delete c; + } + + void toolsCall_unknownTool() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",2},{"method","tools/call"}, + {"params",QJsonObject{{"name","nonexistent.tool"},{"arguments",QJsonObject{}}}}}); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32601); + c->disconnectFromServer(); delete c; + } + + void toolsCall_missingToolName() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",3},{"method","tools/call"}, + {"params",QJsonObject{{"arguments",QJsonObject{}}}}}); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32602); + c->disconnectFromServer(); delete c; + } + + void toolsCall_reconnect() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + QCOMPARE(m_srv->clientCount(), 1); + + // Call mcp.reconnect — should get response then get disconnected + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",7},{"method","tools/call"}, + {"params",QJsonObject{{"name","mcp.reconnect"},{"arguments",QJsonObject{}}}}}); + QCOMPARE(r["id"].toInt(), 7); + QVERIFY(r.contains("result")); + QVERIFY(r["result"].toObject()["content"].toArray()[0].toObject()["text"] + .toString().contains("Disconnected")); + + // Wait for server-side disconnect + QTest::qWait(300); + QCOMPARE(m_srv->clientCount(), 0); + + // Reconnect — should work fine + auto* c2 = makeClient(P, this); QVERIFY(c2); + auto r2 = initRpc(c2); + QVERIFY(r2.contains("result")); + QCOMPARE(m_srv->clientCount(), 1); + + // Verify the new connection works + auto r3 = rpc(c2, {{"jsonrpc","2.0"},{"id",8},{"method","tools/list"}}); + QCOMPARE(r3["id"].toInt(), 8); + QVERIFY(r3["result"].toObject()["tools"].toArray().size() > 0); + + c2->disconnectFromServer(); delete c; delete c2; + } + + void toolsCall_reconnect_otherClientUnaffected() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + initRpc(c1); initRpc(c2); + QCOMPARE(m_srv->clientCount(), 2); + + // c1 calls reconnect — only c1 should disconnect + rpc(c1, {{"jsonrpc","2.0"},{"id",1},{"method","tools/call"}, + {"params",QJsonObject{{"name","mcp.reconnect"},{"arguments",QJsonObject{}}}}}); + QTest::qWait(300); + QCOMPARE(m_srv->clientCount(), 1); + + // c2 still works + auto r = rpc(c2, {{"jsonrpc","2.0"},{"id",2},{"method","tools/list"}}); + QCOMPARE(r["id"].toInt(), 2); + QVERIFY(r["result"].toObject()["tools"].toArray().size() > 0); + + c2->disconnectFromServer(); delete c1; delete c2; + } +}; + +QTEST_GUILESS_MAIN(TestMcp) +#include "test_mcp.moc" diff --git a/tests/test_scanner.cpp b/tests/test_scanner.cpp index e327a59..7d42181 100644 --- a/tests/test_scanner.cpp +++ b/tests/test_scanner.cpp @@ -1507,7 +1507,7 @@ private slots: data[0x8100] = char(0xFF); QVector regions; regions.append({0x8000, 0x1000, true, true, false, {}}); - auto prov = std::make_shared(data2, regions); + auto prov = std::make_shared(data, regions); ScanEngine engine; QSignalSpy finSpy(&engine, &ScanEngine::finished); ScanRequest req;