Source code for aiohttp_tiny_mcp.server.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 aiohttp_tiny_mcp.protocol.core import Call, Failure, FailureKind, NeedsInput
from aiohttp_tiny_mcp.storage.namespaces import scoped
from aiohttp_tiny_mcp.storage.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",
]