From 5f19ebe9f4d6c8536f405fc78b2fc2b9359f796a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 12:27:32 +0000 Subject: [PATCH] Bound peer JSON nesting and body size Peer json::parse had no depth or size limit, so a deeply nested stdio line or unauthenticated POST could overflow the stack. Match the Ruby MCP SDK: nesting depth 64 and a 4 MiB frame/body cap, and keep serving with a JSON-RPC error instead of crashing. Fixes #63 Co-authored-by: Zihong Zhang --- include/mcp_message.h | 48 ++++++ include/mcp_server.h | 4 +- include/mcp_stdio_client.h | 3 + src/mcp_server.cpp | 136 +++++++++++++---- src/mcp_sse_client.cpp | 10 +- src/mcp_stdio_client.cpp | 181 +++++++++++------------ test/mcp_test.cpp | 292 +++++++++++++++++++++++++++++++++++++ 7 files changed, 547 insertions(+), 127 deletions(-) diff --git a/include/mcp_message.h b/include/mcp_message.h index 1d3061a..01c1971 100644 --- a/include/mcp_message.h +++ b/include/mcp_message.h @@ -27,6 +27,54 @@ using json = nlohmann::ordered_json; // MCP version constexpr const char* MCP_VERSION = "2025-03-26"; +// Peer JSON limits. These match the official Ruby MCP SDK +// (MCP::Server::Transports::StreamableHTTPTransport): +// MAX_JSON_NESTING = 64 +// DEFAULT_MAX_REQUEST_BYTES / stdio max_line_bytes = 4 MiB +// RFC 8259 section 9 allows an implementation to cap nesting depth. MCP itself +// does not set one; the TypeScript SDK uses the same 4 MiB body default. +inline constexpr int kMaxJsonNesting = 64; +inline constexpr std::size_t kMaxRequestBytes = 4u * 1024u * 1024u; + +// Thrown when a peer message is larger than kMaxRequestBytes. Callers map this +// to JSON-RPC invalid request (-32600) and, on HTTP, status 413. +class peer_json_too_large : public std::runtime_error { +public: + peer_json_too_large() + : std::runtime_error("Payload too large") {} +}; + +// Thrown when a peer message is nested deeper than kMaxJsonNesting. Callers map +// this to JSON-RPC parse error (-32700) and, on HTTP, status 400. +class peer_json_too_deep : public std::runtime_error { +public: + peer_json_too_deep() + : std::runtime_error("JSON nested too deeply") {} +}; + +// Parse JSON that arrived from a peer. Rejects inputs larger than +// kMaxRequestBytes before parsing, and aborts once nesting would exceed +// kMaxJsonNesting so a deeply nested document cannot exhaust the stack. +// +// nlohmann's parser callback reports how many containers are already open. +// A start event at depth == kMaxJsonNesting would open one more, which is +// deeper than the Ruby SDK's max_nesting of 64. +inline json parse_peer_json(const std::string& input) { + if (input.size() > kMaxRequestBytes) { + throw peer_json_too_large(); + } + + return json::parse(input, [](int depth, json::parse_event_t event, json&) { + if (depth > kMaxJsonNesting || + (depth == kMaxJsonNesting && + (event == json::parse_event_t::array_start || + event == json::parse_event_t::object_start))) { + throw peer_json_too_deep(); + } + return true; + }); +} + // MCP error codes (JSON-RPC 2.0 standard codes) enum class error_code { parse_error = -32700, // Invalid JSON diff --git a/include/mcp_server.h b/include/mcp_server.h index 76915c0..d893378 100644 --- a/include/mcp_server.h +++ b/include/mcp_server.h @@ -487,8 +487,8 @@ class server { void handle_mcp_get(const httplib::Request& req, httplib::Response& res); void handle_mcp_delete(const httplib::Request& req, httplib::Response& res); - // Parse a single JSON-RPC message from JSON - request parse_jsonrpc_message(const json& j) const; + // Parse a single JSON-RPC message from JSON. Moves `params` out of `j`. + request parse_jsonrpc_message(json& j) const; // Send a JSON-RPC message to a client void send_jsonrpc(const std::string& session_id, const json& message); diff --git a/include/mcp_stdio_client.h b/include/mcp_stdio_client.h index 012b934..7608dbd 100644 --- a/include/mcp_stdio_client.h +++ b/include/mcp_stdio_client.h @@ -171,6 +171,9 @@ class stdio_client : public client { // Read thread function void read_thread_func(); + + // Handle one complete stdout frame from the server process. + void on_stdout_line(const std::string& line); // Send JSON-RPC request json send_jsonrpc(const request& req); diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp index fe3d06b..933d274 100644 --- a/src/mcp_server.cpp +++ b/src/mcp_server.cpp @@ -7,6 +7,7 @@ */ #include "mcp_server.h" +#include #include namespace { @@ -14,6 +15,74 @@ bool file_exists(const std::string& path) { struct stat st; return ::stat(path.c_str(), &st) == 0; } + +// Read one newline-delimited stdio frame, storing at most kMaxRequestBytes. +// A longer frame is discarded through the next newline (or EOF) and reported +// via `too_large`, so the following frame stays aligned and the buffer cannot +// grow without bound. Returns false only on EOF with nothing to process. +bool read_stdio_frame(std::string& line, bool& too_large) { + line.clear(); + too_large = false; + + std::streambuf* buf = std::cin.rdbuf(); + while (true) { + const int ch = buf->sbumpc(); + if (ch == EOF) { + return too_large || !line.empty(); + } + if (ch == '\n') { + return true; + } + if (line.size() >= mcp::kMaxRequestBytes) { + too_large = true; + line.clear(); + line.shrink_to_fit(); + std::cin.ignore(std::numeric_limits::max(), '\n'); + return true; + } + line.push_back(static_cast(ch)); + } +} + +void write_peer_json_http_error(httplib::Response& res, const std::exception& error) { + if (dynamic_cast(&error) != nullptr) { + res.status = 413; + res.set_content( + mcp::response::create_error( + nullptr, + mcp::error_code::invalid_request, + "Payload too large").to_json().dump(), + "application/json"); + return; + } + + res.status = 400; + res.set_content( + mcp::response::create_error( + nullptr, + mcp::error_code::parse_error, + "Invalid JSON").to_json().dump(), + "application/json"); +} + +void configure_peer_input_limits(httplib::Server& http) { + http.set_payload_max_length(mcp::kMaxRequestBytes); + http.set_error_handler([](const httplib::Request&, httplib::Response& res) { + // httplib rejects a declared Content-Length above the cap before the + // route handler runs, with an empty body. Fill in the JSON-RPC error + // Ruby returns for the same case. Leave handler-authored 4xx bodies alone. + if (res.status != 413 || !res.body.empty()) { + return httplib::Server::HandlerResponse::Unhandled; + } + res.set_content( + mcp::response::create_error( + nullptr, + mcp::error_code::invalid_request, + "Payload too large").to_json().dump(), + "application/json"); + return httplib::Server::HandlerResponse::Handled; + }); +} } // anonymous namespace namespace mcp { @@ -49,6 +118,8 @@ server::server(const server::configuration& conf) #else http_server_ = std::make_unique(); #endif + + configure_peer_input_limits(*http_server_); } server::~server() { @@ -58,12 +129,25 @@ server::~server() { void server::start_stdio() { running_ = true; std::string line; + bool too_large = false; std::string session_id = "stdio_session_" + std::to_string(std::time(nullptr)); - while (std::getline(std::cin, line)) { + while (read_stdio_frame(line, too_large)) { + if (too_large) { + json err_res = { + {"jsonrpc", "2.0"}, + {"error", { + {"code", static_cast(error_code::invalid_request)}, + {"message", "Payload too large"} + }}, + {"id", nullptr} + }; + std::cout << err_res.dump() << "\n" << std::flush; + continue; + } if (line.empty()) continue; try { - json req_json = json::parse(line); + json req_json = parse_peer_json(line); request req; const bool valid_id = req_json.is_object() && req_json.contains("id") && (req_json["id"].is_string() || req_json["id"].is_number_integer()); @@ -80,7 +164,7 @@ void server::start_stdio() { } req.method = req_json.value("method", ""); if (req_json.contains("params")) { - req.params = req_json["params"]; + req.params = std::move(req_json["params"]); } json res = process_request(req, session_id); @@ -593,8 +677,8 @@ void server::register_tool(const tool& tool, tool_handler handler) { if (tool_args.is_string()) { try { - tool_args = json::parse(tool_args.get()); - } catch (const json::exception& e) { + tool_args = parse_peer_json(tool_args.get()); + } catch (const std::exception& e) { throw mcp_exception(error_code::invalid_params, "Invalid JSON arguments: " + std::string(e.what())); } } @@ -869,14 +953,14 @@ void server::handle_jsonrpc(const httplib::Request& req, httplib::Response& res) } } - // Parse request + // Parse request before any session lookup. Depth and size limits apply here + // so an unauthenticated body cannot exhaust the stack or be parsed unbounded. json req_json; try { - req_json = json::parse(req.body); - } catch (const json::exception& e) { + req_json = parse_peer_json(req.body); + } catch (const std::exception& e) { LOG_ERROR("Failed to parse JSON request: ", e.what()); - res.status = 400; - res.set_content("{\"error\":\"Invalid JSON\"}", "application/json"); + write_peer_json_http_error(res, e); return; } @@ -909,7 +993,7 @@ void server::handle_jsonrpc(const httplib::Request& req, httplib::Response& res) } mcp_req.method = req_json["method"].get(); if (req_json.contains("params")) { - mcp_req.params = req_json["params"]; + mcp_req.params = std::move(req_json["params"]); } } catch (const std::exception& e) { LOG_ERROR("Failed to create request object: ", e.what()); @@ -955,7 +1039,7 @@ void server::handle_jsonrpc(const httplib::Request& req, httplib::Response& res) // Streamable HTTP transport (2025-03-26 spec) // --------------------------------------------------------------------------- -request server::parse_jsonrpc_message(const json& j) const { +request server::parse_jsonrpc_message(json& j) const { request req; req.jsonrpc = j.value("jsonrpc", "2.0"); if (j.contains("id") && !j["id"].is_null()) { @@ -965,7 +1049,7 @@ request server::parse_jsonrpc_message(const json& j) const { req.method = j["method"].get(); } if (j.contains("params")) { - req.params = j["params"]; + req.params = std::move(j["params"]); } return req; } @@ -978,16 +1062,14 @@ void server::handle_mcp_post(const httplib::Request& req, httplib::Response& res res.set_header("Access-Control-Expose-Headers", "Mcp-Session-Id, MCP-Protocol-Version"); res.set_header("MCP-Protocol-Version", MCP_VERSION); - // Parse JSON body + // Parse JSON body before the session lookup. An oversized or too-deep body + // must be rejected here, including for an unauthenticated POST to /mcp. json body; try { - body = json::parse(req.body); - } catch (const json::exception& e) { + body = parse_peer_json(req.body); + } catch (const std::exception& e) { LOG_ERROR("Failed to parse JSON: ", e.what()); - res.status = 400; - res.set_content( - response::create_error(nullptr, error_code::parse_error, "Invalid JSON").to_json().dump(), - "application/json"); + write_peer_json_http_error(res, e); return; } @@ -1027,14 +1109,16 @@ void server::handle_mcp_post(const httplib::Request& req, httplib::Response& res } } - // Handle batched requests + // Handle batched requests. Move each element so params are not copied again + // when parse_jsonrpc_message takes them. std::vector items; if (body.is_array()) { - for (const auto& item : body) { - items.push_back(item); + items.reserve(body.size()); + for (auto& item : body) { + items.push_back(std::move(item)); } } else { - items.push_back(body); + items.push_back(std::move(body)); } // Categorize: are there any requests (with id), or only notifications/responses? @@ -1049,7 +1133,7 @@ void server::handle_mcp_post(const httplib::Request& req, httplib::Response& res // If all notifications/responses, process and return 202 if (all_notifications_or_responses && !has_requests) { - for (const auto& item : items) { + for (auto& item : items) { auto mcp_req = parse_jsonrpc_message(item); if (!session_id.empty()) { process_request(mcp_req, session_id); @@ -1099,7 +1183,7 @@ void server::handle_mcp_post(const httplib::Request& req, httplib::Response& res // Process all items, collect responses for requests json responses = json::array(); - for (const auto& item : items) { + for (auto& item : items) { auto mcp_req = parse_jsonrpc_message(item); if (mcp_req.is_notification()) { diff --git a/src/mcp_sse_client.cpp b/src/mcp_sse_client.cpp index c9e02b8..6af6c0b 100644 --- a/src/mcp_sse_client.cpp +++ b/src/mcp_sse_client.cpp @@ -373,7 +373,7 @@ bool sse_client::parse_sse_data(const char* data, size_t length) { return true; } else if (event_type == "message") { try { - json response = json::parse(data_content); + json response = parse_peer_json(data_content); if (response.contains("jsonrpc") && response.contains("id") && !response["id"].is_null()) { // Use the same serialization method as when storing the request ID @@ -401,7 +401,7 @@ bool sse_client::parse_sse_data(const char* data, size_t length) { } else { LOG_WARNING("Received invalid JSON-RPC response: ", response.dump()); } - } catch (const json::exception& e) { + } catch (const std::exception& e) { LOG_ERROR("Failed to parse JSON-RPC response: ", e.what()); } return true; @@ -520,7 +520,7 @@ json sse_client::send_jsonrpc(const request& req) { if (result->status / 100 != 2) { try { - json res_json = json::parse(result->body); + json res_json = parse_peer_json(result->body); { std::lock_guard response_lock(response_mutex_); @@ -539,7 +539,9 @@ json sse_client::send_jsonrpc(const request& req) { } else { return json::object(); } - } catch (const json::exception& e) { + } catch (const mcp_exception&) { + throw; + } catch (const std::exception& e) { { std::lock_guard response_lock(response_mutex_); pending_requests_.erase(req_id_key); diff --git a/src/mcp_stdio_client.cpp b/src/mcp_stdio_client.cpp index 1bb596d..55ad816 100644 --- a/src/mcp_stdio_client.cpp +++ b/src/mcp_stdio_client.cpp @@ -26,6 +26,51 @@ #include #include +namespace { + +// Pull complete newline-delimited frames out of `pending`. A frame over +// kMaxRequestBytes, or a buffer that grows past that cap with no newline, is +// discarded so a peer cannot exhaust memory before the line is parsed. +template +void consume_stdio_frames(std::string& pending, bool& discarding, Fn&& on_line) { + if (discarding) { + const auto newline = pending.find('\n'); + if (newline == std::string::npos) { + pending.clear(); + return; + } + pending.erase(0, newline + 1); + discarding = false; + } + + while (true) { + const auto newline = pending.find('\n'); + if (newline == std::string::npos) { + if (pending.size() > mcp::kMaxRequestBytes) { + pending.clear(); + discarding = true; + LOG_ERROR("Dropping stdio frame larger than ", mcp::kMaxRequestBytes, " bytes"); + } + return; + } + if (newline > mcp::kMaxRequestBytes) { + pending.erase(0, newline + 1); + LOG_ERROR("Dropping stdio frame larger than ", mcp::kMaxRequestBytes, " bytes"); + continue; + } + if (newline == 0) { + pending.erase(0, 1); + continue; + } + + std::string line = pending.substr(0, newline); + pending.erase(0, newline + 1); + on_line(line); + } +} + +} // namespace + namespace mcp { stdio_client::stdio_client(const std::string& command, const json& env_vars, const json& capabilities) @@ -580,12 +625,51 @@ void stdio_client::stop_server_process() { LOG_INFO("Server process stopped"); } +void stdio_client::on_stdout_line(const std::string& line) { + try { + json message = parse_peer_json(line); + + if (message.contains("jsonrpc") && message["jsonrpc"] == "2.0") { + if (message.contains("id") && !message["id"].is_null()) { + json id = message["id"]; + + std::lock_guard lock(response_mutex_); + auto it = pending_requests_.find(id); + + if (it != pending_requests_.end()) { + if (message.contains("result")) { + it->second.set_value(message["result"]); + } else if (message.contains("error")) { + json error_result = { + {"isError", true}, + {"error", message["error"]} + }; + it->second.set_value(error_result); + } else { + it->second.set_value(json::object()); + } + + pending_requests_.erase(it); + } else { + LOG_WARNING("Received response for unknown request ID: ", id); + } + } else if (message.contains("method")) { + LOG_INFO("Received request/notification: ", message["method"]); + } + } + } catch (const std::exception& e) { + LOG_ERROR("Failed to parse stdio message: ", e.what()); + } +} + void stdio_client::read_thread_func() { LOG_INFO("Read thread started"); const int buffer_size = 4096; char buffer[buffer_size]; std::string data_buffer; + bool discarding_oversize = false; + auto on_line = [this](const std::string& line) { on_stdout_line(line); }; #if defined(_WIN32) // Windows implementation @@ -604,54 +688,7 @@ void stdio_client::read_thread_func() { retry_count = 0; // Reset retry count buffer[bytes_read] = '\0'; data_buffer.append(buffer, bytes_read); - - // Process complete JSON-RPC message - size_t pos = 0; - while ((pos = data_buffer.find('\n')) != std::string::npos) { - std::string line = data_buffer.substr(0, pos); - data_buffer.erase(0, pos + 1); - - if (!line.empty()) { - try { - json message = json::parse(line); - - if (message.contains("jsonrpc") && message["jsonrpc"] == "2.0") { - if (message.contains("id") && !message["id"].is_null()) { - // This is a response - json id = message["id"]; - - std::lock_guard lock(response_mutex_); - auto it = pending_requests_.find(id); - - if (it != pending_requests_.end()) { - if (message.contains("result")) { - it->second.set_value(message["result"]); - } else if (message.contains("error")) { - json error_result = { - {"isError", true}, - {"error", message["error"]} - }; - it->second.set_value(error_result); - } else { - it->second.set_value(json::object()); - } - - pending_requests_.erase(it); - } else { - LOG_WARNING("Received response for unknown request ID: ", id); - } - } else if (message.contains("method")) { - // This is a request or notification - LOG_INFO("Received request/notification: ", message["method"]); - // Currently not handling requests from the server - } - } - } catch (const json::exception& e) { - (e); - LOG_INFO("message: ", line); - } - } - } + consume_stdio_frames(data_buffer, discarding_oversize, on_line); } else if (!success) { DWORD error = GetLastError(); @@ -714,53 +751,7 @@ void stdio_client::read_thread_func() { if (bytes_read > 0) { buffer[bytes_read] = '\0'; data_buffer.append(buffer, bytes_read); - - // Process complete JSON-RPC message - size_t pos = 0; - while ((pos = data_buffer.find('\n')) != std::string::npos) { - std::string line = data_buffer.substr(0, pos); - data_buffer.erase(0, pos + 1); - - if (!line.empty()) { - try { - json message = json::parse(line); - - if (message.contains("jsonrpc") && message["jsonrpc"] == "2.0") { - if (message.contains("id") && !message["id"].is_null()) { - // This is a response - json id = message["id"]; - - std::lock_guard lock(response_mutex_); - auto it = pending_requests_.find(id); - - if (it != pending_requests_.end()) { - if (message.contains("result")) { - it->second.set_value(message["result"]); - } else if (message.contains("error")) { - json error_result = { - {"isError", true}, - {"error", message["error"]} - }; - it->second.set_value(error_result); - } else { - it->second.set_value(json::object()); - } - - pending_requests_.erase(it); - } else { - LOG_WARNING("Received response for unknown request ID: ", id); - } - } else if (message.contains("method")) { - // This is a request or notification - LOG_INFO("Received request/notification: ", message["method"]); - // Currently not handling requests from the server - } - } - } catch (const json::exception& e) { - LOG_INFO("message: ", line); - } - } - } + consume_stdio_frames(data_buffer, discarding_oversize, on_line); } else if (bytes_read == 0) { // Pipe is closed LOG_WARNING("Pipe closed by server"); diff --git a/test/mcp_test.cpp b/test/mcp_test.cpp index ec43faa..2571543 100644 --- a/test/mcp_test.cpp +++ b/test/mcp_test.cpp @@ -1275,3 +1275,295 @@ TEST(ServerLifecycle, StopClosesActiveSseSessionPromptly) { EXPECT_LT(std::chrono::steady_clock::now() - restart_stop, std::chrono::seconds(2)); EXPECT_FALSE(server.is_running()); } + +namespace { + +int container_depth(const std::string& text) { + int max_depth = 0; + const json parsed = json::parse(text, [&](int depth, json::parse_event_t event, json&) { + if (event == json::parse_event_t::array_start || + event == json::parse_event_t::object_start) { + const int opened = depth + 1; + if (opened > max_depth) { + max_depth = opened; + } + } + return true; + }); + (void)parsed; + return max_depth; +} + +std::string nested_arrays(int depth) { + std::string text; + text.reserve(static_cast(depth) * 2 + 1); + text.append(static_cast(depth), '['); + text.push_back('0'); + text.append(static_cast(depth), ']'); + return text; +} + +std::string nested_empty_objects(int depth) { + if (depth <= 1) { + return "{}"; + } + std::string text; + text.reserve(static_cast(depth) * 6); + for (int i = 1; i < depth; ++i) { + text += "{\"a\":"; + } + text += "{}"; + text.append(static_cast(depth - 1), '}'); + return text; +} + +// Root object + params object + (depth - 2) arrays under params.n. +std::string ping_at_depth(int depth) { + const int arrays = depth - 2; + std::string text = R"({"jsonrpc":"2.0","id":1,"method":"ping","params":{"n":)"; + text.append(static_cast(arrays), '['); + text.push_back('0'); + text.append(static_cast(arrays), ']'); + text += "}}"; + return text; +} + +std::string initialize_at_depth(int depth) { + // Deepest path is root object + params object + arrays under params.n. + // capabilities and clientInfo are siblings of n, so they do not add depth. + const int arrays = depth - 2; + std::string text = + R"({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"},"n":)"; + text.append(static_cast(arrays), '['); + text.push_back('0'); + text.append(static_cast(arrays), ']'); + text += "}}"; + return text; +} + +class scoped_http_server { +public: + explicit scoped_http_server(int port) { + server::configuration conf; + conf.host = "127.0.0.1"; + conf.port = port; + conf.threadpool_size = 2; + server_ = std::make_unique(conf); + started_ = server_->start(false); + } + + ~scoped_http_server() { + if (server_) { + server_->stop(); + } + } + + scoped_http_server(const scoped_http_server&) = delete; + scoped_http_server& operator=(const scoped_http_server&) = delete; + + bool started() const { return started_; } + +private: + std::unique_ptr server_; + bool started_{false}; +}; + +} // namespace + +TEST(PeerJsonLimits, AcceptsNestingAtTheRubyLimit) { + const std::string arrays = nested_arrays(kMaxJsonNesting); + const std::string objects = nested_empty_objects(kMaxJsonNesting); + EXPECT_EQ(container_depth(arrays), kMaxJsonNesting); + EXPECT_EQ(container_depth(objects), kMaxJsonNesting); + + const json parsed_arrays = parse_peer_json(arrays); + json cursor = parsed_arrays; + for (int i = 0; i < kMaxJsonNesting; ++i) { + ASSERT_TRUE(cursor.is_array()); + ASSERT_EQ(cursor.size(), 1u); + cursor = cursor[0]; + } + EXPECT_EQ(cursor, 0); + + const json parsed_objects = parse_peer_json(objects); + EXPECT_TRUE(parsed_objects.is_object()); +} + +TEST(PeerJsonLimits, RejectsNestingPastTheRubyLimit) { + EXPECT_THROW(parse_peer_json(nested_arrays(kMaxJsonNesting + 1)), peer_json_too_deep); + EXPECT_THROW(parse_peer_json(nested_empty_objects(kMaxJsonNesting + 1)), peer_json_too_deep); + + // A scalar inside the allowed depth still parses. One more container does not, + // even when that container is empty (no value callback at depth + 1). + EXPECT_NO_THROW(parse_peer_json(nested_arrays(kMaxJsonNesting))); + EXPECT_THROW(parse_peer_json(std::string(kMaxJsonNesting + 1, '[') + + std::string(kMaxJsonNesting + 1, ']')), + peer_json_too_deep); +} + +TEST(PeerJsonLimits, RejectsBodyOverFourMebibytes) { + std::string at_limit(kMaxRequestBytes, 'a'); + at_limit.front() = '"'; + at_limit.back() = '"'; + ASSERT_EQ(at_limit.size(), kMaxRequestBytes); + const json parsed = parse_peer_json(at_limit); + EXPECT_TRUE(parsed.is_string()); + EXPECT_EQ(parsed.get().size(), kMaxRequestBytes - 2); + + std::string over_limit(kMaxRequestBytes + 1, ' '); + EXPECT_THROW(parse_peer_json(over_limit), peer_json_too_large); +} + +TEST_F(StdioTransportTest, NestingAtLimitWorksAndDeeperNestingDoesNotKillTheServer) { + const std::string at_limit = ping_at_depth(kMaxJsonNesting); + ASSERT_EQ(container_depth(at_limit), kMaxJsonNesting); + const auto accepted = process_stdio(at_limit + "\n"); + ASSERT_EQ(accepted.size(), 1u); + EXPECT_EQ(accepted[0]["jsonrpc"], "2.0"); + EXPECT_EQ(accepted[0]["id"], 1); + EXPECT_EQ(accepted[0]["result"], json::object()); + + const json follow_up = {{"jsonrpc", "2.0"}, {"id", 7}, {"method", "ping"}}; + const std::string too_deep = ping_at_depth(kMaxJsonNesting + 1); + ASSERT_EQ(container_depth(too_deep), kMaxJsonNesting + 1); + const auto rejected = process_stdio(too_deep + "\n" + follow_up.dump() + "\n"); + ASSERT_EQ(rejected.size(), 2u); + EXPECT_EQ(rejected[0]["error"]["code"], -32700); + EXPECT_TRUE(rejected[0]["id"].is_null()); + EXPECT_EQ(rejected[1], (json{{"jsonrpc", "2.0"}, {"id", 7}, {"result", json::object()}})); +} + +TEST_F(StdioTransportTest, OversizedLineIsRejectedAndServerKeepsServing) { + const json follow_up = {{"jsonrpc", "2.0"}, {"id", 9}, {"method", "ping"}}; + std::string oversized(kMaxRequestBytes + 1, 'x'); + const auto responses = process_stdio(oversized + "\n" + follow_up.dump() + "\n"); + ASSERT_EQ(responses.size(), 2u); + EXPECT_EQ(responses[0]["jsonrpc"], "2.0"); + EXPECT_EQ(responses[0]["error"]["code"], -32600); + EXPECT_EQ(responses[0]["error"]["message"], "Payload too large"); + EXPECT_EQ(responses[1], (json{{"jsonrpc", "2.0"}, {"id", 9}, {"result", json::object()}})); +} + +TEST_F(StdioTransportTest, DeepToolArgumentStringIsInvalidParams) { + mcp::server::configuration srv_conf; + mcp::server server(srv_conf); + mcp::tool echo_tool = mcp::tool_builder("echo") + .with_description("Echo tool") + .build(); + server.register_tool(echo_tool, [](const mcp::json& params, const std::string&) -> mcp::json { + return json::array({ + {{"type", "text"}, {"text", params.is_array() ? "array" : "other"}} + }); + }); + + const std::string init = + R"({"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}})"; + const std::string initialized = R"({"jsonrpc":"2.0","method":"notifications/initialized"})"; + json shallow_call = { + {"jsonrpc", "2.0"}, + {"id", 1}, + {"method", "tools/call"}, + {"params", {{"name", "echo"}, {"arguments", nested_arrays(4)}}} + }; + json deep_call = { + {"jsonrpc", "2.0"}, + {"id", 2}, + {"method", "tools/call"}, + {"params", {{"name", "echo"}, {"arguments", nested_arrays(kMaxJsonNesting + 1)}}} + }; + const json follow_up = {{"jsonrpc", "2.0"}, {"id", 3}, {"method", "ping"}}; + + std::istringstream in_stream(init + "\n" + initialized + "\n" + shallow_call.dump() + "\n" + + deep_call.dump() + "\n" + follow_up.dump() + "\n"); + std::ostringstream out_stream; + std::cin.rdbuf(in_stream.rdbuf()); + std::cout.rdbuf(out_stream.rdbuf()); + server.start_stdio(); + std::cin.rdbuf(orig_cin); + std::cout.rdbuf(orig_cout); + + std::vector responses; + std::istringstream output_stream(out_stream.str()); + std::string line; + while (std::getline(output_stream, line)) { + if (!line.empty()) { + responses.push_back(json::parse(line)); + } + } + + ASSERT_EQ(responses.size(), 4u); + EXPECT_EQ(responses[1]["id"], 1); + EXPECT_EQ(responses[1]["result"]["content"][0]["text"], "array"); + EXPECT_EQ(responses[2]["id"], 2); + EXPECT_EQ(responses[2]["error"]["code"], -32602); + EXPECT_EQ(responses[3], (json{{"jsonrpc", "2.0"}, {"id", 3}, {"result", json::object()}})); +} + +TEST(PeerJsonLimits, StreamableHttpRejectsDeepAndOversizedBodiesBeforeSessionLookup) { + const int port = ephemeral_loopback_port(); + ASSERT_GT(port, 0); + scoped_http_server server(port); + ASSERT_TRUE(server.started()) << "port " << port; + + httplib::Client client("127.0.0.1", port); + client.set_connection_timeout(2, 0); + client.set_read_timeout(10, 0); + client.set_write_timeout(10, 0); + + const std::string at_limit = initialize_at_depth(kMaxJsonNesting); + ASSERT_EQ(container_depth(at_limit), kMaxJsonNesting); + const auto accepted = client.Post("/mcp", at_limit, "application/json"); + ASSERT_TRUE(accepted) << "depth-64 initialize failed to connect"; + EXPECT_EQ(accepted->status, 200); + const json accepted_body = json::parse(accepted->body); + EXPECT_EQ(accepted_body["id"], 1); + EXPECT_TRUE(accepted_body.contains("result")); + EXPECT_FALSE(accepted->get_header_value("Mcp-Session-Id").empty()); + + const std::string too_deep = ping_at_depth(kMaxJsonNesting + 1); + const auto rejected = client.Post("/mcp", too_deep, "application/json"); + ASSERT_TRUE(rejected) << "deep POST dropped the connection"; + EXPECT_EQ(rejected->status, 400); + const json rejected_body = json::parse(rejected->body); + EXPECT_EQ(rejected_body["error"]["code"], -32700); + EXPECT_EQ(rejected_body["error"]["message"], "Invalid JSON"); + EXPECT_EQ(rejected->body.find("Missing Mcp-Session-Id"), std::string::npos); + + const std::string oversized(kMaxRequestBytes + 1, '{'); + const auto too_big = client.Post("/mcp", oversized, "application/json"); + ASSERT_TRUE(too_big) << "oversize POST dropped the connection"; + EXPECT_EQ(too_big->status, 413); + const json too_big_body = json::parse(too_big->body); + EXPECT_EQ(too_big_body["error"]["code"], -32600); + EXPECT_EQ(too_big_body["error"]["message"], "Payload too large"); + + const auto alive = client.Post("/mcp", + std::string(R"({"jsonrpc":"2.0","id":1,"method":"ping"})"), + "application/json"); + ASSERT_TRUE(alive); + EXPECT_EQ(alive->status, 400); + EXPECT_NE(alive->body.find("Missing Mcp-Session-Id"), std::string::npos); +} + +TEST(PeerJsonLimits, LegacyHttpMessageEndpointRejectsDeepJsonAndKeepsServing) { + const int port = ephemeral_loopback_port(); + ASSERT_GT(port, 0); + scoped_http_server server(port); + ASSERT_TRUE(server.started()) << "port " << port; + + httplib::Client client("127.0.0.1", port); + client.set_connection_timeout(2, 0); + client.set_read_timeout(5, 0); + + const auto rejected = client.Post("/message", ping_at_depth(kMaxJsonNesting + 1), "application/json"); + ASSERT_TRUE(rejected); + EXPECT_EQ(rejected->status, 400); + const json rejected_body = json::parse(rejected->body); + EXPECT_EQ(rejected_body["error"]["code"], -32700); + + const auto alive = client.Post("/message", + std::string(R"({"jsonrpc":"2.0","id":1,"method":"ping"})"), + "application/json"); + ASSERT_TRUE(alive); + EXPECT_EQ(alive->status, 202); +}