mcpstate
Durable, user-keyed state for stateless MCP servers.
State that follows the user, not the session.
mcpstate is a Python library — plus a ready-to-run MCP server — that gives
agents state which survives the end of a conversation, a switch to a different
MCP client, or a move to a different device. Start a research session in
Claude Desktop on your laptop; continue it tomorrow from Claude Code, or from
your phone.
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 — every stateful MCP server now
needs a durable, user-scoped, expiring handle store, and nothing standardizes
one.
mcpstate is that store: minting, persistence, versioning, TTL, identity
scoping, and conflict handling behind one small API — with the storage backend
deciding how far your state can travel.
The three axes of continuity
flowchart LR
subgraph yesterday [Monday, laptop]
A[Claude Desktop\nconversation 1]
end
subgraph today [Tuesday, laptop]
B[Claude Code\nconversation 2]
end
subgraph tonight [Tuesday night, phone]
C[Claude app\nconversation 3]
end
A -- "research_k3v9x2mq" --> S[(mcpstate\nhandle store)]
S -- resume --> B
B -- save --> S
S -- resume --> C
| Axis | Scenario | Backend needed |
|---|---|---|
| Across conversations | Context window filled up; next conversation resumes the work | SQLite (default, zero config) |
| Across clients | Started in Claude Desktop, continuing in Claude Code or Cursor | SQLite (default, zero config) |
| Across devices | Laptop to phone, desk to server | Redis (any shared instance) |
The first axis is the everyday one: today, every MCP server forgets everything
between conversations. With mcpstate, work products survive by default.
Quickstart: end users (the flagship server)
Install and add one entry to your MCP client config — every agent you run gains durable memory:
pip install "mcpstate[fastmcp]"
{
"mcpServers": {
"state": {
"command": "mcpstate",
"args": ["serve"]
}
}
}
The server exposes five tools:
| 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 that can never conflict (append, set key, merge) |
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")
# 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)
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 systems need CRDTs because their clients cannot reason about a conflict. An agent can. So 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, which is better than a structural one:
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:
- Versioned saves — every snapshot carries a version;
savedeclares the version it read; mismatches raiseStaleWritewith the current snapshot inside. - Commutative patches —
Append/SetKey/DelKey/Mergecommute with each other, so they apply without version checks and cannot conflict. Most agent-state mutations are additive; most writes never see a conflict. - Freshness metadata — every read returns version,
updated_at, andlast_writer, so a resuming session knows what happened while it was away.
Backends
flowchart TD
T[flagship server tools] --> HS[HandleStore]
LIB[your server's own tools] --> HS
HS --> B{backend URL}
B -- "sqlite:///..." --> SQ[(SQLite\none machine, zero config)]
B -- "redis://..." --> RD[(Redis\nshared: multi-instance,\ncross-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 | optimistic WATCH/MULTI transactions |
Configuration is three environment variables:
MCPSTATE_BACKEND— backend URL (or pass--backendtomcpstate serve).MCPSTATE_USER— identity override for local/stdio use. Remote servers running with OAuth resolve the user from the access token instead; stdio servers default to"local".MCPSTATE_WRITER— the label recorded aslast_writeron every write (e.g.laptop/claude-code); defaults to the machine's hostname, so cross-device hand-offs are attributable out of the box.
Security defaults worth knowing: states are capped at 1 MiB
(HandleStore(max_state_bytes=...) to change; oversized saves get a
structured state_too_large error), credentials never appear in error
messages, and mcpstate serve --transport http has no authentication of its
own — keep it on localhost or behind an authenticating proxy; multi-user
identity requires running under FastMCP OAuth.
API reference
HandleStore
| Method | Behavior | Raises |
|---|---|---|
from_url(url=None) |
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 |
get(handle, *, user) -> Snapshot |
State + version + timestamps + last writer | HandleNotFound, HandleExpired |
save/mint/patch size guard |
States over max_state_bytes (default 1 MiB) are rejected |
StateTooLarge |
save(handle, state, *, user, expect_version, writer=None) -> Snapshot |
Versioned full replace; returns the new snapshot | StaleWrite, HandleNotFound, HandleExpired |
patch(handle, ops, *, user, writer=None) -> Snapshot |
Apply commutative ops; no version needed | PatchError, 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 | — |
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) |
{"op": "merge", "mapping": {...}} |
path is a dotted path into the state ("profile.tags"); "" is the root.
Errors
Every error carries .code and .to_payload() — a structured dict written
for a model to read: stale_write includes the full current snapshot;
handle_expired is distinguished from handle_not_found and says when it
expired and what the TTL was.
Design notes
Read docs/concepts.md for the full conceptual story: the relay-baton model, why hand-off (not CRDTs) is the right v1 for agent systems, the conflict ladder, and the honest limits.
Roadmap
Deliberately out of v1, in rough order:
- Append-only changelog and
changes_since(handle, version)— richer resume UX and the substrate for op-based merging. - Advisory activity leases — "another session is working on this right now."
- Merge hooks / CRDTs behind the same handle API — true concurrent sync.
- Push via MCP resource subscriptions, where client support allows.
- Postgres backend; sidecar service for non-Python servers.
Development
python3 -m pip install -e ".[dev]"
python3 -m pytest
MIT licensed.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mcpstate-0.1.0.tar.gz.
File metadata
- Download URL: mcpstate-0.1.0.tar.gz
- Upload date:
- Size: 242.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1588a7da52f68a8c35645583be4b9eb4cac87bf90c2652dacfe9ce6b3ae35bd6
|
|
| MD5 |
92c32d155b45b7d500cf16f760f086b1
|
|
| BLAKE2b-256 |
2505f0b017ce8139eb28e7c3278426d7068731c9aae2cdc2db83106b583620e0
|
Provenance
The following attestation bundles were made for mcpstate-0.1.0.tar.gz:
Publisher:
publish.yml on varmabudharaju/mcpstate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcpstate-0.1.0.tar.gz -
Subject digest:
1588a7da52f68a8c35645583be4b9eb4cac87bf90c2652dacfe9ce6b3ae35bd6 - Sigstore transparency entry: 2220697620
- Sigstore integration time:
-
Permalink:
varmabudharaju/mcpstate@982220f95bd63e9127d8aaea6c5748e8d0c899e9 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/varmabudharaju
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@982220f95bd63e9127d8aaea6c5748e8d0c899e9 -
Trigger Event:
release
-
Statement type:
File details
Details for the file mcpstate-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mcpstate-0.1.0-py3-none-any.whl
- Upload date:
- Size: 23.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
def026b62990ad03dcc7162227df8c83a00bef55aeba92d9c10faad67705ce24
|
|
| MD5 |
63cace762bab8b126bfc4eac36cb3767
|
|
| BLAKE2b-256 |
8291822e77b08934d593dbebe55d9574d05000594067e1b2c33c82ee7ad278d4
|
Provenance
The following attestation bundles were made for mcpstate-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on varmabudharaju/mcpstate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
mcpstate-0.1.0-py3-none-any.whl -
Subject digest:
def026b62990ad03dcc7162227df8c83a00bef55aeba92d9c10faad67705ce24 - Sigstore transparency entry: 2220698449
- Sigstore integration time:
-
Permalink:
varmabudharaju/mcpstate@982220f95bd63e9127d8aaea6c5748e8d0c899e9 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/varmabudharaju
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@982220f95bd63e9127d8aaea6c5748e8d0c899e9 -
Trigger Event:
release
-
Statement type: