mcp-persist 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Armaan Sandhu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-persist
3
+ Version: 0.1.0
4
+ Summary: Production-grade persistence backends for the MCP Python SDK
5
+ Project-URL: Homepage, https://github.com/Ar-maan05/mcp-persist
6
+ Project-URL: Repository, https://github.com/Ar-maan05/mcp-persist
7
+ Project-URL: Issues, https://github.com/Ar-maan05/mcp-persist/issues
8
+ Author: Armaan Sandhu
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: eventstore,mcp,model-context-protocol,redis,resumability,sse
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: mcp>=1.0.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: anyio>=4.9; extra == 'dev'
25
+ Requires-Dist: fakeredis>=2.26.0; extra == 'dev'
26
+ Requires-Dist: pyright>=1.1.400; extra == 'dev'
27
+ Requires-Dist: pytest-anyio; extra == 'dev'
28
+ Requires-Dist: pytest>=8.3.4; extra == 'dev'
29
+ Requires-Dist: ruff>=0.8.5; extra == 'dev'
30
+ Provides-Extra: redis
31
+ Requires-Dist: redis>=4.2.0; extra == 'redis'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # mcp-persist
35
+
36
+ Production-grade persistence backends for the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
37
+
38
+ The MCP SDK ships an `EventStore` interface but only an in-memory reference implementation. `mcp-persist` provides backends for real deployments where you need durability across process restarts and multi-worker environments.
39
+
40
+ ## Backends
41
+
42
+ | Backend | Extra | Use case |
43
+ |---|---|---|
44
+ | `RedisEventStore` | `redis` | Multi-process / multi-worker SSE resumability |
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install "mcp-persist[redis]"
50
+ ```
51
+
52
+ ## Quickstart
53
+
54
+ ```python
55
+ import redis.asyncio as aioredis
56
+ from mcp_persist import RedisEventStore
57
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
58
+
59
+ redis_client = aioredis.from_url("redis://localhost:6379")
60
+ store = RedisEventStore(redis_client, ttl=3600) # 1 hour TTL
61
+
62
+ session_manager = StreamableHTTPSessionManager(
63
+ app=mcp_server,
64
+ event_store=store,
65
+ )
66
+ ```
67
+
68
+ ## RedisEventStore
69
+
70
+ Stores MCP SSE events in Redis so clients can resume interrupted streams — even across worker restarts or load-balanced deployments.
71
+
72
+ ### How it works
73
+
74
+ Redis data layout:
75
+
76
+ ```
77
+ {prefix}counter — atomic INCR source for monotonic event IDs
78
+ {prefix}event:{event_id} — HASH: stream_id + serialized payload
79
+ {prefix}stream:{stream_id} — ZSET: event IDs sorted by score for O(log N) range queries
80
+ ```
81
+
82
+ - **Atomic monotonic IDs** via Redis `INCR` — collision-free across concurrent workers
83
+ - **O(log N) replay** via sorted set `ZRANGEBYSCORE`
84
+ - **TTL support** — automatic key expiry to prevent unbounded memory growth
85
+ - **Multi-tenant isolation** via configurable `key_prefix`
86
+ - **Priming event handling** — sentinel empty-string payloads are stored but never replayed to clients
87
+
88
+ ### Configuration
89
+
90
+ ```python
91
+ RedisEventStore(
92
+ redis, # redis.asyncio.Redis instance
93
+ key_prefix="mcp:", # isolate multiple servers on one Redis instance
94
+ ttl=3600, # seconds; None = never expire (not recommended)
95
+ )
96
+ ```
97
+
98
+ **TTL guidance:** Set `ttl` to at least 2× your session idle timeout. If you leave it as `None`, a warning is logged and events accumulate indefinitely.
99
+
100
+ ### Multi-tenant deployments
101
+
102
+ If multiple MCP servers share a Redis instance, use different prefixes:
103
+
104
+ ```python
105
+ store_a = RedisEventStore(redis_client, key_prefix="server-a:")
106
+ store_b = RedisEventStore(redis_client, key_prefix="server-b:")
107
+ ```
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ git clone https://github.com/Ar-maan05/mcp-persist
113
+ cd mcp-persist
114
+ pip install -e ".[redis,dev]"
115
+ pytest tests/
116
+ ```
117
+
118
+ Tests use [fakeredis](https://github.com/cunla/fakeredis-py) — no external Redis server required.
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,89 @@
1
+ # mcp-persist
2
+
3
+ Production-grade persistence backends for the [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
4
+
5
+ The MCP SDK ships an `EventStore` interface but only an in-memory reference implementation. `mcp-persist` provides backends for real deployments where you need durability across process restarts and multi-worker environments.
6
+
7
+ ## Backends
8
+
9
+ | Backend | Extra | Use case |
10
+ |---|---|---|
11
+ | `RedisEventStore` | `redis` | Multi-process / multi-worker SSE resumability |
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install "mcp-persist[redis]"
17
+ ```
18
+
19
+ ## Quickstart
20
+
21
+ ```python
22
+ import redis.asyncio as aioredis
23
+ from mcp_persist import RedisEventStore
24
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
25
+
26
+ redis_client = aioredis.from_url("redis://localhost:6379")
27
+ store = RedisEventStore(redis_client, ttl=3600) # 1 hour TTL
28
+
29
+ session_manager = StreamableHTTPSessionManager(
30
+ app=mcp_server,
31
+ event_store=store,
32
+ )
33
+ ```
34
+
35
+ ## RedisEventStore
36
+
37
+ Stores MCP SSE events in Redis so clients can resume interrupted streams — even across worker restarts or load-balanced deployments.
38
+
39
+ ### How it works
40
+
41
+ Redis data layout:
42
+
43
+ ```
44
+ {prefix}counter — atomic INCR source for monotonic event IDs
45
+ {prefix}event:{event_id} — HASH: stream_id + serialized payload
46
+ {prefix}stream:{stream_id} — ZSET: event IDs sorted by score for O(log N) range queries
47
+ ```
48
+
49
+ - **Atomic monotonic IDs** via Redis `INCR` — collision-free across concurrent workers
50
+ - **O(log N) replay** via sorted set `ZRANGEBYSCORE`
51
+ - **TTL support** — automatic key expiry to prevent unbounded memory growth
52
+ - **Multi-tenant isolation** via configurable `key_prefix`
53
+ - **Priming event handling** — sentinel empty-string payloads are stored but never replayed to clients
54
+
55
+ ### Configuration
56
+
57
+ ```python
58
+ RedisEventStore(
59
+ redis, # redis.asyncio.Redis instance
60
+ key_prefix="mcp:", # isolate multiple servers on one Redis instance
61
+ ttl=3600, # seconds; None = never expire (not recommended)
62
+ )
63
+ ```
64
+
65
+ **TTL guidance:** Set `ttl` to at least 2× your session idle timeout. If you leave it as `None`, a warning is logged and events accumulate indefinitely.
66
+
67
+ ### Multi-tenant deployments
68
+
69
+ If multiple MCP servers share a Redis instance, use different prefixes:
70
+
71
+ ```python
72
+ store_a = RedisEventStore(redis_client, key_prefix="server-a:")
73
+ store_b = RedisEventStore(redis_client, key_prefix="server-b:")
74
+ ```
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ git clone https://github.com/Ar-maan05/mcp-persist
80
+ cd mcp-persist
81
+ pip install -e ".[redis,dev]"
82
+ pytest tests/
83
+ ```
84
+
85
+ Tests use [fakeredis](https://github.com/cunla/fakeredis-py) — no external Redis server required.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,15 @@
1
+ """mcp-persist: Production-grade persistence backends for the MCP Python SDK.
2
+
3
+ Currently ships:
4
+ RedisEventStore — Redis-backed EventStore for SSE stream resumability
5
+ across multi-process/multi-worker deployments.
6
+
7
+ Usage:
8
+ pip install "mcp-persist[redis]"
9
+
10
+ from mcp_persist import RedisEventStore
11
+ """
12
+
13
+ from mcp_persist.redis import RedisEventStore
14
+
15
+ __all__ = ["RedisEventStore"]
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "mcp-persist"
3
+ version = "0.1.0"
4
+ description = "Production-grade persistence backends for the MCP Python SDK"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "Armaan Sandhu", github = "Ar-maan05" }]
8
+ keywords = ["mcp", "model-context-protocol", "redis", "eventstore", "sse", "resumability"]
9
+ license = { text = "MIT" }
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ ]
21
+ dependencies = [
22
+ "mcp>=1.0.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ redis = ["redis>=4.2.0"]
27
+ dev = [
28
+ "pytest>=8.3.4",
29
+ "anyio>=4.9",
30
+ "fakeredis>=2.26.0",
31
+ "pytest-anyio",
32
+ "pyright>=1.1.400",
33
+ "ruff>=0.8.5",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/Ar-maan05/mcp-persist"
38
+ Repository = "https://github.com/Ar-maan05/mcp-persist"
39
+ Issues = "https://github.com/Ar-maan05/mcp-persist/issues"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/mcp_persist"]
47
+
48
+ [tool.pytest.ini_options]
49
+ asyncio_mode = "auto"
50
+ addopts = "--color=yes -p anyio"
51
+
52
+ [tool.ruff]
53
+ line-length = 120
54
+ target-version = "py310"
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,169 @@
1
+ """Redis-backed EventStore for MCP SSE stream resumability.
2
+
3
+ Requires the redis extra:
4
+ pip install "mcp-persist[redis]"
5
+
6
+ Quickstart:
7
+ import redis.asyncio as aioredis
8
+ from mcp_persist import RedisEventStore
9
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
10
+
11
+ redis_client = aioredis.from_url("redis://localhost:6379")
12
+ store = RedisEventStore(redis_client, ttl=3600)
13
+
14
+ session_manager = StreamableHTTPSessionManager(
15
+ app=mcp_server,
16
+ event_store=store,
17
+ )
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from mcp.server.streamable_http import (
26
+ EventCallback,
27
+ EventId,
28
+ EventMessage,
29
+ EventStore,
30
+ StreamId,
31
+ )
32
+ from mcp.types import JSONRPCMessage, jsonrpc_message_adapter
33
+
34
+ if TYPE_CHECKING:
35
+ pass
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ class RedisEventStore(EventStore):
41
+ """EventStore backed by Redis for production multi-process deployments.
42
+
43
+ Redis data layout:
44
+ {prefix}counter — STRING, atomic INCR source for EventIds
45
+ {prefix}event:{event_id} — HASH, fields: stream_id + payload
46
+ {prefix}stream:{stream_id} — ZSET, members: event_ids, scores: int(event_id)
47
+
48
+ Args:
49
+ redis: An already-connected redis.asyncio.Redis instance.
50
+ key_prefix: Prefix for all Redis keys. Use different prefixes when
51
+ multiple MCP servers share one Redis instance.
52
+ Default: "mcp:".
53
+ ttl: Seconds after which keys expire automatically.
54
+ None means keys never expire — strongly discouraged in
55
+ production. Recommended: at least 2× session_idle_timeout.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ redis: Any, # redis.asyncio.Redis at runtime
61
+ *,
62
+ key_prefix: str = "mcp:",
63
+ ttl: int | None = None,
64
+ ) -> None:
65
+ self._redis = redis
66
+ self._prefix = key_prefix
67
+ self._ttl = ttl
68
+
69
+ if ttl is None:
70
+ logger.warning(
71
+ "RedisEventStore created with ttl=None. "
72
+ "Events will accumulate indefinitely in Redis. "
73
+ "Set ttl= to a positive number of seconds "
74
+ "(recommended: at least 2× your session_idle_timeout)."
75
+ )
76
+
77
+ # Key helpers
78
+
79
+ def _counter_key(self) -> str:
80
+ return f"{self._prefix}counter"
81
+
82
+ def _event_key(self, event_id: EventId) -> str:
83
+ return f"{self._prefix}event:{event_id}"
84
+
85
+ def _stream_key(self, stream_id: StreamId) -> str:
86
+ return f"{self._prefix}stream:{stream_id}"
87
+
88
+ # EventStore interface
89
+
90
+ async def store_event(
91
+ self,
92
+ stream_id: StreamId,
93
+ message: JSONRPCMessage | None,
94
+ ) -> EventId:
95
+ """Store an event and return its unique, monotonically increasing ID."""
96
+ event_id_int: int = await self._redis.incr(self._counter_key())
97
+ event_id: EventId = str(event_id_int)
98
+
99
+ if message is None:
100
+ payload = ""
101
+ else:
102
+ payload = jsonrpc_message_adapter.dump_json(
103
+ message,
104
+ by_alias=True,
105
+ exclude_none=True,
106
+ ).decode("utf-8")
107
+
108
+ await self._redis.hset(
109
+ self._event_key(event_id),
110
+ mapping={
111
+ "stream_id": stream_id,
112
+ "payload": payload,
113
+ },
114
+ )
115
+
116
+ await self._redis.zadd(
117
+ self._stream_key(stream_id),
118
+ {event_id: event_id_int},
119
+ )
120
+
121
+ if self._ttl is not None:
122
+ await self._redis.expire(self._event_key(event_id), self._ttl)
123
+ await self._redis.expire(self._stream_key(stream_id), self._ttl)
124
+ await self._redis.expire(self._counter_key(), self._ttl)
125
+
126
+ return event_id
127
+
128
+ async def replay_events_after(
129
+ self,
130
+ last_event_id: EventId,
131
+ send_callback: EventCallback,
132
+ ) -> StreamId | None:
133
+ """Replay all events on the same stream that occurred after last_event_id."""
134
+ stream_id_raw: bytes | None = await self._redis.hget(
135
+ self._event_key(last_event_id), "stream_id"
136
+ )
137
+
138
+ if stream_id_raw is None:
139
+ return None
140
+
141
+ stream_id: StreamId = stream_id_raw.decode("utf-8")
142
+
143
+ last_int = int(last_event_id)
144
+ raw_ids: list[bytes] = await self._redis.zrangebyscore(
145
+ self._stream_key(stream_id),
146
+ min=last_int + 1,
147
+ max="+inf",
148
+ )
149
+
150
+ for eid_bytes in raw_ids:
151
+ eid: EventId = eid_bytes.decode("utf-8")
152
+
153
+ payload_raw: bytes | None = await self._redis.hget(
154
+ self._event_key(eid), "payload"
155
+ )
156
+
157
+ if payload_raw is None:
158
+ logger.debug("Event %s payload missing during replay (expired?)", eid)
159
+ continue
160
+
161
+ payload_str = payload_raw.decode("utf-8")
162
+
163
+ if not payload_str:
164
+ continue
165
+
166
+ message = jsonrpc_message_adapter.validate_json(payload_str)
167
+ await send_callback(EventMessage(message=message, event_id=eid))
168
+
169
+ return stream_id
@@ -0,0 +1,326 @@
1
+ # pyright: reportUnknownParameterType=false
2
+ # pyright: reportMissingParameterType=false
3
+ # pyright: reportUnknownArgumentType=false
4
+ # pyright: reportUnknownVariableType=false
5
+ # pyright: reportUnknownMemberType=false
6
+ """Tests for RedisEventStore.
7
+
8
+ Uses fakeredis — no external Redis server required.
9
+ All tests are async (anyio/asyncio backend).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+
17
+ import fakeredis.aioredis as fakeredis
18
+ import pytest
19
+
20
+ from mcp_persist import RedisEventStore
21
+ from mcp.server.streamable_http import EventId, EventMessage, StreamId
22
+ from mcp.types import JSONRPCRequest
23
+
24
+ # ── Helpers ───────────────────────────────────────────────────────────────────
25
+
26
+ SAMPLE_MSG = JSONRPCRequest(jsonrpc="2.0", id="1", method="tools/list")
27
+
28
+
29
+ @pytest.fixture
30
+ async def redis_client():
31
+ client = fakeredis.FakeRedis()
32
+ try:
33
+ yield client
34
+ finally:
35
+ try:
36
+ await client.aclose()
37
+ except AttributeError:
38
+ await client.close()
39
+
40
+
41
+ @pytest.fixture
42
+ def store(redis_client, recwarn):
43
+ return RedisEventStore(redis_client, key_prefix="test:", ttl=None)
44
+
45
+
46
+ @pytest.fixture
47
+ def store_with_ttl(redis_client):
48
+ return RedisEventStore(redis_client, key_prefix="test:", ttl=60)
49
+
50
+
51
+ # ── Shared helper ─────────────────────────────────────────────────────────────
52
+
53
+
54
+ async def collect_events(
55
+ store: RedisEventStore,
56
+ last_event_id: EventId,
57
+ ) -> tuple[list[EventMessage], StreamId | None]:
58
+ captured: list[EventMessage] = []
59
+
60
+ async def cb(event: EventMessage) -> None:
61
+ captured.append(event)
62
+
63
+ stream_id = await store.replay_events_after(last_event_id, cb)
64
+ return captured, stream_id
65
+
66
+
67
+ # ─────────────────────────────────────────────────────────────────────────────
68
+ # store_event tests
69
+ # ─────────────────────────────────────────────────────────────────────────────
70
+
71
+
72
+ @pytest.mark.anyio
73
+ async def test_store_event_returns_string_integer(store):
74
+ id1 = await store.store_event("stream-A", SAMPLE_MSG)
75
+ assert isinstance(id1, str)
76
+ assert id1.isdigit()
77
+
78
+
79
+ @pytest.mark.anyio
80
+ async def test_store_event_ids_are_monotonically_increasing(store):
81
+ id1 = await store.store_event("stream-A", SAMPLE_MSG)
82
+ id2 = await store.store_event("stream-A", SAMPLE_MSG)
83
+ id3 = await store.store_event("stream-B", SAMPLE_MSG)
84
+
85
+ assert int(id1) < int(id2) < int(id3)
86
+ assert id1 == "1"
87
+
88
+
89
+ @pytest.mark.anyio
90
+ async def test_store_priming_event_writes_empty_payload(store, redis_client):
91
+ event_id = await store.store_event("stream-A", None)
92
+
93
+ raw = await redis_client.hget(f"test:event:{event_id}", "payload")
94
+ assert raw == b""
95
+
96
+
97
+ @pytest.mark.anyio
98
+ async def test_store_event_writes_stream_id_to_hash(store, redis_client):
99
+ event_id = await store.store_event("my-stream", SAMPLE_MSG)
100
+
101
+ raw_stream = await redis_client.hget(f"test:event:{event_id}", "stream_id")
102
+ assert raw_stream == b"my-stream"
103
+
104
+
105
+ @pytest.mark.anyio
106
+ async def test_store_event_adds_to_sorted_set(store, redis_client):
107
+ id1 = await store.store_event("stream-A", SAMPLE_MSG)
108
+ id2 = await store.store_event("stream-A", SAMPLE_MSG)
109
+
110
+ members = await redis_client.zrange("test:stream:stream-A", 0, -1)
111
+ decoded = [m.decode() for m in members]
112
+ assert id1 in decoded
113
+ assert id2 in decoded
114
+ assert decoded.index(id1) < decoded.index(id2)
115
+
116
+
117
+ @pytest.mark.anyio
118
+ async def test_concurrent_store_event_produces_unique_ids(store):
119
+ tasks = [asyncio.create_task(store.store_event("stream-X", SAMPLE_MSG)) for _ in range(50)]
120
+ ids = await asyncio.gather(*tasks)
121
+
122
+ assert len(set(ids)) == 50
123
+ assert all(id_.isdigit() for id_ in ids)
124
+
125
+
126
+ # ─────────────────────────────────────────────────────────────────────────────
127
+ # replay_events_after tests
128
+ # ─────────────────────────────────────────────────────────────────────────────
129
+
130
+
131
+ @pytest.mark.anyio
132
+ async def test_replay_unknown_id_returns_none(store):
133
+ events, stream_id = await collect_events(store, "9999")
134
+ assert stream_id is None
135
+ assert events == []
136
+
137
+
138
+ @pytest.mark.anyio
139
+ async def test_replay_returns_correct_stream_id(store):
140
+ anchor = await store.store_event("my-stream", SAMPLE_MSG)
141
+
142
+ events, stream_id = await collect_events(store, anchor)
143
+ assert stream_id == "my-stream"
144
+ assert events == []
145
+
146
+
147
+ @pytest.mark.anyio
148
+ async def test_replay_skips_priming_events(store):
149
+ anchor = await store.store_event("stream-A", SAMPLE_MSG)
150
+ _ = await store.store_event("stream-A", None)
151
+ id3 = await store.store_event("stream-A", SAMPLE_MSG)
152
+
153
+ events, _ = await collect_events(store, anchor)
154
+
155
+ assert len(events) == 1
156
+ assert events[0].event_id == id3
157
+
158
+
159
+ @pytest.mark.anyio
160
+ async def test_replay_skips_expired_event_payloads(store, redis_client):
161
+ anchor = await store.store_event("stream-A", SAMPLE_MSG)
162
+ id2 = await store.store_event("stream-A", SAMPLE_MSG)
163
+ id3 = await store.store_event("stream-A", SAMPLE_MSG)
164
+
165
+ await redis_client.delete(f"test:event:{id2}")
166
+
167
+ events, _ = await collect_events(store, anchor)
168
+
169
+ assert len(events) == 1
170
+ assert events[0].event_id == id3
171
+
172
+
173
+ @pytest.mark.anyio
174
+ async def test_replay_events_are_in_ascending_order(store):
175
+ anchor = await store.store_event("stream-A", SAMPLE_MSG)
176
+ id2 = await store.store_event("stream-A", SAMPLE_MSG)
177
+ id3 = await store.store_event("stream-A", SAMPLE_MSG)
178
+
179
+ events, _ = await collect_events(store, anchor)
180
+
181
+ assert len(events) == 2
182
+ assert events[0].event_id == id2
183
+ assert events[1].event_id == id3
184
+
185
+
186
+ @pytest.mark.anyio
187
+ async def test_replay_excludes_anchor_event_itself(store):
188
+ anchor = await store.store_event("stream-A", SAMPLE_MSG)
189
+ id2 = await store.store_event("stream-A", SAMPLE_MSG)
190
+
191
+ events, _ = await collect_events(store, anchor)
192
+
193
+ event_ids = [e.event_id for e in events]
194
+ assert anchor not in event_ids
195
+ assert id2 in event_ids
196
+
197
+
198
+ @pytest.mark.anyio
199
+ async def test_replay_stream_isolation(store):
200
+ anchor = await store.store_event("stream-A", SAMPLE_MSG)
201
+
202
+ _ = await store.store_event("stream-B", SAMPLE_MSG)
203
+ _ = await store.store_event("stream-B", SAMPLE_MSG)
204
+
205
+ id4 = await store.store_event("stream-A", SAMPLE_MSG)
206
+
207
+ events, stream_id = await collect_events(store, anchor)
208
+
209
+ assert stream_id == "stream-A"
210
+ assert len(events) == 1
211
+ assert events[0].event_id == id4
212
+
213
+
214
+ @pytest.mark.anyio
215
+ async def test_replay_message_content_round_trips(store):
216
+ original = JSONRPCRequest(jsonrpc="2.0", id="99", method="resources/list")
217
+ anchor = await store.store_event("stream-A", original)
218
+ await store.store_event("stream-A", original)
219
+
220
+ events, _ = await collect_events(store, anchor)
221
+
222
+ assert len(events) == 1
223
+ replayed = events[0].message
224
+ assert isinstance(replayed, JSONRPCRequest)
225
+ assert replayed.method == "resources/list"
226
+ assert replayed.id == "99"
227
+
228
+
229
+ @pytest.mark.anyio
230
+ async def test_replay_event_id_is_attached_to_event_message(store):
231
+ anchor = await store.store_event("stream-A", SAMPLE_MSG)
232
+ id2 = await store.store_event("stream-A", SAMPLE_MSG)
233
+
234
+ events, _ = await collect_events(store, anchor)
235
+
236
+ assert events[0].event_id == id2
237
+
238
+
239
+ # ─────────────────────────────────────────────────────────────────────────────
240
+ # TTL tests
241
+ # ─────────────────────────────────────────────────────────────────────────────
242
+
243
+
244
+ @pytest.mark.anyio
245
+ async def test_event_key_has_ttl_when_configured(store_with_ttl, redis_client):
246
+ event_id = await store_with_ttl.store_event("stream-A", SAMPLE_MSG)
247
+
248
+ ttl = await redis_client.ttl(f"test:event:{event_id}")
249
+ assert 0 < ttl <= 60
250
+
251
+
252
+ @pytest.mark.anyio
253
+ async def test_stream_key_has_ttl_when_configured(store_with_ttl, redis_client):
254
+ await store_with_ttl.store_event("stream-A", SAMPLE_MSG)
255
+
256
+ ttl = await redis_client.ttl("test:stream:stream-A")
257
+ assert 0 < ttl <= 60
258
+
259
+
260
+ @pytest.mark.anyio
261
+ async def test_counter_key_has_ttl_when_configured(store_with_ttl, redis_client):
262
+ await store_with_ttl.store_event("stream-A", SAMPLE_MSG)
263
+
264
+ ttl = await redis_client.ttl("test:counter")
265
+ assert 0 < ttl <= 60
266
+
267
+
268
+ @pytest.mark.anyio
269
+ async def test_no_ttl_on_keys_when_not_configured(store, redis_client):
270
+ event_id = await store.store_event("stream-A", SAMPLE_MSG)
271
+
272
+ event_ttl = await redis_client.ttl(f"test:event:{event_id}")
273
+ stream_ttl = await redis_client.ttl("test:stream:stream-A")
274
+ counter_ttl = await redis_client.ttl("test:counter")
275
+
276
+ assert event_ttl == -1
277
+ assert stream_ttl == -1
278
+ assert counter_ttl == -1
279
+
280
+
281
+ # ─────────────────────────────────────────────────────────────────────────────
282
+ # Key prefix test
283
+ # ─────────────────────────────────────────────────────────────────────────────
284
+
285
+
286
+ @pytest.mark.anyio
287
+ async def test_custom_key_prefix_isolates_two_stores(redis_client, recwarn):
288
+ store_a = RedisEventStore(redis_client, key_prefix="server-a:", ttl=None)
289
+ store_b = RedisEventStore(redis_client, key_prefix="server-b:", ttl=None)
290
+
291
+ id_a = await store_a.store_event("stream-1", SAMPLE_MSG)
292
+ id_b = await store_b.store_event("stream-1", SAMPLE_MSG)
293
+
294
+ assert id_a == "1"
295
+ assert id_b == "1"
296
+
297
+ a_keys = [k.decode() for k in await redis_client.keys("server-a:*")]
298
+ b_keys = [k.decode() for k in await redis_client.keys("server-b:*")]
299
+
300
+ assert all("server-b:" not in k for k in a_keys)
301
+ assert all("server-a:" not in k for k in b_keys)
302
+
303
+ events_a, stream_id_a = await collect_events(store_a, id_a)
304
+ assert stream_id_a == "stream-1"
305
+ assert events_a == []
306
+
307
+
308
+ # ─────────────────────────────────────────────────────────────────────────────
309
+ # Warning / logging tests
310
+ # ─────────────────────────────────────────────────────────────────────────────
311
+
312
+
313
+ @pytest.mark.anyio
314
+ async def test_no_ttl_emits_log_warning(redis_client, caplog):
315
+ with caplog.at_level(logging.WARNING, logger="mcp_persist.redis"):
316
+ RedisEventStore(redis_client, ttl=None)
317
+
318
+ assert any("ttl=None" in record.message for record in caplog.records)
319
+
320
+
321
+ @pytest.mark.anyio
322
+ async def test_with_ttl_no_warning_emitted(redis_client, caplog):
323
+ with caplog.at_level(logging.WARNING, logger="mcp_persist.redis"):
324
+ RedisEventStore(redis_client, ttl=3600)
325
+
326
+ assert not any("ttl" in record.message.lower() for record in caplog.records)