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:
noita-player
2026-03-08 20:49:59 -07:00
parent 51de48a6ed
commit 4d0782db68
5 changed files with 549 additions and 62 deletions

View File

@@ -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

View File

@@ -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"}});
}

View File

@@ -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
View 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"

View File

@@ -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;