Sessions & SSE Streaming¶
litestar_mcp.agent persists multi-turn conversation history through the pluggable SessionStore protocol (MemorySessionStore and LitestarStoreSessionStore), enforces strict owner isolation against Insecure Direct Object Reference (IDOR) attacks, and streams typed AgentStreamFrame events over Server-Sent Events (SSE) via AgentChatController and QueuedAgentChatController.
SessionStore Protocol & Backends¶
Every conversation transcript is a sequence of AgentMessage (msgspec.Struct) records keyed by (owner, session_id):
from collections.abc import Sequence
from contextlib import AbstractAsyncContextManager
from typing import Protocol
from litestar_mcp.agent import AgentMessage
class SessionStore(Protocol):
async def load(self, owner: str, session_id: str) -> list[AgentMessage] | None: ...
async def save(self, owner: str, session_id: str, messages: Sequence[AgentMessage]) -> None: ...
def lock(self, owner: str, session_id: str) -> AbstractAsyncContextManager[None]: ...
AgentRuntime.produce acquires async with self.sessions.lock(store_owner, sid): before loading history, running the multi-turn model/tool loop, and saving the updated transcript. Concurrent requests targeting the same (owner, session_id) serialize cleanly rather than overwriting each other's turns.
Backend Comparison¶
| Implementation | Storage Backend | Concurrency & Eviction | Best For |
|---|---|---|---|
MemorySessionStore |
Process-local OrderedDict[tuple[str, str], tuple[float, list[AgentMessage]]] |
Per-(owner, session_id) anyio.Lock; LRU capacity eviction (max_sessions=1024) and monotonic TTL expiry (ttl_seconds=3600.0) |
Local development, single-worker processes, and unit tests |
LitestarStoreSessionStore |
Any Litestar Store in StoreRegistry (RedisStore, FileStore, MemoryStore) |
Per-key anyio.Lock; JSON encoding via litestar.serialization.encode_json / decode_json with backend expires_in=ttl_seconds (3600) under {key_prefix}:{owner}:{session_id} |
Multi-replica production deployments sharing a Redis or persistent Litestar Store |
Wiring LitestarStoreSessionStore¶
Pass any Litestar Store instance to LitestarStoreSessionStore (litestar_mcp.agent.sessions.LitestarStoreSessionStore) and supply it to AgentRuntime(sessions=...):
from litestar import Litestar
from litestar.stores.memory import MemoryStore
from litestar_mcp.agent import Agent, AgentChatController, AgentRuntime
from litestar_mcp.agent.sessions import LitestarStoreSessionStore
session_backing_store = MemoryStore()
session_store = LitestarStoreSessionStore(
store=session_backing_store,
ttl_seconds=7200,
key_prefix="litestar_mcp:session",
)
assistant = Agent(name="assistant", instructions="You are a concise technical assistant.")
app = Litestar(
route_handlers=[AgentChatController],
plugins=[AgentRuntime(assistant, sessions=session_store)],
)
Owner Isolation & IDOR Prevention¶
AgentRuntime partitions every session by an authenticated owner key resolved from ToolContext.owner_key():
- Authenticated Principals (
"<tenant_or_default>:<user_id>"): When authentication middleware populatesrequest.userorrequest.auth,ToolContext.owner_key()extracts the principal's identifier (id,sub,username, orpk) and optionaltenant_id(defaulting to"default"). Becausestore_owneris part of the storage lookup key ((owner, session_id)inMemorySessionStoreand{key_prefix}:{owner}:{session_id}inLitestarStoreSessionStore), User A cannot load, append to, or inspect asession_idbelonging to User B—even if User A knows the exact UUID. - Anonymous Callers (
ANONYMOUS_OWNER = "anonymous"): When a request is unauthenticated (turn.context.owner_key() is None),AgentRuntime._resolve_sessionenforces server-minted session identifiers: - If the anonymous caller omits
session_id, the runtime generates a freshuuid4().hex. - If the anonymous caller supplies a
session_id, the runtime checks whether(ANONYMOUS_OWNER, turn.session_id)already exists inSessionStore. If it does not exist, the runtime ignores the client-supplied value and mints a freshuuid4().hex, preventing anonymous callers from pre-seeding predictable session IDs.
HTTP & SSE Streaming with AgentChatController¶
Mounting AgentChatController (litestar_mcp.agent.AgentChatController) on your Litestar application exposes three endpoints under /agent backed by the injected runtime: NamedDependency[AgentRuntime]:
| Endpoint | Request / Response Model | Description |
|---|---|---|
POST /agent/chat |
ChatRequest → ServerSentEvent (text/event-stream) |
Streams live AgentStreamFrame events for the turn with StreamCleanupMiddleware disconnect cleanup and periodic ping frames every 15.0 seconds (ping_interval). |
POST /agent/turns |
ChatRequest → TurnResult (application/json) |
Executes the turn synchronously to completion and returns {session_id, turn_id, agent_name, output, messages, token_usage} (or HTTP 504 on BudgetExceededError). |
GET /agent/sessions/{session_id:str} |
FromPath[str] → SessionHistory (application/json) |
Returns {session_id, messages} for the authenticated owner's session, or raises SessionNotFoundError (HTTP 404) when the session does not exist for that owner. |
ChatRequest (msgspec.Struct, forbid_unknown_fields=True) accepts:
message: str: User prompt text initiating the turn.session_id: str | None = None: Optional existing session ID to resume.dynamic_context: str | None = None: Optional per-turn context prepended to the active agent's system instructions.
Background Streaming & Cursor Replay (QueuedAgentChatController)¶
For long-running agent turns distributed across background workers via litestar-queues (litestar-mcp[queues]), QueuedAgentChatController (litestar_mcp.queues.QueuedAgentChatController) decouples turn submission from SSE consumption:
flowchart LR
Client["HTTP / SSE Client"]
Controller["QueuedAgentChatController"]
Queue["QueuedTurnManager (litestar-queues)"]
Channels["ChannelsPlugin History & Live Stream"]
Client -->|POST /agent/turns| Controller
Controller -->|Enqueue QueuedTurn| Queue
Queue -->|emit_agent_frame| Channels
Client -->|GET /agent/turns/id/stream| Channels
POST /agent/turns(202 Accepted): Captures aPrincipalSnapshot(user_id,tenant,roles,scopes) from the request, enqueuesrun_agent_turnonto the"agent"queue viaQueuedTurnManager, and immediately returns:GET /agent/turns/{turn_id:str}/stream(Live SSE + Replay): Subscribes to the turn's LitestarChannelsPlugintopic (litestar_mcp:agent:{turn_id}) withhistory=1000(replay_limit), replays bufferedAgentStreamFrameevents in monotonicseqorder, deduplicates frames (frame.seq <= last_seq), and terminates automatically when a terminal event (completeorerror) arrives.- SSE
id&Last-Event-IDCursor Recovery: EveryAgentStreamFrameencodes its 1-based sequence counter (frame.seq) as the standard SSEid:field (ServerSentEventMessage(id=str(self.seq), event=self.event_type, data=...)). Reconnecting SSE clients can resume from their last seenseqcursor without duplicate processing.
Typed AgentStreamFrame Events¶
Every SSE message emitted by AgentRuntime, AgentChatController, and QueuedAgentChatController serializes an AgentStreamFrame (litestar_mcp.agent.AgentStreamFrame):
event_type |
Terminal | Populated Fields | Wire Meaning |
|---|---|---|---|
session |
No | turn_id, seq=1, agent_name, payload={"session_id": ..., "turn_id": ...} |
First frame emitted at turn start identifying the resolved session_id and turn_id. |
thought |
No | turn_id, seq, agent_name, text |
Incremental reasoning trace emitted by a thinking model (such as gemini-3.8-flash). |
delta |
No | turn_id, seq, agent_name, text |
Incremental assistant response text chunk. |
tool_call |
No | turn_id, seq, agent_name, tool_name, call_id, payload (arguments dict) |
Model requested a tool invocation. |
tool_result |
No | turn_id, seq, agent_name, tool_name, call_id, payload ({call_id, name, content, is_error}) |
Tool execution completed and returned a result or error dict. |
agent_transfer |
No | turn_id, seq, agent_name (target specialist), payload={"reason": ...} |
Coordinator handed control to a specialist agent in an AgentGroup. |
ping |
No | turn_id, event_type="ping" |
Keepalive frame emitted when no model or tool frame arrives within ping_interval (15.0s). |
complete |
Yes | turn_id, seq, agent_name, payload={"output": ..., "token_usage": ...} |
Turn finished normally; payload contains the full concatenated output and token usage counts. |
error |
Yes | turn_id, seq, agent_name, payload={"error": ...} |
Turn aborted due to BudgetExceededError or an unhandled runtime exception. |
Consuming Streams in Python with iter_agent_stream¶
Use iter_agent_stream (litestar_mcp.agent.iter_agent_stream) with an httpx2.AsyncClient streaming response to decode SSE events directly into typed AgentStreamFrame structs:
import httpx2
from litestar_mcp.agent import iter_agent_stream
async def stream_chat(client: httpx2.AsyncClient, prompt_text: str) -> str:
"""Stream an agent turn and return the final completed output."""
chunks: list[str] = []
async with client.stream(
"POST",
"http://localhost:8000/agent/chat",
json={"message": prompt_text},
) as response:
async for frame in iter_agent_stream(response):
if frame.event_type == "delta" and frame.text:
chunks.append(frame.text)
elif frame.event_type == "complete":
break
return "".join(chunks)