Add SSE server transport for HTTP+SSE filter chain#215
Merged
gophergogo merged 8 commits intomainfrom Apr 20, 2026
Merged
Conversation
❌ 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. |
added 6 commits
April 19, 2026 17:39
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.
e4584b2 to
f3f91c7
Compare
added 2 commits
April 19, 2026 21:54
Pull the SSE session-id-to-connection map out of the HTTP+SSE filter chain factory's .cc and into include/mcp/filter/sse_session_registry.h + src/filter/sse_session_registry.cc. No behavior change; the factory includes the header and uses the class exactly as before. The extraction is driven by testability: as a file-local class the registry could only be exercised indirectly through a full filter chain, which meant there was no narrow unit test for the tiny piece of shared state responsible for routing every POST /callback response. Giving it a real header also makes the class reusable if another MCP transport ever needs the same session-id-to-stream mapping pattern (the registry itself is transport-agnostic). Also add two small introspection accessors (sessionCount, hasSession) that tests use to assert state after a sequence of register/removeSession calls, without reaching into private members.
Seven gtest cases, all running through the dispatcher-thread
invariant that the registry's asserts require. Six are pure
state-machine tests that never need a real connection (register
returns unique IDs, remove clears lookup, remove on unknown id is a
safe no-op, sendResponse returns false for unknown sessions, a
removed session no longer routes, multiple live sessions are
independent). The seventh — SendResponseWritesBytesToRegisteredConnection
— builds a real TCP socketpair-backed ConnectionImpl with a
RawBufferTransportSocket, registers it, calls sendResponse, and
reads the bytes off the peer IoHandle to confirm the payload
actually reached the wire rather than mocking out the Connection.
This covers the contracts the SSE server transport depends on:
- POST /callback/{id} handler: sendResponse must return false if
the SSE stream is gone, so it can drop the response rather than
pretending delivery.
- Filter destructor: removeSession must tolerate double-removal
and unknown ids during teardown.
- Connection close race: a sendResponse arriving after
removeSession must not dereference the stale pointer.
Tests use the existing tests/integration/real_io_test_base.h harness
for dispatcher-thread execution and real socketpair creation — no
raw-socket mocks per the project's testing rule.
47a2eee to
a1a59bd
Compare
caleb2h
approved these changes
Apr 20, 2026
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
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.
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
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.
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
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.
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
) 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.
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
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.
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
Pull the SSE session-id-to-connection map out of the HTTP+SSE filter chain factory's .cc and into include/mcp/filter/sse_session_registry.h + src/filter/sse_session_registry.cc. No behavior change; the factory includes the header and uses the class exactly as before. The extraction is driven by testability: as a file-local class the registry could only be exercised indirectly through a full filter chain, which meant there was no narrow unit test for the tiny piece of shared state responsible for routing every POST /callback response. Giving it a real header also makes the class reusable if another MCP transport ever needs the same session-id-to-stream mapping pattern (the registry itself is transport-agnostic). Also add two small introspection accessors (sessionCount, hasSession) that tests use to assert state after a sequence of register/removeSession calls, without reaching into private members.
This was referenced Apr 20, 2026
gophergogo
pushed a commit
that referenced
this pull request
Apr 20, 2026
Extends the SSE transport round-trip test with the second leg that PR #215's test plan left unchecked: a POST /callback/{session_id} on a separate server connection returns 202 Accepted, and a JSON-RPC response produced on that POST connection is rerouted by HttpSseJsonRpcProtocolFilter::onWrite through SseSessionRegistry onto the original SSE stream — not framed back as HTTP bytes on the POST socket. The test runs two ServerConnections over separate socketpairs against a single shared HttpSseFilterChainFactory, which is the key to exercising the cross-connection routing: both filter instances share the factory's SseSessionRegistry, so the POST-side filter can find and write to the SSE-side connection. No McpServer bootstrap is needed. A test-only McpProtocolCallbacks emits the response via connection().write() from onRequest — the same write chain the real McpServer's JSON-RPC encoder would drive — so the onWrite interception runs against real JSON-RPC bytes rather than a mocked payload. Header comment updated to reflect that both legs are now covered.
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.