AttestoMCP.Server.SessionStore.Ecto (attesto_mcp_server v0.14.0)

Copy Markdown View Source

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.

Types

store()

@type store() :: %{
  repo: module(),
  namespace: String.t(),
  schema_prefix: String.t() | nil
}

Functions

new(opts \\ [])

@spec new(keyword()) :: {:ok, store()} | {:error, term()}

Builds a stateless store handle for a host repository.