Commit 2ef57fb
Implement SSE server transport with per-factory session registry (#215)
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.1 parent f82ecfb commit 2ef57fb
2 files changed
Lines changed: 351 additions & 22 deletions
File tree
- include/mcp/filter
- src/filter
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
| |||
83 | 84 | | |
84 | 85 | | |
85 | 86 | | |
86 | | - | |
87 | | - | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
96 | 92 | | |
97 | 93 | | |
98 | 94 | | |
| |||
189 | 185 | | |
190 | 186 | | |
191 | 187 | | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
192 | 198 | | |
193 | 199 | | |
194 | 200 | | |
| |||
0 commit comments