Add SSE server transport for HTTP+SSE filter chain#214
Closed
gophergogo wants to merge 5 commits intomainfrom
Closed
Add SSE server transport for HTTP+SSE filter chain#214gophergogo wants to merge 5 commits intomainfrom
gophergogo wants to merge 5 commits intomainfrom
Conversation
added 5 commits
April 19, 2026 16:50
Two small config changes on McpServerConfig in preparation for the SSE server transport landing on top of this PR stack: - Default http_sse_path changes from /events to /sse. /events does not convey intent (SSE vs. arbitrary event stream), and /sse matches what the Python/TypeScript MCP SDKs use, keeping the cross-language wire contract consistent. - Add external_url. The SSE GET handler has to announce a callback URL in the endpoint event; when the server sits behind a reverse proxy that rewrites scheme or path (e.g. https:// → http:// + /mcp/ prefix), the Host header alone doesn't reconstruct the client-visible URL. external_url is the explicit override; empty means derive from Host. No behavior change yet — the factory that consumes these lands in a later commit on this branch.
Extend HttpSseFilterChainFactory's constructor with three optional
server-side parameters so McpServer can propagate its configured
endpoint paths down into the filter chain without the factory having
to reach back into McpServerConfig:
- sse_path: server-side SSE endpoint (matches McpServerConfig
http_sse_path, default "/sse")
- rpc_path: server-side JSON-RPC endpoint (default "/mcp")
- external_url: absolute URL the server is reachable at from the
client's perspective; used to build the endpoint-event
callback URL advertised on GET /sse. Empty means derive
the URL from the incoming Host header.
Defaulted so existing callers (tests, client-mode construction,
example apps that only care about http_path/http_host) keep compiling
unchanged. The factory does not consume these fields yet — that wiring
lands in a follow-up commit once the SSE server transport itself goes
in on top of this stack.
MCP's SSE transport splits each JSON-RPC request/response across two
TCP connections on the server: a long-lived GET {sse_path} stream, and
short POST /callback/{session_id} connections that carry the request
body. The server returns 202 Accepted on the POST and routes the real
JSON-RPC response back through the SSE stream registered under the
matching session ID. This commit lands the server side of that dance
inside HttpSseJsonRpcProtocolFilter.
Design notes:
- SseSessionRegistry is owned by HttpSseFilterChainFactory, not a
process-wide singleton. The upstream draft used a global singleton
with a std::mutex; that conflicts with the project rule to prefer
state machines and dispatcher-thread invariants over mutex-guarded
shared state, leaks session IDs across McpServer instances in the
same process, and encourages cross-thread connection.write() calls.
A per-factory registry keeps lifetime bounded by the server, and
since all filter callbacks on a given server already fire on one
dispatcher thread, the map needs no lock — only an assert on each
public method to keep that invariant honest if someone later splits
the server across workers.
- The filter, not the registry, holds the session-ID-to-connection
binding lifecycle: registerSession in onHeaders for GET {sse_path},
removeSession in the filter destructor when the SSE stream connection
tears down. The destructor runs on the dispatcher thread per the
filter/connection contract, so a POST /callback arriving between
"SSE connection closed" and "filter destructed" is handled by an
already-empty registry lookup returning false — no use-after-free
window.
- onHeaders sends the SSE prelude (HTTP 200 + text/event-stream headers
+ the "event: endpoint" line) via connection().write() inline, same
for the POST's 202 Accepted. A sse_writing_handshake_ pass-through
flag keeps our own onWrite from re-framing those raw bytes as an SSE
event when the write chain calls back into us — the alternative
(posting through the dispatcher) would race the client reading the
handshake before we could register the session in the registry.
- onWrite intercepts the JSON-RPC response when sse_callback_session_id_
is set, drains the buffer, hands the JSON to the registry, and
returns StopIteration so the response doesn't get written back to
the POST connection (which already got its 202).
- The filter ctor gains four trailing defaulted params (sse_path,
rpc_path, external_url, SseSessionRegistry*). All four default to
the values that match pre-existing behavior, so no caller that
doesn't care about SSE server transport needs to change.
- Factory ctor/destructor are moved out-of-line so the unique_ptr
member of the forward-declared SseSessionRegistry class compiles
cleanly from the header.
The existing HttpSse/HttpsSse/SseCodec/HttpCodecSse test suite (10
cases) continues to pass unchanged — the new transport is opt-in via
the GET {sse_path} / POST /callback/{id} URL pattern and back-compat
factories that don't use either path see no behavioral change.
Integration test for the new round-trip lands in the next commit.
McpServerConfig already carries http_sse_path, http_rpc_path, and the new external_url (added earlier in this stack), but the fallback "default HTTP/SSE filter chain factory" construction path was still building the factory with no path arguments — so the SSE server transport was hardcoded to /sse and /mcp with no external URL, regardless of what the operator configured. Forward all three fields through the ctor. Also set http_path to http_rpc_path explicitly so a server that uses this fallback path surfaces a consistent RPC endpoint; http_host stays "localhost" because it's the client-mode Host header and irrelevant in server mode. Note: upstream's version of this change also rewrote the active_connections_ cleanup on close to post-based deferral, but the PR #212 crash-fix stack already replaced that code with Dispatcher::deferredDelete, which is the correct primitive here and handles the use-after-free window that post() only partially covers. Intentionally skipping that part of the upstream diff.
When a server sits behind a reverse proxy that terminates TLS and rewrites paths (Traefik, nginx, an ingress controller), external_url is typically set to the full proxy-side URL — e.g. https://gateway.example.com/v1/mcp/gateways/abc123. The endpoint event then announces the callback URL under that prefix: data: https://gateway.example.com/v1/mcp/gateways/abc123/callback/client_1 and the client POSTs to /v1/mcp/gateways/abc123/callback/client_1. The proxy forwards the full path through to the server, so the incoming request's :path does not start with /callback/. Switch the match from "path begins with /callback/" to "path contains /callback/ anywhere" (rfind), and strip through the last /callback/ occurrence to recover the session ID from the tail. Direct (non-proxy) deployments still work because /callback/{id} has only one /callback/ and rfind lands at position 0. Skipping the request-log line from the upstream version of this fix — it's debug noise, not part of the routing change.
❌ Code Formatting Check FailedSome files in this PR are not properly formatted according to the project's clang-format rules. To fix this issue: make formatThen commit and push the changes. |
Collaborator
Author
|
duplicate, close this. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HttpSseJsonRpcProtocolFilter: long-livedGET {sse_path}opens a stream, the server announces acallback/{session_id}URL viaevent: endpoint, and subsequentPOST /callback/{session_id}bodies are 202 Accepted while the real JSON-RPC response is routed back through the matching SSE stream.SseSessionRegistry, owned byHttpSseFilterChainFactory(not a process singleton), mapping session IDs to the SSE-streamConnection*. Lifetime is bounded by the server; all access is on the dispatcher thread, asserted but lock-free.sse_path,rpc_path, andexternal_urltoMcpServerConfig+HttpSseFilterChainFactory, wire them throughMcpServer's fallback factory construction, and defaulthttp_sse_pathto/sseto match the Python/TypeScript MCP SDKs.POST /callback/{id}anywhere in the request path so deployments behind a reverse proxy (Traefik, nginx, ingress) that rewrites the URL still route correctly whenexternal_urlis set to the proxy-side base.Design notes
std::mutexwith cross-threadconnection->write()calls. That conflicts with the dispatcher-thread invariant and the project preference for state machines over mutex-guarded shared state, and leaks session IDs acrossMcpServerinstances in the same process. A per-factory registry keeps lifetime bounded by the server and needs no lock because all filter callbacks run on the same dispatcher thread — asserted on every registry entry point.registerSessioninonHeadersforGET {sse_path};removeSessionin the filter destructor when the SSE-stream connection tears down. Both run on the dispatcher thread per the filter/connection contract, so aPOST /callbackarriving in the narrow "SSE closed but filter not yet destructed" window sees an already-consistent registry and returns a clean lookup miss.onHeaderswrites the HTTP 200 prelude +event: endpointinline viaconnection().write(). Ansse_writing_handshake_pass-through flag keeps our ownonWritefrom re-framing those raw bytes as an SSE event when the write chain calls back in — the alternative (posting through the dispatcher) would race the client reading the handshake before we could register the session.sse_callback_session_id_is set on the POST connection,onWritedrains the response buffer, hands the JSON to the registry, and returnsStopIterationso the JSON never lands on the POST connection (which already got its 202)./callback/" to "path contains/callback/anywhere," usingrfindto recover the session ID from the tail. Direct deployments still work because there is only one/callback/occurrence andrfindlands at position 0.active_connections_post-based deferral rewrite — the PR Fix connection lifetime and init-path crashes #212 crash-fix stack already replaced that withDispatcher::deferredDelete, which is the correct primitive and handles the use-after-free window thatpost()only partially covers. (2) No debug request-log line in the reverse-proxy fix — it's noise, not part of the routing change.Files
include/mcp/server/mcp_server.h—http_sse_pathdefault becomes/sse; newexternal_urlfield.include/mcp/filter/http_sse_filter_chain_factory.h— ctor takes trailingsse_path,rpc_path,external_url; forward-declaresSseSessionRegistry; out-of-line ctor/dtor to accommodate theunique_ptrmember.src/filter/http_sse_filter_chain_factory.cc—SseSessionRegistryclass;HttpSseJsonRpcProtocolFiltergets server-transport state (sse_server_mode_,sse_writing_handshake_,sse_session_id_,sse_callback_session_id_) and theonHeaders/onWrite/onBodytransport paths.src/server/mcp_server.cc— fallback factory construction now passeshttp_sse_path,http_rpc_path, andexternal_urlthrough.Test plan
HttpSse/HttpsSse/SseCodec/HttpCodecSsecases still pass — the new transport is opt-in via theGET {sse_path}/POST /callback/{id}URL pattern; factories that don't use those paths see no behavioral change.cmake --build build --target gopher-mcpclean.http_transport_socket.h.SseSessionRegistryentry point; registry mutation all happens from filter callbacks.GET /sse→ endpoint announce →POST /callback/{id}→ response routed back through stream) will follow as part of PR D alongside the other integration-level tests carved out of Fix connection lifetime and init-path crashes #212.Context
Third carve-out from PR #211's larger "feature/gateway-sse-transport" bundle, following the crash fixes in #212 and the async HTTP client in #213. This PR lands the SSE server transport that uses the async HTTP client's counterpart direction — POST callback response routing via a session registry. The remaining integration-level tests (including a round-trip test for this transport) will follow as PR D.