@@ -23,6 +23,7 @@ internal sealed class StreamableHttpHandler(
2323 ILoggerFactory loggerFactory )
2424{
2525 private const string McpSessionIdHeaderName = "Mcp-Session-Id" ;
26+ private const string LastEventIdHeaderName = "Last-Event-ID" ;
2627
2728 private static readonly JsonTypeInfo < JsonRpcMessage > s_messageTypeInfo = GetRequiredJsonTypeInfo < JsonRpcMessage > ( ) ;
2829 private static readonly JsonTypeInfo < JsonRpcError > s_errorTypeInfo = GetRequiredJsonTypeInfo < JsonRpcError > ( ) ;
@@ -81,17 +82,80 @@ await WriteJsonRpcErrorAsync(context,
8182 return ;
8283 }
8384
85+ StreamableHttpSession ? session = null ;
86+ ISseEventStreamReader ? eventStreamReader = null ;
87+
8488 var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
85- var session = await GetSessionAsync ( context , sessionId ) ;
89+ var lastEventId = context . Request . Headers [ LastEventIdHeaderName ] . ToString ( ) ;
90+
91+ if ( ! string . IsNullOrEmpty ( sessionId ) )
92+ {
93+ session = await GetSessionAsync ( context , sessionId ) ;
94+ if ( session is null )
95+ {
96+ // There was an error obtaining the session; consider the request failed.
97+ return ;
98+ }
99+ }
100+
101+ if ( ! string . IsNullOrEmpty ( lastEventId ) )
102+ {
103+ if ( HttpServerTransportOptions . Stateless )
104+ {
105+ await WriteJsonRpcErrorAsync ( context ,
106+ "Bad Request: The Last-Event-ID header is not supported in stateless mode." ,
107+ StatusCodes . Status400BadRequest ) ;
108+ return ;
109+ }
110+
111+ eventStreamReader = await GetEventStreamReaderAsync ( context , lastEventId ) ;
112+ if ( eventStreamReader is null )
113+ {
114+ // There was an error obtaining the event stream; consider the request failed.
115+ return ;
116+ }
117+ }
118+
119+ if ( session is not null && eventStreamReader is not null && ! string . Equals ( session . Id , eventStreamReader . SessionId , StringComparison . Ordinal ) )
120+ {
121+ await WriteJsonRpcErrorAsync ( context ,
122+ "Bad Request: The Last-Event-ID header refers to a session with a different session ID." ,
123+ StatusCodes . Status400BadRequest ) ;
124+ return ;
125+ }
126+
127+ if ( eventStreamReader is null || string . Equals ( eventStreamReader . StreamId , StreamableHttpServerTransport . UnsolicitedMessageStreamId , StringComparison . Ordinal ) )
128+ {
129+ await HandleUnsolicitedMessageStreamAsync ( context , session , eventStreamReader ) ;
130+ }
131+ else
132+ {
133+ await HandleResumePostResponseStreamAsync ( context , eventStreamReader ) ;
134+ }
135+ }
136+
137+ private async Task HandleUnsolicitedMessageStreamAsync ( HttpContext context , StreamableHttpSession ? session , ISseEventStreamReader ? eventStreamReader )
138+ {
139+ if ( HttpServerTransportOptions . Stateless )
140+ {
141+ await WriteJsonRpcErrorAsync ( context ,
142+ "Bad Request: Unsolicited messages are not supported in stateless mode." ,
143+ StatusCodes . Status400BadRequest ) ;
144+ return ;
145+ }
146+
86147 if ( session is null )
87148 {
149+ await WriteJsonRpcErrorAsync ( context ,
150+ "Bad Request: Mcp-Session-Id header is required" ,
151+ StatusCodes . Status400BadRequest ) ;
88152 return ;
89153 }
90154
91- if ( ! session . TryStartGetRequest ( ) )
155+ if ( eventStreamReader is null && ! session . TryStartGetRequest ( ) )
92156 {
93157 await WriteJsonRpcErrorAsync ( context ,
94- "Bad Request: This server does not support multiple GET requests. Start a new session to get a new GET SSE response ." ,
158+ "Bad Request: This server does not support multiple GET requests. Use Last-Event-ID header to resume or start a new session ." ,
95159 StatusCodes . Status400BadRequest ) ;
96160 return ;
97161 }
@@ -111,7 +175,7 @@ await WriteJsonRpcErrorAsync(context,
111175 // will be sent in response to a different POST request. It might be a while before we send a message
112176 // over this response body.
113177 await context . Response . Body . FlushAsync ( cancellationToken ) ;
114- await session . Transport . HandleGetRequestAsync ( context . Response . Body , cancellationToken ) ;
178+ await session . Transport . HandleGetRequestAsync ( context . Response . Body , eventStreamReader , cancellationToken ) ;
115179 }
116180 catch ( OperationCanceledException ) when ( cancellationToken . IsCancellationRequested )
117181 {
@@ -120,6 +184,12 @@ await WriteJsonRpcErrorAsync(context,
120184 }
121185 }
122186
187+ private static async Task HandleResumePostResponseStreamAsync ( HttpContext context , ISseEventStreamReader eventStreamReader )
188+ {
189+ InitializeSseResponse ( context ) ;
190+ await eventStreamReader . CopyToAsync ( context . Response . Body , context . RequestAborted ) ;
191+ }
192+
123193 public async Task HandleDeleteRequestAsync ( HttpContext context )
124194 {
125195 var sessionId = context . Request . Headers [ McpSessionIdHeaderName ] . ToString ( ) ;
@@ -131,14 +201,7 @@ public async Task HandleDeleteRequestAsync(HttpContext context)
131201
132202 private async ValueTask < StreamableHttpSession ? > GetSessionAsync ( HttpContext context , string sessionId )
133203 {
134- StreamableHttpSession ? session ;
135-
136- if ( string . IsNullOrEmpty ( sessionId ) )
137- {
138- await WriteJsonRpcErrorAsync ( context , "Bad Request: Mcp-Session-Id header is required" , StatusCodes . Status400BadRequest ) ;
139- return null ;
140- }
141- else if ( ! sessionManager . TryGetValue ( sessionId , out session ) )
204+ if ( ! sessionManager . TryGetValue ( sessionId , out var session ) )
142205 {
143206 // -32001 isn't part of the MCP standard, but this is what the typescript-sdk currently does.
144207 // One of the few other usages I found was from some Ethereum JSON-RPC documentation and this
@@ -194,12 +257,16 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
194257 {
195258 SessionId = sessionId ,
196259 FlowExecutionContextFromRequests = ! HttpServerTransportOptions . PerSessionExecutionContext ,
260+ EventStreamStore = HttpServerTransportOptions . EventStreamStore ,
261+ RetryInterval = HttpServerTransportOptions . RetryInterval ,
197262 } ;
198263 context . Response . Headers [ McpSessionIdHeaderName ] = sessionId ;
199264 }
200265 else
201266 {
202267 // In stateless mode, each request is independent. Don't set any session ID on the transport.
268+ // If in the future we support resuming stateless requests, we should populate
269+ // the event stream store and retry interval here as well.
203270 sessionId = "" ;
204271 transport = new ( )
205272 {
@@ -246,6 +313,28 @@ private async ValueTask<StreamableHttpSession> CreateSessionAsync(
246313 return session ;
247314 }
248315
316+ private async ValueTask < ISseEventStreamReader ? > GetEventStreamReaderAsync ( HttpContext context , string lastEventId )
317+ {
318+ if ( HttpServerTransportOptions . EventStreamStore is not { } eventStreamStore )
319+ {
320+ await WriteJsonRpcErrorAsync ( context ,
321+ "Bad Request: This server does not support resuming streams." ,
322+ StatusCodes . Status400BadRequest ) ;
323+ return null ;
324+ }
325+
326+ var eventStreamReader = await eventStreamStore . GetStreamReaderAsync ( lastEventId , context . RequestAborted ) ;
327+ if ( eventStreamReader is null )
328+ {
329+ await WriteJsonRpcErrorAsync ( context ,
330+ "Bad Request: The specified Last-Event-ID is either invalid or expired." ,
331+ StatusCodes . Status400BadRequest ) ;
332+ return null ;
333+ }
334+
335+ return eventStreamReader ;
336+ }
337+
249338 private static Task WriteJsonRpcErrorAsync ( HttpContext context , string errorMessage , int statusCode , int errorCode = - 32000 )
250339 {
251340 var jsonRpcError = new JsonRpcError
0 commit comments