|
62 | 62 | #include "mcp/filter/json_rpc_protocol_filter.h" |
63 | 63 | #include "mcp/filter/metrics_filter.h" |
64 | 64 | #include "mcp/filter/sse_codec_filter.h" |
| 65 | +#include "mcp/filter/sse_session_registry.h" |
65 | 66 | #include "mcp/json/json_serialization.h" |
66 | 67 | #include "mcp/logging/log_macros.h" |
67 | 68 | #include "mcp/mcp_connection_manager.h" |
|
70 | 71 | namespace mcp { |
71 | 72 | namespace filter { |
72 | 73 |
|
73 | | -// ═══════════════════════════════════════════════════════════════════════════ |
74 | | -// SseSessionRegistry — dispatcher-owned map of SSE session IDs to the |
75 | | -// network::Connection* that is streaming SSE back to each client. |
76 | | -// |
77 | | -// MCP SSE transport splits the request/response pair across two TCP |
78 | | -// connections: |
79 | | -// 1. A long-lived GET /sse stream that the client leaves open for |
80 | | -// server-sent events. The server registers this connection under a |
81 | | -// fresh session ID and announces a POST callback URL containing |
82 | | -// that ID in the "endpoint" event. |
83 | | -// 2. Short POST /callback/{session_id} connections — one per outbound |
84 | | -// JSON-RPC request from the client. The server returns 202 Accepted |
85 | | -// immediately and then routes the actual JSON-RPC response through |
86 | | -// the SSE connection registered under that session ID. |
87 | | -// |
88 | | -// The registry is what lets the POST handler find the SSE connection it |
89 | | -// has to route the response through. It lives on the filter chain |
90 | | -// factory (one per McpServer), not as a process-wide singleton, so: |
91 | | -// - Independent McpServer instances in the same process do not share |
92 | | -// session IDs or leak into each other. |
93 | | -// - Registry lifetime is bounded by the factory, which is owned by |
94 | | -// McpServer — no global state to reason about at shutdown. |
95 | | -// |
96 | | -// Threading model: |
97 | | -// - The MCP server runs on a single dispatcher thread. All filter |
98 | | -// callbacks (onHeaders, onWrite, filter destructor) fire on that |
99 | | -// thread, so registry mutations are naturally single-threaded. |
100 | | -// - Every public method asserts isThreadSafe() so a future move to a |
101 | | -// worker-thread model fails loudly instead of silently corrupting |
102 | | -// the map. |
103 | | -// ═══════════════════════════════════════════════════════════════════════════ |
104 | | -class SseSessionRegistry { |
105 | | - public: |
106 | | - explicit SseSessionRegistry(event::Dispatcher& dispatcher) |
107 | | - : dispatcher_(dispatcher) {} |
108 | | - |
109 | | - // Record an SSE stream connection and hand back a stable session ID. |
110 | | - // Caller is responsible for calling removeSession() when the stream |
111 | | - // closes (filter destructor does this). |
112 | | - std::string registerSession(network::Connection* connection) { |
113 | | - assert(dispatcher_.isThreadSafe() && |
114 | | - "SseSessionRegistry::registerSession off-dispatcher-thread"); |
115 | | - std::string session_id = "client_" + std::to_string(next_id_++); |
116 | | - sessions_[session_id] = connection; |
117 | | - GOPHER_LOG_INFO("SSE session registered: {} (total={})", session_id, |
118 | | - sessions_.size()); |
119 | | - return session_id; |
120 | | - } |
121 | | - |
122 | | - // Drop a session. Safe to call with an unknown ID (no-op). Typically |
123 | | - // invoked from the SSE filter's destructor when the stream connection |
124 | | - // closes. |
125 | | - void removeSession(const std::string& session_id) { |
126 | | - assert(dispatcher_.isThreadSafe() && |
127 | | - "SseSessionRegistry::removeSession off-dispatcher-thread"); |
128 | | - if (sessions_.erase(session_id) > 0) { |
129 | | - GOPHER_LOG_INFO("SSE session removed: {} (total={})", session_id, |
130 | | - sessions_.size()); |
131 | | - } |
132 | | - } |
133 | | - |
134 | | - // Write a JSON-RPC response through the SSE stream registered under |
135 | | - // session_id. Returns true if the session existed and the write was |
136 | | - // handed to the connection (the actual wire bytes are framed into a |
137 | | - // `data: ...\n\n` SSE event by the SSE codec filter further down that |
138 | | - // connection's write chain). Returns false if the session has gone |
139 | | - // away (e.g. client already disconnected while we were handling the |
140 | | - // POST), in which case the caller should drop the response on the |
141 | | - // floor rather than pretending it was delivered. |
142 | | - bool sendResponse(const std::string& session_id, |
143 | | - const std::string& json_data) { |
144 | | - assert(dispatcher_.isThreadSafe() && |
145 | | - "SseSessionRegistry::sendResponse off-dispatcher-thread"); |
146 | | - auto it = sessions_.find(session_id); |
147 | | - if (it == sessions_.end()) { |
148 | | - GOPHER_LOG_WARN("SSE session not found for response routing: {}", |
149 | | - session_id); |
150 | | - return false; |
151 | | - } |
152 | | - OwnedBuffer buffer; |
153 | | - buffer.add(json_data.c_str(), json_data.length()); |
154 | | - it->second->write(buffer, /*end_stream=*/false); |
155 | | - GOPHER_LOG_DEBUG("SSE response routed to session {} ({} bytes)", session_id, |
156 | | - json_data.size()); |
157 | | - return true; |
158 | | - } |
159 | | - |
160 | | - private: |
161 | | - event::Dispatcher& dispatcher_; |
162 | | - std::map<std::string, network::Connection*> sessions_; |
163 | | - uint64_t next_id_{1}; |
164 | | -}; |
| 74 | +// SseSessionRegistry is defined in mcp/filter/sse_session_registry.h so |
| 75 | +// unit tests can exercise it directly without going through the full |
| 76 | +// filter chain. |
165 | 77 |
|
166 | 78 | // Forward declaration |
167 | 79 | class HttpSseJsonRpcProtocolFilter; |
|
0 commit comments