256 lines
7.8 KiB
C++
256 lines
7.8 KiB
C++
// MCP Stdio Transport tests
|
|
// Catch2 migration from GTest (yams-3s4 / yams-84g)
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <chrono>
|
|
#include <iostream>
|
|
#include <sstream>
|
|
#include <thread>
|
|
|
|
#include <yams/mcp/error_handling.h>
|
|
#include <yams/mcp/mcp_server.h>
|
|
|
|
using namespace yams::mcp;
|
|
using test_json = nlohmann::json;
|
|
|
|
namespace {
|
|
|
|
// MCP stdio spec: newline-delimited JSON (NDJSON)
|
|
std::string frameMessage(const test_json& j) {
|
|
return j.dump() + "\n";
|
|
}
|
|
|
|
std::string frameRaw(const std::string& raw) {
|
|
return raw + "\n";
|
|
}
|
|
|
|
class StdioTransportFixture {
|
|
public:
|
|
std::unique_ptr<StdioTransport> transport;
|
|
std::stringstream inputStream;
|
|
std::stringstream outputStream;
|
|
std::streambuf* originalCout;
|
|
std::streambuf* originalCin;
|
|
|
|
StdioTransportFixture() {
|
|
// Save original cout/cin
|
|
originalCout = std::cout.rdbuf();
|
|
originalCin = std::cin.rdbuf();
|
|
|
|
// Redirect cout to our stream
|
|
std::cout.rdbuf(outputStream.rdbuf());
|
|
|
|
transport = std::make_unique<StdioTransport>();
|
|
}
|
|
|
|
~StdioTransportFixture() {
|
|
// Restore original cout/cin
|
|
std::cout.rdbuf(originalCout);
|
|
std::cin.rdbuf(originalCin);
|
|
}
|
|
|
|
void setInput(const std::string& input) {
|
|
inputStream.str(input);
|
|
inputStream.clear();
|
|
std::cin.rdbuf(inputStream.rdbuf());
|
|
}
|
|
|
|
std::string getOutput() { return outputStream.str(); }
|
|
|
|
void clearOutput() {
|
|
outputStream.str("");
|
|
outputStream.clear();
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Initial state",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
CHECK(transport->isConnected());
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Send message",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
test_json testMessage = {{"jsonrpc", "2.0"}, {"method", "test"}, {"id", 1}};
|
|
|
|
transport->send(testMessage);
|
|
|
|
// Flush to ensure output is written to our stringstream
|
|
std::cout.flush();
|
|
|
|
std::string output = getOutput();
|
|
REQUIRE_FALSE(output.empty());
|
|
|
|
// MCP stdio spec: output should be NDJSON (JSON + newline)
|
|
CHECK(output.back() == '\n');
|
|
CHECK(output.find("Content-Length:") == std::string::npos); // No LSP headers
|
|
CHECK(output.find("Content-Type:") == std::string::npos); // No LSP headers
|
|
|
|
// Should contain the JSON fields
|
|
CHECK(output.find("\"jsonrpc\"") != std::string::npos);
|
|
CHECK(output.find("\"method\"") != std::string::npos);
|
|
CHECK(output.find("\"test\"") != std::string::npos);
|
|
CHECK(output.find("\"id\"") != std::string::npos);
|
|
|
|
// Should be parseable as JSON (without the newline)
|
|
auto parsed = test_json::parse(output.substr(0, output.size() - 1));
|
|
CHECK(parsed["method"] == "test");
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Receive valid JSON",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
test_json testMessage = {{"jsonrpc", "2.0"}, {"method", "initialize"}, {"id", 1}};
|
|
|
|
setInput(frameMessage(testMessage));
|
|
|
|
auto result = transport->receive();
|
|
|
|
REQUIRE(result.has_value());
|
|
const test_json& received = result.value();
|
|
CHECK(received["jsonrpc"] == "2.0");
|
|
CHECK(received["method"] == "initialize");
|
|
CHECK(received["id"] == 1);
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Receive invalid JSON",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
setInput(frameRaw("invalid json"));
|
|
|
|
auto result = transport->receive();
|
|
|
|
// Should return error for invalid JSON
|
|
REQUIRE_FALSE(result.has_value());
|
|
const auto& error = result.error();
|
|
CHECK(error.code == yams::ErrorCode::InvalidData);
|
|
CHECK_FALSE(error.message.empty());
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Receive empty line",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
setInput("\n"); // Just a newline, no framed message
|
|
|
|
auto result = transport->receive();
|
|
|
|
// Should skip empty lines and eventually timeout/close
|
|
REQUIRE_FALSE(result.has_value());
|
|
// Could be transport closed or timeout
|
|
CHECK(result.error().code == yams::ErrorCode::NetworkError);
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Receive multiple messages",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
test_json message1 = {{"jsonrpc", "2.0"}, {"id", 1}, {"method", "test1"}};
|
|
test_json message2 = {{"jsonrpc", "2.0"}, {"id", 2}, {"method", "test2"}};
|
|
|
|
setInput(frameMessage(message1) + frameMessage(message2));
|
|
|
|
auto result1 = transport->receive();
|
|
REQUIRE(result1.has_value());
|
|
CHECK(result1.value()["id"] == 1);
|
|
CHECK(result1.value()["method"] == "test1");
|
|
|
|
auto result2 = transport->receive();
|
|
REQUIRE(result2.has_value());
|
|
CHECK(result2.value()["id"] == 2);
|
|
CHECK(result2.value()["method"] == "test2");
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Send multiple messages",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
test_json message1 = {{"id", 1}, {"method", "test1"}};
|
|
test_json message2 = {{"id", 2}, {"method", "test2"}};
|
|
|
|
transport->send(message1);
|
|
transport->send(message2);
|
|
|
|
std::string output = getOutput();
|
|
|
|
// Both messages should be in output, each with newline (NDJSON)
|
|
CHECK(output.find("test1") != std::string::npos);
|
|
CHECK(output.find("test2") != std::string::npos);
|
|
|
|
// Count newlines - should be 2 (one per message)
|
|
size_t newlineCount = std::count(output.begin(), output.end(), '\n');
|
|
CHECK(newlineCount == 2);
|
|
|
|
// Should NOT have LSP headers
|
|
CHECK(output.find("Content-Length:") == std::string::npos);
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Close transport",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
CHECK(transport->isConnected());
|
|
|
|
transport->close();
|
|
|
|
CHECK_FALSE(transport->isConnected());
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Send after close",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
transport->close();
|
|
|
|
test_json testMessage = {{"test", "message"}};
|
|
|
|
// Should not crash when sending after close
|
|
REQUIRE_NOTHROW(transport->send(testMessage));
|
|
|
|
// Should not produce output
|
|
std::string output = getOutput();
|
|
CHECK(output.empty());
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Receive after close",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
transport->close();
|
|
|
|
setInput("{\"test\": \"message\"}\n");
|
|
|
|
auto result = transport->receive();
|
|
|
|
// Should return transport closed error
|
|
REQUIRE_FALSE(result.has_value());
|
|
CHECK(result.error().code == yams::ErrorCode::NetworkError);
|
|
}
|
|
|
|
TEST_CASE_METHOD(StdioTransportFixture, "StdioTransport - Complex JSON message",
|
|
"[mcp][transport][stdio][catch2]") {
|
|
test_json complexMessage = {
|
|
{"jsonrpc", "2.0"},
|
|
{"id", 42},
|
|
{"method", "tools/call"},
|
|
{"params",
|
|
{{"name", "search"},
|
|
{"arguments",
|
|
{{"query", "complex search with spaces"},
|
|
{"limit", 10},
|
|
{"filters",
|
|
{{"type", "document"},
|
|
{"tags", {"important", "urgent"}},
|
|
{"date_range", {{"start", "2024-01-01"}, {"end", "2024-12-31"}}}}}}}}}};
|
|
|
|
// Test sending complex message
|
|
transport->send(complexMessage);
|
|
std::string output = getOutput();
|
|
CHECK_FALSE(output.empty());
|
|
|
|
clearOutput();
|
|
|
|
// Test receiving complex message
|
|
setInput(frameMessage(complexMessage));
|
|
auto result = transport->receive();
|
|
|
|
REQUIRE(result.has_value());
|
|
const test_json& received = result.value();
|
|
CHECK(received["jsonrpc"] == "2.0");
|
|
CHECK(received["id"] == 42);
|
|
CHECK(received["method"] == "tools/call");
|
|
REQUIRE(received.contains("params"));
|
|
|
|
auto params = received["params"];
|
|
CHECK(params["name"] == "search");
|
|
CHECK(params.contains("arguments"));
|
|
}
|