Skip to main content
Glama
varmabudharaju

mcpstate

README.md
# mcpstate

**Durable, user-keyed state for stateless MCP servers.**

*State that follows the user, not the session.*

[![CI](https://github.com/varmabudharaju/mcpstate/actions/workflows/ci.yml/badge.svg)](https://github.com/varmabudharaju/mcpstate/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)
[![PyPI](https://img.shields.io/pypi/v/mcpstate.svg)](https://pypi.org/project/mcpstate/)
![Typed](https://img.shields.io/badge/typed-mypy%20strict-brightgreen.svg)

`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`](examples/research_assistant.py); full
walkthrough in [docs/use-case.md](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](docs/screenshots/05-3-resume.png)

**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](docs/screenshots/06-4-conflict.png)

---

## 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](https://modelcontextprotocol.io/specification/draft/changelog),
[announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/)).
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:

```mermaid
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:

```bash
pip install "mcpstate[fastmcp]"
```

```json
{
  "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:

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

## Quickstart — server authors (the library)

```bash
pip install mcpstate
```

```python
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:

```mermaid
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 patches** — `Append` / `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](docs/concepts.md) for the relay-baton model, why hand-off
(not CRDTs) is the right v1, and the honest limits.

## Backends

```mermaid
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](CHANGELOG.md). 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

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

MIT licensed.