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.
- mcp_persist-0.1.0/LICENSE +21 -0
- mcp_persist-0.1.0/PKG-INFO +122 -0
- mcp_persist-0.1.0/README.md +89 -0
- mcp_persist-0.1.0/__init__.py +15 -0
- mcp_persist-0.1.0/pyproject.toml +57 -0
- mcp_persist-0.1.0/redis.py +169 -0
- mcp_persist-0.1.0/test_redis_event_store.py +326 -0
|
@@ -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)
|