|
37 | 37 | from pydantic import BaseModel, Discriminator, Tag, TypeAdapter, ValidationError |
38 | 38 | from typing_extensions import Self, TypeVar, deprecated |
39 | 39 |
|
40 | | -from mcp.client._transport import ReadStream, WriteStream |
| 40 | +from mcp.client._transport import SESSION_EXPIRED, SESSION_EXPIRED_MARKER, ReadStream, WriteStream |
41 | 41 | from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult |
42 | 42 | from mcp.client.subscriptions import ListenRoute |
43 | 43 | from mcp.shared._compat import resync_tracer |
@@ -415,6 +415,8 @@ def __init__( |
415 | 415 | self._negotiated_version: str | None = None |
416 | 416 | self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp |
417 | 417 | self._task_group: anyio.abc.TaskGroup | None = None |
| 418 | + self._session_recovery_lock = anyio.Lock() |
| 419 | + self._session_generation = 0 |
418 | 420 | # subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered) |
419 | 421 | self._listen_routes: dict[RequestId, ListenRoute] = {} |
420 | 422 | if dispatcher is not None: |
@@ -504,26 +506,15 @@ async def _deliver_bound_notifications( |
504 | 506 | # A raising handler costs only that delivery, as in _on_notify. |
505 | 507 | logger.exception("notification binding handler for %r raised", binding.method) |
506 | 508 |
|
507 | | - async def send_request( |
| 509 | + async def _send_request_once( |
508 | 510 | self, |
509 | 511 | request: types.ClientRequest | types.Request[Any, Any], |
510 | 512 | 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, |
514 | 516 | ) -> 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.""" |
527 | 518 | data = request.model_dump(by_alias=True, mode="json", exclude_none=True) |
528 | 519 | method: str = data["method"] |
529 | 520 | opts: CallOptions = {} |
@@ -562,6 +553,76 @@ async def send_request( |
562 | 553 | return result_type.validate_python(raw, by_name=False) |
563 | 554 | return result_type.model_validate(raw, by_name=False) |
564 | 555 |
|
| 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 | + |
565 | 626 | async def send_notification(self, notification: types.ClientNotification) -> None: |
566 | 627 | """Send a one-way notification. Usable before entering the context manager. |
567 | 628 |
|
|
0 commit comments