Optional Ecto-backed implementation of AttestoMCP.Server.SessionStore.
The adapter is stateless: its store handle contains the host repository,
namespace, and optional PostgreSQL schema prefix. The host application remains
responsible for supervising the repository and applying the migration
that creates attesto_mcp_sessions.
A row is keyed by {namespace, session_id}. The namespace is deliberately
persisted as part of the key so separate named MCP servers can safely use
one table. The complete versioned record is stored in JSON, while its
expiry fields are duplicated in integer columns for indexed, bounded list
and cleanup queries. Records accept only JSON-native values with binary
object keys, so every accepted unknown field survives the JSONB round trip
without atom conversion or key collisions.
Ecto is an optional dependency of this package. Consumers that do not include Ecto do not compile this module; ETS remains the built-in store.
This adapter targets PostgreSQL (Ecto.Adapters.Postgres). It relies on
PostgreSQL row locks for atomic read-modify-write operations and JSONB
encoding for the record column; other Ecto adapters are rejected at
construction time. Lock waits are limited to one second, individual query
calls to 1.5 seconds, and transactions to three seconds so the adapter
returns a neutral outage before the server's call budget is exhausted.
Operations that need an adapter-owned row-locking transaction are
unsupported inside a caller-owned repo.transaction. This includes
updates, record listings, expiry cleanup, and corrupt-row cleanup reached
by a load. The adapter detects that context before opening its own
transaction or changing transaction-local settings and returns
{:error, :nested_transaction_unsupported}; call those operations outside
the host transaction instead.
Malformed or unrepresentable external keys are treated as absent by
load/2, update/3, and update_ttl/3, and as an idempotent success by
delete/2. A valid key bound to another namespace still returns
{:error, :namespace_mismatch}.
The table contains authenticated principal and tenant bindings and is part
of the authorization trust boundary. Give the application role access only
to its intended schema/table, keep direct writers out, and provision enough
Repo connections for normal application traffic plus concurrent MCP calls.
A structurally corrupt row is removed under a row lock during direct load
or record listing instead of blocking every later call. A row with a future
format_version, or a valid atom-bearing binding that this node cannot
decode without creating an atom, is preserved. Future-version rows are also
excluded from expiry maintenance. The adapter emits a bounded
[:attesto_mcp_server, :session_store, :failure] event with outcome
:corrupt_discarded and no row content or identifier.
Configuration
{:ok, store} =
AttestoMCP.Server.SessionStore.Ecto.new(
repo: MyApp.Repo,
namespace: "primary-mcp",
schema_prefix: "mcp"
)
AttestoMCP.Server.start_link(
session_store: {AttestoMCP.Server.SessionStore.Ecto, store},
session_namespace: "primary-mcp"
):repo and :namespace are required. The namespace must be a non-empty
UTF-8 string up to 256 bytes and is bound to the store handle: every key
operation and every list/cleanup query is restricted to that namespace.
When this handle is configured on AttestoMCP.Server, its namespace must
exactly match the server's :session_namespace option; mismatches are
rejected during startup.
:schema_prefix defaults to nil (the repository's default schema);
arbitrary table names are intentionally not accepted, keeping migrations
and runtime queries aligned.
Record-bearing listings return the first eight active rows ordered by expiry and session ID; they are bounded snapshots, not a pagination API. Counting uses a separate SQL aggregate. Cleanup trusts the indexed expiry column, selects only keys, and claims at most 1,000 rows. The server repeats cleanup periodically, so a large expired backlog is drained in bounded batches without loading record payloads. Corruption is detected and reported only when a direct load or record-bearing listing validates the complete record against its indexed expiry mirrors.
Summary
Functions
Builds a stateless store handle for a host repository.