Sessions and state

Use a session when several calls need shared values, such as a user’s current selection or the progress of a workflow. Use request state when a handler needs to preserve a computed value while waiting for the answer to one of its questions. Both are stored through the SessionStore configured on the registry.

In a remote deployment, the next call can reach another process or machine. The receiving worker reads the record from the shared store using the identifier carried by the client. Writes check the record version and retry from fresh data when another worker has updated it. See Stores and hubs for the backend contract.

State

Addressed by

Lifetime and owner

Protocol session and application session values

HTTP session ID where supported, or an explicit application handle

Shared across calls until expiry or deletion

Explicit question state

Request-state identifier returned with the question

Managed by the library for the follow-up call

Handler locals and resolved Python dependencies

The current Exchange

Local to one invocation; not saved in the store

The application chooses what values to put in a session or pass as question state. Ordinary business records can stay in your application’s database; they do not need to be copied into an MCP session.

Values that outlive a call

ex.session is the session this request reaches, where the revision has one.

from pydantic import BaseModel

from aiohttp_tiny_mcp import Client, Exchange, Registry
from aiohttp_tiny_mcp.protocol.selection import AdapterSet

registry = Registry("notes", "1.0")


class Seen(BaseModel):
    what: str


class Nothing(BaseModel):
    pass


@registry.tool
async def remember(args: Seen, ex: Exchange) -> str:
    """Add to what this caller has seen."""
    session = await reach(ex)
    seen = [*session.get("seen", []), args.what]
    await session.set("seen", seen)
    return f"{len(seen)} so far"

reach is written below, because how a session is addressed is the one thing that genuinely differs.

Addressing a session

A revision with a handshake has one already: the server issues Mcp-Session-Id at initialize, the client returns it on every later request, and ex.session is that session.

For 2026-07-28, this library uses explicit application handles rather than protocol sessions. The server creates a handle, returns it in a result, and the caller sends it back as an ordinary argument on later calls.

ex.sessions reaches the same Session object either way:

async def reach(ex: Exchange, handle: str | None = None):
    """The session this call belongs to, on any revision."""
    if ex.session is not None:
        return ex.session  # a handshake opened one
    if handle is not None:
        found = await ex.sessions.use(handle)
        if found is not None:
            return found
    return await ex.sessions.open()  # mints a handle the caller must keep

A tool that serves 2026-07-28 therefore takes the handle as an argument and returns it, exactly as the revision intends:

class Visit(BaseModel):
    what: str
    handle: str | None = None


@registry.tool
async def visit(args: Visit, ex: Exchange) -> dict:
    """Record a visit, and say where to send the next one."""
    session = await reach(ex, args.handle)
    seen = [*session.get("seen", []), args.what]
    await session.set("seen", seen)
    return {"handle": session.id, "seen": seen}


url = await serve(registry)
async with Client(url, AdapterSet.default().by_version["2026-07-28"]) as client:
    await client.initialize()
    first = await client.call_tool("visit", {"what": "a"})
    handle = first.structured_content["handle"]
    second = await client.call_tool("visit", {"what": "b", "handle": handle})

assert second.structured_content["seen"] == ["a", "b"]

On a revision with a handshake the same tool works without the argument, because ex.session is already there:

async with Client(url, AdapterSet.default().by_version["2025-11-25"]) as client:
    await client.initialize()
    await client.call_tool("visit", {"what": "a"})
    second = await client.call_tool("visit", {"what": "b"})

assert second.structured_content["seen"] == ["a", "b"]

Writing safely

Reads answer from the copy loaded with the request. Writes go through compare-and-set: a worker that lost a race re-reads and applies the change again rather than overwriting.

import asyncio

from pydantic import BaseModel

from aiohttp_tiny_mcp import Client, Exchange, Registry
from aiohttp_tiny_mcp.protocol.selection import AdapterSet

registry = Registry("notes", "1.0")


class Bump(BaseModel):
    pass


@registry.tool
async def bump(args: Bump, ex: Exchange) -> int:
    """Increase a counter that several callers share."""
    session = ex.session or await ex.sessions.open()
    await session.update(lambda values: {**values, "n": values.get("n", 0) + 1})
    return session.get("n")


url = await serve(registry)
async with Client(url, AdapterSet.default().by_version["2025-11-25"]) as client:
    await client.initialize()
    await asyncio.gather(*(client.call_tool("bump", {}) for _ in range(10)))
    final = await client.call_tool("bump", {})

assert final.content[0].text == "11"

Pass a function, not a value. It runs again on each attempt against values re-read from the store, which is the difference between retrying and silently putting back what the first read saw.

State between two calls

When a handler asks a question, whatever it leaves in state waits on the server. The client carries only an identifier.

That identifier is unguessable, it is bound to the call it came from – an id issued for purge cache cannot finish purge database – its key carries the namespace, and it is dropped as soon as the round trip ends, so it cannot be replayed.

See the authentication guide for verified namespace selection and session ownership.

import pytest
from pydantic import BaseModel

from aiohttp_tiny_mcp import (
    Client,
    ClientError,
    Exchange,
    NeedInput,
    Registry,
    elicit,
    elicit_accept,
)

registry = Registry("notes", "1.0")
from aiohttp_tiny_mcp.protocol.selection import AdapterSet


class Target(BaseModel):
    name: str


@registry.tool
async def purge(args: Target, ex: Exchange) -> str:
    """Confirm, then act on what was resolved before the question."""
    if ex.answered("confirm"):
        return f"purged {ex.state['resolved']}"
    raise NeedInput(
        {"confirm": elicit(f"Purge {args.name}?")},
        state={"resolved": args.name.upper()},
    )


url = await serve(registry)
adapter = AdapterSet.default().by_version["2026-07-28"]
answered = {"confirm": elicit_accept({})}

async with Client(url, adapter) as client:
    await client.initialize()
    asked = await client.call_tool("purge", {"name": "cache"})
    state = asked["requestState"]

    # An id from one call does not finish another.
    with pytest.raises(ClientError, match="requestState"):
        await client.call_tool(
            "purge", {"name": "database"}, input_responses=answered, request_state=state
        )

    done = await client.call_tool(
        "purge", {"name": "cache"}, input_responses=answered, request_state=state
    )
    assert done.content[0].text == "purged CACHE"

    # Spent once the round trip ended.
    with pytest.raises(ClientError, match="requestState"):
        await client.call_tool(
            "purge", {"name": "cache"}, input_responses=answered, request_state=state
        )

Because the state lives in the store and not in one worker’s memory, the second call may reach a different node than the first. Nothing routes a client back to where it started, and nothing has to.

Lifetimes

Default

Set with

Session

1 hour

Registry(session_ttl_seconds=...)

Request state

10 minutes

Registry(request_state_ttl_seconds=...)

Sessions and request state expire on separate schedules. Set the request-state TTL long enough for the interaction you expect; a follow-up call with an expired state identifier is rejected.

Every request that carries Mcp-Session-Id renews the session, so the TTL is the longest silence a client may keep, not the longest conversation. A request naming a session the store no longer holds – expired, deleted, or lost to a restart of a process-local store – is answered with 404 and a JSON-RPC error, as the specification requires. Client handles that by calling initialize again and repeating the request once.

An open GET notification stream does not renew the TTL after its initial request. A reconnect can therefore receive 404 even when the stream was open before a server restart. A persistent store preserves live sessions across restarts; it does not restore expired records.

Diagnosing session rejection

The aiohttp_tiny_mcp.sessions logger records creation at INFO and rejection at WARNING. Rejections distinguish a missing or expired record from an owner mismatch. The store interface does not distinguish missing records from expired records. DEBUG adds successful TTL renewals without enabling protocol payload logging:

import logging

logging.getLogger("aiohttp_tiny_mcp.sessions").setLevel(logging.DEBUG)

Each entry includes the store class, configured TTL, and two fingerprints: session hashes the client session ID; key hashes the namespaced storage key. Both are the first 16 hexadecimal characters of SHA-256 over the UTF-8 value. Invalid UTF-8 bytes in a session header are preserved when computing the hashes. The raw identifiers, namespace, owner, credentials, and query string are omitted. The same session with a different key indicates a namespace change. Compare creation, renewal, and rejection entries across workers and restarts. A matching store class alone does not prove that workers use the same database.