From 1873f0922956b0b5145f3537650667a849cdaa8a Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 16 Jul 2026 11:56:59 +0800 Subject: [PATCH 01/15] Capture Streamable HTTP MCP session id Summary: - Capture Mcp-Session-Id from Streamable HTTP requests, including case-insensitive header matches. - Store the session id on the HTTP/SSE protocol filter for request-session association. --- src/filter/http_sse_filter_chain_factory.cc | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/filter/http_sse_filter_chain_factory.cc b/src/filter/http_sse_filter_chain_factory.cc index 60c398a9f..a9f6ad4df 100644 --- a/src/filter/http_sse_filter_chain_factory.cc +++ b/src/filter/http_sse_filter_chain_factory.cc @@ -51,6 +51,7 @@ #include "mcp/filter/http_sse_filter_chain_factory.h" #include +#include #include #include #include @@ -771,6 +772,31 @@ class HttpSseJsonRpcProtocolFilter server_mode_->currentMode() == ServerConnMode::Undetermined) { server_mode_->handleEvent(ServerConnEvent::PlainHttpDetected); } + streamable_http_session_id_.clear(); + auto session_it = headers.find("mcp-session-id"); + if (session_it != headers.end()) { + streamable_http_session_id_ = session_it->second; + } else { + static constexpr const char* expected = "mcp-session-id"; + static constexpr size_t expected_len = 14; + for (const auto& header : headers) { + if (header.first.size() != expected_len) { + continue; + } + bool matches = true; + for (size_t i = 0; i < expected_len; ++i) { + if (std::tolower(static_cast(header.first[i])) != + expected[i]) { + matches = false; + break; + } + } + if (matches) { + streamable_http_session_id_ = header.second; + break; + } + } + } auto accept = headers.find("accept"); if (accept != headers.end() && accept->second.find("text/event-stream") != std::string::npos) { @@ -1263,6 +1289,10 @@ class HttpSseJsonRpcProtocolFilter // registered under this ID instead of writing back to the POST // connection. std::string sse_callback_session_id_; + // Session ID from Mcp-Session-Id on Streamable HTTP requests. This is the + // durable request-session identity for POST /mcp clients that do not use the + // SSE callback path. + std::string streamable_http_session_id_; // Messages queued during SSE endpoint negotiation (client mode only). // Drained once the state machine reaches EndpointReceived. From fc3409991a19ae4d0a5426de58f8aadfd8e704fe Mon Sep 17 00:00:00 2001 From: RahulHere Date: Tue, 21 Jul 2026 18:18:23 +0800 Subject: [PATCH 02/15] Fix streamable HTTP tool call parsing Summary: - Treat end-of-stream HTTP JSON-RPC bodies as a single JSON document before falling back to newline-delimited parsing. - Extend reconnect readiness wait for remote HTTPS/SSE backends using the configured request timeout. - Add low-noise MCP flow logs around HTTP/SSE body forwarding, JSON-RPC parsing, and server dispatch. --- src/client/mcp_client.cc | 20 ++++++--- src/filter/http_sse_filter_chain_factory.cc | 18 ++++++++ src/filter/json_rpc_protocol_filter.cc | 47 +++++++++++++++++++++ src/server/mcp_server.cc | 11 +++++ 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/client/mcp_client.cc b/src/client/mcp_client.cc index c574d7c8a..f55701aee 100644 --- a/src/client/mcp_client.cc +++ b/src/client/mcp_client.cc @@ -744,10 +744,19 @@ void McpClient::sendRequestInternal(std::shared_ptr context) { "is_stale={}", idle_seconds, kConnectionIdleTimeoutSec, is_stale); - // Check if connection is stale or not open - need to reconnect - // Maximum retries to wait for connection after reconnect (50 * 10ms = 500ms - // max) - static constexpr int kMaxReconnectRetries = 50; + // Check if connection is stale or not open - need to reconnect. + // + // Reconnect readiness is driven by dispatcher I/O and can take several + // seconds for remote HTTPS/SSE backends. The previous fixed 500ms budget was + // enough for local tests but too short for real gateway backends after an + // idle connection went stale. + static constexpr int kReconnectRetryDelayMs = 10; + const auto reconnect_wait_budget = std::min( + std::max(config_.request_timeout, std::chrono::milliseconds(5000)), + std::chrono::milliseconds(30000)); + const auto kMaxReconnectRetries = + static_cast(std::max( + 1, reconnect_wait_budget.count() / kReconnectRetryDelayMs)); // THREAD SAFETY: Use atomic connected_ flag instead of isConnectionOpen() // isConnectionOpen() reads McpConnectionManager::active_connection_ without @@ -765,7 +774,8 @@ void McpClient::sendRequestInternal(std::shared_ptr context) { context->retry_count++; context->retry_timer = main_dispatcher_->createTimer( [this, context]() { sendRequestInternal(context); }); - context->retry_timer->enableTimer(std::chrono::milliseconds(10)); + context->retry_timer->enableTimer( + std::chrono::milliseconds(kReconnectRetryDelayMs)); return; } // Connected now, proceed with send below diff --git a/src/filter/http_sse_filter_chain_factory.cc b/src/filter/http_sse_filter_chain_factory.cc index a9f6ad4df..90afea312 100644 --- a/src/filter/http_sse_filter_chain_factory.cc +++ b/src/filter/http_sse_filter_chain_factory.cc @@ -823,11 +823,19 @@ class HttpSseJsonRpcProtocolFilter } void onBody(const std::string& data, bool end_stream) override { + GOPHER_LOG_FLOW_DEBUG( + "HTTP/SSE server body received mode={} bytes={} end_stream={}", + server_mode_ + ? ServerConnectionMode::getModeName(server_mode_->currentMode()) + : "", + data.size(), end_stream ? "true" : "false"); + // The long-lived GET /sse request has no request body — but if the // codec surfaces any trailing bytes we don't want to push them down // into the JSON-RPC parser. Ignore bodies on the SSE stream // connection entirely. if (server_mode_ && server_mode_->isSseStream()) { + GOPHER_LOG_FLOW_DEBUG("HTTP/SSE server body ignored on SSE stream"); return; } // Server receives JSON-RPC in request body regardless of SSE mode @@ -836,6 +844,9 @@ class HttpSseJsonRpcProtocolFilter // Server always receives JSON-RPC in request body pending_json_data_.add(data); if (end_stream) { + GOPHER_LOG_FLOW_DEBUG( + "HTTP/SSE server forwarding JSON-RPC body bytes={} to parser", + pending_json_data_.length()); jsonrpc_filter_->onData(pending_json_data_, true); pending_json_data_.drain(pending_json_data_.length()); } @@ -868,11 +879,18 @@ class HttpSseJsonRpcProtocolFilter } void onMessageComplete() override { + GOPHER_LOG_FLOW_DEBUG( + "HTTP/SSE message complete sse_active={} pending_json_bytes={}", + isSseActive() ? "true" : "false", pending_json_data_.length()); + // HTTP message complete — flush any remaining JSON-RPC data that // was not yet processed. In SSE mode the data flows through the // SSE codec instead, so we only flush for non-SSE connections. if (!isSseActive() && pending_json_data_.length() > 0) { // Process any remaining JSON-RPC data + GOPHER_LOG_FLOW_DEBUG( + "HTTP/SSE message complete forwarding pending JSON-RPC bytes={}", + pending_json_data_.length()); jsonrpc_filter_->onData(pending_json_data_, true); pending_json_data_.drain(pending_json_data_.length()); } diff --git a/src/filter/json_rpc_protocol_filter.cc b/src/filter/json_rpc_protocol_filter.cc index 349ec86e9..90e53a2d5 100644 --- a/src/filter/json_rpc_protocol_filter.cc +++ b/src/filter/json_rpc_protocol_filter.cc @@ -298,6 +298,43 @@ network::FilterStatus JsonRpcProtocolFilter::onData(Buffer& data, bool end_stream) { GOPHER_LOG_TRACE("onData called - buffer size: {}, end_stream: {}", data.length(), end_stream); + GOPHER_LOG_FLOW_DEBUG( + "JSON-RPC parser received bytes={} end_stream={} partial_before={}", + data.length(), end_stream ? "true" : "false", partial_message_.size()); + + // Streamable HTTP POST bodies are complete JSON documents. They may be + // pretty-printed and contain whitespace newlines, which are not message + // delimiters in HTTP body mode. Prefer parsing the full end-of-stream body as + // one JSON value; fall back to newline-delimited parsing when it is not a + // single JSON document, preserving stream/NDJSON behavior. + if (end_stream && !use_framing_) { + std::string body = partial_message_ + data.toString(); + data.drain(data.length()); + + const auto first = body.find_first_not_of(" \t\r\n"); + const auto last = body.find_last_not_of(" \t\r\n"); + if (first != std::string::npos) { + std::string trimmed = body.substr(first, last - first + 1); + try { + (void)json::JsonValue::parse(trimmed); + GOPHER_LOG_FLOW_DEBUG( + "JSON-RPC parser treating end_stream body as single message " + "bytes={}", + trimmed.size()); + parseMessage(trimmed); + partial_message_.clear(); + return network::FilterStatus::Continue; + } catch (const json::JsonException&) { + // Not a single JSON document; restore the bytes and use the legacy + // newline-delimited parser below. + partial_message_ = std::move(body); + } + } else { + partial_message_.clear(); + return network::FilterStatus::Continue; + } + } + // Parse JSON-RPC messages from the data buffer // This data has already been processed by lower protocol layers (HTTP/SSE) parseMessages(data); @@ -306,6 +343,9 @@ network::FilterStatus JsonRpcProtocolFilter::onData(Buffer& data, // message This handles HTTP requests where the body doesn't end with a // newline if (end_stream && !partial_message_.empty() && !use_framing_) { + GOPHER_LOG_FLOW_DEBUG( + "JSON-RPC parser flushing partial message bytes={} on end_stream", + partial_message_.size()); parseMessage(partial_message_); partial_message_.clear(); } @@ -382,6 +422,8 @@ void JsonRpcProtocolFilter::parseMessages(Buffer& buffer) { bool JsonRpcProtocolFilter::parseMessage(const std::string& json_str) { GOPHER_LOG_TRACE("JsonRpcProtocolFilter attempting to parse: {}", json_str); + GOPHER_LOG_FLOW_DEBUG("JSON-RPC parser parsing message bytes={}", + json_str.size()); try { // Parse JSON string auto json_val = json::JsonValue::parse(json_str); @@ -391,6 +433,8 @@ bool JsonRpcProtocolFilter::parseMessage(const std::string& json_str) { if (json_val.contains("id")) { // JSON-RPC Request jsonrpc::Request request = json::from_json(json_val); + GOPHER_LOG_FLOW_DEBUG("JSON-RPC parser dispatch request method={}", + request.method); GOPHER_LOG_DEBUG("JsonRpcFilter dispatching request for method: {}", request.method); requests_received_++; @@ -400,6 +444,9 @@ bool JsonRpcProtocolFilter::parseMessage(const std::string& json_str) { // JSON-RPC Notification jsonrpc::Notification notification = json::from_json(json_val); + GOPHER_LOG_FLOW_DEBUG( + "JSON-RPC parser dispatch notification method={}", + notification.method); notifications_received_++; DispatchContextImpl context(*this); handler_.onNotificationWithContext(notification, context); diff --git a/src/server/mcp_server.cc b/src/server/mcp_server.cc index 867ac47f4..e3619664e 100644 --- a/src/server/mcp_server.cc +++ b/src/server/mcp_server.cc @@ -870,6 +870,11 @@ void McpServer::onRequestWithContext(const jsonrpc::Request& request, MessageDispatchContext& context) { GOPHER_LOG_DEBUG("McpServer::onRequest called with method: {}", request.method); + GOPHER_LOG_FLOW_DEBUG( + "MCP server dispatch request method={} transport_session={}", + request.method, + context.transportSessionId().empty() ? "" + : context.transportSessionId()); // Handle request in dispatcher context - already in dispatcher server_stats_.requests_total++; @@ -1016,6 +1021,12 @@ void McpServer::onNotification(const jsonrpc::Notification& notification) { void McpServer::onNotificationWithContext( const jsonrpc::Notification& notification, MessageDispatchContext& context) { + GOPHER_LOG_FLOW_DEBUG( + "MCP server dispatch notification method={} transport_session={}", + notification.method, + context.transportSessionId().empty() ? "" + : context.transportSessionId()); + // Handle notification in dispatcher context server_stats_.notifications_total++; From 674d1eba9e32e6d9b211f8e02293c41431685afc Mon Sep 17 00:00:00 2001 From: RahulHere Date: Tue, 21 Jul 2026 23:49:19 +0800 Subject: [PATCH 03/15] Apply formatting cleanup --- src/client/mcp_client.cc | 5 ++--- src/filter/json_rpc_protocol_filter.cc | 5 ++--- src/filter/request_logger_filter.cc | 12 ++++++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/client/mcp_client.cc b/src/client/mcp_client.cc index f55701aee..36d9a587d 100644 --- a/src/client/mcp_client.cc +++ b/src/client/mcp_client.cc @@ -754,9 +754,8 @@ void McpClient::sendRequestInternal(std::shared_ptr context) { const auto reconnect_wait_budget = std::min( std::max(config_.request_timeout, std::chrono::milliseconds(5000)), std::chrono::milliseconds(30000)); - const auto kMaxReconnectRetries = - static_cast(std::max( - 1, reconnect_wait_budget.count() / kReconnectRetryDelayMs)); + const auto kMaxReconnectRetries = static_cast(std::max( + 1, reconnect_wait_budget.count() / kReconnectRetryDelayMs)); // THREAD SAFETY: Use atomic connected_ flag instead of isConnectionOpen() // isConnectionOpen() reads McpConnectionManager::active_connection_ without diff --git a/src/filter/json_rpc_protocol_filter.cc b/src/filter/json_rpc_protocol_filter.cc index 90e53a2d5..1b0c40e04 100644 --- a/src/filter/json_rpc_protocol_filter.cc +++ b/src/filter/json_rpc_protocol_filter.cc @@ -444,9 +444,8 @@ bool JsonRpcProtocolFilter::parseMessage(const std::string& json_str) { // JSON-RPC Notification jsonrpc::Notification notification = json::from_json(json_val); - GOPHER_LOG_FLOW_DEBUG( - "JSON-RPC parser dispatch notification method={}", - notification.method); + GOPHER_LOG_FLOW_DEBUG("JSON-RPC parser dispatch notification method={}", + notification.method); notifications_received_++; DispatchContextImpl context(*this); handler_.onNotificationWithContext(notification, context); diff --git a/src/filter/request_logger_filter.cc b/src/filter/request_logger_filter.cc index 2da7bdeb2..4b0f10158 100644 --- a/src/filter/request_logger_filter.cc +++ b/src/filter/request_logger_filter.cc @@ -128,7 +128,8 @@ void RequestLoggerFilter::onRequest(const jsonrpc::Request& request) { if (next_callbacks_) { next_callbacks_->onRequest(request); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } @@ -139,7 +140,8 @@ void RequestLoggerFilter::onRequestWithContext( if (next_callbacks_) { next_callbacks_->onRequestWithContext(request, context); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } @@ -197,7 +199,8 @@ void RequestLoggerFilter::onResponse(const jsonrpc::Response& response) { if (next_callbacks_) { next_callbacks_->onResponse(response); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } @@ -221,7 +224,8 @@ void RequestLoggerFilter::onProtocolError(const Error& error) { if (next_callbacks_) { next_callbacks_->onProtocolError(error); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } From 993bd154461c262617aa6d642c85ac3c6e0ae067 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Wed, 22 Jul 2026 00:11:52 +0800 Subject: [PATCH 04/15] Release 0.1.14 --- CHANGELOG.md | 234 +++++++++++++++++++++++++++++++++++++++++++++++++ CMakeLists.txt | 2 +- 2 files changed, 235 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 872625786..8c4d29842 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,246 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +### Added + +### Changed + +## [0.1.14] - 2026-07-22 + +### Fixed + +- Fix streamable HTTP tool call parsing +- Capture Streamable HTTP MCP session id + +## [0.1.13] - 2026-07-08 + +### Added + +- Add MCP HTTP header passthrough support (#250) + +### Fixed + +- Fix SSL transport use-after-free in posted lambdas (#245) +- Null-guard OpenSSL 3.x error-string accessors to prevent crash (#246) +- Fix SSL transport data loss on full network BIO (#247) +- Route MCP responses to the originating connection (#248) +- Improve transport and MCP flow logging (#249) +- Skip TLS peer metadata when verification is disabled (#251) +- Avoid retaining streamed HTTP client bodies (#260) +- Guard oversized HTTP body callbacks (#255) +- Defer HTTP parser callback errors (#256) +- Post HTTP parser error callbacks (#252) +- Defer active connection destruction on close (#257) +- Bind llhttp symbols inside shared library (#259) +- Honor preferred HTTP transport (#253) + +## [0.1.12] - 2026-07-03 + +### Added + +- Add MCP HTTP header passthrough support +- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch + +## [0.1.11] - 2026-07-02 + +### Added + +- Add MCP HTTP header passthrough support +- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch + +### Changed + +- make format +- Bind llhttp symbols inside shared library +- Defer active connection destruction on close +- Post HTTP parser error callbacks +- Defer HTTP parser callback errors +- Guard oversized HTTP body callbacks +- Avoid retaining streamed HTTP client bodies +- Skip TLS peer metadata when verification is disabled + +### Fixed + +- Fix SSL transport data loss on full network BIO +- Fix SSL transport use-after-free in posted lambdas + +## [0.1.10] - 2026-06-30 + +### Added + +- Add MCP HTTP header passthrough support +- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch + + +## [0.1.9] - 2026-06-30 + ### Added +- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch + ### Changed +- Route MCP responses to the originating connection (fix concurrent-request hangs) + ### Fixed +- Fix SSL transport data loss on full network BIO +- Fix SSL transport use-after-free in posted lambdas + +## [0.1.8] - 2026-06-24 + +### Added + +- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch + +### Changed + +- Scope MCP flow logging to a dedicated GOPHER_MCP_LOG_FLOW switch +- Demote filter registry init/registration logs to Debug +- Surface SSL errors via warning log +- Null-guard OpenSSL 3.x error-string accessors to prevent crash + +### Fixed + +- Fix SSL transport data loss on full network BIO +- Fix SSL transport use-after-free in posted lambdas + +## [0.1.7] - 2026-06-22 + +### Added + +### Changed + +- Demote filter registry init/registration logs to Debug +- Surface SSL errors via warning log +- Null-guard OpenSSL 3.x error-string accessors to prevent crash +- Unstaged changes: CMakeLists.txt + +### Fixed + +- Fix SSL transport use-after-free in posted lambdas + +## [0.1.6] - 2026-06-11 + +### Added + +- Add a request-scoped _meta carrier to SessionContext (#236) - set/getRequestMeta hold the in-flight request's params._meta as its stringified-JSON form, so a tool handler can read out-of-band metadata (e.g. correlation ids) without the dispatch forking - The value is per-request but stored on the per-session SessionContext; this is safe because a session handles one request at a time on the dispatcher thread and handleCallTool sets it fresh before each dispatch, so it always reflects the current request +- Add client-side notification handler registration to McpClient (#237) +- Add integration tests for ServerConnectionMode wiring in filter (#226) +- Add comprehensive unit tests for ServerConnectionMode (#226) +- Add ServerConnectionMode for server-side connection lifecycle (#226) +- Add integration tests for ClientSseStateMachine wiring in filter (#226) +- Add comprehensive unit tests for ClientSseStateMachine (#226) +- Add ClientSseStateMachine for client-side SSE negotiation lifecycle (#226) +- Add fmt::runtime calls to all runtime format strings + +### Changed + +- Release 0.1.5 +- Release 0.1.4 +- Enhance dump-version.sh with GitHub release check and auto-generated changelog +- Release 0.1.3 +- Format code (#236) +- Surface request params._meta to server-side tool handlers (#236) - handleCallTool stashes params._meta onto the session before dispatch, beside the existing arguments extraction; cleared when absent so a prior request's _meta never aliases this one - The tool handler already receives the session, so no handler signature changes +- Populate ReadResourceResult contents in McpClient::readResource (#238) +- Format code (#226) +- Remove dead RequestStream code from HttpSseJsonRpcProtocolFilter (#226) +- Wire state change logging for both state machines (#226) +- Remove is_sse_mode_ and unify SSE detection through state machines (#226) +- Integrate ServerConnectionMode into HttpSseJsonRpcProtocolFilter (#226) +- Wire SSE negotiation timeout to error propagation and message drain (#226) +- Replace client-side boolean flags with ClientSseStateMachine queries (#226) +- Wire ClientSseStateMachine into HttpSseJsonRpcProtocolFilter as shadow (#226) +- build: Add MSVC 26 support and improve build configuration +- Restyle README architecture overview to nested-box layout and align right edges (#225) +- Unstaged changes: CMakeLists.txt + +### Fixed + +- Fix ConnectionPoolImpl timeout SEGFAULT from premature write event (#226) + +## [0.1.5] - 2026-04-21 + +### Added + +- Add idle-read timeout to ConnectionImpl (#224) +- Add real-IO SSE server transport handshake test (#216) +- Add unit tests for SseSessionRegistry (#215) +- Implement SSE server transport with per-factory session registry (#215) +- Add SSE/RPC path and external_url params to filter chain factory (#215) +- Add integration tests for HttpAsyncClient (#213) +- Add HttpAsyncClient built on HttpCodecFilter (#213) +- Add unit tests for crash-fix contracts (#212) + +### Changed + +- Release 0.1.4 +- Enhance dump-version.sh with GitHub release check and auto-generated changelog +- Release 0.1.3 +- Cover server idle-read timeout end-to-end (#224) +- Switch idle-read close to NoFlush so LocalClose actually propagates (#224) +- Arm idle-read timeout on every accepted McpServer connection (#224) +- Cover abortive TCP close in McpServer connection-lifecycle test (#223) +- Cover ConnectionPoolImpl timeout timer against stack-capture UAF (#222) +- Stop capturing stack-local PendingConnection in pool timeout timer (#222) +- Run deferred close through dispatcher post instead of a stack-local timer (#221) +- Drop write-only num_connections_ in favor of public stat (#220) +- Drop vestigial ConnectionCallbacks inheritance from McpServer (#220) +- Cover connections_active/total across three concurrent accepts (#219) +- Count TCP server connections in the public stats (#219) +- Drop leak-on-teardown workaround from initialize-routing test (#218) +- Cover McpServer connection-lifecycle cleanup and shutdown-drain (#218) +- Drain active_connections_ during McpServer::shutdown on the dispatcher (#218) +- Cover dispatcher-thread commit inside McpClient::initializeProtocol (#217) +- Cover POST /callback routing back through the SSE stream (#216) +- Extract SseSessionRegistry into its own translation unit (#215) +- make format (#215) +- Match POST /callback/{id} under reverse-proxy path prefixes (#215) +- Wire McpServerConfig endpoint paths into HttpSseFilterChainFactory (#215) +- Rename default SSE path to /sse and add external_url config (#215) +- Surface numeric :status pseudo-header from HTTP client codec (#213) +- Test that client-mode HTTP codec actually disables body_timeout (#212) +- Cover lifecycle-adapter self+peer deferred-delete pattern (#212) +- Drive ConnectionManager event tests through the dispatcher thread (#212) +- Disable body timeout for client-mode HTTP codec (#212) +- Route initializeProtocol state commit back to the dispatcher thread (#212) +- Bind server connection callbacks per connection via adapter (#212) +- Defer closed-connection destruction via Dispatcher::deferredDelete (#212) +- Unstaged changes: CMakeLists.txt + +### Fixed + +- Fix background-task timer lifetime in McpServer (#212) +- Fix scheduleCallbackCurrentIteration to defer past caller's stack frame (#212) + +## [0.1.4] - 2026-04-08 + +### Added + +- Add unit tests for resources/read response and ResourceManager handlers (#206) +- Add read handlers to example server resource registrations (#206) +- Add ResourceReadHandler callback to ResourceManager (#206) + +### Changed + +- Enhance dump-version.sh with GitHub release check and auto-generated changelog +- Release 0.1.3 +- Run clang-format on resource read implementation files (#206) +- Unstaged changes: CMakeLists.txt + +### Fixed + +- Fix resources/read response to match MCP schema (#206) + +## [0.1.3] - 2026-04-08 + +### Added + +### Changed + +### Fixed ## [0.1.1] - 2026-03-03 diff --git a/CMakeLists.txt b/CMakeLists.txt index 2da2179e9..002f20e37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ Please create a build directory and run cmake from there: You may need to remove CMakeCache.txt and CMakeFiles/") endif() -project(gopher-mcp VERSION 0.1.1 LANGUAGES C CXX) +project(gopher-mcp VERSION 0.1.14 LANGUAGES C CXX) # Set library version for shared libraries set(GOPHER_MCP_VERSION_MAJOR ${PROJECT_VERSION_MAJOR}) From bb85f29b2b1d7c92e89a110552e39c5f0d7b9b8d Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 08:00:58 +0800 Subject: [PATCH 05/15] Propagate streamable HTTP session ids Summary: - Return Mcp-Session-Id from HTTP/SSE dispatch contexts when no SSE callback session is active. - Add a real-IO server-mode regression test for Streamable HTTP POST /mcp session binding. Verification: - cmake --build build --target test_http_sse_filter_server_mode - ./build/tests/test_http_sse_filter_server_mode --gtest_filter='ServerModeFilterTest.*' --- src/filter/http_sse_filter_chain_factory.cc | 15 +++++--- .../test_http_sse_filter_server_mode.cc | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/filter/http_sse_filter_chain_factory.cc b/src/filter/http_sse_filter_chain_factory.cc index 90afea312..cd99d92d1 100644 --- a/src/filter/http_sse_filter_chain_factory.cc +++ b/src/filter/http_sse_filter_chain_factory.cc @@ -977,11 +977,11 @@ class HttpSseJsonRpcProtocolFilter // ===== JsonRpcProtocolFilter::MessageHandler ===== /** - * Per-message dispatch context for messages decoded on this composite - * chain. Origin is the connection the message physically arrived on (for - * HTTP+SSE, the short-lived POST connection); the transport session id is - * the SSE stream id parsed from POST /callback/{id} — the durable client - * identity the server keys its session on. + * Per-message dispatch context for messages decoded on this composite chain. + * Origin is the connection the message physically arrived on. The transport + * session id is the durable client identity for transports where logical MCP + * sessions span short-lived HTTP connections: the callback id parsed from + * POST /callback/{id}, or Mcp-Session-Id from Streamable HTTP POST /mcp. * * The reply sink writes the bare JSON to the origin connection, exactly * the bytes the server used to write to its ambient current-connection @@ -1001,7 +1001,10 @@ class HttpSseJsonRpcProtocolFilter } const std::string& transportSessionId() const override { - return parent_.sse_callback_session_id_; + if (!parent_.sse_callback_session_id_.empty()) { + return parent_.sse_callback_session_id_; + } + return parent_.streamable_http_session_id_; } VoidResult sendResponse(const jsonrpc::Response& response) override { diff --git a/tests/integration/test_http_sse_filter_server_mode.cc b/tests/integration/test_http_sse_filter_server_mode.cc index 1c8a76438..e93fcd68f 100644 --- a/tests/integration/test_http_sse_filter_server_mode.cc +++ b/tests/integration/test_http_sse_filter_server_mode.cc @@ -431,6 +431,43 @@ TEST_F(ServerModeFilterTest, PlainPost_AnnouncesEmptyBinding) { closeOnDispatcher(std::move(conn), std::move(factory)); } +TEST_F(ServerModeFilterTest, StreamablePost_AnnouncesMcpSessionIdBinding) { + ServerModeCallbacks callbacks; + std::unique_ptr conn; + network::IoHandlePtr peer; + std::shared_ptr factory; + + executeInDispatcher([&]() { + auto h = makeServerHarness(callbacks); + conn = std::move(h.conn); + peer = std::move(h.peer); + factory = std::move(h.factory); + + std::string body = R"({"jsonrpc":"2.0","method":"ping","id":3})"; + std::string request = + "POST /mcp HTTP/1.1\r\n" + "Host: localhost\r\n" + "Content-Type: application/json\r\n" + "Mcp-Session-Id: streamable-client-1\r\n" + "Content-Length: " + + std::to_string(body.size()) + + "\r\n" + "\r\n" + + body; + writeClientBytes(*peer, request); + }); + + std::this_thread::sleep_for(200ms); + + ASSERT_EQ(callbacks.requests_.size(), 1u); + EXPECT_EQ(callbacks.requests_[0].method, "ping"); + EXPECT_EQ(callbacks.binding_at_request_[0], "streamable-client-1") + << "Streamable HTTP dispatch must propagate Mcp-Session-Id as the " + "transport session id"; + + closeOnDispatcher(std::move(conn), std::move(factory)); +} + // ── Callback-proxy notification must not leak a 202 onto the SSE stream ── // // A notification POSTed to /callback/{id} is already answered with a 202 From 99e1cc296cf6a14aaaab5f6634b4b014c95d35f0 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 08:12:28 +0800 Subject: [PATCH 06/15] Remove redundant session header scan Summary: - Drop the unreachable case-insensitive Mcp-Session-Id scan because HTTP codec callbacks already receive lowercase header names. - Remove the now-unused cctype include. Verification: - ./build/tests/test_http_sse_filter_server_mode --gtest_filter='ServerModeFilterTest.StreamablePost_AnnouncesMcpSessionIdBinding:ServerModeFilterTest.PlainPost_AnnouncesEmptyBinding' - git diff --check --- src/filter/http_sse_filter_chain_factory.cc | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/filter/http_sse_filter_chain_factory.cc b/src/filter/http_sse_filter_chain_factory.cc index cd99d92d1..80f0fea78 100644 --- a/src/filter/http_sse_filter_chain_factory.cc +++ b/src/filter/http_sse_filter_chain_factory.cc @@ -51,7 +51,6 @@ #include "mcp/filter/http_sse_filter_chain_factory.h" #include -#include #include #include #include @@ -776,26 +775,6 @@ class HttpSseJsonRpcProtocolFilter auto session_it = headers.find("mcp-session-id"); if (session_it != headers.end()) { streamable_http_session_id_ = session_it->second; - } else { - static constexpr const char* expected = "mcp-session-id"; - static constexpr size_t expected_len = 14; - for (const auto& header : headers) { - if (header.first.size() != expected_len) { - continue; - } - bool matches = true; - for (size_t i = 0; i < expected_len; ++i) { - if (std::tolower(static_cast(header.first[i])) != - expected[i]) { - matches = false; - break; - } - } - if (matches) { - streamable_http_session_id_ = header.second; - break; - } - } } auto accept = headers.find("accept"); if (accept != headers.end() && From 645b93b834e74bcf5e2d7d16cdca80080ece6669 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 12:01:35 +0800 Subject: [PATCH 07/15] Bound reconnect readiness wait Summary: - Derive reconnect readiness wait from a bounded fraction of request_timeout so polling cannot consume the full request deadline. - Add deterministic client reconnection tests for default, medium, short, and very small request timeouts. Verification: - cmake --build build --target test_client_reconnection_and_logging - ./build/tests/test_client_reconnection_and_logging --gtest_filter='ClientReconnectionTest.*' - git diff --check --- include/mcp/client/mcp_client.h | 2 + src/client/mcp_client.cc | 16 +++++--- .../test_client_reconnection_and_logging.cc | 37 +++++++++++-------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/include/mcp/client/mcp_client.h b/include/mcp/client/mcp_client.h index ef818bdf5..a9c5ed965 100644 --- a/include/mcp/client/mcp_client.h +++ b/include/mcp/client/mcp_client.h @@ -433,6 +433,8 @@ class McpClient : public application::ApplicationBase { void disconnect(); bool isConnected() const { return connected_; } bool isConnectionOpen() const; // Check actual connection state + static std::chrono::milliseconds reconnectWaitBudgetForRequestTimeout( + std::chrono::milliseconds request_timeout); // Shutdown the client (stops workers and event loop) void shutdown() override; diff --git a/src/client/mcp_client.cc b/src/client/mcp_client.cc index 36d9a587d..0063ea272 100644 --- a/src/client/mcp_client.cc +++ b/src/client/mcp_client.cc @@ -311,6 +311,12 @@ bool McpClient::isConnectionOpen() const { return connection_manager_->isConnected(); } +std::chrono::milliseconds McpClient::reconnectWaitBudgetForRequestTimeout( + std::chrono::milliseconds request_timeout) { + return std::min(std::max(request_timeout / 3, std::chrono::milliseconds(250)), + std::chrono::milliseconds(5000)); +} + // Reconnect using stored URI VoidResult McpClient::reconnect() { if (current_uri_.empty()) { @@ -747,13 +753,11 @@ void McpClient::sendRequestInternal(std::shared_ptr context) { // Check if connection is stale or not open - need to reconnect. // // Reconnect readiness is driven by dispatcher I/O and can take several - // seconds for remote HTTPS/SSE backends. The previous fixed 500ms budget was - // enough for local tests but too short for real gateway backends after an - // idle connection went stale. + // seconds for remote HTTPS/SSE backends, but it must leave request-deadline + // headroom for the actual send and response. static constexpr int kReconnectRetryDelayMs = 10; - const auto reconnect_wait_budget = std::min( - std::max(config_.request_timeout, std::chrono::milliseconds(5000)), - std::chrono::milliseconds(30000)); + const auto reconnect_wait_budget = + reconnectWaitBudgetForRequestTimeout(config_.request_timeout); const auto kMaxReconnectRetries = static_cast(std::max( 1, reconnect_wait_budget.count() / kReconnectRetryDelayMs)); diff --git a/tests/client/test_client_reconnection_and_logging.cc b/tests/client/test_client_reconnection_and_logging.cc index ee3fe6fe5..50a4fcd66 100644 --- a/tests/client/test_client_reconnection_and_logging.cc +++ b/tests/client/test_client_reconnection_and_logging.cc @@ -365,22 +365,29 @@ TEST_F(ClientReconnectionTest, RetryTimerDelayIs10Milliseconds) { EXPECT_LE(elapsed, 50); // But not more than 50ms } -// Test maximum retry count is 50 -TEST_F(ClientReconnectionTest, MaximumRetryCountIs50) { - // The code defines kMaxReconnectRetries = 50 - // This gives 50 * 10ms = 500ms maximum retry time - - using namespace jsonrpc; - RequestId id = 1; - RequestContext context(id, "test.method"); - - // Simulate reaching max retries - context.retry_count = 50; - EXPECT_EQ(context.retry_count, 50); +TEST_F(ClientReconnectionTest, ReconnectWaitBudgetLeavesRequestHeadroom) { + EXPECT_EQ(McpClient::reconnectWaitBudgetForRequestTimeout( + std::chrono::milliseconds(30000)), + std::chrono::milliseconds(5000)) + << "Default request timeout should not be fully consumed by reconnect " + "readiness polling"; + EXPECT_EQ(McpClient::reconnectWaitBudgetForRequestTimeout( + std::chrono::milliseconds(15000)), + std::chrono::milliseconds(5000)); + EXPECT_EQ(McpClient::reconnectWaitBudgetForRequestTimeout( + std::chrono::milliseconds(5000)), + std::chrono::milliseconds(1666)); +} - // At 51 retries, the code should fail the request - context.retry_count = 51; - EXPECT_GT(context.retry_count, 50); +TEST_F(ClientReconnectionTest, ReconnectWaitBudgetHonorsShortTimeouts) { + EXPECT_EQ(McpClient::reconnectWaitBudgetForRequestTimeout( + std::chrono::milliseconds(1000)), + std::chrono::milliseconds(333)); + EXPECT_EQ(McpClient::reconnectWaitBudgetForRequestTimeout( + std::chrono::milliseconds(100)), + std::chrono::milliseconds(250)) + << "Very small request timeouts get only a small readiness floor, not " + "the old multi-second minimum"; } // ============================================================================ From 0ab5ea22b2c93b98a443ee8520cd830bc78a1a61 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 12:05:24 +0800 Subject: [PATCH 08/15] Rename reconnect retry budget variable Summary: - Rename the runtime-derived reconnect retry count from kMaxReconnectRetries to max_reconnect_retries. - Update stale test commentary that referenced the old constant-style name. Verification: - cmake --build build --target test_client_reconnection_and_logging - ./build/tests/test_client_reconnection_and_logging --gtest_filter='ClientReconnectionTest.*' - git diff --check --- src/client/mcp_client.cc | 6 +++--- tests/client/test_client_reconnection_and_logging.cc | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/mcp_client.cc b/src/client/mcp_client.cc index 0063ea272..a7bb08722 100644 --- a/src/client/mcp_client.cc +++ b/src/client/mcp_client.cc @@ -758,7 +758,7 @@ void McpClient::sendRequestInternal(std::shared_ptr context) { static constexpr int kReconnectRetryDelayMs = 10; const auto reconnect_wait_budget = reconnectWaitBudgetForRequestTimeout(config_.request_timeout); - const auto kMaxReconnectRetries = static_cast(std::max( + const auto max_reconnect_retries = static_cast(std::max( 1, reconnect_wait_budget.count() / kReconnectRetryDelayMs)); // THREAD SAFETY: Use atomic connected_ flag instead of isConnectionOpen() @@ -768,7 +768,7 @@ void McpClient::sendRequestInternal(std::shared_ptr context) { if (is_stale || !connected_) { // Track if this is a retry after reconnect if (context->retry_count > 0 && - context->retry_count <= kMaxReconnectRetries) { + context->retry_count <= max_reconnect_retries) { // This is a retry - check if we're connected now if (!connected_) { // Still not connected, schedule another retry with timer delay @@ -782,7 +782,7 @@ void McpClient::sendRequestInternal(std::shared_ptr context) { return; } // Connected now, proceed with send below - } else if (context->retry_count > kMaxReconnectRetries) { + } else if (context->retry_count > max_reconnect_retries) { // Too many retries context->promise.set_value(Response::make_error( context->id, Error(::mcp::jsonrpc::INTERNAL_ERROR, diff --git a/tests/client/test_client_reconnection_and_logging.cc b/tests/client/test_client_reconnection_and_logging.cc index 50a4fcd66..1ccaca9bf 100644 --- a/tests/client/test_client_reconnection_and_logging.cc +++ b/tests/client/test_client_reconnection_and_logging.cc @@ -297,7 +297,7 @@ TEST_F(ClientReconnectionTest, RetryCountCanBeIncremented) { context.retry_count++; EXPECT_EQ(context.retry_count, 2); - // Can increment up to kMaxReconnectRetries (50) + // Retry count remains ordinary mutable request context state. context.retry_count = 50; EXPECT_EQ(context.retry_count, 50); } From 1731ea2d1c1346b3389bd083a8124d2060a0645d Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 15:58:59 +0800 Subject: [PATCH 09/15] Cover end-stream JSON-RPC body parsing Summary: - Add parser coverage for pretty-printed complete HTTP bodies, NDJSON fallback, and malformed end-stream input. - Exercise the end_stream non-framed branch used by Streamable HTTP request bodies. Verification: - cmake --build build --target test_json_rpc_protocol_filter - ./build/tests/test_json_rpc_protocol_filter --gtest_filter='JsonRpcProtocolFilterTest.EndStreamParsesPrettyPrintedBodyAsOneJson:JsonRpcProtocolFilterTest.EndStreamFallsBackToNdjsonBodies:JsonRpcProtocolFilterTest.EndStreamMalformedBodyReportsParseError' - git diff --check Note: - Full JsonRpcProtocolFilterTest.* run hit existing unrelated bind failure in SendResponseFailsOnClosedConnection. --- tests/filter/test_json_rpc_protocol_filter.cc | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/filter/test_json_rpc_protocol_filter.cc b/tests/filter/test_json_rpc_protocol_filter.cc index 7e34a6ec3..23458d2c5 100644 --- a/tests/filter/test_json_rpc_protocol_filter.cc +++ b/tests/filter/test_json_rpc_protocol_filter.cc @@ -230,6 +230,63 @@ TEST_F(JsonRpcProtocolFilterTest, InvalidJson) { EXPECT_EQ(network::FilterStatus::Continue, status); } +TEST_F(JsonRpcProtocolFilterTest, EndStreamParsesPrettyPrintedBodyAsOneJson) { + EXPECT_CALL(*callbacks_, onRequest(_)) + .WillOnce([](const jsonrpc::Request& req) { + EXPECT_EQ("test.pretty", req.method); + EXPECT_TRUE(holds_alternative(req.id)); + EXPECT_EQ(7, get(req.id)); + }); + EXPECT_CALL(*callbacks_, onProtocolError(_)).Times(0); + + const std::string json_str = + "{\n" + " \"jsonrpc\": \"2.0\",\n" + " \"id\": 7,\n" + " \"method\": \"test.pretty\",\n" + " \"params\": {\n" + " \"message\": \"line one\\nline two\"\n" + " }\n" + "}"; + + auto status = processData(json_str, true); + EXPECT_EQ(network::FilterStatus::Continue, status); +} + +TEST_F(JsonRpcProtocolFilterTest, EndStreamFallsBackToNdjsonBodies) { + InSequence seq; + EXPECT_CALL(*callbacks_, onNotification(_)) + .WillOnce([](const jsonrpc::Notification& notification) { + EXPECT_EQ("first", notification.method); + }); + EXPECT_CALL(*callbacks_, onNotification(_)) + .WillOnce([](const jsonrpc::Notification& notification) { + EXPECT_EQ("second", notification.method); + }); + EXPECT_CALL(*callbacks_, onProtocolError(_)).Times(0); + + const std::string json_str = + R"({"jsonrpc":"2.0","method":"first"})" + "\n" + R"({"jsonrpc":"2.0","method":"second"})" + "\n"; + + auto status = processData(json_str, true); + EXPECT_EQ(network::FilterStatus::Continue, status); +} + +TEST_F(JsonRpcProtocolFilterTest, EndStreamMalformedBodyReportsParseError) { + EXPECT_CALL(*callbacks_, onRequest(_)).Times(0); + EXPECT_CALL(*callbacks_, onNotification(_)).Times(0); + EXPECT_CALL(*callbacks_, onResponse(_)).Times(0); + EXPECT_CALL(*callbacks_, onProtocolError(_)).WillOnce([](const Error& error) { + EXPECT_EQ(jsonrpc::PARSE_ERROR, error.code); + }); + + auto status = processData("{ invalid json }", true); + EXPECT_EQ(network::FilterStatus::Continue, status); +} + /** * Test message framing mode */ From a9f87485d9128238b40a146106fcda897f006471 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 16:03:33 +0800 Subject: [PATCH 10/15] Avoid double parsing JSON-RPC bodies Summary: - Add a parsed-message dispatch helper so parseMessage and complete HTTP body parsing share dispatch logic. - Dispatch end-stream single-document bodies from the parsed JsonValue instead of parsing the same payload twice. - Preserve JSON parse and internal error mapping for existing parser paths. Verification: - cmake --build build --target test_json_rpc_protocol_filter - ./build/tests/test_json_rpc_protocol_filter --gtest_filter='JsonRpcProtocolFilterTest.ParseRequest:JsonRpcProtocolFilterTest.ParseNotification:JsonRpcProtocolFilterTest.ParseResponse:JsonRpcProtocolFilterTest.ParseErrorResponse:JsonRpcProtocolFilterTest.InvalidJson:JsonRpcProtocolFilterTest.EndStreamParsesPrettyPrintedBodyAsOneJson:JsonRpcProtocolFilterTest.EndStreamFallsBackToNdjsonBodies:JsonRpcProtocolFilterTest.EndStreamMalformedBodyReportsParseError' - git diff --check --- include/mcp/filter/json_rpc_protocol_filter.h | 7 +++++ src/filter/json_rpc_protocol_filter.cc | 29 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/include/mcp/filter/json_rpc_protocol_filter.h b/include/mcp/filter/json_rpc_protocol_filter.h index 32f06650f..3ec877252 100644 --- a/include/mcp/filter/json_rpc_protocol_filter.h +++ b/include/mcp/filter/json_rpc_protocol_filter.h @@ -192,6 +192,13 @@ class JsonRpcProtocolFilter : public network::Filter { */ bool parseMessage(const std::string& json_str); + /** + * Dispatch a parsed JSON-RPC message + * @param json_val Parsed JSON value + * @return True if message shape was valid + */ + bool dispatchMessage(const json::JsonValue& json_val); + /** * Frame outgoing message with length prefix if needed * @param data Buffer containing message to frame diff --git a/src/filter/json_rpc_protocol_filter.cc b/src/filter/json_rpc_protocol_filter.cc index 1b0c40e04..bd406dc72 100644 --- a/src/filter/json_rpc_protocol_filter.cc +++ b/src/filter/json_rpc_protocol_filter.cc @@ -316,12 +316,12 @@ network::FilterStatus JsonRpcProtocolFilter::onData(Buffer& data, if (first != std::string::npos) { std::string trimmed = body.substr(first, last - first + 1); try { - (void)json::JsonValue::parse(trimmed); + auto json_val = json::JsonValue::parse(trimmed); GOPHER_LOG_FLOW_DEBUG( "JSON-RPC parser treating end_stream body as single message " "bytes={}", trimmed.size()); - parseMessage(trimmed); + dispatchMessage(json_val); partial_message_.clear(); return network::FilterStatus::Continue; } catch (const json::JsonException&) { @@ -427,7 +427,30 @@ bool JsonRpcProtocolFilter::parseMessage(const std::string& json_str) { try { // Parse JSON string auto json_val = json::JsonValue::parse(json_str); + return dispatchMessage(json_val); + } catch (const json::JsonException& e) { + // JSON parse error + Error error; + error.code = jsonrpc::PARSE_ERROR; + error.message = "JSON parse error: " + std::string(e.what()); + protocol_errors_++; + handler_.onProtocolError(error); + return false; + + } catch (const std::exception& e) { + // Other errors + Error error; + error.code = jsonrpc::INTERNAL_ERROR; + error.message = "Internal error: " + std::string(e.what()); + protocol_errors_++; + handler_.onProtocolError(error); + return false; + } +} + +bool JsonRpcProtocolFilter::dispatchMessage(const json::JsonValue& json_val) { + try { // Determine message type and dispatch to callbacks if (json_val.contains("method")) { if (json_val.contains("id")) { @@ -470,7 +493,6 @@ bool JsonRpcProtocolFilter::parseMessage(const std::string& json_str) { return true; } catch (const json::JsonException& e) { - // JSON parse error Error error; error.code = jsonrpc::PARSE_ERROR; error.message = "JSON parse error: " + std::string(e.what()); @@ -479,7 +501,6 @@ bool JsonRpcProtocolFilter::parseMessage(const std::string& json_str) { return false; } catch (const std::exception& e) { - // Other errors Error error; error.code = jsonrpc::INTERNAL_ERROR; error.message = "Internal error: " + std::string(e.what()); From 4ec307f01d4ca091705ded7cc252fd6f6f0ee1ba Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 16:09:10 +0800 Subject: [PATCH 11/15] Report one parse error for malformed HTTP bodies Summary: - Validate end-stream bodies as NDJSON before falling back from single-document parsing. - Report one PARSE_ERROR for malformed multi-line bodies instead of parsing each line fragment independently. - Add regression coverage for malformed pretty-printed JSON bodies. Verification: - cmake --build build --target test_json_rpc_protocol_filter - ./build/tests/test_json_rpc_protocol_filter --gtest_filter='JsonRpcProtocolFilterTest.ParseRequest:JsonRpcProtocolFilterTest.ParseNotification:JsonRpcProtocolFilterTest.ParseResponse:JsonRpcProtocolFilterTest.ParseErrorResponse:JsonRpcProtocolFilterTest.InvalidJson:JsonRpcProtocolFilterTest.EndStreamParsesPrettyPrintedBodyAsOneJson:JsonRpcProtocolFilterTest.EndStreamFallsBackToNdjsonBodies:JsonRpcProtocolFilterTest.EndStreamMalformedBodyReportsParseError:JsonRpcProtocolFilterTest.EndStreamMalformedPrettyBodyReportsOneParseError' - git diff --check --- src/filter/json_rpc_protocol_filter.cc | 45 +++++++++++++++++-- tests/filter/test_json_rpc_protocol_filter.cc | 19 ++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/filter/json_rpc_protocol_filter.cc b/src/filter/json_rpc_protocol_filter.cc index bd406dc72..b5c7fb2a7 100644 --- a/src/filter/json_rpc_protocol_filter.cc +++ b/src/filter/json_rpc_protocol_filter.cc @@ -9,6 +9,9 @@ #include "mcp/filter/json_rpc_protocol_filter.h" +#include +#include + #include "mcp/json/json_serialization.h" #include "mcp/logging/log_macros.h" #include "mcp/mcp_connection_manager.h" @@ -324,10 +327,44 @@ network::FilterStatus JsonRpcProtocolFilter::onData(Buffer& data, dispatchMessage(json_val); partial_message_.clear(); return network::FilterStatus::Continue; - } catch (const json::JsonException&) { - // Not a single JSON document; restore the bytes and use the legacy - // newline-delimited parser below. - partial_message_ = std::move(body); + } catch (const json::JsonException& e) { + std::vector ndjson_messages; + bool valid_ndjson = true; + std::istringstream lines(trimmed); + std::string line; + while (std::getline(lines, line)) { + const auto line_first = line.find_first_not_of(" \t\r"); + if (line_first == std::string::npos) { + continue; + } + const auto line_last = line.find_last_not_of(" \t\r"); + const std::string line_trimmed = + line.substr(line_first, line_last - line_first + 1); + try { + ndjson_messages.push_back(json::JsonValue::parse(line_trimmed)); + } catch (const json::JsonException&) { + valid_ndjson = false; + break; + } + } + + if (valid_ndjson && !ndjson_messages.empty()) { + GOPHER_LOG_FLOW_DEBUG( + "JSON-RPC parser treating end_stream body as NDJSON messages " + "count={}", + ndjson_messages.size()); + for (const auto& json_val : ndjson_messages) { + dispatchMessage(json_val); + } + } else { + Error error; + error.code = jsonrpc::PARSE_ERROR; + error.message = "JSON parse error: " + std::string(e.what()); + protocol_errors_++; + handler_.onProtocolError(error); + } + partial_message_.clear(); + return network::FilterStatus::Continue; } } else { partial_message_.clear(); diff --git a/tests/filter/test_json_rpc_protocol_filter.cc b/tests/filter/test_json_rpc_protocol_filter.cc index 23458d2c5..b1abb668e 100644 --- a/tests/filter/test_json_rpc_protocol_filter.cc +++ b/tests/filter/test_json_rpc_protocol_filter.cc @@ -287,6 +287,25 @@ TEST_F(JsonRpcProtocolFilterTest, EndStreamMalformedBodyReportsParseError) { EXPECT_EQ(network::FilterStatus::Continue, status); } +TEST_F(JsonRpcProtocolFilterTest, + EndStreamMalformedPrettyBodyReportsOneParseError) { + EXPECT_CALL(*callbacks_, onRequest(_)).Times(0); + EXPECT_CALL(*callbacks_, onNotification(_)).Times(0); + EXPECT_CALL(*callbacks_, onResponse(_)).Times(0); + EXPECT_CALL(*callbacks_, onProtocolError(_)).WillOnce([](const Error& error) { + EXPECT_EQ(jsonrpc::PARSE_ERROR, error.code); + }); + + const std::string json_str = + "{\n" + " \"jsonrpc\": \"2.0\",\n" + " \"id\": 9,\n" + " \"method\": \"broken\"\n"; + + auto status = processData(json_str, true); + EXPECT_EQ(network::FilterStatus::Continue, status); +} + /** * Test message framing mode */ From 20f8b4f8bd010ebe318e0cc94cdbf87ef3552dc4 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 16:22:39 +0800 Subject: [PATCH 12/15] Improve changelog release notes Summary: - Align changelog history with published gopher-mcp release notes. - Keep 0.1.14 focused on Streamable HTTP parsing, session id propagation, and reconnect timeout fixes. - Remove generated release bookkeeping and duplicated historical entries. --- CHANGELOG.md | 217 ++++++++++++++++++--------------------------------- 1 file changed, 74 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c4d29842..1d3c1b353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,246 +7,177 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - ### Added ### Changed +### Fixed + ## [0.1.14] - 2026-07-22 ### Fixed -- Fix streamable HTTP tool call parsing -- Capture Streamable HTTP MCP session id +- Fix Streamable HTTP tool-call body parsing for pretty-printed JSON, newline-delimited JSON fallback, and malformed body error reporting. +- Propagate `Mcp-Session-Id` from Streamable HTTP requests as the transport session id. +- Bound reconnect readiness polling so it cannot consume the full request timeout. ## [0.1.13] - 2026-07-08 ### Added -- Add MCP HTTP header passthrough support (#250) +- Add MCP HTTP header passthrough support (#250). ### Fixed -- Fix SSL transport use-after-free in posted lambdas (#245) -- Null-guard OpenSSL 3.x error-string accessors to prevent crash (#246) -- Fix SSL transport data loss on full network BIO (#247) -- Route MCP responses to the originating connection (#248) -- Improve transport and MCP flow logging (#249) -- Skip TLS peer metadata when verification is disabled (#251) -- Avoid retaining streamed HTTP client bodies (#260) -- Guard oversized HTTP body callbacks (#255) -- Defer HTTP parser callback errors (#256) -- Post HTTP parser error callbacks (#252) -- Defer active connection destruction on close (#257) -- Bind llhttp symbols inside shared library (#259) -- Honor preferred HTTP transport (#253) +- Fix SSL transport use-after-free in posted lambdas (#245). +- Null-guard OpenSSL 3.x error-string accessors to prevent crash (#246). +- Fix SSL transport data loss on full network BIO (#247). +- Route MCP responses to the originating connection (#248). +- Improve transport and MCP flow logging (#249). +- Skip TLS peer metadata when verification is disabled (#251). +- Avoid retaining streamed HTTP client bodies (#260). +- Guard oversized HTTP body callbacks (#255). +- Defer HTTP parser callback errors (#256). +- Post HTTP parser error callbacks (#252). +- Defer active connection destruction on close (#257). +- Bind llhttp symbols inside the shared library (#259). +- Honor preferred HTTP transport (#253). ## [0.1.12] - 2026-07-03 ### Added -- Add MCP HTTP header passthrough support -- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch +- Add MCP HTTP header passthrough support. +- Add MCP client and HTTP transport invoke logging controlled by `GOPHER_LOG_LEVEL`. ## [0.1.11] - 2026-07-02 -### Added - -- Add MCP HTTP header passthrough support -- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch - ### Changed -- make format -- Bind llhttp symbols inside shared library -- Defer active connection destruction on close -- Post HTTP parser error callbacks -- Defer HTTP parser callback errors -- Guard oversized HTTP body callbacks -- Avoid retaining streamed HTTP client bodies -- Skip TLS peer metadata when verification is disabled +- Bind llhttp symbols inside the shared library. +- Defer active connection destruction on close. +- Post and defer HTTP parser error callbacks safely. +- Guard oversized HTTP body callbacks. +- Avoid retaining streamed HTTP client bodies. +- Skip TLS peer metadata when verification is disabled. ### Fixed -- Fix SSL transport data loss on full network BIO -- Fix SSL transport use-after-free in posted lambdas +- Fix SSL transport data loss on full network BIO. +- Fix SSL transport use-after-free in posted lambdas. ## [0.1.10] - 2026-06-30 ### Added -- Add MCP HTTP header passthrough support -- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch - +- Add MCP HTTP header passthrough support. +- Add MCP client and HTTP transport invoke logging controlled by `GOPHER_LOG_LEVEL`. ## [0.1.9] - 2026-06-30 ### Added -- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch +- Add MCP client and HTTP transport invoke logging controlled by `GOPHER_LOG_LEVEL`. ### Changed -- Route MCP responses to the originating connection (fix concurrent-request hangs) +- Route MCP responses to the originating connection to fix concurrent request hangs. ### Fixed -- Fix SSL transport data loss on full network BIO -- Fix SSL transport use-after-free in posted lambdas +- Fix SSL transport data loss on full network BIO. +- Fix SSL transport use-after-free in posted lambdas. ## [0.1.8] - 2026-06-24 ### Added -- Add invoke logging to MCP client and HTTP transport with GOPHER_LOG_LEVEL switch +- Add MCP client and HTTP transport invoke logging controlled by `GOPHER_LOG_LEVEL`. ### Changed -- Scope MCP flow logging to a dedicated GOPHER_MCP_LOG_FLOW switch -- Demote filter registry init/registration logs to Debug -- Surface SSL errors via warning log -- Null-guard OpenSSL 3.x error-string accessors to prevent crash +- Scope MCP flow logging to a dedicated `GOPHER_MCP_LOG_FLOW` switch. +- Demote filter registry initialization and registration logs to debug level. +- Surface SSL errors through warning logs. +- Null-guard OpenSSL 3.x error-string accessors. ### Fixed -- Fix SSL transport data loss on full network BIO -- Fix SSL transport use-after-free in posted lambdas +- Fix SSL transport data loss on full network BIO. +- Fix SSL transport use-after-free in posted lambdas. ## [0.1.7] - 2026-06-22 -### Added - ### Changed -- Demote filter registry init/registration logs to Debug -- Surface SSL errors via warning log -- Null-guard OpenSSL 3.x error-string accessors to prevent crash -- Unstaged changes: CMakeLists.txt +- Demote filter registry initialization and registration logs to debug level. +- Surface SSL errors through warning logs. +- Null-guard OpenSSL 3.x error-string accessors. ### Fixed -- Fix SSL transport use-after-free in posted lambdas +- Fix SSL transport use-after-free in posted lambdas. ## [0.1.6] - 2026-06-11 ### Added -- Add a request-scoped _meta carrier to SessionContext (#236) - set/getRequestMeta hold the in-flight request's params._meta as its stringified-JSON form, so a tool handler can read out-of-band metadata (e.g. correlation ids) without the dispatch forking - The value is per-request but stored on the per-session SessionContext; this is safe because a session handles one request at a time on the dispatcher thread and handleCallTool sets it fresh before each dispatch, so it always reflects the current request -- Add client-side notification handler registration to McpClient (#237) -- Add integration tests for ServerConnectionMode wiring in filter (#226) -- Add comprehensive unit tests for ServerConnectionMode (#226) -- Add ServerConnectionMode for server-side connection lifecycle (#226) -- Add integration tests for ClientSseStateMachine wiring in filter (#226) -- Add comprehensive unit tests for ClientSseStateMachine (#226) -- Add ClientSseStateMachine for client-side SSE negotiation lifecycle (#226) -- Add fmt::runtime calls to all runtime format strings +- Add request-scoped `_meta` storage to `SessionContext` for tool handlers. +- Add client-side notification handler registration to `McpClient`. +- Add server and client SSE state machines with integration coverage. +- Add runtime formatting support for dynamic format strings. ### Changed -- Release 0.1.5 -- Release 0.1.4 -- Enhance dump-version.sh with GitHub release check and auto-generated changelog -- Release 0.1.3 -- Format code (#236) -- Surface request params._meta to server-side tool handlers (#236) - handleCallTool stashes params._meta onto the session before dispatch, beside the existing arguments extraction; cleared when absent so a prior request's _meta never aliases this one - The tool handler already receives the session, so no handler signature changes -- Populate ReadResourceResult contents in McpClient::readResource (#238) -- Format code (#226) -- Remove dead RequestStream code from HttpSseJsonRpcProtocolFilter (#226) -- Wire state change logging for both state machines (#226) -- Remove is_sse_mode_ and unify SSE detection through state machines (#226) -- Integrate ServerConnectionMode into HttpSseJsonRpcProtocolFilter (#226) -- Wire SSE negotiation timeout to error propagation and message drain (#226) -- Replace client-side boolean flags with ClientSseStateMachine queries (#226) -- Wire ClientSseStateMachine into HttpSseJsonRpcProtocolFilter as shadow (#226) -- build: Add MSVC 26 support and improve build configuration -- Restyle README architecture overview to nested-box layout and align right edges (#225) -- Unstaged changes: CMakeLists.txt +- Populate `ReadResourceResult` contents in `McpClient::readResource`. +- Integrate SSE connection state machines into `HttpSseJsonRpcProtocolFilter`. +- Remove obsolete request-stream and SSE mode state from the HTTP/SSE filter. +- Improve MSVC build configuration. +- Update README architecture documentation. ### Fixed -- Fix ConnectionPoolImpl timeout SEGFAULT from premature write event (#226) +- Fix `ConnectionPoolImpl` timeout crash from premature write events. ## [0.1.5] - 2026-04-21 ### Added -- Add idle-read timeout to ConnectionImpl (#224) -- Add real-IO SSE server transport handshake test (#216) -- Add unit tests for SseSessionRegistry (#215) -- Implement SSE server transport with per-factory session registry (#215) -- Add SSE/RPC path and external_url params to filter chain factory (#215) -- Add integration tests for HttpAsyncClient (#213) -- Add HttpAsyncClient built on HttpCodecFilter (#213) -- Add unit tests for crash-fix contracts (#212) +- Add idle-read timeout handling to `ConnectionImpl` and `McpServer`. +- Add SSE server transport support with a per-factory session registry. +- Add configurable SSE/RPC paths and external URL parameters to the filter chain factory. +- Add `HttpAsyncClient` built on `HttpCodecFilter`. +- Add integration and lifecycle tests for HTTP, SSE, and connection cleanup behavior. ### Changed -- Release 0.1.4 -- Enhance dump-version.sh with GitHub release check and auto-generated changelog -- Release 0.1.3 -- Cover server idle-read timeout end-to-end (#224) -- Switch idle-read close to NoFlush so LocalClose actually propagates (#224) -- Arm idle-read timeout on every accepted McpServer connection (#224) -- Cover abortive TCP close in McpServer connection-lifecycle test (#223) -- Cover ConnectionPoolImpl timeout timer against stack-capture UAF (#222) -- Stop capturing stack-local PendingConnection in pool timeout timer (#222) -- Run deferred close through dispatcher post instead of a stack-local timer (#221) -- Drop write-only num_connections_ in favor of public stat (#220) -- Drop vestigial ConnectionCallbacks inheritance from McpServer (#220) -- Cover connections_active/total across three concurrent accepts (#219) -- Count TCP server connections in the public stats (#219) -- Drop leak-on-teardown workaround from initialize-routing test (#218) -- Cover McpServer connection-lifecycle cleanup and shutdown-drain (#218) -- Drain active_connections_ during McpServer::shutdown on the dispatcher (#218) -- Cover dispatcher-thread commit inside McpClient::initializeProtocol (#217) -- Cover POST /callback routing back through the SSE stream (#216) -- Extract SseSessionRegistry into its own translation unit (#215) -- make format (#215) -- Match POST /callback/{id} under reverse-proxy path prefixes (#215) -- Wire McpServerConfig endpoint paths into HttpSseFilterChainFactory (#215) -- Rename default SSE path to /sse and add external_url config (#215) -- Surface numeric :status pseudo-header from HTTP client codec (#213) -- Test that client-mode HTTP codec actually disables body_timeout (#212) -- Cover lifecycle-adapter self+peer deferred-delete pattern (#212) -- Drive ConnectionManager event tests through the dispatcher thread (#212) -- Disable body timeout for client-mode HTTP codec (#212) -- Route initializeProtocol state commit back to the dispatcher thread (#212) -- Bind server connection callbacks per connection via adapter (#212) -- Defer closed-connection destruction via Dispatcher::deferredDelete (#212) -- Unstaged changes: CMakeLists.txt +- Match `/callback/{id}` requests under reverse-proxy path prefixes. +- Rename the default SSE path to `/sse`. +- Surface numeric `:status` pseudo-headers from the HTTP client codec. +- Disable body timeout for client-mode HTTP codec. +- Defer closed connection destruction through dispatcher cleanup. +- Drain active server connections during shutdown. ### Fixed -- Fix background-task timer lifetime in McpServer (#212) -- Fix scheduleCallbackCurrentIteration to defer past caller's stack frame (#212) +- Fix background-task timer lifetime in `McpServer`. +- Fix callback scheduling so deferred callbacks run after caller stack unwinds. ## [0.1.4] - 2026-04-08 ### Added -- Add unit tests for resources/read response and ResourceManager handlers (#206) -- Add read handlers to example server resource registrations (#206) -- Add ResourceReadHandler callback to ResourceManager (#206) +- Add resource read handlers to example server registrations. +- Add `ResourceReadHandler` callback support to `ResourceManager`. +- Add tests for `resources/read` responses and resource manager handlers. ### Changed -- Enhance dump-version.sh with GitHub release check and auto-generated changelog -- Release 0.1.3 -- Run clang-format on resource read implementation files (#206) -- Unstaged changes: CMakeLists.txt +- Run formatting on resource read implementation files. ### Fixed -- Fix resources/read response to match MCP schema (#206) - -## [0.1.3] - 2026-04-08 - -### Added - -### Changed - -### Fixed +- Fix `resources/read` responses to match the MCP schema. ## [0.1.1] - 2026-03-03 From 71bf7aff4f361ec009bbb3aebe97499d7c95e636 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 16:28:33 +0800 Subject: [PATCH 13/15] Remove request logger formatting churn Summary: - Restore unchanged RequestLogger no-handler log lines to their original layout. - Keep the passthrough bugfix PR focused on behavior changes. --- src/filter/request_logger_filter.cc | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/filter/request_logger_filter.cc b/src/filter/request_logger_filter.cc index 4b0f10158..2da7bdeb2 100644 --- a/src/filter/request_logger_filter.cc +++ b/src/filter/request_logger_filter.cc @@ -128,8 +128,7 @@ void RequestLoggerFilter::onRequest(const jsonrpc::Request& request) { if (next_callbacks_) { next_callbacks_->onRequest(request); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" - << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; } } @@ -140,8 +139,7 @@ void RequestLoggerFilter::onRequestWithContext( if (next_callbacks_) { next_callbacks_->onRequestWithContext(request, context); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" - << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; } } @@ -199,8 +197,7 @@ void RequestLoggerFilter::onResponse(const jsonrpc::Response& response) { if (next_callbacks_) { next_callbacks_->onResponse(response); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" - << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; } } @@ -224,8 +221,7 @@ void RequestLoggerFilter::onProtocolError(const Error& error) { if (next_callbacks_) { next_callbacks_->onProtocolError(error); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" - << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; } } From 4548ad1e4c87ccb35dd336c98b161916a55f5b0b Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 16:39:18 +0800 Subject: [PATCH 14/15] Return 404 for unknown HTTP paths Summary: - Invoke the HTTP routing default handler before forwarding unmatched requests to protocol handling. - Return immediate JSON 404 responses for unknown HTTP/SSE server paths while preserving MCP, SSE, and callback transport pass-through. - Add routing unit tests and a server-mode integration regression test for unknown discovery paths. --- include/mcp/filter/http_routing_filter.h | 3 +- src/filter/http_routing_filter.cc | 32 ++++- src/filter/http_sse_filter_chain_factory.cc | 36 ++++- .../filter/test_http_routing_filter_simple.cc | 128 +++++++++++++++++- .../test_http_sse_filter_server_mode.cc | 29 ++++ 5 files changed, 219 insertions(+), 9 deletions(-) diff --git a/include/mcp/filter/http_routing_filter.h b/include/mcp/filter/http_routing_filter.h index 4e4b0f606..a7bc7450d 100644 --- a/include/mcp/filter/http_routing_filter.h +++ b/include/mcp/filter/http_routing_filter.h @@ -137,6 +137,7 @@ class HttpRoutingFilter : public HttpCodecFilter::MessageCallbacks { // State for POST requests that need body bool pending_post_request_ = false; + bool suppress_current_request_ = false; RequestContext pending_context_; HandlerFunc pending_handler_; std::string accumulated_body_; @@ -164,4 +165,4 @@ class HttpRoutingFilterFactory { } // namespace filter } // namespace mcp -#endif // MCP_FILTER_HTTP_ROUTING_FILTER_H \ No newline at end of file +#endif // MCP_FILTER_HTTP_ROUTING_FILTER_H diff --git a/src/filter/http_routing_filter.cc b/src/filter/http_routing_filter.cc index a757d1d43..e3873fce1 100644 --- a/src/filter/http_routing_filter.cc +++ b/src/filter/http_routing_filter.cc @@ -109,18 +109,41 @@ void HttpRoutingFilter::onHeaders( if (resp.status_code != 0) { // Handler wants to handle this - send response immediately // This is appropriate for endpoints that don't need the body + suppress_current_request_ = true; sendResponse(resp); return; // Don't forward to next layer } } - // No handler or handler returned 0 - pass through + // No registered handler matched, or the matched handler explicitly returned + // status 0. Give the default handler a chance to answer before forwarding to + // the protocol layer. Transport paths can still opt into pass-through by + // returning status 0 from the default handler. + { + RequestContext ctx; + ctx.method = method; + ctx.path = full_url; + ctx.headers = headers; + ctx.keep_alive = keep_alive; + Response resp = default_handler_(ctx); + if (resp.status_code != 0) { + suppress_current_request_ = true; + sendResponse(resp); + return; // Handled or rejected; do not forward to next layer. + } + } + + // Default handler signalled pass-through - forward to next layer if (next_callbacks_) { next_callbacks_->onHeaders(headers, keep_alive); } } void HttpRoutingFilter::onBody(const std::string& data, bool end_stream) { + if (suppress_current_request_) { + return; + } + // If we're accumulating body for a POST handler, buffer it if (pending_post_request_) { accumulated_body_ += data; @@ -136,6 +159,11 @@ void HttpRoutingFilter::onBody(const std::string& data, bool end_stream) { void HttpRoutingFilter::onMessageComplete() { GOPHER_LOG_DEBUG("HttpRoutingFilter::onMessageComplete called"); + if (suppress_current_request_) { + suppress_current_request_ = false; + return; + } + // If we have a pending POST request, now we have the complete body if (pending_post_request_) { pending_context_.body = accumulated_body_; @@ -292,4 +320,4 @@ std::shared_ptr HttpRoutingFilterFactory::createWithHandlers( } } // namespace filter -} // namespace mcp \ No newline at end of file +} // namespace mcp diff --git a/src/filter/http_sse_filter_chain_factory.cc b/src/filter/http_sse_filter_chain_factory.cc index 80f0fea78..2b7227c43 100644 --- a/src/filter/http_sse_filter_chain_factory.cc +++ b/src/filter/http_sse_filter_chain_factory.cc @@ -1206,10 +1206,15 @@ class HttpSseJsonRpcProtocolFilter return resp; }); - // Default handler - handle OPTIONS for CORS preflight on any path, - // pass through other methods to MCP protocol handling + // Default handler - handle OPTIONS for CORS preflight on any path, pass + // through the real MCP transport paths to protocol handling, and return a + // definitive 404 for everything else. Unknown non-RPC paths must get an + // immediate response; otherwise they fall through to a protocol layer that + // has no request to answer and the connection can wait until client timeout. + const std::string rpc_path = configured_rpc_path_; + const std::string sse_path = configured_sse_path_; routing_filter_->registerDefaultHandler( - [](const HttpRoutingFilter::RequestContext& req) { + [rpc_path, sse_path](const HttpRoutingFilter::RequestContext& req) { // Handle OPTIONS for CORS preflight on any path if (req.method == "OPTIONS") { HttpRoutingFilter::Response resp; @@ -1223,9 +1228,30 @@ class HttpSseJsonRpcProtocolFilter resp.headers["Content-Length"] = "0"; return resp; } - // Return status 0 to indicate pass-through for MCP endpoints + + std::string path = req.path; + auto query_start = path.find('?'); + if (query_start != std::string::npos) { + path = path.substr(0, query_start); + } + + const bool is_transport_path = + path == rpc_path || path == sse_path || path == "/rpc" || + path == "/events" || path == "/mcp/events" || + (req.method == "POST" && + path.find("/callback/") != std::string::npos); + if (is_transport_path) { + HttpRoutingFilter::Response resp; + resp.status_code = 0; + return resp; + } + HttpRoutingFilter::Response resp; - resp.status_code = 0; + resp.status_code = 404; + resp.headers["content-type"] = "application/json"; + resp.headers["Access-Control-Allow-Origin"] = "*"; + resp.body = R"({"error":"not_found"})"; + resp.headers["content-length"] = std::to_string(resp.body.length()); return resp; }); diff --git a/tests/filter/test_http_routing_filter_simple.cc b/tests/filter/test_http_routing_filter_simple.cc index eae739b10..9e9353320 100644 --- a/tests/filter/test_http_routing_filter_simple.cc +++ b/tests/filter/test_http_routing_filter_simple.cc @@ -182,6 +182,132 @@ TEST_F(HttpRoutingFilterSimpleTest, CustomDefaultHandler) { EXPECT_FALSE(default_handler_called); } +TEST_F(HttpRoutingFilterSimpleTest, UnmatchedRequestUsesDefaultHandler) { + std::atomic default_handler_called(false); + + executeInDispatcher([this, &default_handler_called]() { + filter_->registerDefaultHandler( + [&default_handler_called]( + const HttpRoutingFilter::RequestContext& req) { + default_handler_called = true; + EXPECT_EQ(req.method, "GET"); + EXPECT_EQ(req.path, "/missing?source=test"); + HttpRoutingFilter::Response resp; + resp.status_code = 404; + resp.body = "not found"; + resp.headers["content-length"] = std::to_string(resp.body.length()); + return resp; + }); + + EXPECT_CALL(*next_callbacks_, onHeaders(_, _)).Times(0); + + std::map headers; + headers[":method"] = "GET"; + headers[":path"] = "/missing?source=test"; + + filter_->onHeaders(headers, true); + }); + + EXPECT_TRUE(default_handler_called); +} + +TEST_F(HttpRoutingFilterSimpleTest, DefaultHandlerStatusZeroPassesThrough) { + std::atomic default_handler_called(false); + + executeInDispatcher([this, &default_handler_called]() { + filter_->registerDefaultHandler( + [&default_handler_called]( + const HttpRoutingFilter::RequestContext& req) { + default_handler_called = true; + EXPECT_EQ(req.path, "/mcp"); + HttpRoutingFilter::Response resp; + resp.status_code = 0; + return resp; + }); + + EXPECT_CALL(*next_callbacks_, onHeaders(_, true)).Times(1); + + std::map headers; + headers[":method"] = "POST"; + headers[":path"] = "/mcp"; + + filter_->onHeaders(headers, true); + }); + + EXPECT_TRUE(default_handler_called); +} + +TEST_F(HttpRoutingFilterSimpleTest, HandlerStatusZeroFallsBackToDefault) { + std::atomic handler_called(false); + std::atomic default_handler_called(false); + + executeInDispatcher([this, &handler_called, &default_handler_called]() { + filter_->registerHandler( + "GET", "/passthrough", + [&handler_called](const HttpRoutingFilter::RequestContext& req) { + handler_called = true; + HttpRoutingFilter::Response resp; + resp.status_code = 0; + return resp; + }); + filter_->registerDefaultHandler( + [&default_handler_called]( + const HttpRoutingFilter::RequestContext& req) { + default_handler_called = true; + EXPECT_EQ(req.path, "/passthrough"); + HttpRoutingFilter::Response resp; + resp.status_code = 404; + resp.body = "not found"; + resp.headers["content-length"] = std::to_string(resp.body.length()); + return resp; + }); + + EXPECT_CALL(*next_callbacks_, onHeaders(_, _)).Times(0); + + std::map headers; + headers[":method"] = "GET"; + headers[":path"] = "/passthrough"; + + filter_->onHeaders(headers, true); + }); + + EXPECT_TRUE(handler_called); + EXPECT_TRUE(default_handler_called); +} + +TEST_F(HttpRoutingFilterSimpleTest, HandledDefaultResponseConsumesRequestBody) { + std::atomic default_handler_called(false); + + executeInDispatcher([this, &default_handler_called]() { + filter_->registerDefaultHandler( + [&default_handler_called]( + const HttpRoutingFilter::RequestContext& req) { + default_handler_called = true; + EXPECT_EQ(req.method, "POST"); + EXPECT_EQ(req.path, "/unknown"); + HttpRoutingFilter::Response resp; + resp.status_code = 404; + resp.body = "not found"; + resp.headers["content-length"] = std::to_string(resp.body.length()); + return resp; + }); + + EXPECT_CALL(*next_callbacks_, onHeaders(_, _)).Times(0); + EXPECT_CALL(*next_callbacks_, onBody(_, _)).Times(0); + EXPECT_CALL(*next_callbacks_, onMessageComplete()).Times(0); + + std::map headers; + headers[":method"] = "POST"; + headers[":path"] = "/unknown"; + + filter_->onHeaders(headers, true); + filter_->onBody(R"({"jsonrpc":"2.0","method":"ping","id":1})", true); + filter_->onMessageComplete(); + }); + + EXPECT_TRUE(default_handler_called); +} + // Test request context structure TEST_F(HttpRoutingFilterSimpleTest, RequestContext) { HttpRoutingFilter::RequestContext ctx; @@ -456,4 +582,4 @@ TEST_F(HttpRoutingFilterSimpleTest, GetRequestImmediateExecution) { } // namespace } // namespace filter -} // namespace mcp \ No newline at end of file +} // namespace mcp diff --git a/tests/integration/test_http_sse_filter_server_mode.cc b/tests/integration/test_http_sse_filter_server_mode.cc index e93fcd68f..a216854d4 100644 --- a/tests/integration/test_http_sse_filter_server_mode.cc +++ b/tests/integration/test_http_sse_filter_server_mode.cc @@ -248,6 +248,35 @@ TEST_F(ServerModeFilterTest, GetSseWithQueryString_StillSseStream) { closeOnDispatcher(std::move(conn), std::move(factory)); } +TEST_F(ServerModeFilterTest, UnknownHttpPath_ReturnsNotFoundImmediately) { + ServerModeCallbacks callbacks; + std::unique_ptr conn; + network::IoHandlePtr peer; + std::shared_ptr factory; + + executeInDispatcher([&]() { + auto h = makeServerHarness(callbacks); + conn = std::move(h.conn); + peer = std::move(h.peer); + factory = std::move(h.factory); + + writeClientBytes(*peer, + "GET /.well-known/oauth-protected-resource HTTP/1.1\r\n" + "Host: localhost\r\n" + "\r\n"); + }); + + std::string wire = drainPeer(*peer, 500ms); + EXPECT_NE(wire.find("HTTP/1.1 404"), std::string::npos) + << "Expected immediate 404, got: " << wire; + EXPECT_NE(wire.find(R"({"error":"not_found"})"), std::string::npos) + << "Expected JSON not_found body, got: " << wire; + EXPECT_TRUE(callbacks.requests_.empty()) + << "Unknown HTTP paths must not reach JSON-RPC dispatch"; + + closeOnDispatcher(std::move(conn), std::move(factory)); +} + // ── POST /mcp → PlainHttp mode ──────────────────────────────────── TEST_F(ServerModeFilterTest, PostMcp_PlainHttpMode) { From dc483413757c9f534c9b154ff3045302d63905f2 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 23 Jul 2026 16:45:03 +0800 Subject: [PATCH 15/15] Apply formatting cleanup --- src/filter/http_sse_filter_chain_factory.cc | 3 ++- src/filter/request_logger_filter.cc | 12 ++++++++---- tests/filter/test_json_rpc_protocol_filter.cc | 9 ++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/filter/http_sse_filter_chain_factory.cc b/src/filter/http_sse_filter_chain_factory.cc index 2b7227c43..9d5077a35 100644 --- a/src/filter/http_sse_filter_chain_factory.cc +++ b/src/filter/http_sse_filter_chain_factory.cc @@ -1210,7 +1210,8 @@ class HttpSseJsonRpcProtocolFilter // through the real MCP transport paths to protocol handling, and return a // definitive 404 for everything else. Unknown non-RPC paths must get an // immediate response; otherwise they fall through to a protocol layer that - // has no request to answer and the connection can wait until client timeout. + // has no request to answer and the connection can wait until client + // timeout. const std::string rpc_path = configured_rpc_path_; const std::string sse_path = configured_sse_path_; routing_filter_->registerDefaultHandler( diff --git a/src/filter/request_logger_filter.cc b/src/filter/request_logger_filter.cc index 2da7bdeb2..4b0f10158 100644 --- a/src/filter/request_logger_filter.cc +++ b/src/filter/request_logger_filter.cc @@ -128,7 +128,8 @@ void RequestLoggerFilter::onRequest(const jsonrpc::Request& request) { if (next_callbacks_) { next_callbacks_->onRequest(request); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } @@ -139,7 +140,8 @@ void RequestLoggerFilter::onRequestWithContext( if (next_callbacks_) { next_callbacks_->onRequestWithContext(request, context); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } @@ -197,7 +199,8 @@ void RequestLoggerFilter::onResponse(const jsonrpc::Response& response) { if (next_callbacks_) { next_callbacks_->onResponse(response); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } @@ -221,7 +224,8 @@ void RequestLoggerFilter::onProtocolError(const Error& error) { if (next_callbacks_) { next_callbacks_->onProtocolError(error); } else { - std::cout << "⚠️ [RequestLogger] No next handler registered!" << std::endl; + std::cout << "⚠️ [RequestLogger] No next handler registered!" + << std::endl; } } diff --git a/tests/filter/test_json_rpc_protocol_filter.cc b/tests/filter/test_json_rpc_protocol_filter.cc index b1abb668e..ad7eb4c13 100644 --- a/tests/filter/test_json_rpc_protocol_filter.cc +++ b/tests/filter/test_json_rpc_protocol_filter.cc @@ -265,11 +265,10 @@ TEST_F(JsonRpcProtocolFilterTest, EndStreamFallsBackToNdjsonBodies) { }); EXPECT_CALL(*callbacks_, onProtocolError(_)).Times(0); - const std::string json_str = - R"({"jsonrpc":"2.0","method":"first"})" - "\n" - R"({"jsonrpc":"2.0","method":"second"})" - "\n"; + const std::string json_str = R"({"jsonrpc":"2.0","method":"first"})" + "\n" + R"({"jsonrpc":"2.0","method":"second"})" + "\n"; auto status = processData(json_str, true); EXPECT_EQ(network::FilterStatus::Continue, status);