Skip to main content

mcp-persist

CI PyPI Downloads Python versions License: MIT

When an MCP client reconnects, the server has to replay the events it missed, and with only the SDK's in-memory EventStore, that replay is impossible: the session lived in one process's memory, so a restart or a reconnect to a different worker loses it. mcp-persist adds drop-in durable EventStore backends for SQLite, Redis, and PostgreSQL that survive process restarts and scale across multi-worker deployments, keeping SSE stream resumability intact.

📚 This README is the quick tour. Full reference lives in docs/: backends, CLI, the programmatic API, architecture, benchmarks, and the production guide.

⚠️ Requires the MCP Python SDK 2.0 or newer. The 2.0 release renamed FastMCP to MCPServer, moved the wire types to the mcp_types package, and replaced httpx with httpx2. Supporting both SDK majors would mean import shims through all of it, so mcp-persist 2.0 and later target 2.x only. Still on mcp 1.x? Pin mcp-persist==1.12.2, which is feature-identical apart from the fixes in 2.0.0. Stored events are unaffected either way: the on-disk JSON is byte-identical across both SDK majors, so an existing store keeps replaying correctly after you upgrade.

Quickstart: with_persistence()

The fastest way to add resumability to an MCPServer. Wiring it by hand means an event store, a StreamableHTTPSessionManager, a Starlette lifespan to open and close them, and a Mount. with_persistence() collapses all of it to two lines: pass your MCPServer instance and get back a runnable Starlette ASGI app with the store and session manager already wired in, opened on startup and closed on shutdown.

import uvicorn
from mcp.server.mcpserver import MCPServer
from mcp_persist import with_persistence

mcp = MCPServer(name="MyServer")

# Swap backend="redis" / "postgres" with the matching url:
app = with_persistence(mcp, backend="sqlite", url="events.db", ttl=3600)
uvicorn.run(app, host="127.0.0.1", port=8000)  # MCP endpoint at /mcp

That replaces ~35 lines of manual lifespan/Mount/session-manager boilerplate. There are three ways to supply the store, resolved in order:

# A: config kwargs; the app builds the store and owns its lifecycle:
app = with_persistence(mcp, backend="redis", url="redis://localhost:6379", ttl=3600)

# B: bring your own store; you own its lifecycle (the app does NOT close it):
async with SQLiteEventStore.create("events.db", ttl=3600) as store:
    app = with_persistence(mcp, store=store)
    await uvicorn.Server(uvicorn.Config(app, port=8000)).serve()

# C: configure from the environment (MCP_PERSIST_BACKEND / _URL / _TTL / …):
app = with_persistence(mcp)

The explicit form accepts the same production options as environment configuration, including tenant_id, compression, keyring, and Redis or Postgres batching. For example:

app = with_persistence(
    mcp,
    backend="postgres",
    url="postgresql://localhost/app",
    tenant_id="team-a",
    compression="zstd",
    batch_max_events=128,
)

The live store is exposed on app.state.event_store, so you can run a PurgeScheduler alongside the server. No extra dependency is required: starlette and the session manager ship with mcp. See examples/fastmcp_plugin_server.py.

Under the hood, whichever setup you use, it's the same layering: a StreamableHTTPSessionManager backed by a durable EventStore you choose.

MCP Server
     │
     ▼
StreamableHTTPSessionManager
     │
     ▼
EventStore
 ├─ SQLite
 ├─ Redis
 └─ PostgreSQL

Not using MCPServer, or want to own the wiring yourself? Build a store and pass it to StreamableHTTPSessionManager directly; see Manual wiring.

Surviving a restart: durable_sessions=True

A persistent event store keeps the events. It does not keep the session. The SDK holds live sessions in an in-process dict, so after a restart the client's Mcp-Session-Id is unknown and every request 404s: the events are still on disk and the client cannot reach them. A request landing on a second worker hits the same wall, which is why resumability across a load balancer has needed sticky routing.

app = with_persistence(mcp, backend="sqlite", url="events.db", durable_sessions=True)

Session ids are now recorded next to the events, and a process that meets an id it did not create resumes it instead of rejecting it. Restarts, rolling deploys and non-sticky load balancing keep working; the credential bound to a session is still enforced, and a terminated session is never resumed.

mcp-persist sessions list                    # who is connected
mcp-persist sessions terminate <session-id>  # cut one off, permanently

What it restores is the session's identity and its event history, which is what stream resumability means. It does not restore server-side state from a tool call that was mid-flight when the process died. Off by default; see docs/sessions.md.

Every protocol version: record=True

Events and durable sessions apply to clients on a handshake-era protocol revision. From 2026-07-28 the SDK routes each request to a stateless single-exchange handler: no initialize handshake, no Mcp-Session-Id, one request in and one response out. That path never reaches your event store, so there is nothing to replay and no session to persist.

A record is the half that still works. It is a small durable note of one handled message, written on every protocol revision, so a single store keeps telling you what happened while a deployment migrates across revisions.

app = with_persistence(mcp, backend="sqlite", url="events.db", record=True)
handshake era (2024-11-052025-11-25) 2026-07-28 and later
Durable event store yes no, never consulted
SSE replay / resumability yes not possible: no event id on the wire
Durable sessions yes not applicable: no session id exists
Durable records yes yes

Records carry the method, the protocol version that handled it, the outcome (including cancelled when a client disconnects), the duration, and the tool name. Params are not captured unless you name them in an allowlist, and a record has no free-text error field at all, so a validation message or exception string can never leak into the store.

Writing happens off the request path: a bounded queue with a background writer, so a slow record backend costs a request nothing. The trade is that a full queue drops records, which is always counted and never silent, through app.state.record_flusher.stats() or a record_metrics= collector.

Off by default. With an event store configured, the first request on a stateless protocol version logs one warning so the boundary is discoverable, and mcp-persist doctor reports it as a protocol support check. Full detail in docs/records.md.

Seeing what is actually in there: mcp-persist dashboard

mcp-persist dashboard        # http://localhost:8765

One self-contained page, refreshing itself: totals and a health dot, every stream with its event counts and id range, the events of a stream you click (newest first, labelled by JSON-RPC method or result/error, click to expand the raw message), and the durable sessions. Payloads are shown decompressed and decrypted, because it reads through the same store your server uses.

Read-only, no external assets, and no authentication, which is why it binds 127.0.0.1 and refuses a public address unless you insist. --redact-payloads drops message bodies for a shared screen. See docs/cli.md.

Resumability without touching the server: PersistenceProxy

When you can't (or don't want to) modify the MCP server, such as a third-party server, another language, or a binary you don't own, run the proxy in front of it. It forwards requests upstream and intercepts the SSE responses, persisting every event to a store and assigning its own event IDs. A client that disconnects reconnects with Last-Event-ID; the proxy replays the missed events from the store and continues live. The upstream needs no event store of its own: the proxy is the store.

Running a TypeScript (or any non-Python) MCP server? The proxy speaks plain HTTP, so it adds resumability in front of it without touching the server. See docs/typescript.md for a step-by-step guide.

Point your clients at the proxy's address instead of the server's (e.g. http://localhost:8000/mcp); nothing else on the client changes. Resumability rides the standard SSE Last-Event-ID header, so any MCP client that reconnects after a drop gets its missed events back automatically.

# Point at a running MCP server (no extra install needed: httpx2 is a
# dependency and uvicorn ships with mcp):
mcp-persist-proxy --upstream http://localhost:8001 \
    --backend sqlite --url events.db --port 8000

# …or start the server as a subprocess, wait for it, and proxy it:
mcp-persist-proxy --backend redis --url redis://localhost:6379 \
    --port 8000 --upstream-port 8001 -- uvicorn my_server:app --port 8001

Or embed it as an ASGI app:

import uvicorn
from mcp_persist import PersistenceProxy


async def serve():
    async with PersistenceProxy.create("http://localhost:8001", backend="sqlite", url="events.db", ttl=3600) as proxy:
        await uvicorn.Server(uvicorn.Config(proxy, port=8000)).serve()

The store is resolved exactly like with_persistence: a pre-built store=, backend=+url=, or MCP_PERSIST_* env vars. (ttl is how long stored events are kept, in seconds; it's available as --ttl on the CLI too.)

What it does and does not do. It adds resumability against a stable upstream: a server that stays up while clients come and go. It survives client disconnects (flaky networks, mobile, tunnels), and, with a durable store like SQLite or Postgres, a restart of the proxy itself. Two things it can't do: it can't recover from the upstream server restarting: a restarted server is a clean break, so the proxy can replay what it already stored but can't carry the old connection over to the new server; and it can't replay an event that was never stored: if the client and the proxy both drop before an event is saved, it's gone. It never makes delivery less reliable than talking to the server directly.

Browser clients (CORS). A browser-based MCP client (a web UI) talks to the proxy through fetch, so it needs CORS. Pass --cors to let the proxy answer the preflight itself and stamp Access-Control-Allow-Origin on every response (including the SSE streams it synthesizes, which is where a browser otherwise fails with "Failed to fetch"). It also exposes mcp-session-id so the client's JavaScript can read the session id. --cors allows any origin (*); pass an explicit origin to restrict it (--cors https://app.example):

mcp-persist-proxy --upstream http://localhost:8001 \
    --backend sqlite --url events.db --port 8000 --cors

Command-line tools

Diagnostic commands for operating a live store, plus an upstream pre-flight for the proxy. The store commands resolve their target from --backend/--url flags or the MCP_PERSIST_* env vars. Full reference, sample output, JSON schema, and exit-code semantics in docs/cli.md.

# Pass/fail health checklist (runtime, driver, connectivity, retention):
mcp-persist doctor --backend sqlite --url events.db --ttl 3600

# Per-stream event inventory + latency probe:
mcp-persist stats --backend sqlite --url events.db

# Force a purge of expired events (--dry-run to count first, --older-than by age):
mcp-persist purge --backend sqlite --url events.db --ttl 3600

# Export one stream to portable JSON and load it into a fresh store:
mcp-persist dump session-abc --backend sqlite --url events.db -o session.json
mcp-persist load session.json --backend sqlite --url repro.db

# Copy every stream from one backend to another:
mcp-persist migrate --from-backend sqlite --from-url events.db \
    --to-backend postgres --to-url postgresql://localhost/app

# Verify an upstream is reachable and speaks Streamable HTTP, then exit:
mcp-persist-proxy --upstream http://localhost:8001 --check

Backends & choosing one

Backend Extra Use case
SQLiteEventStore sqlite Single-process SSE resumability across restarts, with no external service
RedisEventStore redis Multi-process / multi-worker SSE resumability
PostgresEventStore postgres Durable resumability for deployments already running Postgres, including multi-node / team setups

Start from how you deploy:

If your deployment… Use
Runs as a single process and you want zero extra infrastructure SQLiteEventStore
Runs multiple workers / replicas behind a load balancer RedisEventStore
Already runs PostgreSQL, or needs durable storage at team / multi-node scale PostgresEventStore
Runs on serverless / a read-only or ephemeral filesystem RedisEventStore or PostgresEventStore (never SQLite)

Any replica count > 1 needs a shared store (Redis/Postgres), not SQLite. A local SQLite file is visible only to the process that opened it, so behind a load balancer (or during a rolling deploy, when a reconnecting client lands on a different pod) that pod won't have the client's events and the resume silently returns nothing. SQLite is for a genuine single process. See deployment topologies.

How they compare:

SQLite Redis Postgres
External service None Redis PostgreSQL
Multi-process / multi-worker No (single writer) Yes Yes
Durable across restarts Yes (on disk) Depends on Redis persistence config Yes
Automatic expiry No (call purge_expired()) Yes (native key TTL) No (call purge_expired())
Best fit Single node, edge, local dev Load-balanced / ephemeral fan-out Teams already running Postgres

On a standalone (non-cluster) Redis, store_event runs as a single server-side EVALSHA (counter increment plus the event write in one step) rather than an INCR followed by a pipeline, halving the per-event round-trips. The store probes for this on its first write and falls back automatically on Redis Cluster or any server without scripting, so behavior is identical either way.

Per-backend construction, configuration, write-behind tuning, and multi-tenant setup live in docs/backends.md; latency and throughput characteristics in docs/benchmarks.md.

Installation

# SQLite backend (no external service needed)
pip install "mcp-persist[sqlite]"

# Redis backend
pip install "mcp-persist[redis]"

# Postgres backend
pip install "mcp-persist[postgres]"

# Multiple backends
pip install "mcp-persist[sqlite,redis,postgres]"

# Optional extras: zstd compression, OpenTelemetry metrics export,
# AES-256-GCM encryption at rest
pip install "mcp-persist[zstd]"
pip install "mcp-persist[otel]"
pip install "mcp-persist[crypto]"

Programmatic features at a glance

Beyond drop-in resumability, every store exposes a small set of building blocks. Full API and examples in docs/api.md.

  • subscribe(): push new events to an in-process consumer as they're written (Redis pub/sub, Postgres LISTEN/NOTIFY, SQLite polling).
  • migrate(): copy events between backends (e.g. SQLite → Postgres as you grow), preserving per-stream ordering.
  • compression="gzip" / "zstd": transparently compress large payloads above a threshold; decompression on read is automatic and config-independent. zstd (via the zstd extra) gives a better ratio for JSON-RPC.
  • Encryption at rest: pass a keyring= (via the crypto extra) to AES-256-GCM encrypt payloads before they reach the backend; decryption on read is automatic and marker-driven, composes with compression, and supports zero-downtime key rotation. See docs/encryption.md.
  • Multi-tenancy: bind a store to a tenant_id to isolate event streams per customer inside one shared backend (scoped reads, purge, and metrics). See docs/multi-tenancy.md.
  • Per-team retention: enforce per-tenant policies (RetentionPolicy) and record deletions to an append-only audit trail (DatabaseAuditSink) via the RetentionScheduler:
    policy = RetentionPolicy(windows={"team-a": 86400, None: 3600}, default=172800)
    async with RetentionScheduler(store, policy, DatabaseAuditSink(store), interval=300):
        ...
    
  • BatchingEventStore: buffer writes for high-throughput Redis/Postgres deployments, flushing on a size or latency ceiling while still returning event IDs synchronously. See docs/api.md.
  • Records: a durable note of every handled message, written on every protocol version including the stateless 2026-07-28 transport where the event store is bypassed. Allowlist-only param capture, no free-text error field, and writing happens off the request path. See docs/records.md.
  • Tiered storage: archive expired events into cold storage instead of deleting them (ArchiveScheduler), and resume across both tiers (ChainedEventStore). See docs/tiered-storage.md.
  • Event stream forking: branch an existing stream at any point and replay from that branch with different inputs or models (preserving the original branch intact), turning the linear log into a tree for systematic A/B evaluation. See docs/api.md.
  • Metrics: pass a metrics= collector (a Protocol, the built-in LoggingMetricsCollector, or OTelMetricsCollector for OpenTelemetry) to emit to Prometheus/Datadog/etc.; zero overhead when unused. The proxy adds an optional on_proxy_replay hook for reconnect/replay rates and blocked cross-session attempts.
  • PurgeScheduler: run purge_expired() on an interval for SQLite/Postgres (Redis expires natively).
  • event_store_from_env(): pick the backend at deploy time from MCP_PERSIST_* env vars, no branching in code.
  • ping() and health(): backend liveness/readiness probes for health endpoints; health() adds latency and backend-specific detail (DB size, memory, pool) as a HealthReport.
  • export_stream() / import_stream() (and mcp-persist dump / load): capture one stream to portable JSON and restore it into a fresh store, for bug reports and test fixtures.

Architecture & guarantees

  • Ordering: event IDs are monotonically increasing; replay order is preserved per-stream.
  • Concurrency: duplicate IDs are structurally impossible (AUTOINCREMENT / IDENTITY / INCR); Redis and Postgres take concurrent writes, SQLite is single-writer.
  • Durability: SQLite uses WAL, Postgres is ACID, Redis depends on its persistence config (use AOF for strong durability).

Full treatment, including the Redis write-ceiling caveat, in docs/architecture.md.

Examples

The examples/ directory contains minimal, runnable MCP servers: the with_persistence() one-liner, plus each backend wired manually into a real StreamableHTTPSessionManager:

File Approach Run
fastmcp_plugin_server.py with_persistence() plugin (SQLite) python examples/fastmcp_plugin_server.py
sqlite_server.py Manual SQLiteEventStore python examples/sqlite_server.py
redis_server.py Manual RedisEventStore python examples/redis_server.py
postgres_server.py Manual PostgresEventStore python examples/postgres_server.py

Each one is a self-contained MCP server you can connect to with any MCP client at http://localhost:8000/mcp (the three backend servers are a note-taking app; the plugin server is a minimal echo server). See examples/README.md for prerequisites, setup, and a client snippet.

Benchmarks

Measured at --events 5000 --concurrency 500 (AMD Ryzen AI 7 350, local Redis 8 / Postgres 18; indicative, not authoritative):

Backend store throughput replay 1,000
SQLite 23,517 ev/s 6.51 ms
Redis 7,857 ev/s 8.79 ms
Postgres 7,427 ev/s 6.58 ms

Full methodology, environment spec, percentiles, and analysis in docs/benchmarks.md. Run it yourself with uv run python benchmarks/benchmark.py --events 5000 --concurrency 500.

📚 Documentation

Guide What's in it
docs/backends.md Manual wiring, per-backend config, write-behind commits, multi-tenant isolation, create() lifecycle
docs/cli.md doctor, stats, purge (incl. --older-than), dump/load & migrate full reference: sample output, --json, exit codes
docs/api.md subscribe, migrate, export_stream/import_stream, metrics + OpenTelemetry + DEBUG_PERSIST, compression, batching, tiered storage, PurgeScheduler, env config, ping/health
docs/sessions.md Durable sessions: surviving a restart or a non-sticky worker, the registry, credential enforcement, mcp-persist sessions
docs/records.md Records: persistence on every protocol version, the support matrix, PayloadPolicy, outcomes, drop accounting
docs/encryption.md AES-256-GCM encryption at rest: KeyRing, env config, key rotation, composition with compression, threat model
docs/multi-tenancy.md Per-tenant isolation: binding tenant_id, scoped reads/purge/metrics, how each backend isolates
docs/tiered-storage.md Archiving expired events to cold storage: ArchiveScheduler, ChainedEventStore, resume across tiers
docs/architecture.md Event ordering, concurrency & write semantics, consistency & durability
docs/benchmarks.md Benchmark methodology, environment spec, full result tables
docs/production.md Deployment topologies, sizing, failure modes, TLS/credentials, checklist
docs/typescript.md Proxying a TypeScript (or any non-Python) MCP server

Deploying to production

Once a backend is wired in, see the production guide for operating it: scheduling purge_expired() so storage doesn't grow without bound, treating the store as a critical dependency (failure modes), pre-creating schema under restricted database permissions, TLS and credential handling, connection and pool sizing across workers, and a deployment checklist.

Development

git clone https://github.com/Ar-maan05/mcp-persist
cd mcp-persist
uv sync --all-extras --dev
uv run pytest tests/

The suite is 300+ async tests covering all three backends. The Redis tests use fakeredis and the SQLite tests use in-memory aiosqlite, so the default run needs no external servers. The Postgres tests require a real server and are skipped unless MCP_TEST_POSTGRES_URL is set; to run them and the Redis suite against real backends:

MCP_TEST_REDIS_URL=redis://localhost:6379/0 \
MCP_TEST_POSTGRES_URL=postgresql://postgres@localhost:5432/postgres \
uv run pytest tests/

See CONTRIBUTING.md for more.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mcp_persist-2.1.0.tar.gz (2.7 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mcp_persist-2.1.0-py3-none-any.whl (164.3 kB view details)

Uploaded Python 3

File details

Details for the file mcp_persist-2.1.0.tar.gz.

File metadata

  • Download URL: mcp_persist-2.1.0.tar.gz
  • Upload date:
  • Size: 2.7 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for mcp_persist-2.1.0.tar.gz
Algorithm Hash digest
SHA256 60aab79ec06ddd6850caddd27a47c3a49e36159d8ff9f90fb8bee566ade39ae0
MD5 024c3b5f55363279e48d367284021fa6
BLAKE2b-256 269f26679c82610db6310c47b3f0f5a877bd8053bdac3568cdf4d5bbb9957d53

See more details on using hashes here.

File details

Details for the file mcp_persist-2.1.0-py3-none-any.whl.

File metadata

  • Download URL: mcp_persist-2.1.0-py3-none-any.whl
  • Upload date:
  • Size: 164.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for mcp_persist-2.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a6b2c0392a4edd6bd7736675f17179acae9085c93c87018362bcb8e2ff9df2cc
MD5 383913a04264ed2b5b56c16310623cd3
BLAKE2b-256 f3fbc618933e03fcf3cea665e705e1fd303621b24abff8c52931d2daa17ffad7

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

2.1.0 This release

2 files

2.0.0

2 files

1.12.3

2 files

1.12.2

2 files

1.12.1

2 files

1.12.0

2 files

1.11.1

2 files

1.11.0

2 files

1.10.0

2 files

1.9.0

2 files

1.8.4

2 files

1.8.3

2 files

1.8.2

2 files

1.8.1

2 files

1.8.0

2 files

1.7.0

2 files

1.5.0

2 files

1.4.0

2 files

1.3.0

2 files

1.2.1

2 files

1.2.0

2 files

1.1.4

2 files

1.1.3

2 files

1.1.2

2 files

1.1.1

2 files

1.1.0

2 files

1.0.3

2 files

1.0.2

2 files

1.0.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page