mcpstate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcpstate/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """mcpstate — durable, user-keyed state for stateless MCP servers.
2
+
3
+ State that follows the user, not the session.
4
+ """
5
+ from .errors import (
6
+ BackendError,
7
+ HandleExpired,
8
+ HandleNotFound,
9
+ McpStateError,
10
+ PatchError,
11
+ StaleWrite,
12
+ StateTooLarge,
13
+ Unauthenticated,
14
+ InternalError,
15
+ )
16
+ from .ops import Append, DelKey, Merge, PatchOp, SetKey, apply_ops, op_from_dict
17
+ from .store import HandleInfo, HandleStore, Snapshot
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "HandleStore",
23
+ "Snapshot",
24
+ "HandleInfo",
25
+ "McpStateError",
26
+ "HandleNotFound",
27
+ "HandleExpired",
28
+ "StaleWrite",
29
+ "PatchError",
30
+ "StateTooLarge",
31
+ "Unauthenticated",
32
+ "InternalError",
33
+ "BackendError",
34
+ "Append",
35
+ "SetKey",
36
+ "DelKey",
37
+ "Merge",
38
+ "PatchOp",
39
+ "apply_ops",
40
+ "op_from_dict",
41
+ "__version__",
42
+ ]
@@ -0,0 +1,3 @@
1
+ from .base import Backend, Record
2
+
3
+ __all__ = ["Backend", "Record"]
@@ -0,0 +1,42 @@
1
+ """Storage protocol. Six methods; implement them and any store works.
2
+
3
+ Backends persist opaque records keyed by ``(user, handle)``. All conflict
4
+ semantics live in the single primitive ``cas_put`` — an atomic write that
5
+ succeeds only when the stored version still matches ``expected_version``.
6
+ Expiry is not a backend concern; the store applies TTL logic on top.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Protocol
12
+
13
+
14
+ @dataclass
15
+ class Record:
16
+ kind: str
17
+ state: dict
18
+ version: int
19
+ created_at: float
20
+ updated_at: float
21
+ expires_at: float | None
22
+ last_writer: str | None
23
+
24
+
25
+ class Backend(Protocol):
26
+ def get(self, user: str, handle: str) -> Record | None:
27
+ """Return the record, or None if absent."""
28
+
29
+ def put_new(self, user: str, handle: str, record: Record) -> bool:
30
+ """Create the record. False (and no write) if the handle already exists."""
31
+
32
+ def cas_put(self, user: str, handle: str, expected_version: int, record: Record) -> bool:
33
+ """Atomically replace the record iff its version equals expected_version."""
34
+
35
+ def delete(self, user: str, handle: str) -> bool:
36
+ """Remove the record. False if it was absent."""
37
+
38
+ def list(self, user: str) -> list[tuple[str, Record]]:
39
+ """All of the user's (handle, record) pairs, most recently updated first."""
40
+
41
+ def close(self) -> None:
42
+ """Release resources."""
@@ -0,0 +1,120 @@
1
+ """Redis backend — shared reach.
2
+
3
+ Point multiple server instances (or the same server on several machines) at
4
+ one Redis and state follows the user across all of them: this is the backend
5
+ that turns hand-off into cross-device sync. CAS uses an optimistic
6
+ WATCH/MULTI transaction so it works on any Redis-compatible server.
7
+
8
+ The module never imports the ``redis`` package — behavior comes entirely from
9
+ the injected client, so the core library stays dependency-free and tests can
10
+ use fakeredis.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any
16
+ from urllib.parse import quote
17
+
18
+ from .base import Record
19
+
20
+ _PREFIX = "mcpstate"
21
+
22
+
23
+ def _q(part: str) -> str:
24
+ # Percent-encode so a user or handle containing ':' (an OAuth sub like
25
+ # "org:alice") cannot collide with the key layout.
26
+ return quote(part, safe="")
27
+
28
+
29
+ # Records and indexes live under disjoint prefixes ("h" vs "i") so no username
30
+ # — even the literal "index" — can ever land in the index keyspace.
31
+ def _key(user: str, handle: str) -> str:
32
+ return f"{_PREFIX}:h:{_q(user)}:{_q(handle)}"
33
+
34
+
35
+ def _index(user: str) -> str:
36
+ return f"{_PREFIX}:i:{_q(user)}"
37
+
38
+
39
+ def _dump(record: Record) -> str:
40
+ return json.dumps(
41
+ {
42
+ "kind": record.kind,
43
+ "state": record.state,
44
+ "version": record.version,
45
+ "created_at": record.created_at,
46
+ "updated_at": record.updated_at,
47
+ "expires_at": record.expires_at,
48
+ "last_writer": record.last_writer,
49
+ }
50
+ )
51
+
52
+
53
+ def _load(raw: bytes | str) -> Record:
54
+ d = json.loads(raw)
55
+ return Record(
56
+ kind=d["kind"],
57
+ state=d["state"],
58
+ version=d["version"],
59
+ created_at=d["created_at"],
60
+ updated_at=d["updated_at"],
61
+ expires_at=d["expires_at"],
62
+ last_writer=d["last_writer"],
63
+ )
64
+
65
+
66
+ class RedisBackend:
67
+ def __init__(self, client: Any) -> None:
68
+ self._r = client
69
+
70
+ def get(self, user: str, handle: str) -> Record | None:
71
+ raw = self._r.get(_key(user, handle))
72
+ return _load(raw) if raw is not None else None
73
+
74
+ def put_new(self, user: str, handle: str, record: Record) -> bool:
75
+ created = bool(self._r.set(_key(user, handle), _dump(record), nx=True))
76
+ if created:
77
+ self._r.sadd(_index(user), handle)
78
+ return created
79
+
80
+ def cas_put(self, user: str, handle: str, expected_version: int, record: Record) -> bool:
81
+ key = _key(user, handle)
82
+ with self._r.pipeline() as pipe:
83
+ try:
84
+ pipe.watch(key)
85
+ raw = pipe.get(key)
86
+ if raw is None or _load(raw).version != expected_version:
87
+ pipe.unwatch()
88
+ return False
89
+ pipe.multi()
90
+ pipe.set(key, _dump(record))
91
+ pipe.execute()
92
+ return True
93
+ except Exception as exc:
94
+ # WatchError means a concurrent write landed first: the CAS loses.
95
+ # Caught by name so this module imports without the redis package.
96
+ if type(exc).__name__ == "WatchError":
97
+ return False
98
+ raise
99
+
100
+ def delete(self, user: str, handle: str) -> bool:
101
+ removed = self._r.delete(_key(user, handle)) == 1
102
+ self._r.srem(_index(user), handle)
103
+ return removed
104
+
105
+ def list(self, user: str) -> list[tuple[str, Record]]:
106
+ handles = [
107
+ h.decode() if isinstance(h, bytes) else h for h in self._r.smembers(_index(user))
108
+ ]
109
+ out = []
110
+ for handle in handles:
111
+ raw = self._r.get(_key(user, handle))
112
+ if raw is not None:
113
+ out.append((handle, _load(raw)))
114
+ out.sort(key=lambda pair: pair[1].updated_at, reverse=True)
115
+ return out
116
+
117
+ def close(self) -> None:
118
+ close = getattr(self._r, "close", None)
119
+ if close:
120
+ close()
@@ -0,0 +1,130 @@
1
+ """SQLite backend — the zero-config default.
2
+
3
+ Covers cross-conversation and cross-client continuity on one machine. The
4
+ compare-and-swap is a single ``UPDATE ... WHERE version = ?`` statement, which
5
+ SQLite executes atomically; a lock serializes connection access across threads.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ import threading
12
+ from pathlib import Path
13
+
14
+ from .base import Record
15
+
16
+ _SCHEMA = """
17
+ CREATE TABLE IF NOT EXISTS handles (
18
+ user TEXT NOT NULL,
19
+ handle TEXT NOT NULL,
20
+ kind TEXT NOT NULL,
21
+ state TEXT NOT NULL,
22
+ version INTEGER NOT NULL,
23
+ created_at REAL NOT NULL,
24
+ updated_at REAL NOT NULL,
25
+ expires_at REAL,
26
+ last_writer TEXT,
27
+ PRIMARY KEY (user, handle)
28
+ )
29
+ """
30
+
31
+
32
+ class SQLiteBackend:
33
+ def __init__(self, path: str) -> None:
34
+ if path != ":memory:":
35
+ p = Path(path).expanduser()
36
+ p.parent.mkdir(parents=True, exist_ok=True)
37
+ path = str(p)
38
+ self._lock = threading.Lock()
39
+ # timeout doubles as the busy handler: a second process writing the same
40
+ # DB (the cross-client axis) waits up to 10s instead of erroring.
41
+ self._conn = sqlite3.connect(path, check_same_thread=False, timeout=10.0)
42
+ with self._lock:
43
+ self._conn.execute("PRAGMA journal_mode=WAL")
44
+ self._conn.execute(_SCHEMA)
45
+ self._conn.commit()
46
+
47
+ def _row_to_record(self, row: tuple) -> Record:
48
+ kind, state, version, created_at, updated_at, expires_at, last_writer = row
49
+ return Record(
50
+ kind=kind,
51
+ state=json.loads(state),
52
+ version=version,
53
+ created_at=created_at,
54
+ updated_at=updated_at,
55
+ expires_at=expires_at,
56
+ last_writer=last_writer,
57
+ )
58
+
59
+ def get(self, user: str, handle: str) -> Record | None:
60
+ with self._lock:
61
+ row = self._conn.execute(
62
+ "SELECT kind, state, version, created_at, updated_at, expires_at, last_writer "
63
+ "FROM handles WHERE user = ? AND handle = ?",
64
+ (user, handle),
65
+ ).fetchone()
66
+ return self._row_to_record(row) if row else None
67
+
68
+ def put_new(self, user: str, handle: str, record: Record) -> bool:
69
+ with self._lock:
70
+ cur = self._conn.execute(
71
+ "INSERT OR IGNORE INTO handles "
72
+ "(user, handle, kind, state, version, created_at, updated_at, expires_at, last_writer) "
73
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
74
+ (
75
+ user,
76
+ handle,
77
+ record.kind,
78
+ json.dumps(record.state),
79
+ record.version,
80
+ record.created_at,
81
+ record.updated_at,
82
+ record.expires_at,
83
+ record.last_writer,
84
+ ),
85
+ )
86
+ self._conn.commit()
87
+ return cur.rowcount == 1
88
+
89
+ def cas_put(self, user: str, handle: str, expected_version: int, record: Record) -> bool:
90
+ with self._lock:
91
+ cur = self._conn.execute(
92
+ "UPDATE handles SET kind = ?, state = ?, version = ?, created_at = ?, "
93
+ "updated_at = ?, expires_at = ?, last_writer = ? "
94
+ "WHERE user = ? AND handle = ? AND version = ?",
95
+ (
96
+ record.kind,
97
+ json.dumps(record.state),
98
+ record.version,
99
+ record.created_at,
100
+ record.updated_at,
101
+ record.expires_at,
102
+ record.last_writer,
103
+ user,
104
+ handle,
105
+ expected_version,
106
+ ),
107
+ )
108
+ self._conn.commit()
109
+ return cur.rowcount == 1
110
+
111
+ def delete(self, user: str, handle: str) -> bool:
112
+ with self._lock:
113
+ cur = self._conn.execute(
114
+ "DELETE FROM handles WHERE user = ? AND handle = ?", (user, handle)
115
+ )
116
+ self._conn.commit()
117
+ return cur.rowcount == 1
118
+
119
+ def list(self, user: str) -> list[tuple[str, Record]]:
120
+ with self._lock:
121
+ rows = self._conn.execute(
122
+ "SELECT handle, kind, state, version, created_at, updated_at, expires_at, last_writer "
123
+ "FROM handles WHERE user = ? ORDER BY updated_at DESC",
124
+ (user,),
125
+ ).fetchall()
126
+ return [(row[0], self._row_to_record(row[1:])) for row in rows]
127
+
128
+ def close(self) -> None:
129
+ with self._lock:
130
+ self._conn.close()
mcpstate/errors.py ADDED
@@ -0,0 +1,71 @@
1
+ """Structured, agent-legible errors.
2
+
3
+ Every error renders to a JSON payload a model can read and act on: a stable
4
+ ``code``, a message written as an instruction, and the details needed to
5
+ recover (for example, the current state inside a stale-write rejection).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class McpStateError(Exception):
13
+ """Base class for all runtime state errors."""
14
+
15
+ code = "mcpstate_error"
16
+
17
+ def __init__(self, message: str, **details: Any) -> None:
18
+ super().__init__(message)
19
+ self.details = details
20
+
21
+ def to_payload(self) -> dict[str, Any]:
22
+ """Render as a structured payload suitable for a tool result."""
23
+ return {"code": self.code, "message": str(self), **self.details}
24
+
25
+
26
+ class HandleNotFound(McpStateError):
27
+ """The handle does not exist for this user (never minted, or revoked)."""
28
+
29
+ code = "handle_not_found"
30
+
31
+
32
+ class HandleExpired(McpStateError):
33
+ """The handle existed but its TTL has elapsed."""
34
+
35
+ code = "handle_expired"
36
+
37
+
38
+ class StaleWrite(McpStateError):
39
+ """A versioned save lost a race; details carry the current snapshot."""
40
+
41
+ code = "stale_write"
42
+
43
+
44
+ class PatchError(McpStateError):
45
+ """A patch op could not be applied to the state's shape."""
46
+
47
+ code = "patch_error"
48
+
49
+
50
+ class StateTooLarge(McpStateError):
51
+ """The state exceeds the store's configured size limit."""
52
+
53
+ code = "state_too_large"
54
+
55
+
56
+ class Unauthenticated(McpStateError):
57
+ """No caller identity could be resolved on a transport that requires one."""
58
+
59
+ code = "unauthenticated"
60
+
61
+
62
+ class InternalError(McpStateError):
63
+ """An unexpected failure; message is scrubbed of sensitive detail."""
64
+
65
+ code = "internal_error"
66
+
67
+
68
+ class BackendError(McpStateError):
69
+ """The storage backend failed or is misconfigured."""
70
+
71
+ code = "backend_error"
mcpstate/fastmcp.py ADDED
@@ -0,0 +1,70 @@
1
+ """FastMCP integration helpers. Optional — the core library never imports FastMCP."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ from .errors import Unauthenticated
7
+ from .store import HandleStore
8
+
9
+
10
+ def store_from_env() -> HandleStore:
11
+ """Build a HandleStore from MCPSTATE_BACKEND (default: local SQLite)."""
12
+ return HandleStore.from_url(os.environ.get("MCPSTATE_BACKEND"))
13
+
14
+
15
+ def current_writer() -> str:
16
+ """A label for who is writing, shown as last_writer in freshness metadata.
17
+
18
+ MCPSTATE_WRITER env (e.g. "laptop/claude-code") -> hostname fallback, so
19
+ cross-device hand-offs are attributable out of the box.
20
+ """
21
+ writer = os.environ.get("MCPSTATE_WRITER")
22
+ if writer:
23
+ return writer
24
+ import socket
25
+
26
+ return socket.gethostname()
27
+
28
+
29
+ def _oauth_subject() -> str | None:
30
+ """The authenticated caller's identity, issuer-scoped, or None."""
31
+ try:
32
+ from fastmcp.server.dependencies import get_access_token
33
+
34
+ token = get_access_token()
35
+ if token is None:
36
+ return None
37
+ claims = getattr(token, "claims", None) or {}
38
+ sub = claims.get("sub")
39
+ if sub:
40
+ issuer = claims.get("iss")
41
+ # Scope by issuer: an OAuth `sub` is only unique within its issuer,
42
+ # so two IdPs can mint the same sub for different humans.
43
+ return f"{issuer}#{sub}" if issuer else str(sub)
44
+ client_id = getattr(token, "client_id", None)
45
+ if client_id:
46
+ return f"client:{client_id}"
47
+ except Exception:
48
+ return None
49
+ return None
50
+
51
+
52
+ def current_user(require_auth: bool = False) -> str:
53
+ """Resolve the state-scoping user for the current request.
54
+
55
+ Order: OAuth subject (issuer-scoped, when the server runs with FastMCP
56
+ auth) -> MCPSTATE_USER env -> "local" (single-user stdio servers).
57
+
58
+ When ``require_auth`` is set (the server does this on the HTTP transport),
59
+ an unresolvable identity raises :class:`Unauthenticated` instead of falling
60
+ back to the shared "local" bucket — a security boundary must fail closed.
61
+ """
62
+ subject = _oauth_subject()
63
+ if subject:
64
+ return subject
65
+ if require_auth:
66
+ raise Unauthenticated(
67
+ "No authenticated caller could be resolved. This server requires auth "
68
+ "on the HTTP transport so that state stays isolated per user."
69
+ )
70
+ return os.environ.get("MCPSTATE_USER", "local")
mcpstate/ops.py ADDED
@@ -0,0 +1,168 @@
1
+ """Commutative patch operations.
2
+
3
+ Patches are applied without version checks: adding an item, setting a key, or
4
+ merging a mapping commutes with the same operations from another session, so
5
+ two devices patching concurrently both land. Only full-state saves need the
6
+ version protocol.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ from dataclasses import dataclass
12
+ from typing import Any, Sequence, Union
13
+
14
+ from .errors import PatchError
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Append:
19
+ """Append ``value`` to the list at ``path``."""
20
+
21
+ path: str
22
+ value: Any
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SetKey:
27
+ """Set ``key`` to ``value`` in the object at ``path``."""
28
+
29
+ path: str
30
+ key: str
31
+ value: Any
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class DelKey:
36
+ """Remove ``key`` from the object at ``path`` (no-op if absent)."""
37
+
38
+ path: str
39
+ key: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Merge:
44
+ """Shallow-merge ``mapping`` into the state root."""
45
+
46
+ mapping: dict
47
+
48
+
49
+ PatchOp = Union[Append, SetKey, DelKey, Merge]
50
+
51
+
52
+ def _resolve(state: dict, path: str, op_name: str) -> Any:
53
+ node: Any = state
54
+ if path == "":
55
+ return node
56
+ for part in path.split("."):
57
+ if not isinstance(node, dict) or part not in node:
58
+ raise PatchError(
59
+ f"Path '{path}' does not exist in the state; cannot apply {op_name}. "
60
+ "Load the current state to see its shape.",
61
+ op=op_name,
62
+ path=path,
63
+ reason="path_not_found",
64
+ )
65
+ node = node[part]
66
+ return node
67
+
68
+
69
+ def _apply_one(state: dict, op: PatchOp) -> None:
70
+ if isinstance(op, Append):
71
+ target = _resolve(state, op.path, "append")
72
+ if not isinstance(target, list):
73
+ raise PatchError(
74
+ f"Path '{op.path}' holds a {type(target).__name__}, but append needs a list.",
75
+ op="append",
76
+ path=op.path,
77
+ reason="expected_list",
78
+ )
79
+ target.append(op.value)
80
+ elif isinstance(op, SetKey):
81
+ target = _resolve(state, op.path, "set_key")
82
+ if not isinstance(target, dict):
83
+ raise PatchError(
84
+ f"Path '{op.path}' holds a {type(target).__name__}, but set_key needs an object.",
85
+ op="set_key",
86
+ path=op.path,
87
+ reason="expected_object",
88
+ )
89
+ target[op.key] = op.value
90
+ elif isinstance(op, DelKey):
91
+ target = _resolve(state, op.path, "del_key")
92
+ if not isinstance(target, dict):
93
+ raise PatchError(
94
+ f"Path '{op.path}' holds a {type(target).__name__}, but del_key needs an object.",
95
+ op="del_key",
96
+ path=op.path,
97
+ reason="expected_object",
98
+ )
99
+ target.pop(op.key, None)
100
+ elif isinstance(op, Merge):
101
+ if not isinstance(op.mapping, dict):
102
+ raise PatchError(
103
+ f"merge needs an object mapping, got {type(op.mapping).__name__}.",
104
+ op="merge",
105
+ reason="expected_object",
106
+ )
107
+ state.update(op.mapping)
108
+ else: # pragma: no cover - defensive
109
+ raise PatchError(f"Unknown op {op!r}", op=str(op), reason="unknown_op")
110
+
111
+
112
+ def get_path(state: dict, path: str) -> Any:
113
+ """Select the subtree at a dotted ``path`` (``""`` returns the whole state)."""
114
+ return _resolve(state, path, "load")
115
+
116
+
117
+ def apply_ops(state: dict, ops: Sequence[PatchOp]) -> dict:
118
+ """Return a new state with every op applied. The input is never mutated."""
119
+ out = copy.deepcopy(state)
120
+ for op in ops:
121
+ _apply_one(out, op)
122
+ return out
123
+
124
+
125
+ def _require(value: object, typ: tuple, field: str, op_name: str) -> None:
126
+ if not isinstance(value, typ) or isinstance(value, bool) and bool not in typ:
127
+ names = " or ".join(t.__name__ for t in typ)
128
+ raise PatchError(
129
+ f"Op '{op_name}' field '{field}' must be {names}, got {type(value).__name__}.",
130
+ op=op_name,
131
+ reason="bad_field_type",
132
+ )
133
+
134
+
135
+ def op_from_dict(d: dict) -> PatchOp:
136
+ """Parse the wire form used by the flagship server's state_patch tool."""
137
+ if not isinstance(d, dict):
138
+ raise PatchError(
139
+ f"Each patch op must be an object, got {type(d).__name__}.",
140
+ reason="not_an_object",
141
+ )
142
+ kind = d.get("op")
143
+ try:
144
+ if kind == "append":
145
+ _require(d["path"], (str,), "path", "append")
146
+ return Append(d["path"], d["value"])
147
+ if kind == "set_key":
148
+ _require(d["path"], (str,), "path", "set_key")
149
+ _require(d["key"], (str,), "key", "set_key")
150
+ return SetKey(d["path"], d["key"], d["value"])
151
+ if kind == "del_key":
152
+ _require(d["path"], (str,), "path", "del_key")
153
+ _require(d["key"], (str,), "key", "del_key")
154
+ return DelKey(d["path"], d["key"])
155
+ if kind == "merge":
156
+ _require(d["mapping"], (dict,), "mapping", "merge")
157
+ return Merge(d["mapping"])
158
+ except KeyError as missing:
159
+ raise PatchError(
160
+ f"Op '{kind}' is missing required field {missing}.",
161
+ op=str(kind),
162
+ reason="missing_field",
163
+ ) from None
164
+ raise PatchError(
165
+ f"Unknown op '{kind}'. Supported: append, set_key, del_key, merge.",
166
+ op=str(kind),
167
+ reason="unknown_op",
168
+ )
mcpstate/py.typed ADDED
File without changes