"""In-process notification subscriptions for MCP 2026-07-28."""
import asyncio
import contextlib
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from litestar_mcp.core.serialization import from_json
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
__all__ = ("StreamLimitExceeded", "SubscriptionManager")
_CLOSED = object()
_BROKER_CHANNEL = "litestar-mcp-subscriptions"
_BROKER_RETRY_INITIAL = 0.5
_BROKER_RETRY_MAX = 30.0
_logger = logging.getLogger(__name__)
_METHOD_FILTERS = {
"notifications/tools/list_changed": "toolsListChanged",
"notifications/prompts/list_changed": "promptsListChanged",
"notifications/resources/list_changed": "resourcesListChanged",
}
class StreamLimitExceeded(Exception): # noqa: N818
"""Raised when the configured subscription stream cap is reached."""
@dataclass
class _Subscription:
stream_id: "str"
subscription_id: "Any"
notifications: "dict[str, Any]"
queue: "asyncio.Queue[dict[str, Any] | object]"
closed: "bool" = False
[docs]
class SubscriptionManager:
"""Manage stateless, filtered subscription response streams."""
[docs]
def __init__(
self, *, max_streams: "int" = 10_000, queue_capacity: "int" = 256, channels: "Any | None" = None
) -> "None":
self._max_streams = max_streams
self._queue_capacity = queue_capacity
self._channels = channels
self._streams: dict[str, _Subscription] = {}
self._lock = asyncio.Lock()
self._broker_task: asyncio.Task[None] | None = None
[docs]
def start(self) -> None:
"""Start cross-worker fan-out when a ChannelsPlugin was supplied."""
if self._channels is not None and self._broker_task is None:
self._broker_task = asyncio.create_task(self._consume_broker())
[docs]
async def open(
self,
subscription_id: "Any",
notifications: "dict[str, Any]",
) -> "tuple[str, AsyncGenerator[dict[str, Any], None]]":
"""Open a filtered stream whose first item is its acknowledgement."""
async with self._lock:
if len(self._streams) >= self._max_streams:
msg = f"Subscription stream limit exceeded (max_streams={self._max_streams})"
raise StreamLimitExceeded(msg)
stream_id = str(uuid4())
accepted = self._normalize_filter(notifications)
state = _Subscription(
stream_id=stream_id,
subscription_id=subscription_id,
notifications=accepted,
queue=asyncio.Queue(maxsize=self._queue_capacity),
)
self._streams[stream_id] = state
state.queue.put_nowait(self._acknowledgement(state))
async def stream() -> "AsyncGenerator[dict[str, Any], None]":
try:
while True:
if state.closed and state.queue.empty():
return
message = await state.queue.get()
if message is _CLOSED:
return
yield message # type: ignore[misc]
finally:
await self.disconnect(stream_id)
return stream_id, stream()
[docs]
async def publish(self, method: "str", params: "dict[str, Any]") -> "None":
"""Publish a notification only to subscriptions whose filter matches."""
if self._channels is not None:
self._channels.publish({"method": method, "params": params}, channels=_BROKER_CHANNEL)
return
await self._publish_local(method, params)
async def _publish_local(self, method: "str", params: "dict[str, Any]") -> "None":
async with self._lock:
states = tuple(self._streams.values())
for state in states:
if not self._matches(state.notifications, method, params):
continue
tagged_params = dict(params)
meta = dict(tagged_params.get("_meta") or {})
meta["io.modelcontextprotocol/subscriptionId"] = state.subscription_id
tagged_params["_meta"] = meta
try:
state.queue.put_nowait({"jsonrpc": "2.0", "method": method, "params": tagged_params})
except asyncio.QueueFull:
async with self._lock:
self._streams.pop(state.stream_id, None)
_logger.warning("Closing slow MCP subscriber %s after queue overflow", state.stream_id)
self._complete(state)
[docs]
async def disconnect(self, stream_id: "str") -> "None":
"""Remove one stream and wake its consumer."""
async with self._lock:
state = self._streams.pop(stream_id, None)
if state is not None:
self._close(state)
[docs]
async def close_all(self) -> "None":
"""Gracefully close every active stream."""
if self._broker_task is not None:
self._broker_task.cancel()
await asyncio.gather(self._broker_task, return_exceptions=True)
self._broker_task = None
async with self._lock:
states = tuple(self._streams.values())
self._streams.clear()
for state in states:
self._complete(state)
@classmethod
def _complete(cls, state: "_Subscription") -> "None":
"""End a stream with its terminal response, dropping undelivered notifications if the queue is full."""
state.closed = True
try:
state.queue.put_nowait(cls._completion(state))
except asyncio.QueueFull:
while not state.queue.empty():
state.queue.get_nowait()
state.queue.put_nowait(cls._completion(state))
cls._close(state)
@staticmethod
def _close(state: "_Subscription") -> "None":
state.closed = True
with contextlib.suppress(asyncio.QueueFull):
state.queue.put_nowait(_CLOSED)
async def _consume_broker(self) -> "None":
channels = self._channels
if channels is None:
return
delay = _BROKER_RETRY_INITIAL
while True:
try:
async with channels.start_subscription(_BROKER_CHANNEL) as subscriber:
delay = _BROKER_RETRY_INITIAL
async for event in subscriber.iter_events():
await self._publish_broker_event(event)
except asyncio.CancelledError:
raise
except Exception:
_logger.exception("MCP subscription broker failed; resubscribing in %.1fs", delay)
else:
_logger.warning("MCP subscription broker stream ended; resubscribing in %.1fs", delay)
await asyncio.sleep(delay)
delay = min(delay * 2, _BROKER_RETRY_MAX)
async def _publish_broker_event(self, event: "Any") -> "None":
try:
payload = from_json(event)
except Exception: # noqa: BLE001
_logger.warning("Ignoring undecodable MCP subscription broker event")
return
if isinstance(payload, dict) and isinstance(payload.get("method"), str):
params = payload.get("params")
if isinstance(params, dict):
await self._publish_local(payload["method"], params)
@staticmethod
def _normalize_filter(notifications: "dict[str, Any]") -> "dict[str, Any]":
accepted: dict[str, Any] = {}
for filter_name in ("toolsListChanged", "promptsListChanged", "resourcesListChanged"):
if notifications.get(filter_name) is True:
accepted[filter_name] = True
resources = notifications.get("resourceSubscriptions")
if isinstance(resources, list) and all(isinstance(uri, str) for uri in resources):
accepted["resourceSubscriptions"] = list(dict.fromkeys(resources))
tasks = notifications.get("taskIds")
if isinstance(tasks, list) and all(isinstance(task_id, str) for task_id in tasks):
accepted["taskIds"] = list(dict.fromkeys(tasks))
return accepted
@staticmethod
def _acknowledgement(state: "_Subscription") -> "dict[str, Any]":
return {
"jsonrpc": "2.0",
"method": "notifications/subscriptions/acknowledged",
"params": {
"_meta": {"io.modelcontextprotocol/subscriptionId": state.subscription_id},
"notifications": state.notifications,
},
}
@staticmethod
def _completion(state: "_Subscription") -> "dict[str, Any]":
return {
"jsonrpc": "2.0",
"id": state.subscription_id,
"result": {
"resultType": "complete",
"_meta": {"io.modelcontextprotocol/subscriptionId": state.subscription_id},
},
}
@staticmethod
def _matches(notifications: "dict[str, Any]", method: "str", params: "dict[str, Any]") -> "bool":
filter_name = _METHOD_FILTERS.get(method)
if filter_name is not None:
return notifications.get(filter_name) is True
if method == "notifications/resources/updated":
return params.get("uri") in notifications.get("resourceSubscriptions", ())
if method == "notifications/tasks":
return params.get("taskId") in notifications.get("taskIds", ())
return False