|
5 | 5 | import hashlib |
6 | 6 | import logging |
7 | 7 | import uuid |
8 | | -from collections.abc import Awaitable, Callable, Mapping |
9 | | -from contextlib import AsyncExitStack |
| 8 | +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping |
| 9 | +from contextlib import AsyncExitStack, asynccontextmanager |
10 | 10 | from dataclasses import KW_ONLY, dataclass, field |
| 11 | +from itertools import count |
11 | 12 | from typing import Any, Literal, TypeVar, cast |
12 | 13 |
|
13 | 14 | import anyio |
14 | 15 | import anyio.lowlevel |
15 | 16 | import mcp_types as types |
16 | 17 | from mcp_types import ( |
17 | 18 | INVALID_PARAMS, |
| 19 | + SUBSCRIPTION_ID_META_KEY, |
18 | 20 | CacheableResult, |
19 | 21 | CallToolResult, |
20 | 22 | CompleteResult, |
|
37 | 39 | RequestParamsMeta, |
38 | 40 | ResourceTemplateReference, |
39 | 41 | ServerCapabilities, |
| 42 | + SubscriptionFilter, |
40 | 43 | ) |
41 | 44 | from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS |
42 | 45 | from typing_extensions import deprecated |
|
56 | 59 | SamplingFnT, |
57 | 60 | ) |
58 | 61 | from mcp.client.streamable_http import streamable_http_client |
| 62 | +from mcp.client.subscriptions import ListenNarrowedError, ListenNotSupportedError, Subscription |
59 | 63 | from mcp.server import Server |
60 | 64 | from mcp.server.mcpserver import MCPServer |
61 | 65 | from mcp.server.runner import modern_on_request |
62 | 66 | from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair |
63 | | -from mcp.shared.dispatcher import Dispatcher, ProgressFnT |
| 67 | +from mcp.shared.dispatcher import CallOptions, Dispatcher, ProgressFnT |
64 | 68 | from mcp.shared.exceptions import MCPDeprecationWarning, MCPError |
65 | 69 | from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher |
66 | 70 | from mcp.shared.session import RequestResponder |
@@ -282,6 +286,7 @@ async def main(): |
282 | 286 | `target_id` when the server is not a URL (no identity can be derived).""" |
283 | 287 |
|
284 | 288 | _entered: bool = field(init=False, default=False) |
| 289 | + _listen_ids: Iterator[int] = field(init=False, default_factory=lambda: count(1), repr=False, compare=False) |
285 | 290 | _session: ClientSession | None = field(init=False, default=None) |
286 | 291 | _exit_stack: AsyncExitStack | None = field(init=False, default=None) |
287 | 292 | _connect: _Connector = field(init=False, repr=False, compare=False) |
@@ -583,13 +588,139 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | |
583 | 588 | # Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST). |
584 | 589 | return await self._drive_input_required(first, retry) |
585 | 590 |
|
| 591 | + @deprecated( |
| 592 | + "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", |
| 593 | + category=MCPDeprecationWarning, |
| 594 | + ) |
586 | 595 | async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: |
587 | 596 | """Subscribe to resource updates.""" |
588 | | - return await self.session.subscribe_resource(uri, meta=meta) |
| 597 | + return await self.session.subscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] |
589 | 598 |
|
| 599 | + @deprecated( |
| 600 | + "resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.", |
| 601 | + category=MCPDeprecationWarning, |
| 602 | + ) |
590 | 603 | async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: |
591 | 604 | """Unsubscribe from resource updates.""" |
592 | | - return await self.session.unsubscribe_resource(uri, meta=meta) |
| 605 | + return await self.session.unsubscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] |
| 606 | + |
| 607 | + @asynccontextmanager |
| 608 | + async def listen( |
| 609 | + self, |
| 610 | + notifications: SubscriptionFilter, |
| 611 | + *, |
| 612 | + strict: bool = False, |
| 613 | + ack_timeout: float = 30.0, |
| 614 | + ) -> AsyncIterator[Subscription]: |
| 615 | + """Open a `subscriptions/listen` stream (2026-07-28) and yield its `Subscription`. |
| 616 | +
|
| 617 | + Entering the context sends the listen request and returns once the |
| 618 | + server's acknowledgement arrives; iterate the yielded `Subscription` |
| 619 | + for the stream's typed change notifications. Exiting the context |
| 620 | + closes the stream. The listen request itself is exempt from |
| 621 | + `read_timeout_seconds` — only the acknowledgement phase is bounded |
| 622 | + (`ack_timeout`). A server result before any acknowledgement is |
| 623 | + tolerated as a degenerate immediately-graceful open: nothing honored, |
| 624 | + iteration ends at once. |
| 625 | +
|
| 626 | + Overlapping streams are independent handles: a notification matching |
| 627 | + several open filters is delivered to each of them. |
| 628 | +
|
| 629 | + Args: |
| 630 | + notifications: The notification types to opt in to. |
| 631 | + strict: Raise `ListenNarrowedError` (closing the stream) when the |
| 632 | + server honors less than requested, instead of returning the |
| 633 | + narrowed subscription. |
| 634 | + ack_timeout: Seconds to wait for the server's acknowledgement. |
| 635 | +
|
| 636 | + Raises: |
| 637 | + ListenNotSupportedError: The negotiated protocol version predates |
| 638 | + `subscriptions/listen`. |
| 639 | + ListenNarrowedError: `strict=True` and the server narrowed the filter. |
| 640 | + MCPError: The server rejected the request, or the connection |
| 641 | + closed before the acknowledgement. |
| 642 | + TimeoutError: No acknowledgement within `ack_timeout`. |
| 643 | + """ |
| 644 | + session = self.session |
| 645 | + if session.protocol_version not in MODERN_PROTOCOL_VERSIONS: |
| 646 | + raise ListenNotSupportedError(session.protocol_version) |
| 647 | + subscription_id = f"listen-{next(self._listen_ids)}" |
| 648 | + request = types.SubscriptionsListenRequest( |
| 649 | + params=types.SubscriptionsListenRequestParams(notifications=notifications) |
| 650 | + ) |
| 651 | + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) |
| 652 | + # The session's stamp (modern `_meta` envelope + transport headers) but NOT |
| 653 | + # its read-timeout fallback: a listen stream must outlive any session |
| 654 | + # default, so the dispatcher is driven directly, with no timeout at all. |
| 655 | + opts: CallOptions = {"request_id": subscription_id} |
| 656 | + session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] |
| 657 | + # The modern stamp suppresses the courtesy cancel, but `close()` relies on |
| 658 | + # it: at a modern version the frame never crosses the wire - the transport |
| 659 | + # translates it into aborting the listen POST's response stream (LR-15). |
| 660 | + opts["cancel_on_abandon"] = True |
| 661 | + open_error: MCPError | None = None |
| 662 | + driver_scope = anyio.CancelScope() |
| 663 | + # Registered BEFORE the send, so the acknowledgement cannot race the route. |
| 664 | + route = session._register_listen_route(subscription_id) # pyright: ignore[reportPrivateUsage] |
| 665 | + |
| 666 | + async def drive() -> None: |
| 667 | + nonlocal open_error |
| 668 | + with driver_scope: |
| 669 | + try: |
| 670 | + raw = await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] |
| 671 | + data["method"], data.get("params"), opts |
| 672 | + ) |
| 673 | + except MCPError as exc: |
| 674 | + # Error response or connection close: pre-ack this wakes |
| 675 | + # `__aenter__` immediately (the settle sets the ack event); |
| 676 | + # post-ack, iteration raises `SubscriptionLost`. |
| 677 | + open_error = exc |
| 678 | + route.settle("remote") |
| 679 | + return |
| 680 | + # Receipt of the result IS the graceful close (validation is |
| 681 | + # tolerant: a foreign shape is logged, never an error). |
| 682 | + raw_meta = raw.get("_meta") |
| 683 | + meta = cast("dict[str, Any]", raw_meta) if isinstance(raw_meta, dict) else {} |
| 684 | + if meta.get(SUBSCRIPTION_ID_META_KEY) != subscription_id: |
| 685 | + logger.debug("subscriptions/listen result for %r missing its subscription-id meta", subscription_id) |
| 686 | + # A result before any ack is the degenerate immediately-graceful |
| 687 | + # open: nothing honored. After a real ack this is a no-op. |
| 688 | + route.set_acked(SubscriptionFilter()) |
| 689 | + route.settle("graceful") |
| 690 | + |
| 691 | + # The driver rides the session's own task group: a local task group here |
| 692 | + # would wrap every exception crossing the `yield` — the typed open |
| 693 | + # failures below AND the caller's own errors — in an ExceptionGroup. |
| 694 | + task_group = session._task_group # pyright: ignore[reportPrivateUsage] |
| 695 | + assert task_group is not None # an entered session (the `.session` gate) always has one |
| 696 | + try: |
| 697 | + task_group.start_soon(drive) |
| 698 | + try: |
| 699 | + with anyio.fail_after(ack_timeout): |
| 700 | + await route.acked.wait() |
| 701 | + except TimeoutError: |
| 702 | + raise TimeoutError(f"server did not acknowledge subscriptions/listen within {ack_timeout}s") from None |
| 703 | + if open_error is not None and route.honored is None: |
| 704 | + raise open_error |
| 705 | + subscription = Subscription( |
| 706 | + subscription_id=subscription_id, |
| 707 | + requested=notifications, |
| 708 | + # None only when the route settled remotely before any ack: |
| 709 | + # the handle then surfaces it as `SubscriptionLost` on iteration. |
| 710 | + honored=route.honored if route.honored is not None else SubscriptionFilter(), |
| 711 | + route=route, |
| 712 | + cancel_driver=driver_scope.cancel, |
| 713 | + ) |
| 714 | + if strict and subscription.narrowed != SubscriptionFilter(): |
| 715 | + raise ListenNarrowedError(subscription.narrowed, subscription.honored) |
| 716 | + yield subscription |
| 717 | + finally: |
| 718 | + # One teardown for every path (failed open, close(), plain exit): |
| 719 | + # the same settle-then-cancel `Subscription.close()` performs, all |
| 720 | + # idempotent against whatever reason already won. |
| 721 | + route.settle("local") |
| 722 | + driver_scope.cancel() |
| 723 | + session._unregister_listen_route(subscription_id) # pyright: ignore[reportPrivateUsage] |
593 | 724 |
|
594 | 725 | async def call_tool( |
595 | 726 | self, |
|
0 commit comments