Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ int main(int argc, char* argv[])
json response;
if(msg.is_array())
{
// JSON-RPC batch
if(msg.empty())
{
json errorResp;
Expand Down
68 changes: 50 additions & 18 deletions src/mcp/mcp_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ json McpServer::makeError(const json& id, int code, const std::string& message)
return resp;
}

json McpServer::makeToolResult(const json& data, bool isError)
json McpServer::makeToolResult(const json& data, bool isError) const
{
json result;
json content;
Expand All @@ -62,11 +62,39 @@ json McpServer::makeToolResult(const json& data, bool isError)
content["text"] = data.dump();

result["content"] = json::array({content});
if(m_protocolVersion == ProtocolVersion::V2025_06_18 && !isError && data.is_object())
result["structuredContent"] = data;
if(isError)
result["isError"] = true;
return result;
}

ProtocolVersion McpServer::negotiateProtocolVersion(const json& params)
{
if(params.contains("protocolVersion"))
{
const auto clientVersion = params["protocolVersion"].get<std::string>();
if(clientVersion == kProtocolVersion2025_03_26)
return ProtocolVersion::V2025_03_26;
if(clientVersion == kProtocolVersion2025_06_18)
return ProtocolVersion::V2025_06_18;
}

return ProtocolVersion::V2025_06_18;
}

const char* McpServer::protocolVersionString(ProtocolVersion version)
{
switch(version)
{
case ProtocolVersion::V2025_03_26:
return kProtocolVersion2025_03_26;
case ProtocolVersion::V2025_06_18:
return kProtocolVersion2025_06_18;
}
return kProtocolVersion;
}

// ── Message dispatch ────────────────────────────────────────────────────────

json McpServer::handleMessage(const json& msg)
Expand All @@ -80,9 +108,13 @@ json McpServer::handleMessage(const json& msg)
json id = msg.value("id", json(nullptr));

// Route methods
if(method == "initialize")
if(method == "ping")
{
if(m_initialized)
return makeResponse(id, json::object());
}
else if(method == "initialize")
{
if(m_initializeSeen)
return makeError(id, -32600, "Server already initialized");
return handleInitialize(msg);
}
Expand Down Expand Up @@ -112,11 +144,10 @@ json McpServer::handleMessage(const json& msg)

json McpServer::handleBatch(const json& arr)
{
// Check for initialize in batch (forbidden by MCP spec)
for(const auto& msg : arr)
if(!m_initializeSeen || m_protocolVersion == ProtocolVersion::V2025_06_18)
{
if(msg.is_object() && msg.value("method", "") == "initialize")
return makeError(nullptr, -32600, "Invalid Request: initialize must not appear in a JSON-RPC batch");
return makeError(nullptr, -32600,
"Invalid Request: JSON-RPC batching is not supported by the negotiated MCP protocol");
}

json responses = json::array();
Expand All @@ -127,12 +158,12 @@ json McpServer::handleBatch(const json& arr)
responses.push_back(makeError(nullptr, -32600, "Invalid Request: batch element is not an object"));
continue;
}

json resp = handleMessage(msg);
if(!resp.is_null())
responses.push_back(resp);
}

// If all were notifications, return nothing
if(responses.empty())
return nullptr;

Expand All @@ -145,24 +176,25 @@ json McpServer::handleInitialize(const json& msg)
{
json id = msg.value("id", json(nullptr));

// Validate client protocol version for compatibility
static constexpr const char* kSupportedProtocolVersion = "2025-03-26";
json params = msg.value("params", json::object());
if (params.contains("protocolVersion")) {
std::string clientVersion = params["protocolVersion"].get<std::string>();
if (clientVersion != kSupportedProtocolVersion) {
return makeError(id, -32602,
"Unsupported protocol version: " + clientVersion +
" (server supports " + kSupportedProtocolVersion + ")");
}
if(!params.is_object())
{
return makeError(id, -32602, "Invalid params: initialize params must be an object");
}
if(params.contains("protocolVersion") && !params["protocolVersion"].is_string())
{
return makeError(id, -32602, "Invalid params: protocolVersion must be a string");
}

m_protocolVersion = negotiateProtocolVersion(params);

json result;
result["protocolVersion"] = kSupportedProtocolVersion;
result["protocolVersion"] = protocolVersionString(m_protocolVersion);
result["capabilities"]["tools"] = json::object();
result["serverInfo"]["name"] = "renderdoc-mcp";
result["serverInfo"]["version"] = "1.0.0";

m_initializeSeen = true;
return makeResponse(id, result);
}

Expand Down
18 changes: 16 additions & 2 deletions src/mcp/mcp_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ namespace renderdoc::core { class DiffSession; }

namespace renderdoc::mcp {

inline constexpr const char* kProtocolVersion2025_03_26 = "2025-03-26";
inline constexpr const char* kProtocolVersion2025_06_18 = "2025-06-18";
inline constexpr const char* kProtocolVersion = kProtocolVersion2025_06_18;

enum class ProtocolVersion
{
V2025_03_26,
V2025_06_18,
};

class McpServer
{
public:
Expand All @@ -21,7 +31,7 @@ class McpServer
// Process a single JSON-RPC message. Returns response JSON, or nullptr for notifications.
nlohmann::json handleMessage(const nlohmann::json& msg);

// Process a JSON-RPC batch (array). Returns response array.
// Process a JSON-RPC batch. Supported only after negotiating a batch-capable protocol.
nlohmann::json handleBatch(const nlohmann::json& arr);

void shutdown();
Expand All @@ -35,14 +45,18 @@ class McpServer
// JSON-RPC response helpers
static nlohmann::json makeResponse(const nlohmann::json& id, const nlohmann::json& result);
static nlohmann::json makeError(const nlohmann::json& id, int code, const std::string& message);
static nlohmann::json makeToolResult(const nlohmann::json& data, bool isError = false);
nlohmann::json makeToolResult(const nlohmann::json& data, bool isError = false) const;
static ProtocolVersion negotiateProtocolVersion(const nlohmann::json& params);
static const char* protocolVersionString(ProtocolVersion version);

std::unique_ptr<core::Session> m_ownedSession; // owned, only set by default ctor
core::Session* m_session = nullptr; // always valid (points to owned or injected)
std::unique_ptr<core::DiffSession> m_ownedDiffSession; // owned, only set by default ctor
core::DiffSession* m_diffSession = nullptr; // always valid (points to owned or injected)
std::unique_ptr<ToolRegistry> m_ownedRegistry; // owned, only set by default ctor
ToolRegistry* m_registry = nullptr; // always valid (points to owned or injected)
ProtocolVersion m_protocolVersion = ProtocolVersion::V2025_06_18;
bool m_initializeSeen = false;
bool m_initialized = false;
};

Expand Down
35 changes: 17 additions & 18 deletions tests/integration/test_protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include <chrono>
#include <thread>

#include "mcp/mcp_server.h"

#ifdef _WIN32
#include <windows.h>
#endif
Expand Down Expand Up @@ -250,7 +252,7 @@ class ProtocolTest : public ::testing::Test {
initReq["jsonrpc"] = "2.0";
initReq["id"] = 0;
initReq["method"] = "initialize";
initReq["params"]["protocolVersion"] = "2025-03-26";
initReq["params"]["protocolVersion"] = renderdoc::mcp::kProtocolVersion;
initReq["params"]["clientInfo"]["name"] = "test-runner";
initReq["params"]["clientInfo"]["version"] = "1.0.0";
initReq["params"]["capabilities"] = json::object();
Expand Down Expand Up @@ -326,6 +328,7 @@ TEST_F(ProtocolTest, InitializeHandshake)
auto& result = s_initResponse["result"];

EXPECT_TRUE(result.contains("protocolVersion"));
EXPECT_EQ(result["protocolVersion"], renderdoc::mcp::kProtocolVersion);
EXPECT_TRUE(result.contains("serverInfo"));
EXPECT_TRUE(result["serverInfo"].contains("name"));
EXPECT_EQ(result["serverInfo"]["name"], "renderdoc-mcp");
Expand Down Expand Up @@ -408,30 +411,26 @@ TEST_F(ProtocolTest, MethodNotFound_UnknownMethod)
EXPECT_EQ((*resp)["error"]["code"].get<int>(), -32601);
}

TEST_F(ProtocolTest, BatchRequest_ArrayResponse)
TEST_F(ProtocolTest, Ping_ReturnsEmptyResult)
{
auto req = makeRequest("ping");
auto resp = send(req);
ASSERT_TRUE(resp.has_value());
ASSERT_TRUE(resp->contains("result"));
EXPECT_TRUE((*resp)["result"].is_object());
EXPECT_TRUE((*resp)["result"].empty());
}

TEST_F(ProtocolTest, BatchRequest_Rejected)
{
json batch = json::array();
batch.push_back(makeRequest("tools/list", json::object(), 1));
batch.push_back(makeRequest("nonexistent/method", json::object(), 2));

auto resp = send(batch);
ASSERT_TRUE(resp.has_value());
ASSERT_TRUE(resp->is_array());
EXPECT_EQ(resp->size(), 2u);

// Find responses by id
bool foundToolsList = false;
bool foundMethodNotFound = false;
for (auto& r : *resp) {
if (r.contains("id")) {
if (r["id"] == 1 && r.contains("result"))
foundToolsList = true;
if (r["id"] == 2 && r.contains("error") && r["error"]["code"] == -32601)
foundMethodNotFound = true;
}
}
EXPECT_TRUE(foundToolsList) << "Batch should contain tools/list result";
EXPECT_TRUE(foundMethodNotFound) << "Batch should contain method-not-found error";
ASSERT_TRUE(resp->contains("error"));
EXPECT_EQ((*resp)["error"]["code"].get<int>(), -32600);
}

TEST_F(ProtocolTest, ProcessStable_MultipleRequests)
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_workflow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include <optional>
#include <chrono>

#include "mcp/mcp_server.h"

#ifdef _WIN32
#include <windows.h>
#endif
Expand Down Expand Up @@ -218,7 +220,7 @@ class WorkflowTest : public ::testing::Test {
initReq["jsonrpc"] = "2.0";
initReq["id"] = 0;
initReq["method"] = "initialize";
initReq["params"]["protocolVersion"] = "2025-03-26";
initReq["params"]["protocolVersion"] = renderdoc::mcp::kProtocolVersion;
initReq["params"]["clientInfo"]["name"] = "test-runner";
initReq["params"]["clientInfo"]["version"] = "1.0.0";
initReq["params"]["capabilities"] = json::object();
Expand Down
Loading