MCP Persist

by Ar-maan05• 9•• Scanned•Claim this listing•Request removal
Coding Agentsmcpmcp protocolmcp servermcp serverspostgresql

Production-grade persistence backends for the MCP Python SDK.

MCP Persist is an open-source mcp server by Ar-maan05, written mainly in Python, with 9 GitHub stars and the MIT license. Its code was last updated Oct 4, 2026.

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: the SDK's session manager backed by a durable EventStore you choose, with each session scoped to its own events (SessionScopedSessionManager).

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

Not using MCPServer, or want to own the wiring yourself? Build a store and pass it to SessionScopedSessionManager, a drop-in for the SDK's StreamableHTTPSessionManager. Do not pass it to the SDK's manager directly: it files events by request id rather than session, so sessions would be replayed each other's events. 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-05 … 2025-11-25)2026-07-28 and later
Durable event storeyesno, never consulted
SSE replay / resumabilityyesnot possible: no event id on the wire
Durable sessionsyesnot applicable: no session id exists
Durable recordsyesyes

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.

The proxy holds no credentials of its own, so it lets the upstream decide who may resume a session: stored history is only replayed once the upstream has accepted the reconnecting request, and an upstream refusal (a 401, an unknown session) reaches the client unchanged.

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

BackendExtraUse case
SQLiteEventStoresqliteSingle-process SSE resumability across restarts, with no external service
RedisEventStoreredisMulti-process / multi-worker SSE resumability
PostgresEventStorepostgresDurable 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 infrastructureSQLiteEventStore
Runs multiple workers / replicas behind a load balancerRedisEventStore
Already runs PostgreSQL, or needs durable storage at team / multi-node scalePostgresEventStore
Runs on serverless / a read-only or ephemeral filesystemRedisEventStore 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:

SQLiteRedisPostgres
External serviceNoneRedisPostgreSQL
Multi-process / multi-workerNo (single writer)YesYes
Durable across restartsYes (on disk)Depends on Redis persistence configYes
Automatic expiryNo (call purge_expired())Yes (native key TTL)No (call purge_expired())
Best fitSingle node, edge, local devLoad-balanced / ephemeral fan-outTeams 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 SessionScopedSessionManager:

FileApproachRun
fastmcp_plugin_server.pywith_persistence() plugin (SQLite)python examples/fastmcp_plugin_server.py
sqlite_server.pyManual SQLiteEventStorepython examples/sqlite_server.py
redis_server.pyManual RedisEventStorepython examples/redis_server.py
postgres_server.pyManual PostgresEventStorepython 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):

Backendstore throughputreplay 1,000
SQLite23,517 ev/s6.51 ms
Redis7,857 ev/s8.79 ms
Postgres7,427 ev/s6.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

GuideWhat's in it
docs/backends.mdManual wiring, per-backend config, write-behind commits, multi-tenant isolation, create() lifecycle
docs/cli.mddoctor, stats, purge (incl. --older-than), dump/load & migrate full reference: sample output, --json, exit codes
docs/api.mdsubscribe, migrate, export_stream/import_stream, metrics + OpenTelemetry + DEBUG_PERSIST, compression, batching, tiered storage, PurgeScheduler, env config, ping/health
docs/sessions.mdDurable sessions: surviving a restart or a non-sticky worker, the registry, credential enforcement, mcp-persist sessions
docs/records.mdRecords: persistence on every protocol version, the support matrix, PayloadPolicy, outcomes, drop accounting
docs/encryption.mdAES-256-GCM encryption at rest: KeyRing, env config, key rotation, composition with compression, threat model
docs/multi-tenancy.mdPer-tenant isolation: binding tenant_id, scoped reads/purge/metrics, how each backend isolates
docs/tiered-storage.mdArchiving expired events to cold storage: ArchiveScheduler, ChainedEventStore, resume across tiers
docs/architecture.mdEvent ordering, concurrency & write semantics, consistency & durability
docs/benchmarks.mdBenchmark methodology, environment spec, full result tables
docs/production.mdDeployment topologies, sizing, failure modes, TLS/credentials, checklist
docs/typescript.mdProxying 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

MCP Persist alternatives

View more →

Similar mcp servers in Coding Agents, ranked by GitHub stars. Compare licence, recent activity and scan status before you choose.

NameStarsLicenceLast updatedScan
MCP Persist (this page)
Ar-maan05
9MITOct 4, 2026Scanned, no flagged issues
Context Mode
mksglu
33kNone foundNov 1, 2026Not scanned yet
Serena
oraios
31kNone foundOct 28, 2026Not scanned yet
Fastmcp
PrefectHQ
28kApache-2.0Nov 1, 2026Scanned, no flagged issues
Nuclear
nukeop
19kAGPL-3.0Nov 1, 2026Scanned, no flagged issues
MCP For Beginners
microsoft
18kMITOct 30, 2026Scanned, no flagged issues