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 +42 -0
- mcpstate/backends/__init__.py +3 -0
- mcpstate/backends/base.py +42 -0
- mcpstate/backends/redis.py +120 -0
- mcpstate/backends/sqlite.py +130 -0
- mcpstate/errors.py +71 -0
- mcpstate/fastmcp.py +70 -0
- mcpstate/ops.py +168 -0
- mcpstate/py.typed +0 -0
- mcpstate/server.py +200 -0
- mcpstate/store.py +384 -0
- mcpstate-0.1.0.dist-info/METADATA +293 -0
- mcpstate-0.1.0.dist-info/RECORD +16 -0
- mcpstate-0.1.0.dist-info/WHEEL +4 -0
- mcpstate-0.1.0.dist-info/entry_points.txt +2 -0
- mcpstate-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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
|