-
Notifications
You must be signed in to change notification settings - Fork 1
Expand MCP protocol coverage #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,7 +11,12 @@ | |||||||||||
| #include <QTemporaryDir> | ||||||||||||
| #include <memory> | ||||||||||||
| #include <QMainWindow> | ||||||||||||
| #include <unistd.h> | ||||||||||||
|
|
||||||||||||
| #define private public | ||||||||||||
| #include "MCPServer.h" | ||||||||||||
| #undef private | ||||||||||||
|
|
||||||||||||
| #include "Manager.h" | ||||||||||||
| #include "MeshInfoOverlay.h" | ||||||||||||
| #include "PrimitiveObject.h" | ||||||||||||
|
|
@@ -32,6 +37,32 @@ static bool isError(const QJsonObject &result) | |||||||||||
| return result["isError"].toBool(false); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| static QByteArray readTransportMessage(int readFd) | ||||||||||||
| { | ||||||||||||
| QByteArray response; | ||||||||||||
| char buffer[4096]; | ||||||||||||
| ssize_t bytesRead = read(readFd, buffer, sizeof(buffer)); | ||||||||||||
| if (bytesRead > 0) { | ||||||||||||
| response.append(buffer, bytesRead); | ||||||||||||
| } | ||||||||||||
| return response; | ||||||||||||
| } | ||||||||||||
|
Comment on lines
+40
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid blocking
💡 Suggested fix static QByteArray readTransportMessage(int readFd)
{
QByteArray response;
char buffer[4096];
- ssize_t bytesRead = read(readFd, buffer, sizeof(buffer));
- if (bytesRead > 0) {
- response.append(buffer, bytesRead);
- }
+ QElapsedTimer timer;
+ timer.start();
+ while (timer.elapsed() < 1000) {
+ ssize_t bytesRead = read(readFd, buffer, sizeof(buffer));
+ if (bytesRead > 0) {
+ response.append(buffer, bytesRead);
+ break;
+ }
+ QCoreApplication::processEvents(QEventLoop::AllEvents, 10);
+ QThread::msleep(5);
+ }
return response;
}Also applies to: 126-130 🤖 Prompt for AI Agents |
||||||||||||
|
|
||||||||||||
| static QJsonObject extractJsonBody(const QByteArray &transport) | ||||||||||||
| { | ||||||||||||
| const int headerEnd = transport.indexOf("\r\n\r\n"); | ||||||||||||
| EXPECT_NE(headerEnd, -1); | ||||||||||||
| if (headerEnd == -1) { | ||||||||||||
| return QJsonObject(); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| QJsonParseError error; | ||||||||||||
| const QJsonDocument doc = QJsonDocument::fromJson(transport.mid(headerEnd + 4), &error); | ||||||||||||
| EXPECT_EQ(error.error, QJsonParseError::NoError); | ||||||||||||
| EXPECT_TRUE(doc.isObject()); | ||||||||||||
| return doc.object(); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| class MCPServerTest : public ::testing::Test | ||||||||||||
| { | ||||||||||||
| protected: | ||||||||||||
|
|
@@ -65,6 +96,49 @@ class MCPServerTest : public ::testing::Test | |||||||||||
| std::unique_ptr<MCPServer> server; | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| class MCPServerProtocolTest : public ::testing::Test | ||||||||||||
| { | ||||||||||||
| protected: | ||||||||||||
| void SetUp() override | ||||||||||||
| { | ||||||||||||
| app = qobject_cast<QApplication*>(QCoreApplication::instance()); | ||||||||||||
| ASSERT_NE(app, nullptr); | ||||||||||||
| server = std::make_unique<MCPServer>(); | ||||||||||||
| ASSERT_EQ(pipe(outputPipe), 0); | ||||||||||||
| server->setOutputFd(outputPipe[1]); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| void TearDown() override | ||||||||||||
| { | ||||||||||||
| server.reset(); | ||||||||||||
| if (outputPipe[0] != -1) { | ||||||||||||
| close(outputPipe[0]); | ||||||||||||
| } | ||||||||||||
| if (outputPipe[1] != -1) { | ||||||||||||
| close(outputPipe[1]); | ||||||||||||
| } | ||||||||||||
| Manager::kill(); | ||||||||||||
| if (app) { | ||||||||||||
| app->processEvents(); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| QJsonObject processAndRead(const QByteArray &payload) | ||||||||||||
| { | ||||||||||||
| server->processMessage(payload); | ||||||||||||
| return extractJsonBody(readTransportMessage(outputPipe[0])); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| QByteArray makeTransport(const QByteArray &json) | ||||||||||||
| { | ||||||||||||
| return QByteArray("Content-Length: ") + QByteArray::number(json.size()) + "\r\n\r\n" + json; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| QApplication* app = nullptr; | ||||||||||||
| std::unique_ptr<MCPServer> server; | ||||||||||||
| int outputPipe[2] = {-1, -1}; | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| // --- Material tools --- | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerTest, CreateMaterial) | ||||||||||||
|
|
@@ -3548,3 +3622,152 @@ TEST_F(MCPServerTest, OpenScene_ValidFile_LoadsEntities) | |||||||||||
| EXPECT_TRUE(resultText.contains("Scene loaded")); | ||||||||||||
| EXPECT_TRUE(resultText.contains("scene node(s)")); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, ProcessMessageRejectsInvalidJson) | ||||||||||||
| { | ||||||||||||
| const QJsonObject response = processAndRead("not-json"); | ||||||||||||
| ASSERT_TRUE(response.contains("error")); | ||||||||||||
| EXPECT_EQ(response["error"].toObject()["code"].toInt(), -32700); | ||||||||||||
| EXPECT_TRUE(response["error"].toObject()["message"].toString().contains("Parse error")); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, ProcessMessageRejectsNonObjectRequest) | ||||||||||||
| { | ||||||||||||
| const QByteArray payload = QJsonDocument(QJsonArray{1, 2, 3}).toJson(QJsonDocument::Compact); | ||||||||||||
| const QJsonObject response = processAndRead(payload); | ||||||||||||
| ASSERT_TRUE(response.contains("error")); | ||||||||||||
| EXPECT_EQ(response["error"].toObject()["code"].toInt(), -32600); | ||||||||||||
| EXPECT_TRUE(response["error"].toObject()["message"].toString().contains("Invalid Request")); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, ProcessMessageInitializeRespondsWithCapabilities) | ||||||||||||
| { | ||||||||||||
| const QJsonObject request{ | ||||||||||||
| {"jsonrpc", "2.0"}, | ||||||||||||
| {"id", 7}, | ||||||||||||
| {"method", "initialize"}, | ||||||||||||
| {"params", QJsonObject{}} | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| const QJsonObject response = processAndRead(QJsonDocument(request).toJson(QJsonDocument::Compact)); | ||||||||||||
| ASSERT_TRUE(response.contains("result")); | ||||||||||||
| EXPECT_EQ(response["id"].toInt(), 7); | ||||||||||||
| EXPECT_TRUE(server->m_initialized); | ||||||||||||
|
|
||||||||||||
| const QJsonObject result = response["result"].toObject(); | ||||||||||||
| EXPECT_EQ(result["protocolVersion"].toString(), "2024-11-05"); | ||||||||||||
| EXPECT_EQ(result["serverInfo"].toObject()["name"].toString(), "QtMeshEditor"); | ||||||||||||
| EXPECT_TRUE(result["capabilities"].toObject().contains("tools")); | ||||||||||||
| EXPECT_TRUE(result["capabilities"].toObject().contains("resources")); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, ProcessMessageUnknownNotificationLeavesServerStateUnchanged) | ||||||||||||
| { | ||||||||||||
| const QJsonObject request{ | ||||||||||||
| {"jsonrpc", "2.0"}, | ||||||||||||
| {"method", "notifications/custom"}, | ||||||||||||
| {"params", QJsonObject{{"value", 1}}} | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| EXPECT_FALSE(server->m_initialized); | ||||||||||||
| server->processMessage(QJsonDocument(request).toJson(QJsonDocument::Compact)); | ||||||||||||
| EXPECT_FALSE(server->m_initialized); | ||||||||||||
| EXPECT_TRUE(server->m_buffer.isEmpty()); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, ProcessMessageUnknownMethodReturnsMethodNotFound) | ||||||||||||
| { | ||||||||||||
| const QJsonObject request{ | ||||||||||||
| {"jsonrpc", "2.0"}, | ||||||||||||
| {"id", "abc"}, | ||||||||||||
| {"method", "totally/unknown"}, | ||||||||||||
| {"params", QJsonObject{}} | ||||||||||||
| }; | ||||||||||||
|
|
||||||||||||
| const QJsonObject response = processAndRead(QJsonDocument(request).toJson(QJsonDocument::Compact)); | ||||||||||||
| ASSERT_TRUE(response.contains("error")); | ||||||||||||
| EXPECT_EQ(response["id"].toString(), "abc"); | ||||||||||||
| EXPECT_EQ(response["error"].toObject()["code"].toInt(), -32601); | ||||||||||||
| EXPECT_TRUE(response["error"].toObject()["message"].toString().contains("Method not found")); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, HandleResourcesListReturnsExpectedUris) | ||||||||||||
| { | ||||||||||||
| const QJsonObject result = server->handleResourcesList(); | ||||||||||||
| const QJsonArray resources = result["resources"].toArray(); | ||||||||||||
| ASSERT_EQ(resources.size(), 2); | ||||||||||||
| EXPECT_EQ(resources[0].toObject()["uri"].toString(), "qtmesheditor://material/current"); | ||||||||||||
| EXPECT_EQ(resources[1].toObject()["uri"].toString(), "qtmesheditor://scene/info"); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, HandleResourcesReadCurrentMaterialWithoutMainWindowReturnsPlaceholder) | ||||||||||||
| { | ||||||||||||
| const QJsonObject result = server->handleResourcesRead(QJsonObject{{"uri", "qtmesheditor://material/current"}}); | ||||||||||||
| const QJsonArray contents = result["contents"].toArray(); | ||||||||||||
| ASSERT_EQ(contents.size(), 1); | ||||||||||||
| EXPECT_EQ(contents[0].toObject()["mimeType"].toString(), "text/plain"); | ||||||||||||
| EXPECT_TRUE(contents[0].toObject()["text"].toString().contains("No material currently loaded")); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, HandleResourcesReadSceneInfoReturnsSerializedText) | ||||||||||||
| { | ||||||||||||
| ASSERT_TRUE(tryInitOgre()); | ||||||||||||
| createStandardOgreMaterials(); | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+3712
to
+3714
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Skip this OGRE-backed test when headless init is unavailable. Every other OGRE-dependent test in this file skips on 💡 Suggested change- ASSERT_TRUE(tryInitOgre());
- createStandardOgreMaterials();
+ if (!tryInitOgre()) {
+ GTEST_SKIP() << "Skipping: Ogre initialization failed";
+ }
+ createStandardOgreMaterials();🤖 Prompt for AI Agents |
||||||||||||
| const QJsonObject result = server->handleResourcesRead(QJsonObject{{"uri", "qtmesheditor://scene/info"}}); | ||||||||||||
| const QJsonArray contents = result["contents"].toArray(); | ||||||||||||
| ASSERT_EQ(contents.size(), 1); | ||||||||||||
| EXPECT_EQ(contents[0].toObject()["mimeType"].toString(), "application/json"); | ||||||||||||
| EXPECT_TRUE(contents[0].toObject()["text"].toString().contains("Scene Information")); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, BuildToolsListContainsCoreToolDefinitions) | ||||||||||||
| { | ||||||||||||
| const QJsonArray tools = server->buildToolsList(); | ||||||||||||
| EXPECT_GE(tools.size(), 25); | ||||||||||||
|
|
||||||||||||
| bool sawCreateMaterial = false; | ||||||||||||
| bool sawOpenScene = false; | ||||||||||||
| for (const QJsonValue &value : tools) { | ||||||||||||
| const QJsonObject tool = value.toObject(); | ||||||||||||
| if (tool["name"].toString() == "create_material") { | ||||||||||||
| sawCreateMaterial = true; | ||||||||||||
| EXPECT_TRUE(tool.contains("inputSchema")); | ||||||||||||
| } | ||||||||||||
| if (tool["name"].toString() == "open_scene") { | ||||||||||||
| sawOpenScene = true; | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| EXPECT_TRUE(sawCreateMaterial); | ||||||||||||
| EXPECT_TRUE(sawOpenScene); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| TEST_F(MCPServerProtocolTest, OnReadyReadRecoversAfterInvalidHeaderAndParsesMessage) | ||||||||||||
| { | ||||||||||||
| int inputPipe[2] = {-1, -1}; | ||||||||||||
| ASSERT_EQ(pipe(inputPipe), 0); | ||||||||||||
|
|
||||||||||||
| server->m_stdinFd = inputPipe[0]; | ||||||||||||
| server->m_stdinNotifier = new QSocketNotifier(server->m_stdinFd, QSocketNotifier::Read, server.get()); | ||||||||||||
|
|
||||||||||||
| const QJsonObject request{ | ||||||||||||
| {"jsonrpc", "2.0"}, | ||||||||||||
| {"id", 3}, | ||||||||||||
| {"method", "ping"}, | ||||||||||||
| {"params", QJsonObject{}} | ||||||||||||
| }; | ||||||||||||
| const QByteArray payload = QByteArray("garbage\r\n\r\n") + makeTransport(QJsonDocument(request).toJson(QJsonDocument::Compact)); | ||||||||||||
| ASSERT_GT(write(inputPipe[1], payload.constData(), payload.size()), 0); | ||||||||||||
|
|
||||||||||||
|
Comment on lines
+3760
to
+3762
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Assert full pipe write to avoid truncated-frame flakes.
💡 Suggested fix- ASSERT_GT(write(inputPipe[1], payload.constData(), payload.size()), 0);
+ const ssize_t written = write(inputPipe[1], payload.constData(), payload.size());
+ ASSERT_EQ(written, static_cast<ssize_t>(payload.size()));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| server->onReadyRead(); | ||||||||||||
|
|
||||||||||||
| const QJsonObject response = extractJsonBody(readTransportMessage(outputPipe[0])); | ||||||||||||
| EXPECT_EQ(response["id"].toInt(), 3); | ||||||||||||
| EXPECT_TRUE(response["result"].toObject().isEmpty()); | ||||||||||||
|
|
||||||||||||
| delete server->m_stdinNotifier; | ||||||||||||
| server->m_stdinNotifier = nullptr; | ||||||||||||
| close(inputPipe[0]); | ||||||||||||
| close(inputPipe[1]); | ||||||||||||
| } | ||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: fernandotonon/QtMeshEditor
Length of output: 1578
Guard POSIX APIs with
#ifndef Q_OS_WINfor Windows compatibility.The file uses
<unistd.h>and POSIX functions (pipe,read,write,close) without platform guards. These APIs are unavailable on Windows.Wrap the include and all POSIX usage in:
#include <unistd.h>pipe()andclose()inSetUp()andTearDown()pipe(),write(), andclose()in theOnReadyReadtestEither guard with
#ifndef Q_OS_WINor provide Windows-safe alternatives (e.g., named pipes or sockets).🤖 Prompt for AI Agents