From 49e20a484a4bdefbcc648e3d7d363e2386015add Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 13:34:43 +0530 Subject: [PATCH 01/12] feat: add RedisEventStore for production SSE resumability --- pyproject.toml | 2 + src/mcp/server/contrib/__init__.py | 11 + .../server/contrib/event_stores/__init__.py | 5 + src/mcp/server/contrib/event_stores/redis.py | 172 +++++++++ tests/server/contrib/__init__.py | 1 + .../server/contrib/test_redis_event_store.py | 333 ++++++++++++++++++ uv.lock | 43 ++- 7 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 src/mcp/server/contrib/__init__.py create mode 100644 src/mcp/server/contrib/event_stores/__init__.py create mode 100644 src/mcp/server/contrib/event_stores/redis.py create mode 100644 tests/server/contrib/__init__.py create mode 100644 tests/server/contrib/test_redis_event_store.py diff --git a/pyproject.toml b/pyproject.toml index d88869da1c..87cc8a7760 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dependencies = [ rich = ["rich>=13.9.4"] cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] ws = ["websockets>=15.0.1"] +redis = ["redis[asyncio]>=4.2.0"] [project.scripts] mcp = "mcp.cli:app [cli]" @@ -91,6 +92,7 @@ dev = [ "pillow>=12.0", "strict-no-cover", "logfire>=3.0.0", + "fakeredis>=2.26.0", ] docs = [ "mkdocs>=1.6.1", diff --git a/src/mcp/server/contrib/__init__.py b/src/mcp/server/contrib/__init__.py new file mode 100644 index 0000000000..f71f481339 --- /dev/null +++ b/src/mcp/server/contrib/__init__.py @@ -0,0 +1,11 @@ +"""Optional production-grade add-ons for MCP servers. + +WARNING: These modules require optional dependencies that are NOT installed by default. +Install the relevant extra before importing: + +pip install "mcp[redis]" + +Then import directly from the submodule: + +from mcp.server.contrib.event_stores import RedisEventStore +""" diff --git a/src/mcp/server/contrib/event_stores/__init__.py b/src/mcp/server/contrib/event_stores/__init__.py new file mode 100644 index 0000000000..fa5778fffc --- /dev/null +++ b/src/mcp/server/contrib/event_stores/__init__.py @@ -0,0 +1,5 @@ +"""EventStore implementations for production deployments.""" + +from mcp.server.contrib.event_stores.redis import RedisEventStore + +__all__ = ["RedisEventStore"] diff --git a/src/mcp/server/contrib/event_stores/redis.py b/src/mcp/server/contrib/event_stores/redis.py new file mode 100644 index 0000000000..47dd63eaac --- /dev/null +++ b/src/mcp/server/contrib/event_stores/redis.py @@ -0,0 +1,172 @@ +"""Redis-backed EventStore for MCP SSE stream resumability. + +Requires the redis extra: + pip install "mcp[redis]" + +Quickstart: + import redis.asyncio as aioredis + from mcp.server.contrib.event_stores import RedisEventStore + from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + + redis_client = aioredis.from_url("redis://localhost:6379") + store = RedisEventStore(redis_client, ttl=3600) + + session_manager = StreamableHTTPSessionManager( + app=mcp_server, + event_store=store, + ) +""" + +from __future__ import annotations + +import logging +from typing import Any + +from mcp.server.streamable_http import ( + EventCallback, + EventId, + EventMessage, + EventStore, + StreamId, +) +from mcp.types import JSONRPCMessage, jsonrpc_message_adapter + +logger = logging.getLogger(__name__) + + +class RedisEventStore(EventStore): + """EventStore backed by Redis for production multi-process deployments. + + Redis data layout: + {prefix}counter — STRING, atomic INCR source for EventIds + {prefix}event:{event_id} — HASH, fields: stream_id + payload + {prefix}stream:{stream_id} — ZSET, members: event_ids, scores: int(event_id) + + Args: + redis: An already-connected redis.asyncio.Redis instance. + key_prefix: Prefix for all Redis keys. Use different prefixes when + multiple MCP servers share one Redis instance. + Default: "mcp:". + ttl: Seconds after which keys expire automatically. + None means keys never expire — strongly discouraged in + production. Recommended: at least 2× session_idle_timeout. + """ + + def __init__( + self, + redis: Any, # redis.asyncio.Redis at runtime + *, + key_prefix: str = "mcp:", + ttl: int | None = None, + ) -> None: + self._redis = redis + self._prefix = key_prefix + self._ttl = ttl + + if ttl is None: + logger.warning( + "RedisEventStore created with ttl=None. " + "Events will accumulate indefinitely in Redis. " + "Set ttl= to a positive number of seconds " + "(recommended: at least 2× your session_idle_timeout)." + ) + + # Key helpers + + def _counter_key(self) -> str: + return f"{self._prefix}counter" + + def _event_key(self, event_id: EventId) -> str: + return f"{self._prefix}event:{event_id}" + + def _stream_key(self, stream_id: StreamId) -> str: + return f"{self._prefix}stream:{stream_id}" + + # EventStore interface + + async def store_event( + self, + stream_id: StreamId, + message: JSONRPCMessage | None, + ) -> EventId: + """Store an event and return its unique, monotonically increasing ID.""" + # Atomic increment — safe under concurrent writes from multiple workers + event_id_int: int = await self._redis.incr(self._counter_key()) + event_id: EventId = str(event_id_int) + + # Serialise — empty string is the sentinel for priming events (no payload) + if message is None: + payload = "" + else: + payload = jsonrpc_message_adapter.dump_json( + message, + by_alias=True, + exclude_none=True, + ).decode("utf-8") + + # Store event metadata: which stream it belongs to + its payload + await self._redis.hset( + self._event_key(event_id), + mapping={ + "stream_id": stream_id, + "payload": payload, + }, + ) + + # Register in the stream's sorted set — score = int(event_id) for range queries + await self._redis.zadd( + self._stream_key(stream_id), + {event_id: event_id_int}, + ) + + # Refresh TTL on all touched keys (if configured) + if self._ttl is not None: + await self._redis.expire(self._event_key(event_id), self._ttl) + await self._redis.expire(self._stream_key(stream_id), self._ttl) + await self._redis.expire(self._counter_key(), self._ttl) + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replay all events on the same stream that occurred after last_event_id.""" + # Look up which stream owns this event ID + stream_id_raw: bytes | None = await self._redis.hget(self._event_key(last_event_id), "stream_id") + + if stream_id_raw is None: + # Unknown or expired event ID — return None, don't raise + return None + + stream_id: StreamId = stream_id_raw.decode("utf-8") + + # Fetch all event IDs in this stream with id strictly greater than last_event_id + last_int = int(last_event_id) + raw_ids: list[bytes] = await self._redis.zrangebyscore( + self._stream_key(stream_id), + min=last_int + 1, + max="+inf", + ) + + for eid_bytes in raw_ids: + eid: EventId = eid_bytes.decode("utf-8") + + payload_raw: bytes | None = await self._redis.hget(self._event_key(eid), "payload") + + if payload_raw is None: + # Key expired between ZRANGEBYSCORE and HGET — skip silently + logger.debug("Event %s payload missing during replay (expired?)", eid) + continue + + payload_str = payload_raw.decode("utf-8") + + if not payload_str: + # Empty string = priming event — never sent to clients + continue + + message = jsonrpc_message_adapter.validate_json(payload_str) + await send_callback(EventMessage(message=message, event_id=eid)) + + return stream_id diff --git a/tests/server/contrib/__init__.py b/tests/server/contrib/__init__.py new file mode 100644 index 0000000000..dec87eedca --- /dev/null +++ b/tests/server/contrib/__init__.py @@ -0,0 +1 @@ +# contrib tests package diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py new file mode 100644 index 0000000000..17401a4b24 --- /dev/null +++ b/tests/server/contrib/test_redis_event_store.py @@ -0,0 +1,333 @@ +"""Tests for RedisEventStore. + +Uses fakeredis — no external Redis server required. +All tests are async (anyio/asyncio backend, set by tests/conftest.py). +""" + +from __future__ import annotations + +import asyncio +import logging + +import fakeredis.aioredis as fakeredis +import pytest + +from mcp.server.contrib.event_stores import RedisEventStore +from mcp.server.streamable_http import EventId, EventMessage, StreamId +from mcp.types import JSONRPCRequest + +# ── Helpers ────────────────────────────────────────────────────────────────── + +# A reusable JSONRPCMessage for tests that don't care about content +SAMPLE_MSG = JSONRPCRequest(jsonrpc="2.0", id="1", method="tools/list") + + +@pytest.fixture +async def redis_client(): + """FakeRedis is a real async-compatible in-process Redis emulator. + Each test gets a fresh client (function scope = default). + """ + client = fakeredis.FakeRedis() + yield client + await client.aclose() + + +@pytest.fixture +def store(redis_client, recwarn): + """ttl=None triggers a logger.warning. + We suppress it via recwarn so tests don't fail on unexpected warnings. + """ + return RedisEventStore(redis_client, key_prefix="test:", ttl=None) + + +@pytest.fixture +def store_with_ttl(redis_client): + return RedisEventStore(redis_client, key_prefix="test:", ttl=60) + + +# ── Shared helper ───────────────────────────────────────────────────────────── + + +async def collect_events( + store: RedisEventStore, + last_event_id: EventId, +) -> tuple[list[EventMessage], StreamId | None]: + captured: list[EventMessage] = [] + + async def cb(event: EventMessage) -> None: + captured.append(event) + + stream_id = await store.replay_events_after(last_event_id, cb) + return captured, stream_id + + +# ───────────────────────────────────────────────────────────────────────────── +# store_event tests +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_store_event_returns_string_integer(store): + id1 = await store.store_event("stream-A", SAMPLE_MSG) + assert isinstance(id1, str) + assert id1.isdigit() # must be a parseable integer + + +@pytest.mark.anyio +async def test_store_event_ids_are_monotonically_increasing(store): + id1 = await store.store_event("stream-A", SAMPLE_MSG) + id2 = await store.store_event("stream-A", SAMPLE_MSG) + id3 = await store.store_event("stream-B", SAMPLE_MSG) # different stream + + # IDs must increase across streams too (global counter) + assert int(id1) < int(id2) < int(id3) + # First call always returns "1" + assert id1 == "1" + + +@pytest.mark.anyio +async def test_store_priming_event_writes_empty_payload(store, redis_client): + event_id = await store.store_event("stream-A", None) # None = priming + + # Read directly from Redis to verify storage format + raw = await redis_client.hget(f"test:event:{event_id}", "payload") + assert raw == b"" # empty bytes, NOT b"null" or missing + + +@pytest.mark.anyio +async def test_store_event_writes_stream_id_to_hash(store, redis_client): + event_id = await store.store_event("my-stream", SAMPLE_MSG) + + raw_stream = await redis_client.hget(f"test:event:{event_id}", "stream_id") + assert raw_stream == b"my-stream" + + +@pytest.mark.anyio +async def test_store_event_adds_to_sorted_set(store, redis_client): + id1 = await store.store_event("stream-A", SAMPLE_MSG) + id2 = await store.store_event("stream-A", SAMPLE_MSG) + + # Both event IDs must appear in the stream's sorted set + members = await redis_client.zrange("test:stream:stream-A", 0, -1) + decoded = [m.decode() for m in members] + assert id1 in decoded + assert id2 in decoded + # Sorted by score (ascending) = ascending by event_id int value + assert decoded.index(id1) < decoded.index(id2) + + +@pytest.mark.anyio +async def test_concurrent_store_event_produces_unique_ids(store): + # Fire 50 concurrent store_event calls — INCR must be collision-free + tasks = [asyncio.create_task(store.store_event("stream-X", SAMPLE_MSG)) for _ in range(50)] + ids = await asyncio.gather(*tasks) + + # All 50 IDs must be distinct + assert len(set(ids)) == 50 + # Every ID must be a valid integer string + assert all(id_.isdigit() for id_ in ids) + + +# ───────────────────────────────────────────────────────────────────────────── +# replay_events_after tests +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_replay_unknown_id_returns_none(store): + events, stream_id = await collect_events(store, "9999") + assert stream_id is None + assert events == [] + + +@pytest.mark.anyio +async def test_replay_returns_correct_stream_id(store): + anchor = await store.store_event("my-stream", SAMPLE_MSG) + # No further events stored + + events, stream_id = await collect_events(store, anchor) + assert stream_id == "my-stream" + assert events == [] # nothing after anchor + + +@pytest.mark.anyio +async def test_replay_skips_priming_events(store): + anchor = await store.store_event("stream-A", SAMPLE_MSG) # real event + _ = await store.store_event("stream-A", None) # priming + id3 = await store.store_event("stream-A", SAMPLE_MSG) # real event + + events, _ = await collect_events(store, anchor) + + # Priming must be invisible — only id3 appears + assert len(events) == 1 + assert events[0].event_id == id3 + + +@pytest.mark.anyio +async def test_replay_events_are_in_ascending_order(store): + anchor = await store.store_event("stream-A", SAMPLE_MSG) + id2 = await store.store_event("stream-A", SAMPLE_MSG) + id3 = await store.store_event("stream-A", SAMPLE_MSG) + + events, _ = await collect_events(store, anchor) + + # Must come back in ascending ID order + assert len(events) == 2 + assert events[0].event_id == id2 + assert events[1].event_id == id3 + + +@pytest.mark.anyio +async def test_replay_excludes_anchor_event_itself(store): + anchor = await store.store_event("stream-A", SAMPLE_MSG) + id2 = await store.store_event("stream-A", SAMPLE_MSG) + + events, _ = await collect_events(store, anchor) + + # anchor itself must NOT be replayed — only events strictly after it + event_ids = [e.event_id for e in events] + assert anchor not in event_ids + assert id2 in event_ids + + +@pytest.mark.anyio +async def test_replay_stream_isolation(store): + # anchor belongs to stream-A + anchor = await store.store_event("stream-A", SAMPLE_MSG) + + # store events on stream-B — must NOT appear in stream-A's replay + _ = await store.store_event("stream-B", SAMPLE_MSG) + _ = await store.store_event("stream-B", SAMPLE_MSG) + + # more events on stream-A + id4 = await store.store_event("stream-A", SAMPLE_MSG) + + events, stream_id = await collect_events(store, anchor) + + assert stream_id == "stream-A" + assert len(events) == 1 # only id4, not stream-B events + assert events[0].event_id == id4 + + +@pytest.mark.anyio +async def test_replay_message_content_round_trips(store): + original = JSONRPCRequest(jsonrpc="2.0", id="99", method="resources/list") + anchor = await store.store_event("stream-A", original) + await store.store_event("stream-A", original) + + events, _ = await collect_events(store, anchor) + + assert len(events) == 1 + # The deserialized message must match the original + replayed = events[0].message + assert replayed.method == "resources/list" + assert replayed.id == "99" + + +@pytest.mark.anyio +async def test_replay_event_id_is_attached_to_event_message(store): + anchor = await store.store_event("stream-A", SAMPLE_MSG) + id2 = await store.store_event("stream-A", SAMPLE_MSG) + + events, _ = await collect_events(store, anchor) + + # event_id must be set on EventMessage so the client can use it + # as a resumption token + assert events[0].event_id == id2 + + +# ───────────────────────────────────────────────────────────────────────────── +# TTL tests +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_event_key_has_ttl_when_configured(store_with_ttl, redis_client): + event_id = await store_with_ttl.store_event("stream-A", SAMPLE_MSG) + + ttl = await redis_client.ttl(f"test:event:{event_id}") + # TTL should be set and very close to 60 seconds + # (allow 2s tolerance for test execution time) + assert 58 <= ttl <= 60 + + +@pytest.mark.anyio +async def test_stream_key_has_ttl_when_configured(store_with_ttl, redis_client): + await store_with_ttl.store_event("stream-A", SAMPLE_MSG) + + ttl = await redis_client.ttl("test:stream:stream-A") + assert 58 <= ttl <= 60 + + +@pytest.mark.anyio +async def test_counter_key_has_ttl_when_configured(store_with_ttl, redis_client): + await store_with_ttl.store_event("stream-A", SAMPLE_MSG) + + ttl = await redis_client.ttl("test:counter") + assert 58 <= ttl <= 60 + + +@pytest.mark.anyio +async def test_no_ttl_on_keys_when_not_configured(store, redis_client): + event_id = await store.store_event("stream-A", SAMPLE_MSG) + + event_ttl = await redis_client.ttl(f"test:event:{event_id}") + stream_ttl = await redis_client.ttl("test:stream:stream-A") + counter_ttl = await redis_client.ttl("test:counter") + + # Redis returns -1 for keys that exist but have no TTL + assert event_ttl == -1 + assert stream_ttl == -1 + assert counter_ttl == -1 + + +# ───────────────────────────────────────────────────────────────────────────── +# Key prefix test +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_custom_key_prefix_isolates_two_stores(redis_client, recwarn): + store_a = RedisEventStore(redis_client, key_prefix="server-a:", ttl=None) + store_b = RedisEventStore(redis_client, key_prefix="server-b:", ttl=None) + + id_a = await store_a.store_event("stream-1", SAMPLE_MSG) + id_b = await store_b.store_event("stream-1", SAMPLE_MSG) + + # Both stores start from their OWN counter — both return "1" + assert id_a == "1" + assert id_b == "1" + + # Keys from store-a must not appear under server-b: prefix + a_keys = [k.decode() for k in await redis_client.keys("server-a:*")] + b_keys = [k.decode() for k in await redis_client.keys("server-b:*")] + + assert all("server-b:" not in k for k in a_keys) + assert all("server-a:" not in k for k in b_keys) + + # Replaying on store_a must NOT pick up store_b's events + events_a, stream_id_a = await collect_events(store_a, id_a) + assert stream_id_a == "stream-1" + assert events_a == [] # nothing after anchor on store_a + + +# ───────────────────────────────────────────────────────────────────────────── +# Warning / logging tests +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_no_ttl_emits_log_warning(redis_client, caplog): + with caplog.at_level(logging.WARNING, logger="mcp.server.contrib.event_stores.redis"): + RedisEventStore(redis_client, ttl=None) + + assert any("ttl=None" in record.message for record in caplog.records) + + +@pytest.mark.anyio +async def test_with_ttl_no_warning_emitted(redis_client, caplog): + with caplog.at_level(logging.WARNING, logger="mcp.server.contrib.event_stores.redis"): + RedisEventStore(redis_client, ttl=3600) + + # No warning should appear when TTL is configured + assert not any("ttl" in record.message.lower() for record in caplog.records) diff --git a/uv.lock b/uv.lock index b396898b66..dc3cfd6a16 100644 --- a/uv.lock +++ b/uv.lock @@ -75,6 +75,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "attrs" version = "25.3.0" @@ -580,6 +589,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fakeredis" +version = "2.35.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -871,6 +894,9 @@ cli = [ { name = "python-dotenv" }, { name = "typer" }, ] +redis = [ + { name = "redis" }, +] rich = [ { name = "rich" }, ] @@ -882,6 +908,7 @@ ws = [ dev = [ { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, + { name = "fakeredis" }, { name = "inline-snapshot" }, { name = "logfire" }, { name = "mcp", extra = ["cli", "ws"] }, @@ -918,6 +945,7 @@ requires-dist = [ { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=311" }, + { name = "redis", extras = ["asyncio"], marker = "extra == 'redis'", specifier = ">=4.2.0" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=3.0.0" }, { name = "starlette", marker = "python_full_version < '3.14'", specifier = ">=0.27" }, @@ -928,12 +956,13 @@ requires-dist = [ { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.31.1" }, { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, ] -provides-extras = ["cli", "rich", "ws"] +provides-extras = ["cli", "redis", "rich", "ws"] [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, + { name = "fakeredis", specifier = ">=2.26.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" }, { name = "logfire", specifier = ">=3.0.0" }, { name = "mcp", extras = ["cli", "ws"], editable = "." }, @@ -2391,6 +2420,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, +] + [[package]] name = "referencing" version = "0.36.2" From e125c7ecd5ac6f0ff55b24234de23398f6913a35 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 13:51:01 +0530 Subject: [PATCH 02/12] test: add test coverage for expired/evicted event payloads in replay --- tests/server/contrib/test_redis_event_store.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index 17401a4b24..6f443b2ebb 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -163,6 +163,22 @@ async def test_replay_skips_priming_events(store): assert events[0].event_id == id3 +@pytest.mark.anyio +async def test_replay_skips_expired_event_payloads(store, redis_client): + anchor = await store.store_event("stream-A", SAMPLE_MSG) + id2 = await store.store_event("stream-A", SAMPLE_MSG) + id3 = await store.store_event("stream-A", SAMPLE_MSG) + + # Manually delete the event key for id2 from Redis, but keep it in the sorted set + await redis_client.delete(f"test:event:{id2}") + + events, _ = await collect_events(store, anchor) + + # Replay should skip id2 (since its payload was deleted/expired) and return only id3 + assert len(events) == 1 + assert events[0].event_id == id3 + + @pytest.mark.anyio async def test_replay_events_are_in_ascending_order(store): anchor = await store.store_event("stream-A", SAMPLE_MSG) From d1e3739ecf8bcd54a50ac2c3d4e09a4d7137434a Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 13:51:40 +0530 Subject: [PATCH 03/12] refactor: simplify redis dependency by removing unnecessary asyncio extra --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 87cc8a7760..05a369e821 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ rich = ["rich>=13.9.4"] cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] ws = ["websockets>=15.0.1"] -redis = ["redis[asyncio]>=4.2.0"] +redis = ["redis>=4.2.0"] [project.scripts] mcp = "mcp.cli:app [cli]" diff --git a/uv.lock b/uv.lock index dc3cfd6a16..2ed6d26df7 100644 --- a/uv.lock +++ b/uv.lock @@ -945,7 +945,7 @@ requires-dist = [ { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=311" }, - { name = "redis", extras = ["asyncio"], marker = "extra == 'redis'", specifier = ">=4.2.0" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=4.2.0" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=3.0.0" }, { name = "starlette", marker = "python_full_version < '3.14'", specifier = ">=0.27" }, From bc13e549416b15d1600fc3da477d9ac4f929e19a Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 13:57:58 +0530 Subject: [PATCH 04/12] test: support older redis-py client versions in teardown fixture --- tests/server/contrib/test_redis_event_store.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index 6f443b2ebb..d1c4e33a7c 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -29,7 +29,10 @@ async def redis_client(): """ client = fakeredis.FakeRedis() yield client - await client.aclose() + if hasattr(client, "aclose"): + await client.aclose() + else: + await client.close() @pytest.fixture From 6a67d3f4c01dcf301bcc01ed5d6693598fbc7548 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 14:01:58 +0530 Subject: [PATCH 05/12] test: disable strict pyright checking for test parameters/variables --- tests/server/contrib/test_redis_event_store.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index d1c4e33a7c..d0b236cee5 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -1,3 +1,8 @@ +# pyright: reportUnknownParameterType=false +# pyright: reportMissingParameterType=false +# pyright: reportUnknownArgumentType=false +# pyright: reportUnknownVariableType=false +# pyright: reportUnknownMemberType=false """Tests for RedisEventStore. Uses fakeredis — no external Redis server required. From 47e952087a7c70671e64318175697d46b7461034 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 14:05:07 +0530 Subject: [PATCH 06/12] test: resolve all pyright strict type errors in redis event store tests --- tests/server/contrib/test_redis_event_store.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index d0b236cee5..549ce7a80f 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -35,7 +35,7 @@ async def redis_client(): client = fakeredis.FakeRedis() yield client if hasattr(client, "aclose"): - await client.aclose() + await getattr(client, "aclose")() else: await client.close() @@ -244,6 +244,7 @@ async def test_replay_message_content_round_trips(store): assert len(events) == 1 # The deserialized message must match the original replayed = events[0].message + assert isinstance(replayed, JSONRPCRequest) assert replayed.method == "resources/list" assert replayed.id == "99" From e60e977217de831a89422fb7768faa2323ae80be Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 14:10:41 +0530 Subject: [PATCH 07/12] test: simplify redis fixture to achieve 100% branch coverage --- tests/server/contrib/test_redis_event_store.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index 549ce7a80f..9b4c1295f1 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -34,10 +34,7 @@ async def redis_client(): """ client = fakeredis.FakeRedis() yield client - if hasattr(client, "aclose"): - await getattr(client, "aclose")() - else: - await client.close() + await client.close() @pytest.fixture From 07bf1cac4f41e2b9453313c5be250b49ab904ead Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 14:40:43 +0530 Subject: [PATCH 08/12] test: remove client.close() from teardown to avoid version-dependent deprecation warnings --- tests/server/contrib/test_redis_event_store.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index 9b4c1295f1..43caa34706 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -34,7 +34,6 @@ async def redis_client(): """ client = fakeredis.FakeRedis() yield client - await client.close() @pytest.fixture From c0a7d7029dc29c6a7ce41806409be94ca8efdc94 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 14:50:24 +0530 Subject: [PATCH 09/12] test: make TTL assertions in RedisEventStore flake-resistant --- tests/server/contrib/test_redis_event_store.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index 43caa34706..9b27ae982d 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -267,9 +267,8 @@ async def test_event_key_has_ttl_when_configured(store_with_ttl, redis_client): event_id = await store_with_ttl.store_event("stream-A", SAMPLE_MSG) ttl = await redis_client.ttl(f"test:event:{event_id}") - # TTL should be set and very close to 60 seconds - # (allow 2s tolerance for test execution time) - assert 58 <= ttl <= 60 + # TTL should be set and positive (allow execution delay on slow runners) + assert 0 < ttl <= 60 @pytest.mark.anyio @@ -277,7 +276,8 @@ async def test_stream_key_has_ttl_when_configured(store_with_ttl, redis_client): await store_with_ttl.store_event("stream-A", SAMPLE_MSG) ttl = await redis_client.ttl("test:stream:stream-A") - assert 58 <= ttl <= 60 + # TTL should be set and positive (allow execution delay on slow runners) + assert 0 < ttl <= 60 @pytest.mark.anyio @@ -285,7 +285,8 @@ async def test_counter_key_has_ttl_when_configured(store_with_ttl, redis_client) await store_with_ttl.store_event("stream-A", SAMPLE_MSG) ttl = await redis_client.ttl("test:counter") - assert 58 <= ttl <= 60 + # TTL should be set and positive (allow execution delay on slow runners) + assert 0 < ttl <= 60 @pytest.mark.anyio From d67a3e3998e8cf15795dba618551785d4970cd95 Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 15:09:37 +0530 Subject: [PATCH 10/12] test: safely close redis client in fixture teardown to prevent ResourceWarnings --- tests/server/contrib/test_redis_event_store.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index 9b27ae982d..5eb980c850 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -33,7 +33,13 @@ async def redis_client(): Each test gets a fresh client (function scope = default). """ client = fakeredis.FakeRedis() - yield client + try: + yield client + finally: + try: + await client.aclose() + except AttributeError: + await client.close() @pytest.fixture From aa5f3979acaffc158405d88568c980833f4ce38e Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 15:17:13 +0530 Subject: [PATCH 11/12] test: add lax coverage pragmas to version-dependent cleanup code --- tests/server/contrib/test_redis_event_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index 5eb980c850..db1353ad6b 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -37,9 +37,9 @@ async def redis_client(): yield client finally: try: - await client.aclose() + await client.aclose() # pragma: lax no cover except AttributeError: - await client.close() + await client.close() # pragma: lax no cover @pytest.fixture From 1187aa89b64b07b39272aa60105b6ce4cc667dec Mon Sep 17 00:00:00 2001 From: Armaan Sandhu Date: Tue, 26 May 2026 15:23:04 +0530 Subject: [PATCH 12/12] test: add lax coverage pragma to AttributeError catch in redis_client fixture --- tests/server/contrib/test_redis_event_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/contrib/test_redis_event_store.py b/tests/server/contrib/test_redis_event_store.py index db1353ad6b..99e026d7d6 100644 --- a/tests/server/contrib/test_redis_event_store.py +++ b/tests/server/contrib/test_redis_event_store.py @@ -38,7 +38,7 @@ async def redis_client(): finally: try: await client.aclose() # pragma: lax no cover - except AttributeError: + except AttributeError: # pragma: lax no cover await client.close() # pragma: lax no cover