diff --git a/README.md b/README.md index 897e9ec..116a1f1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - **JSON-RPC 2.0 Communication**: Request/response communication based on JSON-RPC 2.0 standard - **Resource Abstraction**: Standard interfaces for resources such as files, APIs, etc. - **Tool Registration**: Register and call tools with structured parameters +- **Prompt Templates**: Register and expose prompt templates to clients for AI workflows - **Extensible Architecture**: Easy to extend with new resource types and tools - **Multi-Transport Support**: Supports HTTP and standard input/output (stdio) communication methods @@ -75,17 +76,22 @@ Implements MCP server functionality. ### HTTP Server Example (`examples/server_example.cpp`) -Example MCP server implementation with custom tools: +Example MCP server implementation over HTTP/SSE with custom tools: - Time tool: Get the current time - Calculator tool: Perform mathematical operations - Echo tool: Echo input with optional transformations (to uppercase, reverse) - Greeting tool: Returns `Hello, `+ input name + `!`, defaults to `Hello, World!` +### Stdio Server Example (`examples/stdio_server_example.cpp`) + +Example MCP server implementation over standard input/output (stdio), which is the default transport method for MCP clients like Claude Desktop, Cursor, and OpenCode. +It implements the same core tools but uses `server.start_stdio()` to communicate via pipes instead of opening an HTTP port. + ### HTTP Client Example (`examples/client_example.cpp`) Example MCP client connecting to a server: - Get server information -- List available tools +- List available tools and prompts - Call tools with parameters - Access resources @@ -156,6 +162,26 @@ server.register_tool(hello_tool, hello_handler); auto file_resource = std::make_shared(""); server.register_resource("file://", file_resource); +// Register prompts +mcp::prompt draft_prompt = mcp::prompt_builder("draft_article") + .with_description("Draft a new article") + .with_argument("topic", "The main topic to write about", true) + .build(); + +server.register_prompt(draft_prompt, [](const mcp::json& args, const std::string /* session_id */) -> mcp::json { + std::string topic = args.value("topic", "Unknown"); + std::string instruction = "Please write an article about " + topic; + + // Return messages array as specified in the MCP protocol + return mcp::json::array({{ + {"role", "user"}, + {"content", { + {"type", "text"}, + {"text", instruction} + }} + }}); +}); + // Start the server server.start(true); // Blocking mode ``` diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index fa215e6..7608d19 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -29,4 +29,9 @@ target_link_libraries(${TARGET} PRIVATE mcp) target_include_directories(${TARGET} PRIVATE ${CMAKE_SOURCE_DIR}/include) if(OPENSSL_FOUND) target_link_libraries(${TARGET} PRIVATE ${OPENSSL_LIBRARIES}) -endif() \ No newline at end of file +endif() + +set(TARGET stdio_server_example) +add_executable(${TARGET} stdio_server_example.cpp) +target_link_libraries(${TARGET} PRIVATE mcp) +target_include_directories(${TARGET} PRIVATE ${CMAKE_SOURCE_DIR}/include) diff --git a/examples/server_example.cpp b/examples/server_example.cpp index b81fc78..11ad575 100644 --- a/examples/server_example.cpp +++ b/examples/server_example.cpp @@ -137,7 +137,8 @@ int main() { // {"resources", {{"subscribe", false}, {"listChanged", true}}} // }; mcp::json capabilities = { - {"tools", mcp::json::object()} + {"tools", mcp::json::object()}, + {"prompts", mcp::json::object()} }; server.set_capabilities(capabilities); @@ -170,6 +171,29 @@ int main() { server.register_tool(calc_tool, calculator_handler); server.register_tool(hello_tool, hello_handler); + // Register prompt + mcp::prompt hello_prompt = mcp::prompt_builder("hello_prompt") + .with_description("A prompt to generate a greeting") + .with_argument("name", "The name to greet", true) + .build(); + + server.register_prompt(hello_prompt, [](const mcp::json& args, const std::string& session_id) -> mcp::json { + std::string name = "World"; + if (args.contains("name")) { + name = args["name"].get(); + } + + mcp::json message = { + {"role", "user"}, + {"content", { + {"type", "text"}, + {"text", "Please greet " + name + " in a friendly way."} + }} + }; + + return mcp::json::array({message}); + }); + // // Register resources // auto file_resource = std::make_shared("./Makefile"); // server.register_resource("file://./Makefile", file_resource); diff --git a/examples/stdio_server_example.cpp b/examples/stdio_server_example.cpp new file mode 100644 index 0000000..ddebc9f --- /dev/null +++ b/examples/stdio_server_example.cpp @@ -0,0 +1,203 @@ +/** + * @file stdio_server_example.cpp + * @brief Server example based on MCP protocol using Standard I/O transport + * + * This example demonstrates how to create an MCP server that communicates + * over standard input (stdin) and standard output (stdout). + * Follows the 2024-11-05 basic protocol specification. + */ +#include "mcp_server.h" +#include "mcp_tool.h" +#include "mcp_resource.h" + +#include +#include +#include +#include +#include +#include + +// Tool handler for getting current time +mcp::json get_time_handler(const mcp::json& params, const std::string& /* session_id */) { + auto now = std::chrono::system_clock::now(); + auto time_t_now = std::chrono::system_clock::to_time_t(now); + + std::string time_str = std::ctime(&time_t_now); + // Remove trailing newline + if (!time_str.empty() && time_str[time_str.length() - 1] == '\n') { + time_str.erase(time_str.length() - 1); + } + + return { + { + {"type", "text"}, + {"text", time_str} + } + }; +} + +// Echo tool handler +mcp::json echo_handler(const mcp::json& params, const std::string& /* session_id */) { + mcp::json result = params; + + if (params.contains("text")) { + std::string text = params["text"]; + + if (params.contains("uppercase") && params["uppercase"].get()) { + std::transform(text.begin(), text.end(), text.begin(), ::toupper); + result["text"] = text; + } + + if (params.contains("reverse") && params["reverse"].get()) { + std::reverse(text.begin(), text.end()); + result["text"] = text; + } + } + + return { + { + {"type", "text"}, + {"text", result["text"].get()} + } + }; +} + +// Calculator tool handler +mcp::json calculator_handler(const mcp::json& params, const std::string& /* session_id */) { + if (!params.contains("operation")) { + throw mcp::mcp_exception(mcp::error_code::invalid_params, "Missing 'operation' parameter"); + } + + std::string operation = params["operation"]; + double result = 0.0; + + if (operation == "add") { + if (!params.contains("a") || !params.contains("b")) { + throw mcp::mcp_exception(mcp::error_code::invalid_params, "Missing 'a' or 'b' parameter"); + } + result = params["a"].get() + params["b"].get(); + } else if (operation == "subtract") { + if (!params.contains("a") || !params.contains("b")) { + throw mcp::mcp_exception(mcp::error_code::invalid_params, "Missing 'a' or 'b' parameter"); + } + result = params["a"].get() - params["b"].get(); + } else if (operation == "multiply") { + if (!params.contains("a") || !params.contains("b")) { + throw mcp::mcp_exception(mcp::error_code::invalid_params, "Missing 'a' or 'b' parameter"); + } + result = params["a"].get() * params["b"].get(); + } else if (operation == "divide") { + if (!params.contains("a") || !params.contains("b")) { + throw mcp::mcp_exception(mcp::error_code::invalid_params, "Missing 'a' or 'b' parameter"); + } + if (params["b"].get() == 0.0) { + throw mcp::mcp_exception(mcp::error_code::invalid_params, "Division by zero not allowed"); + } + result = params["a"].get() / params["b"].get(); + } else { + throw mcp::mcp_exception(mcp::error_code::invalid_params, "Unknown operation: " + operation); + } + + return { + { + {"type", "text"}, + {"text", std::to_string(result)} + } + }; +} + +// Custom API endpoint handler +mcp::json hello_handler(const mcp::json& params, const std::string& /* session_id */) { + std::string name = params.contains("name") ? params["name"].get() : "World"; + return { + { + {"type", "text"}, + {"text", "Hello, " + name + "!"} + } + }; +} + +int main() { + // Ensure file directory exists + std::filesystem::create_directories("./files"); + + // Create and configure server + mcp::server::configuration srv_conf; + // Note: host and port are ignored in stdio mode + + mcp::server server(srv_conf); + server.set_server_info("StdioExampleServer", "1.0.0"); + + // Set server capabilities + mcp::json capabilities = { + {"tools", mcp::json::object()}, + {"prompts", mcp::json::object()} + }; + server.set_capabilities(capabilities); + + // Register tools + mcp::tool time_tool = mcp::tool_builder("get_time") + .with_description("Get current time") + .build(); + + mcp::tool echo_tool = mcp::tool_builder("echo") + .with_description("Echo input with optional transformations") + .with_string_param("text", "Text to echo") + .with_boolean_param("uppercase", "Convert to uppercase", false) + .with_boolean_param("reverse", "Reverse the text", false) + .build(); + + mcp::tool calc_tool = mcp::tool_builder("calculator") + .with_description("Perform basic calculations") + .with_string_param("operation", "Operation to perform (add, subtract, multiply, divide)") + .with_number_param("a", "First operand") + .with_number_param("b", "Second operand") + .build(); + + mcp::tool hello_tool = mcp::tool_builder("hello") + .with_description("Say hello") + .with_string_param("name", "Name to say hello to", "World") + .build(); + + server.register_tool(time_tool, get_time_handler); + server.register_tool(echo_tool, echo_handler); + server.register_tool(calc_tool, calculator_handler); + server.register_tool(hello_tool, hello_handler); + + // Register prompt + mcp::prompt hello_prompt = mcp::prompt_builder("hello_prompt") + .with_description("A prompt to generate a greeting") + .with_argument("name", "The name to greet", true) + .build(); + + server.register_prompt(hello_prompt, [](const mcp::json& args, const std::string& session_id) -> mcp::json { + std::string name = "World"; + if (args.contains("name")) { + name = args["name"].get(); + } + + mcp::json message = { + {"role", "user"}, + {"content", { + {"type", "text"}, + {"text", "Please greet " + name + " in a friendly way."} + }} + }; + + return mcp::json::array({message}); + }); + + // // Register resources + // auto file_resource = std::make_shared("./Makefile"); + // server.register_resource("file://./Makefile", file_resource); + + // Start server + // CRITICAL: We use std::cerr here to output logs, because any output to std::cout + // will corrupt the JSON-RPC pipe and crash the MCP client! + std::cerr << "Starting MCP server in STDIO mode..." << std::endl; + std::cerr << "Awaiting JSON-RPC requests on stdin..." << std::endl; + + server.start_stdio(); + + return 0; +} \ No newline at end of file diff --git a/include/mcp_prompt.h b/include/mcp_prompt.h new file mode 100644 index 0000000..efc45d6 --- /dev/null +++ b/include/mcp_prompt.h @@ -0,0 +1,85 @@ +/** + * @file mcp_prompt.h + * @brief Prompt definitions for MCP + * + * This file provides prompt-related functionality and abstractions for the MCP protocol. + */ + +#ifndef MCP_PROMPT_H +#define MCP_PROMPT_H + +#include "mcp_message.h" +#include +#include +#include + +namespace mcp { + +// MCP Prompt Argument definition +struct prompt_argument { + std::string name; + std::string description; + bool required = false; + + json to_json() const { + json j = { + {"name", name}, + {"description", description}, + {"required", required} + }; + return j; + } +}; + +// MCP Prompt definition +struct prompt { + std::string name; + std::string description; + std::vector arguments; + + json to_json() const { + json args_json = json::array(); + for (const auto& arg : arguments) { + args_json.push_back(arg.to_json()); + } + + json j = { + {"name", name}, + {"description", description} + }; + + if (!args_json.empty()) { + j["arguments"] = args_json; + } + + return j; + } +}; + +class prompt_builder { +public: + prompt_builder(const std::string& name) { + prompt_.name = name; + } + + prompt_builder& with_description(const std::string& desc) { + prompt_.description = desc; + return *this; + } + + prompt_builder& with_argument(const std::string& name, const std::string& desc, bool required = false) { + prompt_.arguments.push_back({name, desc, required}); + return *this; + } + + prompt build() const { + return prompt_; + } + +private: + prompt prompt_; +}; + +} // namespace mcp + +#endif // MCP_PROMPT_H \ No newline at end of file diff --git a/include/mcp_server.h b/include/mcp_server.h index 6e42d09..e5339cb 100644 --- a/include/mcp_server.h +++ b/include/mcp_server.h @@ -12,6 +12,7 @@ #include "mcp_message.h" #include "mcp_resource.h" #include "mcp_tool.h" +#include "mcp_prompt.h" #include "mcp_thread_pool.h" #include "mcp_logger.h" @@ -36,6 +37,7 @@ namespace mcp { using method_handler = std::function; using tool_handler = method_handler; +using prompt_handler = method_handler; using notification_handler = std::function; using auth_handler = std::function; using session_cleanup_handler = std::function; @@ -241,12 +243,19 @@ class server { ~server(); /** - * @brief Start the server - * @param blocking If true, this call blocks until the server stops + * @brief Start the server (HTTP/SSE) + * @param blocking Whether to block the current thread * @return True if the server started successfully */ bool start(bool blocking = true); + /** + * @brief Start the server using stdio transport + * Reads JSON-RPC messages from stdin and writes responses to stdout. + * Blocks the current thread until stdin is closed. + */ + void start_stdio(); + /** * @brief Stop the server */ @@ -325,6 +334,13 @@ class server { */ void register_tool(const tool& tool, tool_handler handler); + /** + * @brief Register a prompt + * @param prompt The prompt to register + * @param handler The function to call when the prompt is invoked + */ + void register_prompt(const prompt& prompt, prompt_handler handler); + /** * @brief Register a session cleanup handler * @param key Tool or resource name to be cleaned up @@ -423,6 +439,7 @@ class server { // Tools map (name -> handler) std::map> tools_; + std::map> prompts_; // Authentication handler auth_handler auth_handler_; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8af47e8..d6d2e2d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -23,6 +23,10 @@ target_compile_definitions(${TARGET} PUBLIC target_link_libraries(${TARGET} PUBLIC ${CMAKE_THREAD_LIBS_INIT}) +if(WIN32) + target_link_libraries(${TARGET} PUBLIC ws2_32 wsock32) +endif() + # If OpenSSL is found, link the OpenSSL libraries if(OPENSSL_FOUND) target_link_libraries(${TARGET} PUBLIC ${OPENSSL_LIBRARIES}) diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp index 4907fd4..0d8cf85 100644 --- a/src/mcp_server.cpp +++ b/src/mcp_server.cpp @@ -55,6 +55,67 @@ server::~server() { stop(); } +void server::start_stdio() { + running_ = true; + std::string line; + std::string session_id = "stdio_session_" + std::to_string(std::time(nullptr)); + + while (std::getline(std::cin, line)) { + if (line.empty()) continue; + try { + json req_json = json::parse(line); + request req; + + if (req_json.is_object() && req_json.contains("jsonrpc") && req_json["jsonrpc"] == "2.0") { + req.jsonrpc = "2.0"; + if (req_json.contains("id")) { + req.id = req_json["id"]; + } else { + req.id = nullptr; + } + req.method = req_json.value("method", ""); + if (req_json.contains("params")) { + req.params = req_json["params"]; + } + + json res = process_request(req, session_id); + if (!res.is_null() && !req.id.is_null()) { + std::cout << res.dump() << "\n" << std::flush; + } else { + std::cerr << "Response is null or ID is null. Method: " << req.method << std::endl; + if (!res.is_null()) { + std::cout << res.dump() << "\n" << std::flush; + } + } + } else { + json err_res = { + {"jsonrpc", "2.0"}, + {"error", { + {"code", static_cast(error_code::invalid_request)}, + {"message", "Invalid JSON-RPC format"} + }} + }; + if (req_json.contains("id")) { + err_res["id"] = req_json["id"]; + } else { + err_res["id"] = nullptr; + } + std::cout << err_res.dump() << "\n" << std::flush; + } + } catch (const std::exception& e) { + json err_res = { + {"jsonrpc", "2.0"}, + {"error", { + {"code", static_cast(error_code::parse_error)}, + {"message", std::string("Parse error: ") + e.what()} + }}, + {"id", nullptr} + }; + std::cout << err_res.dump() << "\n" << std::flush; + } + } + running_ = false; +} bool server::start(bool blocking) { if (running_) { @@ -551,6 +612,54 @@ void server::register_tool(const tool& tool, tool_handler handler) { } } +void server::register_prompt(const prompt& prompt, prompt_handler handler) { + std::lock_guard lock(mutex_); + prompts_[prompt.name] = std::make_pair(prompt, handler); + + // Register methods for prompt listing and calling + if (method_handlers_.find("prompts/list") == method_handlers_.end()) { + method_handlers_["prompts/list"] = [this](const json& params, const std::string& session_id) -> json { + json prompts_json = json::array(); + for (const auto& [name, prompt_pair] : prompts_) { + prompts_json.push_back(prompt_pair.first.to_json()); + } + return json{{"prompts", prompts_json}}; + }; + } + + if (method_handlers_.find("prompts/get") == method_handlers_.end()) { + method_handlers_["prompts/get"] = [this](const json& params, const std::string& session_id) -> json { + if (!params.contains("name")) { + throw mcp_exception(error_code::invalid_params, "Missing 'name' parameter"); + } + + std::string prompt_name = params["name"]; + auto it = prompts_.find(prompt_name); + if (it == prompts_.end()) { + throw mcp_exception(error_code::invalid_params, "Prompt not found: " + prompt_name); + } + + json prompt_args = params.contains("arguments") ? params["arguments"] : json::object(); + + json handler_result = it->second.second(prompt_args, session_id); + + // Expected to return a GetPromptResult structure: {"description": "...", "messages": [...]} + // If it returns just an array, we can auto-wrap it into messages + if (handler_result.is_array()) { + json wrapped_result = { + {"messages", handler_result} + }; + if (!it->second.first.description.empty()) { + wrapped_result["description"] = it->second.first.description; + } + return wrapped_result; + } + + return handler_result; + }; + } +} + void server::register_session_cleanup(const std::string& key, session_cleanup_handler handler) { std::lock_guard lock(mutex_); session_cleanup_handler_[key] = handler; diff --git a/test/mcp_test.cpp b/test/mcp_test.cpp index f9dcd33..72df65a 100644 --- a/test/mcp_test.cpp +++ b/test/mcp_test.cpp @@ -11,8 +11,12 @@ #include "mcp_client.h" #include "mcp_server.h" #include "mcp_tool.h" +#include "mcp_prompt.h" #include "mcp_sse_client.h" +#include +#include + using namespace mcp; using json = nlohmann::ordered_json; @@ -662,6 +666,114 @@ TEST_F(ToolsTest, CallTool) { EXPECT_EQ(tool_result["content"][0]["text"], "Current weather in New York:\nTemperature: 72°F\nConditions: Partly cloudy"); } +class PromptsEnvironment : public ::testing::Environment { +public: + void SetUp() override { + // Set up test environment + server::configuration conf = {.host = "localhost", .port = 8084}; + server_ = std::make_unique(conf); + + // Create a test prompt + prompt test_prompt = prompt_builder("test_prompt") + .with_description("A test prompt") + .with_argument("name", "The user name", true) + .build(); + + // Register prompt + server_->register_prompt(test_prompt, [](const json& params, const std::string& /* session_id */) -> json { + std::string name = "World"; + if (params.contains("name")) { + name = params["name"].get(); + } + + return json::array({ + { + {"role", "user"}, + {"content", { + {"type", "text"}, + {"text", "Hello, " + name + "!"} + }} + } + }); + }); + + // Start server in background thread + server_thread_ = std::thread([this]() { + server_->start(); + }); + + // Wait for server to start + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Connect client + client_ = std::make_unique("http://localhost:8084"); + bool initialized = client_->initialize("TestClient", "1.0"); + EXPECT_TRUE(initialized); + } + + void TearDown() override { + if (client_) { + client_.reset(); + } + if (server_) { + server_->stop(); + } + if (server_thread_.joinable()) { + server_thread_.join(); + } + } + + static std::shared_ptr& GetClient() { + return client_; + } + +private: + std::unique_ptr server_; + std::thread server_thread_; + static std::shared_ptr client_; +}; + +std::shared_ptr PromptsEnvironment::client_ = nullptr; + +class PromptsTest : public ::testing::Test { +protected: + void SetUp() override { + client_ = PromptsEnvironment::GetClient().get(); + } + + sse_client* client_; +}; + +// Test listing prompts +TEST_F(PromptsTest, ListPrompts) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Call list prompts method directly + json prompts_list = client_->send_request("prompts/list").result; + + // Verify prompts list + EXPECT_TRUE(prompts_list.contains("prompts")); + EXPECT_EQ(prompts_list["prompts"].size(), 1); + EXPECT_EQ(prompts_list["prompts"][0]["name"], "test_prompt"); + EXPECT_EQ(prompts_list["prompts"][0]["description"], "A test prompt"); + EXPECT_TRUE(prompts_list["prompts"][0].contains("arguments")); + EXPECT_EQ(prompts_list["prompts"][0]["arguments"][0]["name"], "name"); +} + +// Test getting prompt +TEST_F(PromptsTest, GetPrompt) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Get prompt + json prompt_result = client_->send_request("prompts/get", {{"name", "test_prompt"}, {"arguments", {{"name", "Alice"}}}}).result; + + // Verify prompt result + EXPECT_TRUE(prompt_result.contains("messages")); + EXPECT_EQ(prompt_result["messages"].size(), 1); + EXPECT_EQ(prompt_result["messages"][0]["role"], "user"); + EXPECT_EQ(prompt_result["messages"][0]["content"]["text"], "Hello, Alice!"); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); @@ -670,6 +782,100 @@ int main(int argc, char **argv) { ::testing::AddGlobalTestEnvironment(new VersioningEnvironment()); ::testing::AddGlobalTestEnvironment(new PingEnvironment()); ::testing::AddGlobalTestEnvironment(new ToolsEnvironment()); + ::testing::AddGlobalTestEnvironment(new PromptsEnvironment()); return RUN_ALL_TESTS(); -} \ No newline at end of file +} +// Test Stdio Transport +class StdioTransportTest : public ::testing::Test { +protected: + void SetUp() override { + // Prepare original buffers + orig_cin = std::cin.rdbuf(); + orig_cout = std::cout.rdbuf(); + } + + void TearDown() override { + // Restore buffers + std::cin.rdbuf(orig_cin); + std::cout.rdbuf(orig_cout); + } + + std::streambuf* orig_cin; + std::streambuf* orig_cout; +}; + +TEST_F(StdioTransportTest, StartStdioProcessing) { + mcp::server::configuration srv_conf; + mcp::server server(srv_conf); + + // Register tool + mcp::tool echo_tool = mcp::tool_builder("echo") + .with_description("Echo tool") + .with_string_param("text", "Text to echo", true) + .build(); + + server.register_tool(echo_tool, [](const mcp::json& params, const std::string&) -> mcp::json { + return { + { + {"type", "text"}, + {"text", params["text"]} + } + }; + }); + + // Simulate input sequence + // 1. Initialize request + std::string init_req = "{\"jsonrpc\": \"2.0\", \"id\": 0, \"method\": \"initialize\", \"params\": {\"protocolVersion\": \"2025-03-26\", \"capabilities\": {}, \"clientInfo\": {\"name\": \"test_client\", \"version\": \"1.0\"}}}\n"; + // 2. Initialized notification + std::string init_notif = "{\"jsonrpc\": \"2.0\", \"method\": \"notifications/initialized\"}\n"; + // 3. Tool call request + std::string mock_input = "{\"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"tools/call\", \"params\": {\"name\": \"echo\", \"arguments\": {\"text\": \"hello stdio test\"}}}\n"; + + std::istringstream in_stream(init_req + init_notif + mock_input); + std::ostringstream out_stream; + + std::cin.rdbuf(in_stream.rdbuf()); + std::cout.rdbuf(out_stream.rdbuf()); + + // Run processing + // It should process the lines, then exit when EOF is reached + server.start_stdio(); + + // Verify output + std::string raw_output = out_stream.str(); + ASSERT_FALSE(raw_output.empty()); + + // Collect all non-empty JSON lines from stdout + std::istringstream output_stream(raw_output); + std::string line; + std::vector responses; + while (std::getline(output_stream, line)) { + if (line.empty()) continue; + try { + responses.push_back(json::parse(line)); + } catch (...) { + continue; + } + } + + // Verify we have at least the init response and tool call response + ASSERT_GE(responses.size(), 2); + + // Find the tool call response (id=1) + bool found_tool_result = false; + bool found_init_result = false; + for (const auto& resp : responses) { + if (resp.contains("id") && resp["id"] == 0 && resp.contains("result")) { + found_init_result = true; + } + if (resp.contains("id") && resp["id"] == 1 && resp.contains("result")) { + EXPECT_EQ(resp["jsonrpc"], "2.0"); + EXPECT_EQ(resp["result"]["content"][0]["text"], "hello stdio test"); + found_tool_result = true; + } + } + + EXPECT_TRUE(found_init_result) << "Missing initialize response"; + EXPECT_TRUE(found_tool_result) << "Missing tools/call response"; +}