Skip to content

Add SSE server transport for HTTP+SSE filter chain#214

Closed
gophergogo wants to merge 5 commits intomainfrom
feat/sse-server-transport
Closed

Add SSE server transport for HTTP+SSE filter chain#214
gophergogo wants to merge 5 commits intomainfrom
feat/sse-server-transport

Conversation

@gophergogo
Copy link
Copy Markdown
Collaborator

Summary

  • Implement the server side of MCP's SSE transport inside HttpSseJsonRpcProtocolFilter: long-lived GET {sse_path} opens a stream, the server announces a callback/{session_id} URL via event: endpoint, and subsequent POST /callback/{session_id} bodies are 202 Accepted while the real JSON-RPC response is routed back through the matching SSE stream.
  • Introduce SseSessionRegistry, owned by HttpSseFilterChainFactory (not a process singleton), mapping session IDs to the SSE-stream Connection*. Lifetime is bounded by the server; all access is on the dispatcher thread, asserted but lock-free.
  • Add configurable sse_path, rpc_path, and external_url to McpServerConfig + HttpSseFilterChainFactory, wire them through McpServer's fallback factory construction, and default http_sse_path to /sse to match the Python/TypeScript MCP SDKs.
  • Match POST /callback/{id} anywhere in the request path so deployments behind a reverse proxy (Traefik, nginx, ingress) that rewrites the URL still route correctly when external_url is set to the proxy-side base.

Design notes

  • Per-factory registry, not a global singleton. The upstream draft used a process-wide map guarded by std::mutex with cross-thread connection->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 across McpServer instances 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.
  • Filter owns the registration lifecycle. registerSession in onHeaders for GET {sse_path}; removeSession in the filter destructor when the SSE-stream connection tears down. Both run on the dispatcher thread per the filter/connection contract, so a POST /callback arriving in the narrow "SSE closed but filter not yet destructed" window sees an already-consistent registry and returns a clean lookup miss.
  • Handshake write re-entry. onHeaders writes the HTTP 200 prelude + event: endpoint inline via connection().write(). An 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 in — the alternative (posting through the dispatcher) would race the client reading the handshake before we could register the session.
  • Response routing via StopIteration. When sse_callback_session_id_ is set on the POST connection, onWrite drains the response buffer, hands the JSON to the registry, and returns StopIteration so the JSON never lands on the POST connection (which already got its 202).
  • Reverse-proxy callback match. Changed the POST match from "path begins with /callback/" to "path contains /callback/ anywhere," using rfind to recover the session ID from the tail. Direct deployments still work because there is only one /callback/ occurrence and rfind lands at position 0.
  • Intentionally diverges from upstream in two places. (1) No active_connections_ post-based deferral rewrite — the PR Fix connection lifetime and init-path crashes #212 crash-fix stack already replaced that with Dispatcher::deferredDelete, which is the correct primitive and handles the use-after-free window that post() 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.hhttp_sse_path default becomes /sse; new external_url field.
  • include/mcp/filter/http_sse_filter_chain_factory.h — ctor takes trailing sse_path, rpc_path, external_url; forward-declares SseSessionRegistry; out-of-line ctor/dtor to accommodate the unique_ptr member.
  • src/filter/http_sse_filter_chain_factory.ccSseSessionRegistry class; HttpSseJsonRpcProtocolFilter gets server-transport state (sse_server_mode_, sse_writing_handshake_, sse_session_id_, sse_callback_session_id_) and the onHeaders / onWrite / onBody transport paths.
  • src/server/mcp_server.cc — fallback factory construction now passes http_sse_path, http_rpc_path, and external_url through.

Test plan

  • All 10 existing HttpSse / HttpsSse / SseCodec / HttpCodecSse cases still pass — the new transport is opt-in via the GET {sse_path} / POST /callback/{id} URL pattern; factories that don't use those paths see no behavioral change.
  • cmake --build build --target gopher-mcp clean.
  • No references to legacy http_transport_socket.h.
  • Dispatcher-thread asserts on every SseSessionRegistry entry point; registry mutation all happens from filter callbacks.
  • Real-IO integration test (full 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.

gophergogo 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.
@github-actions
Copy link
Copy Markdown

❌ Code Formatting Check Failed

Some files in this PR are not properly formatted according to the project's clang-format rules.

To fix this issue:

make format

Then commit and push the changes.

@gophergogo
Copy link
Copy Markdown
Collaborator Author

duplicate, close this.

@gophergogo gophergogo closed this Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant