Skip to content

Commit 053e798

Browse files
committed
fix(client): reinitialize expired streamable HTTP sessions
1 parent ce03a03 commit 053e798

1 file changed

Lines changed: 78 additions & 17 deletions

File tree

src/mcp/client/session.py

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from pydantic import BaseModel, Discriminator, Tag, TypeAdapter, ValidationError
3838
from typing_extensions import Self, TypeVar, deprecated
3939

40-
from mcp.client._transport import ReadStream, WriteStream
40+
from mcp.client._transport import SESSION_EXPIRED, SESSION_EXPIRED_MARKER, ReadStream, WriteStream
4141
from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult
4242
from mcp.client.subscriptions import ListenRoute
4343
from mcp.shared._compat import resync_tracer
@@ -415,6 +415,8 @@ def __init__(
415415
self._negotiated_version: str | None = None
416416
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
417417
self._task_group: anyio.abc.TaskGroup | None = None
418+
self._session_recovery_lock = anyio.Lock()
419+
self._session_generation = 0
418420
# subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered)
419421
self._listen_routes: dict[RequestId, ListenRoute] = {}
420422
if dispatcher is not None:
@@ -504,26 +506,15 @@ async def _deliver_bound_notifications(
504506
# A raising handler costs only that delivery, as in _on_notify.
505507
logger.exception("notification binding handler for %r raised", binding.method)
506508

507-
async def send_request(
509+
async def _send_request_once(
508510
self,
509511
request: types.ClientRequest | types.Request[Any, Any],
510512
result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT],
511-
request_read_timeout_seconds: float | None = None,
512-
metadata: ClientMessageMetadata | None = None,
513-
progress_callback: ProgressFnT | None = None,
513+
request_read_timeout_seconds: float | None,
514+
metadata: ClientMessageMetadata | None,
515+
progress_callback: ProgressFnT | None,
514516
) -> ReceiveResultT:
515-
"""Send a request and wait for its typed result.
516-
517-
Args:
518-
metadata: Streamable HTTP resumption hints.
519-
520-
Raises:
521-
MCPError: Error response, read timeout, or connection closed.
522-
RuntimeError: Called before entering the context manager.
523-
ValueError: The request declares `name_param` but its params carry no string name.
524-
pydantic.ValidationError: The server returned a result that does not
525-
conform to the negotiated protocol version.
526-
"""
517+
"""Send one typed request without session-expiry recovery."""
527518
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
528519
method: str = data["method"]
529520
opts: CallOptions = {}
@@ -562,6 +553,76 @@ async def send_request(
562553
return result_type.validate_python(raw, by_name=False)
563554
return result_type.model_validate(raw, by_name=False)
564555

556+
async def _recover_expired_session(self, generation: int) -> None:
557+
"""Reinitialize once for all requests that observed one expired legacy session."""
558+
async with self._session_recovery_lock:
559+
if generation != self._session_generation:
560+
return
561+
self._initialize_result = None
562+
self._discover_result = None
563+
self._discover_server_info = None
564+
self._negotiated_version = None
565+
self._stamp = _preconnect_stamp
566+
self._active_claims = {}
567+
self._call_tool_adapter = _CallToolResultAdapter
568+
self._x_mcp_header_maps.clear()
569+
self._tool_output_schemas.clear()
570+
self._tool_output_validators.clear()
571+
await self.initialize()
572+
self._session_generation += 1
573+
574+
async def send_request(
575+
self,
576+
request: types.ClientRequest | types.Request[Any, Any],
577+
result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT],
578+
request_read_timeout_seconds: float | None = None,
579+
metadata: ClientMessageMetadata | None = None,
580+
progress_callback: ProgressFnT | None = None,
581+
) -> ReceiveResultT:
582+
"""Send a request and wait for its typed result.
583+
584+
An established legacy Streamable HTTP session that receives a 404 is
585+
reinitialized once and the original request is retried once, as required
586+
by the transport specification.
587+
588+
Args:
589+
metadata: Streamable HTTP resumption hints.
590+
591+
Raises:
592+
MCPError: Error response, read timeout, connection closed, or a
593+
repeated session-expiry response.
594+
RuntimeError: Called before entering the context manager.
595+
ValueError: The request declares `name_param` but its params carry no string name.
596+
pydantic.ValidationError: The server returned a result that does not
597+
conform to the negotiated protocol version.
598+
"""
599+
generation = self._session_generation
600+
sent_on_legacy_session = self._initialize_result is not None and self._discover_result is None
601+
try:
602+
return await self._send_request_once(
603+
request,
604+
result_type,
605+
request_read_timeout_seconds,
606+
metadata,
607+
progress_callback,
608+
)
609+
except MCPError as exc:
610+
error_data = exc.data
611+
is_transport_expiry = (
612+
isinstance(error_data, Mapping)
613+
and cast(Mapping[str, object], error_data).get(SESSION_EXPIRED_MARKER) is True
614+
)
615+
if exc.code != SESSION_EXPIRED or not is_transport_expiry or not sent_on_legacy_session:
616+
raise
617+
await self._recover_expired_session(generation)
618+
return await self._send_request_once(
619+
request,
620+
result_type,
621+
request_read_timeout_seconds,
622+
metadata,
623+
progress_callback,
624+
)
625+
565626
async def send_notification(self, notification: types.ClientNotification) -> None:
566627
"""Send a one-way notification. Usable before entering the context manager.
567628

0 commit comments

Comments
 (0)