Skip to main content
Glama
varmabudharaju

mcpstate

mcpstate

Durable, user-keyed state for stateless MCP servers.

State that follows the user, not the session.

CI License: MIT Python PyPI Typed

mcpstate gives MCP agents state that survives the end of a conversation — and a switch to a different client, or a move to a different device. It ships as a Python library plus a ready-to-run MCP server. Start a research session in Claude Desktop on your laptop; continue it tomorrow from Claude Code, or from your phone.


See it work

The demonstration below is real. Each step is a separate operating-system process calling the actual MCP tools, sharing one on-disk backend — so the state surviving between steps is genuine durability, not a mock. Reproduce it with examples/research_assistant.py; full walkthrough in docs/use-case.md.

A new conversation the next day recovers exactly where you left off:

A brand-new conversation recovers the full research state

Two devices editing at once — the conflict becomes a merge the agent performs, and no write is lost:

Two devices editing at once; the conflict becomes an agent-mergeable state


Why this exists

The MCP specification revision of 2026-07-28 made the protocol stateless: Mcp-Session-Id, the initialize handshake, and SSE resumability were all removed (changelog, announcement). Sessions fought load balancers; the spec chose horizontal scale.

The spec's official answer for stateful servers is the handle pattern: mint an explicit handle (a basket_id, a research_id) from a tool, and have the model pass it back as an ordinary argument. How a server persists what a handle points to is explicitly out of scope — so every stateful MCP server now needs a durable, user-scoped, expiring handle store, and nothing standardizes one. mcpstate is that store.

What you can build with it

Any agent whose work is worth keeping between turns:

You're building

The state that persists

A research assistant

collected sources, notes, an evolving outline

A shopping / ordering agent

the cart, across laptop and phone

A trip planner

an itinerary that grows over days

A writing tool

drafts and revisions

A tutor

a learner's progress and history

A long-running ops workflow

a migration checklist worked over days

The three axes of continuity all fall out of one idea — state keyed by the user, not the connection:

flowchart LR
    subgraph a [Mon · laptop]
        A[Claude Desktop]
    end
    subgraph b [Tue · laptop]
        B[Claude Code]
    end
    subgraph c [Tue night · phone]
        C[Claude app]
    end
    A -- "research_k3v9x2mq" --> S[(mcpstate)]
    S -- resume --> B
    B -- save --> S
    S -- resume --> C

Axis

Scenario

Backend

Across conversations

context filled up; the next chat resumes the work

SQLite (default)

Across clients

started in Claude Desktop, continued in Claude Code / Cursor

SQLite (default)

Across devices

laptop to phone, desk to server

Redis (shared)

Quickstart — end users (the flagship server)

One config entry gives every agent you run durable memory:

pip install "mcpstate[fastmcp]"
{
  "mcpServers": {
    "state": { "command": "mcpstate", "args": ["serve"] }
  }
}

The server exposes six tools, written to be driven by a model:

Tool

What the agent uses it for

state_save

Create durable state (mints a handle) or update it (versioned)

state_load

Load state by handle (optionally just a subtree via path)

state_list

"What was I working on?" — list this user's handles

state_patch

Additive edits where every writer lands (append, set key, merge)

state_touch

Renew a TTL before it expires, or make state persistent

state_delete

Permanently remove state

For cross-device reach, point every device at a shared Redis:

{ "args": ["serve", "--backend", "redis://your-redis-host:6379/0"] }

Quickstart — server authors (the library)

pip install mcpstate
from mcpstate import HandleStore, Append, StaleWrite

store = HandleStore.from_url()  # default: sqlite:///~/.mcpstate/state.db

# Mint: create durable state, get back an opaque handle for the model to carry.
handle = store.mint("research", {"sources": [], "notes": ""}, user="alice", ttl_days=7)

# Read: state plus the freshness metadata you need to write it back.
snap = store.get(handle, user="alice")

# Versioned save: declare which version you read. If another session wrote in
# between, you get a StaleWrite carrying the current state — hand it to your
# model to merge and retry.
try:
    store.save(handle, {**snap.state, "notes": "arm64 wins"}, user="alice",
               expect_version=snap.version, writer="laptop/claude-code")
except StaleWrite as conflict:
    current = conflict.details["current"]  # full current snapshot, agent-legible

# Commutative patch: additive edits skip version checks entirely — two devices
# appending at the same moment both land.
store.patch(handle, [Append("sources", "https://arxiv.org/abs/...")],
            user="alice", writer="phone/claude")

# Renew the TTL from now — do this before it elapses; expired state is gone.
store.touch(handle, user="alice", ttl_days=7)

# Resume, any session later: what was this user working on?
for info in store.list("alice", kind="research"):
    print(info.handle, info.updated_at, info.last_writer)

Async server? AsyncHandleStore mirrors every method with the same semantics, running each call on a dedicated worker pool so backend I/O never blocks your event loop: store = AsyncHandleStore.from_url(); await store.get(handle, user=...).

The conflict model

mcpstate implements hand-off sync: state moves between sessions like a relay baton — one active writer at a time is the expected case, and the rare overlap is detected and surfaced, never silently clobbered.

The design bet: your client is an LLM. Traditional sync needs CRDTs because their clients can't reason about a conflict. An agent can. A losing write gets back a structured rejection containing the winner's state and an instruction to re-read and re-apply — and the model performs a semantic merge:

sequenceDiagram
    participant L as Laptop agent
    participant S as mcpstate
    participant P as Phone agent
    L->>S: get(handle) -> version 4
    P->>S: get(handle) -> version 4
    P->>S: save(state', expect_version=4)
    S-->>P: ok, now version 5
    L->>S: save(state'', expect_version=4)
    S-->>L: StaleWrite: modified by phone, now v5, here is the current state
    L->>L: merge intent with phone's state
    L->>S: save(merged, expect_version=5)
    S-->>L: ok, now version 6

Three mechanisms, cheapest first:

  1. Versioned saves — every snapshot carries a version; save declares the version it read; a mismatch raises StaleWrite with the current snapshot.

  2. Commutative patchesAppend / SetKey / DelKey / Merge apply without version checks, so every writer's patch lands and a patch never sees a StaleWrite. Most agent-state mutations are additive, so most writes never see a conflict at all. (Precisely: Append is fully conflict-free; two sessions SetKey/Merge-ing the same key resolve last-write-wins for that key — the freshness metadata shows who won.)

  3. Freshness metadata — every read returns version, updated_at, and last_writer, so a resuming session knows what changed while it was away.

See docs/concepts.md for the relay-baton model, why hand-off (not CRDTs) is the right v1, and the honest limits.

Backends

flowchart TD
    T[flagship server tools] --> HS[HandleStore]
    LIB[your server's own tools] --> HS
    HS --> B{backend URL}
    B -- "sqlite:///..." --> SQ[(SQLite · one machine, zero config)]
    B -- "redis://..." --> RD[(Redis · shared, cross-device)]

SQLite (default)

Redis

URL

sqlite:///~/.mcpstate/state.db

redis://host:6379/0

Reach

one machine: conversations + clients

anywhere the Redis is reachable

Setup

none

pip install "mcpstate[redis]" + a Redis

Concurrency

atomic compare-and-swap via SQL, one WAL connection per thread

optimistic WATCH/MULTI transactions

One durability note: Redis persistence is what your Redis is configured for — with default snapshotting, a crash can lose the last seconds of writes. For state you cannot afford to replay, enable AOF (appendfsync everysec or stricter) on the Redis you point at.

Four environment variables configure it: MCPSTATE_BACKEND (backend URL, or --backend), MCPSTATE_USER (identity for local/stdio; remote servers resolve the OAuth subject instead), MCPSTATE_WRITER (the last_writer label; defaults to hostname), and MCPSTATE_MAX_STATE_BYTES (per-handle state cap; default 1 MiB).

Results & credibility

Everything below is reproducible from a clean checkout with python3 -m pytest.

  • 168 tests, green on Python 3.11 through 3.14 in CI — with ruff and a clean mypy --strict pass on every push.

  • One backend contract suite runs against both SQLite and Redis, so the two backends are held to identical semantics — not tested separately and hoped to match. (Redis is exercised via fakeredis; a real-Redis CI job is on the roadmap.)

  • Concurrency is tested, not assumed. Threaded race tests assert that exactly one writer wins a contended save while every commutative patch lands, on both backends. A contention test drives 8 threads × 20 patches at one handle and asserts all 160 land.

  • Hardened against two rounds of adversarial review. Round one attacked user isolation, injection, resource exhaustion and API contracts. Round two (0.3.0) found and fixed four ways state could be silently lost or retained — see the changelog. Both rounds confirmed the core CAS engine and user-scoping sound: SQLite's conditional UPDATE and Redis's WATCH/MULTI are genuinely atomic, with no read-then-write window. Every finding was fixed with a regression test in tests/test_review_fixes.py.

  • Zero required dependencies in the core library (redis and fastmcp are optional extras); ships py.typed.

Security and durability defaults worth knowing:

  • State is capped at 1 MiB per handle (MCPSTATE_MAX_STATE_BYTES), and each user at 10,000 handles; both return structured errors rather than growing without bound.

  • The SQLite database is created private to you (0600, in a 0700 directory).

  • Credentials never appear in error messages, in userinfo or query-string form, and SQLite paths are never disclosed to clients.

  • mcpstate serve --transport http fails closed — it refuses unauthenticated callers unless you pass --allow-anonymous, so a misconfigured server can't silently merge every user's state. Multi-user identity comes from FastMCP OAuth, issuer-scoped and percent-encoded.

  • Expiry is judged by the backend's clock, so peers sharing one Redis can never disagree about whether a handle is still alive, and Redis enforces the TTL itself rather than leaving expired state resident until someone sweeps.

API reference

HandleStore

Method

Behavior

Raises

from_url(url=None, *, max_state_bytes=1 MiB)

Construct from a backend URL; None uses the SQLite default

ValueError, BackendError

mint(kind, state, *, user, ttl_days=None, writer=None) -> str

Create state, return opaque handle {kind}_{8 chars}

ValueError, StateTooLarge

get(handle, *, user) -> Snapshot

State + version + timestamps + last writer

HandleNotFound, HandleExpired

save(handle, state, *, user, expect_version, writer=None, ttl_days=KEEP_TTL) -> Snapshot

Versioned full replace; ttl_days renews expiry from now (None clears it)

StaleWrite, HandleNotFound, HandleExpired, StateTooLarge

patch(handle, ops, *, user, writer=None) -> Snapshot

Apply commutative ops; no version needed

PatchError, HandleNotFound, HandleExpired

touch(handle, *, user, ttl_days, writer=None) -> Snapshot

Reset expiry from now (None = persistent) without changing state

HandleNotFound, HandleExpired

list(user, *, kind=None, include_expired=False) -> list[HandleInfo]

Metadata only, most recently updated first

revoke(handle, *, user)

Delete

HandleNotFound

sweep(user) -> int

Physically remove expired records (re-checks expiry at delete time)

close()

Release backend resources; the store is unusable afterwards

HandleStore(backend, *, max_state_bytes=1 MiB, max_handles_per_user=10_000, max_contention_seconds=5.0) — the guards are constructor arguments, so you can tune them per deployment.

Error codes

Every error carries .code and .to_payload() — a structured dict written for a model to read.

Code

Means

Recovery the payload supports

invalid_input

A bad kind, ttl_days, state or expect_version

field names what to fix; the message says how

handle_not_found

Never minted, or revoked

handle; call state_list to see what exists

handle_expired

The TTL elapsed

expired_at, ttl_days; mint fresh

stale_write

A versioned save lost a race

current carries the winner's full snapshot to merge against

patch_error

An op did not fit the state's shape

op, path, reason

state_too_large

Over max_state_bytes

size_bytes, limit_bytes

quota_exceeded

Over max_handles_per_user

held, limit

unauthenticated

No caller identity on a transport that requires one

backend_error

The backend failed or is misconfigured

credentials and paths are redacted

internal_error

Unexpected failure; message deliberately scrubbed

AsyncHandleStore exposes the same methods as coroutines (each call runs via a thread pool the store owns, so contended writes can never starve the rest of your application); construct it with AsyncHandleStore.from_url(...) or by wrapping an existing HandleStore.

Patch ops

Op

Wire form (for state_patch)

Append(path, value)

{"op": "append", "path": "sources", "value": ...}

SetKey(path, key, value)

{"op": "set_key", "path": "profile", "key": "name", "value": ...}

DelKey(path, key)

{"op": "del_key", "path": "", "key": "draft"}

Merge(mapping, path="")

{"op": "merge", "mapping": {...}, "path": ""}

path is a dotted path into the state ("profile.tags"); "" is the root. Every segment must already exist — patch ops do not create intermediate objects.

Roadmap

Deliberately out of scope so far, in rough order: append-only changelog and changes_since(handle, version) — so a resuming agent reads a delta instead of the whole state; richer commutative ops (set-add, list removal, counters, auto-vivifying paths); advisory activity leases; merge hooks / CRDTs behind the same handle API; push via the spec's subscriptions/listen; a real-Redis CI job; a Postgres backend and a non-Python sidecar.

Development

python3 -m pip install -e ".[dev]"
python3 -m pytest
python3 -m ruff check src tests
python3 -m mypy src/mcpstate

MIT licensed.

-
license - not tested
-
quality - not tested
A
maintenance

Maintenance

Maintainers
Response time
1dRelease cycle
2Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/varmabudharaju/mcpstate'

If you have feedback or need assistance with the MCP directory API, please join our Discord server