Skip to content

MCP gateway in gopher-mcp not working #207

@bettercallsaulj

Description

@bettercallsaulj

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

  1. Connect McpClient to any remote HTTPS MCP SSE server
  2. initialize completes successfully
  3. Send tools/list request
  4. The request POST gets HTTP 200 but the response future never resolves

Test server:

${MCP SERVER URL}/sse

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.ccmoveFromBio()

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.ccmoveToBio()

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.ccinitializeProtocol()

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.ccconnect() 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.cccreateConnectionConfig() (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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions