gopher-mcp Bug: SSE Transport sendRequest Futures Never Resolve
Date: 2026-03-25
Severity: Critical
Component: McpClient SSE Transport
Affects: All request/response operations after initialize (e.g., tools/list, tools/call)
Summary
After MCP protocol initialization completes successfully, subsequent requests (tools/list, tools/call, etc.) sent via the SSE transport's sendHttpPost mechanism receive HTTP 200 from the server, but the JSON-RPC response delivered on the SSE stream is never processed by the client. The sendRequest future hangs forever.
This means the MCP client can connect and initialize, but cannot list tools, call tools, or perform any other operation.
Reproduction
- Connect
McpClient to any remote HTTPS MCP SSE server
initialize completes successfully
- Send
tools/list request
- The request POST gets HTTP 200 but the response future never resolves
Test server:
Confirmed on:
- Docker Desktop (ARM64, Ubuntu 22.04 container)
- Native Linux x86_64 (Kubernetes, Ubuntu 22.04)
Evidence
# POSTs sent successfully:
sendHttpPost: body_len=160 (initialize) → HTTP 200
sendHttpPost: body_len=54 (initialized) → HTTP 200
sendHttpPost: body_len=58 (tools/list) → HTTP 200
# But tools/list response on SSE stream never delivered to client
# The sendRequest("tools/list") future hangs forever
# The listTools() worker thread blocks on request_future_ptr->get()
Verified with curl that the server does send the tools/list response (61,506 bytes) on the SSE stream correctly:
curl -sN --http1.1 -H "Accept: text/event-stream" "$SSE_URL" > /tmp/sse.txt &
# ... send init + tools/list POSTs ...
wc -c /tmp/sse.txt # → 61506 bytes (includes tools response)
Root Cause
Primary Issue: Event Loop Doesn't Process SSE Read Events
McpClient::connect() creates an internal LibeventDispatcher running in RunUntilExit mode:
// src/client/mcp_client.cc ~line 127
dispatcher_thread_ = std::thread([this]() {
main_dispatcher_->run(RunType::RunUntilExit);
});
RunUntilExit implementation:
// src/event/libevent_dispatcher.cc ~line 377
case RunType::RunUntilExit:
while (!exit_requested_) {
event_base_loop(base_, EVLOOP_ONCE);
runPostCallbacks();
}
After the initialize response is processed from the SSE stream, EVLOOP_ONCE exits and does not properly re-trigger for subsequent SSE data arriving on the socket. The server sends the tools/list response on the SSE stream, the TCP socket receives the data, but the event loop doesn't fire the socket's EV_READ event to process it.
Why initialize Works But tools/list Doesn't
initialize (190 bytes): Arrives in the initial SSE data burst along with the endpoint event. Processed during the same EVLOOP_ONCE iteration. Future resolves immediately.
tools/list (61,000+ bytes): Sent after initialization. The response arrives on the SSE stream in a separate TCP delivery. By this time, EVLOOP_ONCE has returned and the next iteration doesn't pick up the socket read event.
Additional Bugs Found
During investigation, several other bugs were discovered and fixed in the gopher-orch fork:
1. SSL moveFromBio Drops Data on EAGAIN
File: src/transport/ssl_transport_socket.cc — moveFromBio()
BIO_read(network_bio_) consumes the SSL Client Hello data, but inner_socket_->doWrite() returns EAGAIN (socket not ready). The data is lost — never sent to the server.
Fix: Buffer unsent data in pending_write_buffer_ and retry on the next handshake step.
2. SSL moveToBio Only Reads 16KB
File: src/transport/ssl_transport_socket.cc — moveToBio()
inner_socket_->doRead() reads max 16,384 bytes (one TCP read). But TLS records can be larger (~16,400+ bytes with headers/MAC). After one read, SSL_read returns WANT_READ because the TLS record is incomplete.
Fix: Loop doRead() until EAGAIN to drain all available TCP data.
3. Missing notifications/initialized Notification
File: src/client/mcp_client.cc — initializeProtocol()
The MCP spec requires the client to send notifications/initialized after receiving the init response and before sending other requests. McpClient never sends this notification. Servers may silently ignore subsequent requests without it.
Fix: Add sendNotification("notifications/initialized", nullopt) before result_promise->set_value().
4. SSE Default Event Type Not Handled
File: src/filter/http_sse_filter_chain_factory.cc (in gopher-orch submodule)
The onSseEvent callback only handles event == "message". But the SSE spec says when no event: field is present, the default event type is "message". The server sends responses as data: {...} without an event: prefix, so the event type is empty string — not matched by the handler.
Fix: Change condition to event == "message" || event.empty().
5. Default Port 8080 for HTTPS Connections
File: src/mcp_connection_manager.cc — connect() for TransportType::HttpSse
The default port is hardcoded as 8080 regardless of the protocol. For HTTPS URLs without an explicit port, the client connects to port 8080 instead of 443.
Fix: Check http_sse_config.underlying_transport == SSL and default to 443 for HTTPS, 80 for HTTP.
6. Missing SSL Configuration in createConnectionConfig
File: src/client/mcp_client.cc — createConnectionConfig() (on dev_improve_client_and_server branch)
The HttpSse case doesn't set underlying_transport = SSL, ssl_config, or http_path/http_host for HTTPS URLs. The connection fails because no SSL context is created.
Fix: Detect https:// URLs and set full SSL configuration including SNI hostname, ALPN protocols, and verify_peer.
Affected Files
| File |
Issue |
src/client/mcp_client.cc |
Event loop mode, missing initialized notification, missing SSL config |
src/event/libevent_dispatcher.cc |
RunUntilExit event loop doesn't re-trigger for socket events |
src/transport/ssl_transport_socket.cc |
moveFromBio EAGAIN data loss, moveToBio single-read limit |
src/mcp_connection_manager.cc |
Default port 8080, sendHttpPost raw socket issues |
src/filter/http_sse_filter_chain_factory.cc |
SSE default event type not handled |
Suggested Fixes
For the Primary Issue (Event Loop)
Option A: Use EVLOOP_NO_EXIT_ON_EMPTY flag if available in libevent version:
event_base_loop(base_, EVLOOP_NO_EXIT_ON_EMPTY);
Option B: Accept an external dispatcher instead of creating an internal one:
VoidResult connect(const std::string& uri, event::Dispatcher& external_dispatcher);
The caller manages the event loop, ensuring socket events are always processed.
Option C: Add a periodic timer that forces read attempts on the SSE connection:
// After connect, schedule periodic read-kick
read_kick_timer_ = main_dispatcher_->createTimer([this]() {
if (connection_manager_) connection_manager_->triggerRead();
read_kick_timer_->enableTimer(std::chrono::milliseconds(100));
});
For All Other Bugs
The fixes are implemented in the gopher-orch fork at:
third_party/gopher-mcp/src/transport/ssl_transport_socket.cc
third_party/gopher-mcp/src/client/mcp_client.cc
third_party/gopher-mcp/src/mcp_connection_manager.cc
third_party/gopher-mcp/src/filter/http_sse_filter_chain_factory.cc
These should be upstreamed to gopher-mcp.
Workaround
The sendHttpPost was replaced with a libcurl-based implementation in the gopher-orch fork. This correctly handles HTTP/2 ALPN negotiation and avoids the raw socket EAGAIN issues. However, it doesn't fix the core event loop issue — the response still needs to arrive via the SSE stream.
Currently, the gateway connects to backends successfully but registers 0 tools because the tools/list response is never processed.
gopher-mcp Bug: SSE Transport sendRequest Futures Never Resolve
Date: 2026-03-25
Severity: Critical
Component:
McpClientSSE TransportAffects: All request/response operations after
initialize(e.g.,tools/list,tools/call)Summary
After MCP protocol initialization completes successfully, subsequent requests (
tools/list,tools/call, etc.) sent via the SSE transport'ssendHttpPostmechanism receive HTTP 200 from the server, but the JSON-RPC response delivered on the SSE stream is never processed by the client. ThesendRequestfuture hangs forever.This means the MCP client can connect and initialize, but cannot list tools, call tools, or perform any other operation.
Reproduction
McpClientto any remote HTTPS MCP SSE serverinitializecompletes successfullytools/listrequestTest server:
Confirmed on:
Evidence
Verified with
curlthat the server does send thetools/listresponse (61,506 bytes) on the SSE stream correctly:Root Cause
Primary Issue: Event Loop Doesn't Process SSE Read Events
McpClient::connect()creates an internalLibeventDispatcherrunning inRunUntilExitmode:RunUntilExitimplementation:After the
initializeresponse is processed from the SSE stream,EVLOOP_ONCEexits and does not properly re-trigger for subsequent SSE data arriving on the socket. The server sends thetools/listresponse on the SSE stream, the TCP socket receives the data, but the event loop doesn't fire the socket'sEV_READevent to process it.Why
initializeWorks Buttools/listDoesn'tinitialize(190 bytes): Arrives in the initial SSE data burst along with the endpoint event. Processed during the sameEVLOOP_ONCEiteration. Future resolves immediately.tools/list(61,000+ bytes): Sent after initialization. The response arrives on the SSE stream in a separate TCP delivery. By this time,EVLOOP_ONCEhas returned and the next iteration doesn't pick up the socket read event.Additional Bugs Found
During investigation, several other bugs were discovered and fixed in the gopher-orch fork:
1. SSL
moveFromBioDrops Data on EAGAINFile:
src/transport/ssl_transport_socket.cc—moveFromBio()BIO_read(network_bio_)consumes the SSL Client Hello data, butinner_socket_->doWrite()returns EAGAIN (socket not ready). The data is lost — never sent to the server.Fix: Buffer unsent data in
pending_write_buffer_and retry on the next handshake step.2. SSL
moveToBioOnly Reads 16KBFile:
src/transport/ssl_transport_socket.cc—moveToBio()inner_socket_->doRead()reads max 16,384 bytes (one TCP read). But TLS records can be larger (~16,400+ bytes with headers/MAC). After one read,SSL_readreturnsWANT_READbecause the TLS record is incomplete.Fix: Loop
doRead()until EAGAIN to drain all available TCP data.3. Missing
notifications/initializedNotificationFile:
src/client/mcp_client.cc—initializeProtocol()The MCP spec requires the client to send
notifications/initializedafter receiving the init response and before sending other requests.McpClientnever sends this notification. Servers may silently ignore subsequent requests without it.Fix: Add
sendNotification("notifications/initialized", nullopt)beforeresult_promise->set_value().4. SSE Default Event Type Not Handled
File:
src/filter/http_sse_filter_chain_factory.cc(in gopher-orch submodule)The
onSseEventcallback only handlesevent == "message". But the SSE spec says when noevent:field is present, the default event type is"message". The server sends responses asdata: {...}without anevent:prefix, so the event type is empty string — not matched by the handler.Fix: Change condition to
event == "message" || event.empty().5. Default Port 8080 for HTTPS Connections
File:
src/mcp_connection_manager.cc—connect()forTransportType::HttpSseThe default port is hardcoded as
8080regardless of the protocol. For HTTPS URLs without an explicit port, the client connects to port 8080 instead of 443.Fix: Check
http_sse_config.underlying_transport == SSLand default to 443 for HTTPS, 80 for HTTP.6. Missing SSL Configuration in
createConnectionConfigFile:
src/client/mcp_client.cc—createConnectionConfig()(ondev_improve_client_and_serverbranch)The
HttpSsecase doesn't setunderlying_transport = SSL,ssl_config, orhttp_path/http_hostfor HTTPS URLs. The connection fails because no SSL context is created.Fix: Detect
https://URLs and set full SSL configuration including SNI hostname, ALPN protocols, and verify_peer.Affected Files
src/client/mcp_client.ccsrc/event/libevent_dispatcher.ccRunUntilExitevent loop doesn't re-trigger for socket eventssrc/transport/ssl_transport_socket.ccmoveFromBioEAGAIN data loss,moveToBiosingle-read limitsrc/mcp_connection_manager.ccsendHttpPostraw socket issuessrc/filter/http_sse_filter_chain_factory.ccSuggested Fixes
For the Primary Issue (Event Loop)
Option A: Use
EVLOOP_NO_EXIT_ON_EMPTYflag if available in libevent version:event_base_loop(base_, EVLOOP_NO_EXIT_ON_EMPTY);Option B: Accept an external dispatcher instead of creating an internal one:
The caller manages the event loop, ensuring socket events are always processed.
Option C: Add a periodic timer that forces read attempts on the SSE connection:
For All Other Bugs
The fixes are implemented in the gopher-orch fork at:
third_party/gopher-mcp/src/transport/ssl_transport_socket.ccthird_party/gopher-mcp/src/client/mcp_client.ccthird_party/gopher-mcp/src/mcp_connection_manager.ccthird_party/gopher-mcp/src/filter/http_sse_filter_chain_factory.ccThese should be upstreamed to gopher-mcp.
Workaround
The
sendHttpPostwas replaced with alibcurl-based implementation in the gopher-orch fork. This correctly handles HTTP/2 ALPN negotiation and avoids the raw socket EAGAIN issues. However, it doesn't fix the core event loop issue — the response still needs to arrive via the SSE stream.Currently, the gateway connects to backends successfully but registers 0 tools because the
tools/listresponse is never processed.