Source code for aiohttp_tiny_mcp.request_state
"""Store handler state between input rounds; clients carry only an opaque id.
Ids are bound to the original call, scoped by namespace, and expire independently of sessions.
Shared storage allows retries on another node.
"""
from __future__ import annotations
import hashlib
import json
import secrets
from dataclasses import dataclass
from typing import Any
from .core import Call, Failure, FailureKind, NeedsInput
from .namespaces import scoped
from .sessions import SessionStore
PREFIX = "state/"
DEFAULT_TTL_SECONDS = 600
BINDING_KEY = "binding"
PAYLOAD_KEY = "payload"
def binding_of(call: Call) -> str:
"""Hash normalized call arguments and target so state cannot be reused for a different call."""
material = json.dumps(
{
"operation": call.operation.value,
"target": call.target,
"arguments": call.arguments,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(material.encode()).hexdigest()
[docs]
@dataclass
class RequestStates:
"""State rows, in the store the deployment already supplies."""
store: SessionStore
ttl_seconds: int = DEFAULT_TTL_SECONDS
def key(self, state_id: str) -> str:
return scoped(PREFIX + state_id)
[docs]
async def open(self, call: Call, payload: Any) -> str:
"""Store payload and return its opaque client-visible id."""
state_id = secrets.token_urlsafe(24)
created = await self.store.create(
self.key(state_id),
{PAYLOAD_KEY: payload, BINDING_KEY: binding_of(call)},
ttl_seconds=self.ttl_seconds,
)
if not created:
raise RuntimeError("the store refused a fresh request-state id")
return state_id
[docs]
async def read(self, call: Call, state_id: str) -> Any:
"""Read the payload; raise KeyError for missing state or a different call binding."""
record = await self.store.get(self.key(state_id))
if record is None or record.data.get(BINDING_KEY) != binding_of(call):
raise KeyError(state_id)
return record.data.get(PAYLOAD_KEY)
async def drop(self, state_id: str) -> None:
await self.store.delete(self.key(state_id))
async def restore_request_state(call: Call, states: RequestStates) -> Failure | None:
"""Replace the id the client sent with the state the handler left."""
if call.state is None:
return None
if not isinstance(call.state, str):
return Failure(FailureKind.INVALID_PARAMS, "invalid requestState")
try:
call.state = await states.read(call, call.state)
except KeyError:
return Failure(FailureKind.INVALID_PARAMS, "invalid requestState")
return None
async def protect_request_state(
call: Call, outcome: NeedsInput, states: RequestStates
) -> NeedsInput | Failure:
"""Keep the handler's state and hand the client its id instead."""
if outcome.state is None:
return outcome
try:
state_id = await states.open(call, outcome.state)
except (RuntimeError, TypeError) as e:
return Failure(FailureKind.INTERNAL, f"requestState could not be stored: {e}")
return NeedsInput(requests=outcome.requests, state=state_id)
__all__ = [
"DEFAULT_TTL_SECONDS",
"RequestStates",
"binding_of",
"protect_request_state",
"restore_request_state",
]