Skip to content

Commit 53acdda

Browse files
committed
feat: transparent session migration on server restart
When a StreamableHTTP server restarts, client sessions are lost and the server returns 404 'Session not found' for any request using an old session ID. Clients (OpenCode, etc.) do not automatically reinitialize, leaving the connection permanently broken. This change: 1. Detects unknown/expired session IDs and creates a new transport using the client's original session ID, so session ID header validation passes. 2. Starts the new transport in stateless mode (pre-initialized) so the client's first non-initialize request is accepted immediately without requiring the full MCP initialization handshake. Closes #1121
1 parent 161834d commit 53acdda

1 file changed

Lines changed: 32 additions & 17 deletions

File tree

src/mcp/server/streamable_http_manager.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
205205
if request_mcp_session_id is None:
206206
# New session case
207207
logger.debug("Creating new transport")
208+
is_session_migration = False
208209
async with self._session_creation_lock:
209210
new_session_id = uuid4().hex
210211
http_transport = StreamableHTTPServerTransport(
@@ -214,6 +215,32 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
214215
security_settings=self.security_settings,
215216
retry_interval=self.retry_interval,
216217
)
218+
else:
219+
# Unknown or expired session ID - server likely restarted.
220+
# Create a transport with the client's session ID and mark as a
221+
# session migration so the transport starts already initialized.
222+
# This allows clients to reconnect transparently without sending
223+
# a new initialize request.
224+
if request_mcp_session_id in self._server_instances:
225+
# Should not happen: already handled above, but check under lock
226+
transport = self._server_instances[request_mcp_session_id]
227+
await transport.handle_request(scope, receive, send)
228+
return
229+
logger.info(f"Unknown session {request_mcp_session_id}, reusing client session ID")
230+
is_session_migration = True
231+
async with self._session_creation_lock:
232+
if request_mcp_session_id in self._server_instances:
233+
transport = self._server_instances[request_mcp_session_id]
234+
await transport.handle_request(scope, receive, send)
235+
return
236+
new_session_id = request_mcp_session_id
237+
http_transport = StreamableHTTPServerTransport(
238+
mcp_session_id=new_session_id,
239+
is_json_response_enabled=self.json_response,
240+
event_store=self.event_store,
241+
security_settings=self.security_settings,
242+
retry_interval=self.retry_interval,
243+
)
217244

218245
assert http_transport.mcp_session_id is not None
219246
self._server_instances[http_transport.mcp_session_id] = http_transport
@@ -235,11 +262,15 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
235262
http_transport.idle_scope = idle_scope
236263

237264
with idle_scope:
265+
# For session migration (server restart), use stateless mode
266+
# so the session starts already initialized. Otherwise the
267+
# client receives "Received request before initialization"
268+
# for any non-initialize request sent with the old session ID.
238269
await self.app.run(
239270
read_stream,
240271
write_stream,
241272
self.app.create_initialization_options(),
242-
stateless=False,
273+
stateless=is_session_migration,
243274
)
244275

245276
if idle_scope.cancelled_caught:
@@ -268,22 +299,6 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
268299

269300
# Handle the HTTP request and return the response
270301
await http_transport.handle_request(scope, receive, send)
271-
else:
272-
# Unknown or expired session ID - return 404 per MCP spec
273-
# TODO: Align error code once spec clarifies
274-
# See: https://github.com/modelcontextprotocol/python-sdk/issues/1821
275-
logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}")
276-
error_response = JSONRPCError(
277-
jsonrpc="2.0",
278-
id=None,
279-
error=ErrorData(code=INVALID_REQUEST, message="Session not found"),
280-
)
281-
response = Response(
282-
content=error_response.model_dump_json(by_alias=True, exclude_unset=True),
283-
status_code=HTTPStatus.NOT_FOUND,
284-
media_type="application/json",
285-
)
286-
await response(scope, receive, send)
287302

288303

289304
class StreamableHTTPASGIApp:

0 commit comments

Comments
 (0)