mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<QLocalSocket*>(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<QLocalSocket*>(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"}});
|
||||
}
|
||||
|
||||
@@ -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<ClientState> 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<PendingRequest> 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);
|
||||
|
||||
378
tests/test_mcp.cpp
Normal file
378
tests/test_mcp.cpp
Normal file
@@ -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 <QTest>
|
||||
#include <QLocalServer>
|
||||
#include <QLocalSocket>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QElapsedTimer>
|
||||
#include <QTimer>
|
||||
|
||||
// ── 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<Client> 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<QJsonObject> drain(QLocalSocket* s, int ms = 300) {
|
||||
QVector<QJsonObject> 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"
|
||||
@@ -1507,7 +1507,7 @@ private slots:
|
||||
data[0x8100] = char(0xFF);
|
||||
QVector<MemoryRegion> regions;
|
||||
regions.append({0x8000, 0x1000, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(data2, regions);
|
||||
auto prov = std::make_shared<RegionProvider>(data, regions);
|
||||
ScanEngine engine;
|
||||
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||
ScanRequest req;
|
||||
|
||||
Reference in New Issue
Block a user