MCP session ids
A Streamable HTTP MCP session begins atinitialize, which returns an
Mcp-Session-Id header that every later request presents.
Obol’s session ids are opaque: the literal prefix mcs_ followed by a UUID
v7, validated on parse (ADR-0013). The id carries no state. The record lives in
Redis at sess:{session_id}:
The
upstreams field is informational. Upstream MCP sessions are held per
replica by the connection pool and rebuilt on demand, with a 45-second maximum
age so an upstream session never outlives the 60-second service JWT that
authorizes it. Correctness never depends on an upstream session surviving a
replica switch.Why opaque rather than self-describing
An alternative design encrypts the set of upstream session ids into the client-facing id, which removes the shared store. Obol rejected it: rotating the encryption key would invalidate every live session, the id length would grow with the number of upstreams, and workspace binding would have to be layered on top anyway. Redis is already on the hot path for authentication, so the session lookup adds one round trip to a request that was already making one.Expiry
The Redis TTL is the idle timeout,OBOL_SESSION_TTL_S (default 1800 seconds).
Every create, save, and resume rewrites the record and refreshes the TTL. There
is no reaper task. Creation sets the key with the TTL; renewal is a conditional
compare-and-set that never recreates a record that has already expired or been
deleted.
DELETE /mcp removes the session and returns 204. Revoking a key can also
delete its sessions.
Workspace binding
get_or_resume(id, workspace) returns “no such session” for three distinct
cases: a missing id, a malformed id, and a record whose workspace_id differs
from the caller’s. All three render as 404, so a cross-workspace probe is
indistinguishable from a typo. The cross-workspace case is logged as a warning
carrying the workspace id and the session’s last four characters only.
A session store error is 503, never a session-less fallthrough.
Snapshot consumption
The gateway holds no Postgres connection on the request path. Control is the sole writer of Postgres; it compiles per-workspace state and publishes it to Redis, and the gateway hot-reloads (ADR-0006). Two documents make up a publication:- Workspace snapshot at
snap:workspace:{workspace_id}— connections, tools, models, prices, encrypted credentials, environment, frozen flag. - Policy snapshot at
policy:{workspace_id}— the compiled Cedar bundle.
pair once and uses that one
publication for routing and authorization, so a snapshot swap mid-request
cannot route against one revision and authorize against another. Every fallback
model in an LLM request is checked under the same pinned publication as the
primary.
The read path
1
Fresh memory hit
A cached pair less than 30 seconds old is returned directly.
2
Redis revalidation
On a miss or after 30 seconds, one
MGET reads the workspace snapshot, the
policy snapshot, and their shared fingerprint. Only a validated, coherent pair
is installed. A failed revalidation does not extend the previous pair’s
freshness — expired reads fail closed. A missing fingerprint or a partial pair
is an error, not a reason to fall back.3
Control fallback
On a complete Redis miss, the gateway asks control’s internal snapshot API with
a service JWT. An atomic operation creates all three publication keys only if
none exists, publishes an invalidation, and the gateway reads back the
authoritative winning pair. A delayed response never overwrites an existing
publication.
run preloads up to 1000 workspaces by scanning for policy keys.
/readyz requires at least one loaded snapshot plus a successful Redis ping.
Invalidation
Control publishes taggedInvalidation messages on the Redis obol:invalidate
channel. The gateway runs a dedicated subscriber connection that reconnects with
backoff from 100 ms up to 5 s.
Messages are rebroadcast to downstream listeners even when the reload itself
fails, so stale state is dropped either way. Malformed payloads are logged and
discarded. Immediately after subscribing or reconnecting, and every 30 seconds
after that, known workspace ids are reconciled to repair missed notifications.
TTLs remain the fallback when pub/sub is unavailable: a cached
KeyContext
expires after OBOL_KEY_TTL_S (default 60 seconds) regardless.
POST /internal/reload lets control force a reload without waiting for the
channel.
Drain
Shutdown is triggered bySIGTERM or SIGINT and bounded by one
OBOL_DRAIN_TIMEOUT_S deadline (default 30 seconds) covering the whole
sequence:
1
Stop admission
Readiness flips to draining, so
/readyz returns 503 with reason draining
and the load balancer stops sending new connections. /healthz still returns
200 — the process is alive, just not accepting work.2
Await in-flight work
The HTTP servers stop accepting and wait for in-flight connections to finish.
Background tasks — the invalidation subscriber, the metering flusher’s feeders —
are signalled through the same shutdown channel.
3
Reconcile metering
The meter waits for outstanding reservation reconciliations, so a request that
reserved budget settles it rather than leaving an over-reservation behind.
4
Flush the meter queue
The meter’s own shutdown flag flips, the flusher drains its queue, and exits.
partial.
The idempotency TTL is validated at startup to exceed the upstream plus drain
timeouts, so an invocation cannot outlive its own record during a drain.
Related
MCP surface
What runs inside a session: namespacing, filtering, invocation.
Architecture
Where snapshots come from and who writes them.
Invariants
Why Postgres is never on the streaming path.
Metering
What the flusher writes and how usage settles.