This is the detailed configuration and behavior reference. For a first working endpoint, start with the setup guide: it covers prerequisites, transport choice, authentication, and the first client request. The README gives the shorter installation path, and the local Livebook runs a complete authenticated HTTP demonstration with an ephemeral issuer. For an existing catalog or deployment, use the migration runbook.
Use these sections after the basic endpoint works:
| Task | Reference |
|---|---|
| Integrate a Phoenix host or a manual HTTP adapter | Phoenix installation, Bandit, resource metadata |
| Register tools, prompts, and resources; test handlers | Registration, focused tests |
| Apply business authorization and scope policy | Mount authorization, per-definition authorization, scope policy |
| Add progress, subscriptions, or interactive input | Notifications and logging, subscriptions and interactive requests |
| Configure storage, clustering, and request limits | Startup and durable sessions, limits |
| Implement a wire client or select a protocol revision | HTTP mirror headers, stdio, version compatibility |
| Monitor a deployed endpoint | Telemetry, trace context |
The additive 2.4 options are grouped under
request metadata, guidance, and cache hints:
read client input through context.request_meta, provide
caller-specific instructions_provider or
tool_presentation, configure
cache_policy, add server_icons,
or enable export_schema_dialect.
Phoenix installation
This section assumes a Phoenix application with Igniter available and an
already configured attesto_phoenix issuer that can issue access tokens and
load allowed principals. Run installer commands inside the Phoenix application
child, rather than an umbrella root. If these prerequisites are not ready,
follow the setup guide first.
attesto_mcp_server owns the protected-resource protocol boundary. In a host
that also uses attesto_phoenix, the latter remains the authorization server:
it owns issuer, consent, token, refresh, revocation, and sender-constrained
credential behavior. The installer reuses both its validated core
Attesto.Config and its public protected-resource adapter for DPoP
replay/nonce, canonical-request, and mTLS certificate callbacks. The resulting
MCP boundary also applies the host's access-token JTI revocation check and
principal loader before reading the request body; it does not duplicate or
reconfigure those responsibilities. There is no hard dependency from this
package to attesto_phoenix.
Use attesto_phoenix 3.x or 4.x when one Phoenix host serves multiple named profiles.
It binds each automatic protected-resource callback to the named profile
loaded from the otp_app passed to the helper, keeping those profiles
isolated. It does not select a profile from conn.private. The 2.14
compatibility path supports one globally configured profile; using it for
multiple profiles can route persistent replay or revocation reads through that
global profile's store.
These bindings cover the DPoP, mTLS, URL, revocation, and principal callbacks
listed above. Token signature verification uses the keystore carried by the
derived Attesto.Config; each named profile must therefore configure a
keystore that is safe to invoke without Phoenix request-local configuration.
On this automatic path, every authenticated token subject must resolve through
the host's load_principal callback. A revoked JTI, an unresolved subject, or
a callback failure denies the request with a neutral invalid-token response.
Handlers receive that complete loaded value as context.principal and
context.attesto_mcp_principal.
By default, the loaded principal also remains the identity used for ownership,
session and subscription isolation, cursors, and rate/concurrency accounting.
For a loaded struct or any value with fields that can differ between loads,
configure the Plug's principal_binding callback. It receives the loaded
principal and returns the stable identity term directly:
forward "/mcp", AttestoMCP.Server.Plug,
server: MyApp.MCP,
path: "/mcp",
auth: {AttestoMCP.Server.Phoenix, :protected_resource_options, [:my_app]},
principal_binding: {MyApp.MCPIdentity, :binding},
resource: "/mcp",
base_url: "https://mcp.example.com"
defmodule MyApp.MCPIdentity do
def binding(%MyApp.Accounts.User{id: id}), do: {:user, id}
endAnonymous one-argument functions and {module, function, prefix_arguments}
MFAs are also supported. The return is not an {:ok, value} wrapper: it is the
binding itself. It must be non-nil, portable, and at most 64 KiB in its encoded
form. Prefer a small opaque identifier without profile data or other PII.
Invalid returns and callback failures stop the request with a generic internal
error before a POST body is read. The derived value is available to handlers as
context.principal_binding; the complete principal is not replaced.
The durable record format remains version 1 and retains its existing
"principal" field, which stores the binding. Existing 0.14 records therefore
need no database migration and continue to decode. With principal_binding
omitted, their comparison behavior is unchanged. Enabling a new binding on a
mount fails closed for an existing row unless that row's stored principal is
equal to the newly derived binding. Drain those session-bound clients or allow
their sessions to expire and reconnect when changing this policy; modern
session-free clients are unaffected.
With attesto_phoenix already declared directly by the host, run:
mix igniter.install attesto_mcp_server --base-url https://mcp.example.com
When exactly one host Ecto Repo is statically confirmed to use PostgreSQL and to be supervised as a literal application child, the installer wires the bundled session store and orders the MCP child after that Repo. Run the exact generator command printed in the installer notice, followed by the host's normal migration command:
mix attesto_mcp_server.gen.migration --repo MyApp.Repo
mix ecto.migrate
The installer and generator only write source; neither changes the database.
With no Repo, the in-memory ETS default remains in use. With multiple Repos, the
installer refuses to choose until --repo MyApp.Repo is supplied. Use
--session-store ets to retain in-memory sessions deliberately, or
--schema-prefix my_schema with Ecto for a validated PostgreSQL schema. If the
sole Repo is not statically confirmed as a supervised PostgreSQL application
child, automatic selection keeps the in-memory ETS default, adds a notice, and
emits no Ecto session configuration or migration guidance. Explicit
--session-store ecto and/or --repo MyApp.Repo choices remain fail-closed
until PostgreSQL is statically proven.
Static discovery reads regular .exs files below config/ without following
symlinks. Conflicting environment-specific Repo or session-store declarations
are treated as ambiguous; make the selection consistent or pass an explicit
supported choice rather than relying on file order.
Whether or not CIMD is enabled, the installer sets
native_apps.loopback_include_localhost to true only when that key is absent.
This lets a registered portless http://localhost/... native-app callback use
the ephemeral port chosen by the client. An application's existing true or
false choice remains authoritative.
On this combined path the installer does not enable Client ID Metadata Documents
(CIMD) by default. This avoids silently selecting AttestoPhoenix's default
Ecto-backed CIMD cache when an established host has not run the required
attesto_client_id_metadata migration. If the host has verified its CIMD
storage, opt in explicitly:
mix igniter.install attesto_mcp_server \
--base-url https://mcp.example.com \
--enable-cimd
The explicit opt-in adds the compatible Req dependency and, when the corresponding CIMD key is absent, enables it. Together, the relevant defaults are:
config :my_app, AttestoPhoenix.Config,
client_id_metadata: [enabled: true],
native_apps: [loopback_include_localhost: true]The CIMD setting supports clients that use an HTTPS Client ID Metadata
Document. The default AttestoPhoenix fetcher retains its HTTPS, DNS/IP, size,
timeout, redirect, and cache validation. Deployments with a known client set
should add a narrow :allowed_hosts list. Existing configuration is not
replaced, and the installer does not enable dynamic registration or invent
client persistence. Existing cache, repo, table-prefix, allowlist, and disabled
settings remain authoritative; a custom cache module does not require an Ecto
table. Review the generated notice and host migration status before enabling
CIMD.
This path supports direct public-Hex attesto_phoenix requirements that
overlap >= 2.14.1 and < 5.0.0 and Req requirements that overlap
>= 0.6.1 and < 1.0.0. Existing stable requirements are narrowed to their
intersection, which must contain at least one stable release; pre-release-only
matches are intentionally rejected. The task accepts only the public packages
with safe runtime options; an ambiguous dependency catalog or a dynamic,
duplicate, incompatible, restricted, custom-source, or unsupported declaration
makes installation stop before any edit. The explicit-callback path below
remains dependency-neutral.
For an Attesto host without attesto_phoenix, name a zero-arity callback that
returns the verifier configuration:
mix igniter.install attesto_mcp_server \
--base-url https://mcp.example.com \
--attesto-config MyApp.Attesto.config/0
If the dependency is already present, the package task can be called directly:
mix attesto_mcp_server.install \
--base-url https://mcp.example.com \
--attesto-config MyApp.Attesto.config/0
The task requires a canonical public origin rather than inferring one from a request. HTTPS is mandatory except for an explicitly enabled loopback origin:
mix attesto_mcp_server.install \
--base-url http://127.0.0.1:4000 \
--allow-http-loopback \
--attesto-config MyApp.Attesto.config/0
It creates an application-owned <App>.MCP process, adds it to the application
supervisor, adds conservative server_options in config/config.exs, creates
a starter registration test, and mounts two top-level router forwards in this
order. The generated forwards contain server, path, auth, resource, and
base_url. A host that issues workspace.mcp can then add the
scopes_supported and default_scopes lines shown below. The generated
metadata wrapper keeps the two forwarded plug modules distinct for Phoenix 1.7
compatibility. On the automatic AttestoPhoenix path, the exact
protected-resource options are resolved after the server, request header
budget, and HTTP method checks on every protected MCP request. Public metadata
requests resolve the same current options independently:
Elixir.Phoenix.Router.forward(
"/.well-known/oauth-protected-resource/mcp",
Elixir.MyApp.MCP.MetadataPlug,
server: Elixir.MyApp.MCP,
path: "/mcp",
scopes_supported: ["workspace.mcp"],
auth: {Elixir.AttestoMCP.Server.Phoenix, :protected_resource_options, [:my_app]},
resource: "/mcp",
base_url: "https://mcp.example.com"
)
Elixir.Phoenix.Router.forward(
"/mcp",
Elixir.AttestoMCP.Server.Plug,
server: Elixir.MyApp.MCP,
path: "/mcp",
scopes_supported: ["workspace.mcp"],
default_scopes: ["workspace.mcp"],
auth: {Elixir.AttestoMCP.Server.Phoenix, :protected_resource_options, [:my_app]},
resource: "/mcp",
base_url: "https://mcp.example.com"
)protected_resource_options/2 can transform or reject the loaded principal
without replacing the automatic revocation and load_principal path. Its
:principal callback runs only after both steps succeed and receives the
loaded principal, verified claims, and sender information. It must return
{:ok, non_nil_principal} or {:error, reason}:
auth:
{AttestoMCP.Server.Phoenix, :protected_resource_options,
[
:my_app,
[principal: {MyApp.MCPPrincipal, :finalize}]
]}
defmodule MyApp.MCPPrincipal do
def finalize(loaded_principal, claims, _sender) do
{:ok,
%{
account: loaded_principal,
client_id: claims["client_id"]
}}
end
endThe function and MFA forms are strict three-argument callbacks; an MFA with
prefix arguments receives those first. A malformed return or exception denies
authentication. Use the Plug-level authorize option below for mount access
policy that should produce a neutral 403 rather than an invalid-token response.
The explicit --attesto-config path instead keeps the static
auth: [config: &Elixir.MyApp.MCP.attesto_config/0, resource: "/mcp", base_url: "https://mcp.example.com"] form. Such hosts remain responsible for
supplying every replay, nonce, canonical-request, and certificate callback
their sender-constraint policy needs, plus any application-specific
:principal callback that performs principal availability or access-token
revocation checks.
The generated metadata wrapper and MCP forward may both receive
scopes_supported: ["..."]; use the grants that the authorization server can
actually issue for that resource. A reused attesto_routes metadata endpoint
remains owned by AttestoPhoenix, so configure its protected-resource metadata
there. default_scopes: ["..."] separately replaces generic MCP operation
defaults unless a non-empty method entry in scope_map takes precedence.
Keep both forwards outside browser-session and CSRF pipelines. The metadata
route is intentionally public; the MCP route authenticates every protected
leg. The installer inspects the selected Phoenix endpoint. For a direct,
standard Plug.Parsers declaration it wraps that parser with
AttestoMCP.Server.PhoenixParser, which bypasses body parsing for the configured
decoded path prefix, its route-equivalent trailing and repeated slashes, and
every deeper child path, while leaving metadata, browser, JSON, and form routes
outside that prefix unchanged. This is a path-prefix safety boundary, not
proof of router ownership: ordinary routes below the configured prefix also
bypass body parsing, so do not overlap the prefix with unrelated routes.
Custom or ambiguous parser setups make the installer stop before editing any
project file and report the required remediation. Fix
the ambiguity or wire the parser and routes manually before deployment, then
rerun the installer if appropriate. When the endpoint cannot be inferred from
the selected router, or its source cannot be found, the installer warns and
continues without an endpoint edit; the host must then verify the parser
pipeline manually. A statically proven simple endpoint with no direct parser
also produces an informational warning and continues, leaving bounded body
decoding to the MCP Plug; that case needs no immediate endpoint edit. Any
host-owned parser added or discovered later must skip the configured decoded
MCP path prefix and every deeper child path, and use a body-length limit at
least as strict as the MCP Plug's :max_body_bytes, because host parsing
otherwise occurs before MCP authentication. Do not place unrelated routes below
that prefix. Run the task again safely after an interrupted install:
generated modules, configuration, supervision, routes, and tests are
idempotent. Use Igniter's
global --dry-run option to inspect the edits first. If the router cannot be
selected uniquely, pass --router MyAppWeb.Router; the task refuses ambiguous
router selection and prints an exact manual snippet if no router exists.
Additional options are --mcp-path, --server-module, --router,
--attesto-config, --enable-cimd, --reuse-metadata-route,
--session-store, --repo, and --schema-prefix. Run the task inside the Phoenix child application rather
than at an umbrella root. The generated server_status tool is deliberately a
small starter; replace it with application-specific registrations and scopes.
When --reuse-metadata-route is selected, the task requires exactly one
supported zero-option use AttestoPhoenix.Router followed by the literal
attesto_routes(protected_resource_paths: ["/mcp"]) invocation (with the
selected path substituted). It preserves that invocation and inserts only the
MCP forward immediately after it. It does not infer paths from dynamic,
parameterized, glob, scoped, ambiguous, duplicate, or mismatched routes; if
exact equivalence cannot be proven, it stops before editing and prints the
exact manual MCP forward required. --mcp-path must be a non-root ASCII path whose nonempty segments use only
URI-unreserved letters, digits, ., _, ~, and -. The same canonical
path grammar is enforced by the runtime Plug so an encoded client path cannot
silently differ from the configured resource. The generated MCP module owns a
dedicated source file; move unrelated modules or file-level compiler directives
elsewhere before rerunning the installer. A selected Phoenix router must
likewise be the only top-level module in its file so inherited aliases and
imports cannot redirect its routing DSL. Installation also stops when an
existing exact, parameterized, glob, resource, or forwarded route could overlap
either generated mount; resolve the conflict or mount the two forwards manually
at an intentional precedence point.
Three named MCP servers in one Phoenix host
The installer owns one generated mount. A host with several MCP resources can
wire them manually. Give every server a distinct registered name and session
namespace. The bundled Ecto adapter then keeps all sessions in the same
attesto_mcp_sessions table while its {namespace, session_id} key prevents a
session issued at one mount from being accepted at another:
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyApp.Repo,
mcp_child(MyApp.MCP.Catalog, "mcp-catalog", MyApp.MCP.Catalog.registrations()),
mcp_child(MyApp.MCP.Operations, "mcp-operations", MyApp.MCP.Operations.registrations()),
mcp_child(MyApp.MCP.Preview, "mcp-preview", MyApp.MCP.Preview.registrations())
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
defp mcp_child(name, namespace, registrations) do
{:ok, store} =
AttestoMCP.Server.SessionStore.Ecto.new(
repo: MyApp.Repo,
namespace: namespace
)
{AttestoMCP.Server,
name: name,
registrations: registrations,
session_store: {AttestoMCP.Server.SessionStore.Ecto, store},
session_namespace: namespace}
end
endReplace the endpoint's ordinary Plug.Parsers declaration with the wrapper,
retaining the host's parser options. One wrapper accepts up to 32 unique static
MCP paths. It matches each decoded configured path as a segment prefix and
bypasses the prefix, trailing/repeated-slash equivalents, and every deeper child
path; parent paths and sibling segment prefixes still run the host parser.
Because this is a prefix-based body-parsing boundary, ordinary routes below a
configured prefix also bypass parsing and must not overlap it:
plug AttestoMCP.Server.PhoenixParser,
mcp_path: ["/mcp/catalog", "/mcp/operations", "/mcp/preview"],
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()Publish one path-specific protected-resource metadata document and one
protected forward per server. root: false avoids assigning the ambiguous
unsuffixed metadata document to any of the three resources:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use AttestoMCP.Router
scope "/" do
attesto_mcp_protected_resource_metadata "/mcp/catalog",
scopes: ["catalog.mcp"],
base_url: "https://mcp.example.com",
issuer: "https://auth.example.com",
root: false
attesto_mcp_protected_resource_metadata "/mcp/operations",
scopes: ["operations.mcp"],
base_url: "https://mcp.example.com",
issuer: "https://auth.example.com",
root: false
attesto_mcp_protected_resource_metadata "/mcp/preview",
scopes: ["preview.mcp"],
base_url: "https://mcp.example.com",
issuer: "https://auth.example.com",
root: false
end
forward "/mcp/catalog", AttestoMCP.Server.Plug,
server: MyApp.MCP.Catalog,
path: "/mcp/catalog",
scopes_supported: ["catalog.mcp"],
default_scopes: ["catalog.mcp"],
auth: {AttestoMCP.Server.Phoenix, :protected_resource_options, [:my_app]},
resource: "/mcp/catalog",
base_url: "https://mcp.example.com"
forward "/mcp/operations", AttestoMCP.Server.Plug,
server: MyApp.MCP.Operations,
path: "/mcp/operations",
scopes_supported: ["operations.mcp"],
default_scopes: ["operations.mcp"],
auth: {AttestoMCP.Server.Phoenix, :protected_resource_options, [:my_app]},
resource: "/mcp/operations",
base_url: "https://mcp.example.com"
forward "/mcp/preview", AttestoMCP.Server.Plug,
server: MyApp.MCP.Preview,
path: "/mcp/preview",
scopes_supported: ["preview.mcp"],
default_scopes: ["preview.mcp"],
auth: {AttestoMCP.Server.Phoenix, :protected_resource_options, [:my_app]},
resource: "/mcp/preview",
base_url: "https://mcp.example.com"
endKeep these routes outside browser-session and CSRF pipelines. Each metadata
path, Plug :path, protected resource, public origin, advertised scopes, and
server registration must agree for that mount.
Bandit development server
The HTTP boundary is a normal Plug; Phoenix is optional. For a complete local example that creates an issuer, registers a tool, obtains a token, and makes authenticated requests, run the Livebook. The setup guide also covers manual HTTP hosting.
Inside an existing Mix application, declare the HTTP server as a host dependency:
defp deps do
[
{:attesto_mcp_server, "~> 2.8"},
{:bandit, "~> 1.6"}
]
endStart the supervised MCP server before its Bandit adapter, register the
application's catalog, and pass the host's configured Attesto.Config to the
Plug's auth options. The token issuer and verifier must agree on the signing
keys, issuer, canonical MCP resource audience, principal kinds, and scopes.
Bandit does not provide an OAuth authorization server. See
resource metadata for the verifier contract
and the first-request example for the HTTP
envelope after obtaining a suitable token.
From a repository checkout, elixir examples/bandit.exs is an executable
credential-free listener. It registers an echo tool but starts with an empty
static keystore, so protected requests return 401 until the host supplies a
matching issuer configuration and token. It is a transport demonstration,
rather than the authenticated onboarding example.
Frozen conformance fixture
The package includes an authenticated, package-owned Bandit fixture under
test/conformance_fixture_test.exs. It registers representative tools,
resources, a URI template, prompts, and completion, then exercises real TCP
HTTP tool listing, tool calling, and prompt retrieval with an Attesto test
token. Run the fixture independently for each frozen requirements set:
MCP_REQUIREMENTS=2026-07-28 MIX_ENV=test mix test test/conformance_fixture_test.exs --seed 0
MCP_REQUIREMENTS=2025-11-25 MIX_ENV=test mix test test/conformance_fixture_test.exs --seed 0
These are authenticated fixture preflights, not a substitute for the pinned official conformance runner. They do not count disabled Tasks scenarios and do not claim full conformance.
Use TLS at the deployment edge, pin base_url/origin when a reverse proxy
terminates TLS, and configure trusted proxy normalization before the Plug.
Attesto and resource metadata
The auth options enter the approved AttestoMCP.Plug.ProtectResource boundary
before body decoding on every protected POST/GET/DELETE leg. Its prepared
dynamic authorization step receives the route/filter scope union after bounded
POST decoding; Attesto owns token, DPoP, mTLS, RFC 9728 challenges, and scope
algebra. Configure an
issuer/resource verifier, resource: "/mcp" (or a canonical resource
identifier), resource_metadata_url only when explicitly pinned, and DPoP
replay/nonce plus mTLS DER callbacks when used by the deployment. Hosts using
the explicit callback path can also provide a :principal callback for
principal availability and access-token revocation policy. The metadata
endpoint is public; protected POST/GET/DELETE traffic is not.
When a trusted reverse gateway substitutes its own RFC 8707 resource identifier,
keep :resource pinned to the canonical public endpoint and configure the exact
accepted identifiers through Attesto core's :trusted_audiences policy:
auth: [
config: &MyApp.MCP.attesto_config/0,
resource: "https://mcp.example.com/mcp",
trusted_audiences: [
"https://mcp.example.com/mcp",
"https://gateway.example.net/proxy/mcp"
]
]The canonical resource continues to drive protected-resource metadata and authentication challenges. The explicit list replaces single-resource audience verification, including subscription reauthorization, so include the canonical identifier when direct clients should remain valid. Every unlisted audience is rejected.
Static :auth options may select distinct, non-nil, non-boolean atom keys for
:claims_key, :context_key, :principal_key, :scopes_key, and
:sender_key, provided a custom key does not reuse a different package-owned
canonical key. These configured slots and their canonical aliases belong to
the authentication boundary: it clears them before verification and
repopulates them only from the verified result. Do not use those assign names
for unrelated upstream state.
For advanced runtime integration, :auth may be an external zero-arity
function or an MFA whose arguments are portable compile-time literals. The
resolver must return a keyword list. It may supply the canonical absolute
:resource/:resource_audience or :base_url/:origin after runtime
configuration has loaded. The value is validated on every applicable request:
its path must exactly match the mounted Plug path, paired resource and origin
values must agree, and only an http or https URL without user information,
query, or fragment is accepted. The same resolved identifier drives RFC 9728
metadata and token-audience verification. A static top-level pin remains
supported and cannot conflict with a resolver result. Runtime results cannot
replace the mounted resource path or canonical assign keys, and cannot enable
non-header bearer-token locations. Resolution failure produces a generic 500
response. allow_dynamic_origin remains only for explicitly local development;
do not derive a production audience from an untrusted request Host header.
The package requires attesto_mcp >= 1.3.0 and < 3.0.0 and calls the public
ProtectResource.prepare/1, authenticate/2, and authorize/3 contract
directly. There is no automatic fallback to an older authentication boundary.
Per-delivery subscription reauthorization requires an executable
%Attesto.Config{} through auth: [config: ...]. It re-verifies the captured
access token's validity, audience, sender binding, applicable scopes, and any
matched resource definition scopes before modern or session-bound
subscription-notification delivery.
Comparisons with the opening token actor, principal binding, and tenant preserve
the authenticated stream's ownership snapshot. When :principal is configured,
the server invokes it again with the freshly verified claims and the established
sender context before each matching event. The Phoenix integration therefore
rechecks token revocation and reloads the principal for every delivery. Keep
those lookups indexed and bounded; rejection, an invalid return, or a callback
failure suppresses the event. Mount authorization still runs only when the
stream is authenticated. An issuer:
without a verifier configuration is
metadata-only: it may serve RFC 9728 metadata, but protected traffic fails
closed until the host supplies an executable Attesto configuration.
Handler notifications and logging
The handler context contains notify/1 for bounded server-originated
notifications. It returns :ok only when the request's response sink accepted
the notification, or {:error, reason} when the sink is unavailable, the event
is unsupported, the event is over the request queue limit, or policy filters it.
The event must be a JSON-safe JSON-RPC notification with no id, result, or
error; request methods such as sampling/createMessage are available only
through the capability-gated client_request/2 callback. context.progress/3
is the corresponding progress helper and reports delivery failure rather than
claiming success without a sink.
An HTTP handler has a notification sink only when its request uses an SSE
response. Configure stream_tools or stream_all_tools, or supply a progress
token for a request that emits progress. A JSON response has no side channel,
so notify/1 returns {:error, :unsupported} there. For modern
notifications/message, the server must also be started with
capabilities: %{"logging" => %{}} and the request must include a recognized
_meta["io.modelcontextprotocol/logLevel"]; otherwise logging is deliberately
suppressed.
Modern requests may include _meta["io.modelcontextprotocol/logLevel"] with a
recognized syslog severity. notifications/message is suppressed when that
metadata is absent and is delivered only at or above the requested threshold
on the owning response stream. Sessions for earlier revisions start with a conservative
no-log policy and logging/setLevel changes that threshold for that session
only. Logging notifications contain a recognized level, optional bounded
logger, and bounded JSON data; arbitrary protocol envelopes, secrets, and
client requests are rejected.
Registration
Register tools, resources, URI templates, prompts, and completions before
serving traffic. Identity collisions return {:error, {:duplicate, type, identity}}. Registration rejects unsafe names/URIs/templates, malformed
handlers, unsupported JSON Schema dialects/remote references, and schemas
outside the bounded local 2020-12/draft-07 validator. Anchors, local dynamic
references, tuple items, unevaluated items, and content annotations are
validated without network fetches. Registry output is stable
by identity and pagination cursors are opaque, signed, expiring, and bound to
the principal, tenant, scopes, visible catalog content, page size, and
negotiated era.
Resource templates use bounded reverse matching for an RFC 6570 subset. A
template may contain up to 16 separated named or reserved path expressions and
32 globally unique variables, including prefix modifiers such as {id:3}.
Adjacent path expressions remain rejected because their capture boundary is
ambiguous. A single query expression supports {?q,limit} and {?keys*}.
Values are strictly percent-decoded and bounded; simple variables reject raw or
encoded path separators, and every variable rejects traversal. Unsupported or
ambiguous operator layouts are rejected at registration rather than accepted
with nonfunctional matching. A matching resources/read handler receives both the
requested uri and a params map of captured variables. Completion handlers
should register an explicit ref matching the prompt or resource-template
reference; only that handler is invoked, and returned string values preserve
the handler's relevance order, are de-duplicated, and are capped at 100 with
truthful total/hasMore metadata.
Callback inputs are deliberately explicit and primitive-specific:
alias AttestoMCP.Server.{Content, Result}
# Tool
handler: fn %{"left" => left, "right" => right}, context ->
total = left + right
{:ok,
Result.tool_from_context(Content.text("total: #{total}"), context,
structured_content: %{"total" => total}
)}
end
# Prompt whose definition declares "topic" as required
handler: fn %{name: "review", arguments: %{"topic" => topic}}, _context ->
{:ok, [Content.prompt_message(:user, Content.text(topic))]}
end
# Resource or resource template
handler: fn %{uri: uri, params: template_params}, _context ->
{:ok,
Result.resource(
Content.resource_text(uri, inspect(template_params), mime_type: "text/plain")
)}
end
# Completion
handler: fn %{value: value}, _context ->
{:ok, [value]}
endDeclared outer envelope fields use atom keys. A resource MRTR retry also
places its string-keyed input-response entries at the envelope's top level.
Nested MCP arguments, references, and completion context retain their JSON
string keys. For optional prompt arguments, read the nested map with Map.get/2
instead of requiring the key in the callback head. An arity-2 callback's second
argument is the authenticated request context; a completion input's :context
is the separate client-supplied completion context. Arity-1 and MFA handler
forms are also accepted.
Tool handlers receive string-keyed arguments by default. Applications porting handlers that already use atom keys may opt in at server startup:
server_options = [
tool_argument_keys: :atoms,
output_canonicalization: :jason
]:atoms is applied only after the original string-keyed input has passed its
registered JSON Schema. It converts literal keys found under properties only
when the corresponding atom already exists in the VM; no call creates an atom.
Undeclared keys and declared names without an existing atom remain strings.
The conversion follows nested literal properties, array items, and
prefixItems. Keys available only through references, combinators, or
conditional branches remain strings so ambiguous schema paths never broaden
the conversion. The default tool_argument_keys: :strings preserves the wire
map exactly.
Tool input and structured output enforce recognized JSON Schema formats by
default. Set schema_formats: false in the server options to treat format
as an annotation for these schemas. Elicitation URLs and form responses always
enforce their formats. Direct Schema.validate/3 callers can select annotation
semantics with formats: false; its default remains assertion semantics.
Tool output content, prompt messages, and resource contents are checked before
they reach the wire. Supported content includes text, Base64 image/audio,
resource links, embedded resources, and structured tool output. Malformed
handler output is converted to a safe protocol failure or isError tool
result; business and upstream failures remain ordinary MCP error results.
AttestoMCP.Server.Content constructs individual content and prompt values;
AttestoMCP.Server.Result.tool/1,2 and resource/1,2 construct complete
results. Inside an arity-2 tool handler, prefer
AttestoMCP.Server.Result.tool_from_context/2,3. It inherits the supervised
server's JSON budget and output canonicalization settings, so shared adapters
do not have to repeat those options. Its inherited settings cannot be
overridden through that constructor; use standalone Result.tool/2 for a
deliberate per-result override. The constructors emit canonical string-key
maps, apply the same bounded validation as the wire path, and reject unknown or
duplicate options. Image, audio, and blob arguments must already be canonical
padded Base64. Valid handwritten maps remain supported for extensions.
The server rechecks the complete result after adding resultType, cache
metadata, and server identity. A constructor value at the standalone ceiling
can therefore be refused when server-owned fields would make the final result
too large. Results for session-bound revisions receive the same final aggregate check.
Structured tool output is strict by default: non-boolean atom values and
structs are rejected, while atom map keys continue to canonicalize to strings.
output_canonicalization: :json opts into JSON.Encoder; :jason opts into
Jason.Encoder. In either mode, non-boolean atom values become strings and a
struct is encoded only through the selected protocol, decoded back to ordinary
JSON values, and then checked normally. Encoder input, encoded iodata, decoded
nodes, depth, and final JSON bytes all remain bounded by the server's configured
limits. Derive encoders with an explicit :only field list when a struct can
contain private application data.
Raw handler values automatically use the server setting. When constructing a complete tool result inside the handler, use its context so the constructor applies the same setting and byte budget:
Result.tool_from_context(Content.text("loaded"), context,
structured_content: record
)Result.tool/1,2 retain their standalone :strict and 2,000,000-byte defaults
for code that has no handler context.
If canonicalization fails, the client still receives only a generic failure. A
configured trusted exception_reporter additionally receives a bounded,
value-free diagnostic identifying the first JSON Pointer path and rejection
category, with safe value and encoder categories where applicable. When a
public result constructor detects the failure inside the handler, its
ArgumentError message carries the same bounded path and category. The
rejected value is never included in either report or Telemetry metadata.
AttestoMCP.Server.Schema.validate/2 treats JSON Schema default as an
annotation and never changes the original request. A handler may explicitly
call Schema.apply_property_defaults/2 for optional direct properties:
schema = %{
"type" => "object",
"properties" => %{
"format" => %{"type" => "string", "default" => "summary"}
}
}
handler: fn arguments, context ->
with {:ok, arguments} <- AttestoMCP.Server.Schema.apply_property_defaults(arguments, schema) do
{:ok,
AttestoMCP.Server.Result.tool_from_context(
AttestoMCP.Server.Content.text(arguments["format"]),
context
)}
end
endThe helper applies at most 500 defaults, never replaces a present value, and
validates the completed object. It follows only literal properties, recursing
into existing objects or an object supplied by an explicit property default;
it does not infer values through references, combinators, conditionals, array
items, or pattern properties. Dispatch never invokes it automatically, and a
required property must pass the registered input schema before the handler can
run.
For deliberately client-visible business failures, return:
{:error, AttestoMCP.Server.Result.error("account is read-only", "account_read_only")}The message and optional code are length-bounded and validated as UTF-8. Tool
calls receive an isError result, with a supplied code at
_meta["io.attesto/errorCode"] so it cannot violate a declared output
schema. Prompt and resource calls receive a JSON-RPC application error with
HTTP 200. Arbitrary terms, oversized strings, callback failures, and exceptions
remain generic.
HTTP hosts may derive application context after authentication:
context_builder: fn conn ->
%{request_id: Plug.Conn.get_req_header(conn, "x-request-id") |> List.first()}
endThe map is exposed only as context.host_context. Returning anything else or
raising prevents handler invocation.
Mount authorization
Use the HTTP Plug's authorize option for one business-policy gate covering an
entire mount:
forward "/mcp/admin", AttestoMCP.Server.Plug,
server: MyApp.AdminMCP,
path: "/mcp/admin",
auth: {AttestoMCP.Server.Phoenix, :protected_resource_options, [:my_app]},
principal_binding: {MyApp.MCPIdentity, :binding},
authorize: {MyApp.MCPPolicy, :admin_mount?},
resource: "/mcp/admin",
base_url: "https://mcp.example.com"
defmodule MyApp.MCPPolicy do
def admin_mount?(context), do: context.principal.role == :admin
endThe callback accepts a one-argument function, {module, function}, or
{module, function, prefix_arguments}; prefix arguments precede the context.
It receives the authenticated base context, including the complete principal,
stable principal binding, tenant, scopes, claims, sender information, and
host_context when a context_builder is configured. Only literal true
permits access. Every other return and every raise, throw, or exit produces the
same plain 403 response without a WWW-Authenticate challenge or policy
details.
This gate runs once on each authenticated POST, GET, and DELETE leg, before a
POST body is read and before protocol dispatch. Invalid credentials still stop
at authentication and never invoke it. Because the request body has not been
classified yet, the callback does not receive an MCP method or its arguments;
use scope_map, scope_policy, and per-definition authorization for those
decisions. When both context_builder and mount authorization are configured,
the built context is cached and reused for dispatch, so each callback runs only
once. Metadata discovery is public and does not run the mount policy.
For streams and subscriptions, this policy governs the authenticated opening leg. Delivery reauthorization continues to check the captured binding, tenant, sender, and scopes; a later business-policy change takes effect on the next request or reconnect.
Focused tool tests
Use AttestoMCP.Server.Test.call_tool/4 for a per-tool test that still goes
through protocol dispatch. It checks the server's operation scopes, the
tool's scope clauses and authorize callback, its input schema, the handler,
its output schema, and final wire-output validation. The return value is the
complete JSON-RPC response map, so success and neutral denial/error results can
be asserted without constructing an internal request:
response =
AttestoMCP.Server.Test.call_tool(
MyApp.MCP,
"get_item",
%{"id" => "item-7"},
request_id: "get-item-test",
principal: %{id: "user-7"},
scopes: ["items.read"],
host_context: %{account_id: "acct-1"}
)
assert %{
"id" => "get-item-test",
"result" => %{
"resultType" => "complete",
"structuredContent" => %{"id" => "item-7"}
}
} = responseThe helper JSON-encodes and decodes its request before dispatch, matching a
client's nested string keys before any configured tool-argument key policy.
It defaults to protocol revision 2026-07-28, principal "test-principal",
and no scopes; :protocol_version, :client_capabilities, and :timeout are
available for focused variants.
The principal, tenant, scopes, and host context options stand in for a request that has already authenticated. This helper does not exercise token, DPoP, mTLS, HTTP header, parser-order, or mount-policy checks. Keep Plug-level tests for that boundary.
The same request builder backs read_resource/3, get_prompt/4,
complete/4, discover/2, list_tools/2, list_resources/2,
list_resource_templates/2, list_prompts/2, and the generic request/4:
alias AttestoMCP.Server.Test, as: MCPTest
assert %{"result" => %{"contents" => [_]}} =
MCPTest.read_resource(MyApp.MCP, "file:///items/item-7", scopes: ["items.read"])
page = MCPTest.list_tools(MyApp.MCP, scopes: ["items.read"])
next = MCPTest.list_tools(MyApp.MCP, scopes: ["items.read"], cursor: page["result"]["nextCursor"])
retry_options = [
client_capabilities: %{"elicitation" => %{}},
meta: %{"com.example/request-purpose" => "test"}
]
assert %{
"result" => %{
"resultType" => "input_required",
"requestState" => state,
"inputRequests" => requests
}
} = MCPTest.call_tool(MyApp.MCP, "confirm", %{}, retry_options)
# This example's tool asks for one form containing a boolean "ok" property.
[input_key] = Map.keys(requests)
MCPTest.call_tool(
MyApp.MCP,
"confirm",
%{},
retry_options ++
[
request_id: 2,
request_state: state,
input_responses: %{input_key => %{"action" => "accept", "content" => %{"ok" => true}}}
]
):meta supplies application metadata; the helper generates the protocol
version and client-capability keys and raises if :meta sets them.
:request_state and :input_responses send modern retry fields, :cursor
continues a list, and :completion_context sets the completion request's
context. Every helper returns the complete JSON-RPC response, including
protocol errors. Malformed setup raises ArgumentError.
Keep the method, selected definition, original arguments, application metadata,
and authenticated identity unchanged across retry rounds. Use a new JSON-RPC
ID and echo the returned request state. Trace, progress, and client display
metadata may change between rounds. Copy answer keys from inputRequests,
rather than assuming they equal a tool name.
These helpers test the dispatcher: scopes, definition authorization, schemas, handlers, cache hints, and wire validation. They do not establish HTTP header/body mirroring, session-bound negotiation, stdio framing, or disconnect behaviour; keep Plug and stdio tests for those transport paths.
Per-definition authorization
Every tool, resource, resource template, prompt, and completion definition may
include an authorize callback. It accepts any of these forms:
authorize: fn context -> context.principal == "operator" end
authorize: {MyApp.MCPPolicy, :allowed?}
authorize: {MyApp.MCPPolicy, :allowed?, [:reports]}The callback receives the authenticated base context from which the handler
context is built. Handlers additionally receive primitive_type and
primitive_identity; authorize does not, so encode definition-specific input
in the MFA prefix arguments when a shared policy module needs it. For the MFA
forms, the base context is appended after those prefix arguments. On HTTP, it
includes the package-owned principal, tenant, scopes, claims, and sender data;
when context_builder is configured, its result is available as
context.host_context too.
This callback controls all catalog results (tools/list, resources/list,
resources/templates/list, and prompts/list) and all definition lookups
(tools/call, resources/read, prompts/get, and
completion/complete). A literal true permits the definition. false, any
other return value, or a raise, throw, or exit denies it. Catalog methods omit
a denied definition. Lookup methods return the same method-specific unknown
result as an unregistered definition, including across modern and session-bound
protocol revisions, and never invoke its handler. Callback failures are
intentionally converted to denial and are not sent to exception_reporter.
For HTTP, authentication and the effective transport scope check occur before
protocol dispatch. Definition required_scopes are then checked before
authorize; if those scopes are missing, the callback is not invoked. For the
five methods explicitly mapped by an opt-in scope_policy, that policy also
has to permit the definition and runs before the local required_scopes and
authorize checks. Use required_scopes and scope_policy for grantable
Attesto scopes. Use authorize for host business rules that need the
authenticated context or context.host_context. When they are combined, every
applicable check must permit access.
alternative_scope_sets adds bounded alternatives to the primary all-of
required_scopes clause. Any one complete clause permits the definition; a
partial clause never does. For example:
required_scopes: ["documents.read"],
alternative_scope_sets: [
["documents.admin"],
["workspace.read", "documents.execute"]
]The primary clause must be non-empty when alternatives are present. Each
alternative is non-empty and duplicate-free, and clauses that differ only by
member order are duplicates. Registration accepts at most seven alternatives,
128 total scope memberships across all clauses, 8,192 aggregate scope bytes,
and 256 bytes per scope. Catalog filtering, selected lookups, templates,
subscription scope snapshots, and both protocol eras use the same clauses.
authorize is called once only after a clause succeeds. RFC 9728
scopes_supported remains host-configured, so advertise the meaningful narrow
scope clients should normally request rather than automatically exposing every
alternative.
Subscriptions are outside this callback contract. Establishment does not
consult a resource definition's authorize, and subscription-notification
delivery reauthorization checks the captured identity, sender binding,
applicable scopes, any matched resource definition scopes, and the configured
authentication :principal callback. It does not invoke a definition's
authorize callback, so do not rely on that callback to suppress subscription
updates.
Limits and scope policy
max_concurrency, per_principal_concurrency, request_timeout,
max_request_timeout, max_queue, stream_keepalive_ms,
stream_queue_size, max_sessions_per_principal, and
max_legacy_streams_per_session are the bounded server limits. The two
session-bound defaults are 128 active sessions per principal and eight live
streams per session and server process. A principal is the authenticated
subject or, when absent, the client identifier. Hosts where one client identity
fronts many users should raise max_sessions_per_principal to match expected
concurrency. subscription_queue_size
may override max_queue for modern subscriptions; otherwise all stream and
subscription queues use max_queue. A Plug option overrides the corresponding
server option for that adapter. The server scope_map is the default policy;
an explicit Plug scope_map replaces it for HTTP, and the effective map is
used both by the prepared AttestoMCP authorization boundary and by protocol dispatch.
There is no second implicit scope source. Non-empty entries override method
defaults; absent or empty entries use default_scopes when configured and
otherwise retain the fail-closed HTTP defaults. For
completion/complete, one explicit method entry governs both prompt and
resource references; without it, default_scopes applies when configured and
otherwise each reference uses its category read scope.
The map accepts only the request methods implemented by this release:
server/discover, initialize, ping, logging/setLevel, tools/list,
tools/call, resources/list, resources/templates/list, resources/read,
resources/subscribe, resources/unsubscribe, prompts/list, prompts/get,
completion/complete, and subscriptions/listen. Notification names, task
methods, atom keys, and arbitrary extension methods are rejected. Each method
has at most 128 unique scopes, each scope is at most 256 bytes, and per-method
and aggregate byte limits are enforced during initialization.
max_json_bytes bounds JSON Schema instances, handler-result normalization,
final response validation, and JSON-RPC encoding. It defaults to 2,000,000
bytes and accepts explicit finite values from 512 through 64,000,000 bytes.
The minimum leaves room for a bounded protocol error.
max_body_bytes bounds the HTTP body and max_message_bytes bounds JSON-RPC
decoding (and the complete stdio frame). HTTP limits must be positive; the
stdio frame limit must be at least 512 bytes. None may exceed the supervised
server's max_json_bytes value. The nominal HTTP defaults
remain 2,000,000 body bytes and 1,000,000 message bytes; omitted values are
automatically capped by a smaller selected JSON budget. Explicit transport
limits above that budget fail before body reading. Configure all three together
when opting into a larger payload. The stdio adapter defaults to the smaller of
64,000 bytes and the supervised server's JSON budget unless
max_message_bytes is supplied explicitly.
Result.tool_from_context/2,3 inherits the selected budget for the complete
tool-result envelope. Individual Content constructors validate before that
envelope is built, and Result.resource/1,2 remains a standalone constructor;
pass max_json_bytes: selected_budget to either when the value itself exceeds
the secure 2,000,000-byte constructor default.
For hosts that issue definition scopes instead of generic method grants, add an
explicit Plug-only scope_policy:
scope_policy: %{
"tools/list" => :visible_definitions,
"tools/call" => :selected_definition,
"resources/list" => :visible_definitions,
"resources/templates/list" => :visible_definitions,
"resources/read" => :selected_definition
}Visible-definition methods filter catalogs through each definition's required
scopes and authorization callback. Selected-definition methods resolve the
requested tool, exact static resource, or first matching URI template before
authorizing that definition through the prepared Attesto boundary; denial is
the normal neutral unknown result and cannot invoke a handler or consume an
MRTR retry state. The policy is opt-in and only supports the five methods
shown. Omitted policy retains generic method scopes, empty scope_map entries
retain their documented defaults, and a policy method cannot overlap
scope_map even with an empty list. An explicit Plug scope_map replaces the
server map; the effective map is checked again at request time for named or
restarted servers. A definition with required_scopes: [] is authenticated-only
only on an explicitly policy-mapped method; defaults and empty-map methods keep
requiring their generic scope.
Subscriptions are deliberately outside this policy map. Modern
subscriptions/listen opening uses configured default_scopes instead of
generic category scopes when no non-empty method entry exists. A non-empty
scope_map["subscriptions/listen"] entry is additive to the generic category
scopes, and subscription_scopes are always additive. Modern delivery
reauthorizes the captured principal and tenant with that opening union plus its
notification requirements; resource updates also require the generic resource
scope and any matched definition scopes. Session-bound subscription-notification
delivery reauthorizes catalog events with their generic category scope;
resource-update notifications also require any matched definition scopes.
Resource subscriptions for session-bound revisions and modern resource filters per
subscription have fixed defensive bounds of 128 unique URIs and 4,096 bytes
per URI. Repeating an existing session-bound subscription is idempotent,
unsubscribing releases its entry, and modern filters preserve the first
occurrence of each URI. Invalid or over-limit session-bound changes do not refresh
the session idle deadline.
rate_limits is an optional map of bounded token buckets for calls,
completion, subscriptions, auth_checks, and auth_failures; each entry is
%{burst: positive_integer, window_ms: positive_integer}. Defaults are
600/60s, 300/60s, 100/60s, 1,024/60s, and 120/60s respectively.
auth_checks is a refundable concurrent-verification bound: its token is
returned after either success or failure. auth_failures records only failed
authentication and is checked before signature verification. A category can
be set to false only when the host explicitly accepts unlimited traffic for
that category; malformed settings fail closed. Rejections use HTTP 429 and
JSON-RPC -33029 (outside the reserved protocol range), and are isolated by principal plus client address. If
client_ip is omitted, the Plug uses conn.remote_ip verbatim, including a
non-IP local-peer term. An explicit client_ip callback, configured as a
one-argument function or MFA, must return a trusted canonical IPv4 or IPv6
tuple derived from the proxy-aware connection. The callback is used for both
authenticated and failed-authentication buckets; invalid returns or callback
failures fail closed and emit neutral client_ip/exception telemetry. Do not
trust a raw client-supplied
X-Forwarded-For entry; use only connection state normalized by a correctly
configured trusted-proxy component.
Authentication-failure buckets group IPv6 addresses by /64, preventing
interface-identifier rotation from consuming the complete 10,000-bucket
table. Each authentication attempt checks existing failure capacity before
signature verification and atomically reserves auth_checks capacity. The
concurrent-check reservation is refunded after either result, and only a
failure consumes auth_failures capacity. Fully refunded check buckets are
removed immediately. A new source at the 10,000-bucket cap triggers one
bounded-table prune; if every entry remains live, the server reclaims the
oldest bucket from the most populous traffic category before admitting it.
Periodic server cleanup also removes expired buckets.
Atomic startup, telemetry, and durable sessions
The complete catalog is limited to 1,000 definitions across all primitive
types. AttestoMCP.Server.register_all/2 validates at most 1,000 additions and
refuses any batch or repeated registration that would take the catalog above
that total; rejection leaves both the catalog and its revision unchanged. A
successful batch emits one catalog invalidation per affected category, while a
failed batch emits none. Supply the same tuples in the server's
:registrations option to make the complete catalog available before
start_link/1 returns. Registry recovery enforces the same total. Named server
child specs use the registered name as the supervision ID, allowing multiple
independently named servers under one supervisor.
URI-template resolution also has one finite work allowance for the complete
candidate scan. Exhaustion is not treated as a missing or unscoped resource:
resource reads return a bounded internal resource_match_limit error and
resource-notification publication returns
{:error, :template_match_budget_exhausted} without delivery. Exact static
resources bypass template matching.
AttestoMCP.Server.replace_catalog/2 validates one complete catalog of at most
1,000 tuples and then atomically replaces every primitive category, so
definitions omitted from the batch are removed. A failed replacement leaves
the prior definitions, revision, and notifications untouched. An identical
replacement is a no-op. A changed replacement advances the catalog revision
once and emits one list-changed notification for each affected advertised
catalog category. Resources and templates share one category; completion-only
changes emit none. Keep the host's generated or persisted catalog as the source
of truth; this API does not introduce a second catalog store.
telemetry_metadata is a map of at most 16 bounded scalar dimensions. Reserved
protocol keys cannot be replaced. Handler spans include the registered
primitive type and exact identity; unknown wire methods are reported as
:unknown. handler_task_init runs in the request worker before the handler,
and exception_reporter receives a trusted exception report without copying
private details into protocol responses or Telemetry. Function, {Module, :function}, and {Module, :function, prefix_args} callback forms are
supported.
The default session store is package-owned in-memory state. The Phoenix installer selects the bundled PostgreSQL adapter only when exactly one Repo is statically confirmed as both supervised and PostgreSQL-backed. Manual configuration may use the same stateless data handle in application config:
store = %{
repo: MyApp.Repo,
namespace: "primary-mcp",
schema_prefix: nil
}
server_options = [
session_store: {AttestoMCP.Server.SessionStore.Ecto, store},
session_namespace: "primary-mcp"
]AttestoMCP.Server.SessionStore.Ecto.new/1 validates and returns the same
handle when called from application startup code after the Repo module is
available. Do not call it while evaluating config.exs, before host modules
have been compiled.
Generate and apply attesto_mcp_sessions before starting the server:
mix attesto_mcp_server.gen.migration --repo MyApp.Repo
mix ecto.migrate
Existing Ecto installations upgrading to this release should run the generator again. It adds a concurrent expression-index migration for the encoded principal and expiry columns; the generator skips migration files already present and writes only the missing index migration.
The adapter supports PostgreSQL and uses row locks for atomic updates. Its
required namespace is bound into the store handle, so key operations and
indexed listing/cleanup cannot cross server namespaces. Several named MCP
servers can therefore share one table without sharing sessions. It stores the
complete versioned record as JSONB, preserves unknown JSON-native fields with
binary object keys, validates indexed expiry mirrors before use, and uses those
mirrors for bounded listing and cleanup. Record-bearing listings return the
first eight active rows ordered by expiry and session ID; they are bounded
snapshots rather than a pagination API, and concurrent row locks may cause
list_active to omit otherwise active rows. Session counts use a separate SQL
aggregate. Per-principal admission and activity updates share a PostgreSQL
advisory lock, and the generated expression index keeps the admission count
bounded to that principal's live rows. Cleanup trusts the indexed expiry column, selects only keys, and
claims at most 1,000 rows, so periodic passes drain larger backlogs without
loading their record payloads. Direct loads and record-bearing listings verify
the complete record against its expiry mirrors. Malformed rows detected there
fail closed and are removed under a row lock so one row cannot block later
work.
For operator tooling, AttestoMCP.Server.active_session_ids/2 returns bounded,
cursor-based pages of active session IDs without loading or returning principals,
tenants, or session records:
{:ok, %{session_ids: ids, next_cursor: cursor}} =
AttestoMCP.Server.active_session_ids(MyApp.MCP, limit: 100)
next_page =
if cursor do
AttestoMCP.Server.active_session_ids(MyApp.MCP, cursor: cursor, limit: 100)
endThe default page is 100 IDs and the maximum is 1,000. Pass the returned
next_cursor unchanged to continue; a nil cursor means the page is complete.
IDs are ordered lexicographically and are scoped to the server's configured
session_namespace. This is an operator visibility API, not a session-loading
or administration API: use the normal authenticated session operations when a
specific session must be inspected or closed. The method applies to the
session-bound revisions; the 2026 stateless transport has no server-side
session IDs to list.
Custom session-store adapters may implement the namespace-aware
count_active/2 callback for exact server statistics and
count_active_for_principal/3 for durable per-principal admission checks.
Shared adapters should implement save_below_principal_limit/4 as an atomic
count-and-save operation so simultaneous admission on multiple server
processes cannot exceed the configured limit. The bundled adapters implement
all three callbacks. Older adapters remain compatible, but their pre-existing
durable sessions are counted by materializing list_active/1, and their
fallback count-then-save admission is only serialized within one server
process. Adapters may also implement
the optional list_active_keys/4 callback from
AttestoMCP.Server.SessionStore to support this view. It must
return only {namespace, session_id} keys and a cursor, never record payloads
or principal bindings. Adapters that do not implement it return
{:error, :unsupported}; malformed pages and adapter failures are converted to
the neutral :session_store_unavailable result.
Session expiry uses wall-clock timestamps while refreshes preserve monotonic activity. Clock skew between nodes can therefore extend a session's effective TTL; synchronize clocks across every node using the durable store.
Database operations fail closed. The adapter limits PostgreSQL row-lock waits
to one second, individual query calls to 1.5 seconds, and transactions to three
seconds so a stalled database returns a neutral store-unavailable result before
the server's call budget. Size the Repo pool for ordinary application demand
plus concurrent MCP requests, and monitor checkout pressure and
session_store/failure events. The host Repo must supply ecto_sql and
Postgrex; this package keeps only Ecto itself as an optional dependency so
ETS-only consumers do not inherit a SQL stack.
Adapter operations that require their own row-locking transaction must run
outside caller-owned Repo.transaction/2 blocks. Those operations reject
nested use before opening another transaction or changing transaction-local
timeouts, returning {:error, :nested_transaction_unsupported}. Let the MCP
server own its short session-store transaction rather than wrapping it in an
application transaction.
The table contains serialized authenticated principal and tenant bindings. Treat it as part of the authorization trust boundary: grant the application role only the required table/schema privileges, prevent unrelated writers, and apply normal encrypted-backup and database audit controls. Before moving a custom store to Ecto, confirm every persisted record is JSON-native and every map key is a UTF-8 string; atom-keyed or otherwise BEAM-specific records are rejected rather than coerced.
Principal and tenant bindings may contain existing atoms. Safe restoration never creates atoms from persisted data, so every node that must use an atom-bearing binding needs those atoms loaded already. A node where an atom is absent treats the binding as unavailable and leaves the durable record intact; use binary or string identities when nodes do not share the same loaded atom set.
Hosts needing another backend can implement
AttestoMCP.Server.SessionStore and configure:
session_store: {MyApp.MCPSessions, store_handle},
session_namespace: "primary-mcp"Records are bounded versioned maps produced by
AttestoMCP.Server.Session.to_record/1; adapters preserve unknown fields and
perform update/TTL/cleanup atomically. Principal and tenant bindings are opaque
BEAM terms with defensive limits: pids, ports, references, functions, bindings
deeper than 32 levels, bindings with more than 10,000 nodes, and encoded
bindings larger than 64 KiB are rejected. The server stops if a monitored store
process is lost; adapters with non-process handles fail closed when an operation
cannot reach their backend.
For multiple Erlang nodes, add session_clustered: true with a genuinely
shared adapter, explicit namespace, and one shared cursor_secret on every
server. The cursor secret must be a binary of at least 16 bytes; 32 bytes is
recommended. Startup rejects clustered session servers that omit it or use a
short value instead of silently generating a per-process secret. A
load-balanced multi-node deployment serving the session-free 2026-07-28
transport must also configure the same explicit cursor secret on every node,
even when session_clustered is false, if cursors may cross nodes. Cursors bind
to final visible catalog content rather than node-local mutation history, so
peers may reach identical content through different registration histories when
the catalog and pagination context agree. Deterministic continuation assumes
all nodes use the same OTP major. Mixed-major rolling upgrades may invalidate
in-flight cursors, so clients should restart pagination across that boundary.
Requests
on any peer can load the same session, publishes fan out asynchronously once to
each live peer, and explicit session deletion or periodic expired-row cleanup
closes matching local streams on every reachable peer.
Peer catalog drift cannot reduce publisher-required scopes; drain mixed old/new clusters rather than rely on them for resource notifications. Use a globally unique namespace when unrelated deployments share the store or Erlang cluster. Streams remain local processes and reopen after failover; event replay and exactly-once delivery across a network partition are not promised. Session-bound notification delivery reloads each live stream's session and rechecks its authorization before enqueueing an event. Store latency and signature verification cost are therefore part of the session-bound fanout path; size durable backends for the expected number of concurrent session-bound streams.
Plug-only streaming selection is explicit and validated at
AttestoMCP.Server.Plug.init/1:
stream_tools: ["tool_name"] enables request-scoped SSE for those tool calls,
while stream_all_tools: true enables it for every tool call. Names must be
unique strings and the all-tools flag must be boolean; malformed values fail
at startup rather than during a request. These options are intended for hosts
whose tools produce progress or server notifications. Subscriptions and calls
with a caller progress token remain streaming regardless of this selection.
Modern HTTP mirror headers
The session-free 2026-07-28 transport mirrors routing fields into bounded
HTTP headers and requires them to agree with the decoded JSON-RPC request.
Header names are case-insensitive, but each required header must occur exactly
once.
| Header | 2026-07-28 | 2025-11-25 and 2025-06-18 |
|---|---|---|
Mcp-Protocol-Version | Required on every POST and equal to params._meta["io.modelcontextprotocol/protocolVersion"]. | During initialization it may be omitted; when present, it must be an enabled session-bound revision. After negotiation the protocol requires it on POST, GET, and DELETE, and it must match the session revision. The server accepts omission only for backward compatibility. |
Mcp-Method | Required on every POST and equal to the JSON-RPC method. | Not required. |
Mcp-Name | Required for the selected values listed below and equal to the corresponding body value. | Not required by the mirror contract. |
Mcp-Name carries:
params.namefortools/callandprompts/get;params.uriforresources/read; andparams.taskIdfortasks/get,tasks/update, andtasks/cancel. Tasks are hard-disabled in this release, but malformed task mirrors are still rejected before the method-not-found response.
Other methods omit Mcp-Name, including catalog-list methods,
completion/complete, and subscriptions/listen. Literal ASCII values can be
sent directly. A value needing a header-safe representation can use the strict
=?base64?...?= sentinel described under tool argument mirrors below.
A missing, duplicate, or mismatched required mirror returns HTTP 400 and the same neutral JSON-RPC error; the response does not identify which mirror failed:
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32020,
"message": "Invalid MCP metadata or header",
"data": {"reason": "body_header_mismatch"}
}
}The following complete tools/call request selects the customer_lookup
tool. Replace the URL and bearer token with values issued for the deployed
Attesto resource:
curl https://mcp.example.com/mcp \
--request POST \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--header "Accept: application/json, text/event-stream" \
--header "Mcp-Protocol-Version: 2026-07-28" \
--header "Mcp-Method: tools/call" \
--header "Mcp-Name: customer_lookup" \
--data '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "customer_lookup",
"arguments": {"id": "cus_123"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'
For resources/read, Mcp-Name mirrors the complete params.uri value:
curl https://mcp.example.com/mcp \
--request POST \
--header "Authorization: Bearer $ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--header "Accept: application/json, text/event-stream" \
--header "Mcp-Protocol-Version: 2026-07-28" \
--header "Mcp-Method: resources/read" \
--header "Mcp-Name: urn:customer:cus_123" \
--data '{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {
"uri": "urn:customer:cus_123",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'
Tool argument mirror declarations
Modern tools/call mirror headers are declared by the registered tool's input_schema property, never by request metadata. A property can require a parameter header with the x-mcp-header annotation:
input_schema: %{
"type" => "object",
"properties" => %{
"account" => %{
"type" => "string",
"x-mcp-header" => "account"
}
}
}The x-mcp-header value is a nonempty RFC 9110 tchar suffix; the server
constructs Mcp-Param-{suffix}. It is valid only on statically reachable
string, integer, or boolean properties. If the property is absent or null,
the client omits its header; otherwise exactly one header is required and its
decoded value must equal the nested params.arguments value. The normative
Base64 sentinel is =?base64?SGVsbG8=?=: padding and alphabet are strict, and
values missing either prefix or suffix are compared literally. Mixed-case
header names and RFC 9110 optional whitespace are handled safely. Mcp-Name
mirrors the tool name, while task methods mirror params.taskId.
Modern subscriptions and interactive requests
subscriptions/listen accepts a non-empty notifications object containing
the category flags toolsListChanged, promptsListChanged,
resourcesListChanged, and/or a resourceSubscriptions URI list. The listen
request ID is the subscription ID. The stream begins with
notifications/subscriptions/acknowledged; each later notification keeps its
actual MCP method and carries the ID under
params._meta["io.modelcontextprotocol/subscriptionId"]. Delivery is bounded,
filtered, and checked against the scopes captured when the subscription opens
on HTTP, stdio, and custom transports. Optional host reauthorizers run only
after that scope check. Protected HTTP opens
require configured default_scopes plus additive subscription_scopes when no
non-empty method entry exists. Without host defaults, the requested generic
category scopes are used instead. A non-empty
scope_map["subscriptions/listen"] entry requires the requested generic
category scopes plus the method entry and subscription_scopes. Modern
delivery rechecks that opening union plus its notification requirements;
resource updates also require resources_read and any matched definition
scopes.
Stdio hosts supply granted scopes through Stdio.run/2's :scopes option or
the context map. Custom transports put the granted scope list in the dispatch
context. Direct callers of Subscriptions.open/9 and
Server.open_legacy_stream/7 pass the same list in the final argument. The
older compatibility arities capture an empty list and consequently receive
only notifications with no scope requirement.
Modern tool, resource, and prompt handlers may return {:input_required, requests} containing real elicitation/create, sampling/createMessage, or
roots/list request objects. A keyed request map preserves its unique authored
keys; the singleton and list forms receive server-assigned input_N keys.
The response includes inputRequests and an integrity-protected requestState.
Retry with a new JSON-RPC ID, echo the returned state, and use the actual
inputRequests keys for typed inputResponses. Keep the original method,
selected definition, arguments, application metadata, and authenticated
identity unchanged. Elicitation responses use action (and accepted
content), sampling responses use role, content, model, and stopReason,
and roots responses use a roots array.
A valid retry with missing answers receives another input_required result
containing only the remaining requests. The fresh signed state preserves
validated earlier answers and the original deadline; echo the new state on
the next retry. Unknown response keys are ignored. Tampered, expired, reused,
or unbound state and malformed supplied answers are rejected.
URL elicitations
An elicitation/create request with mode: "url" hands the client a link for a
human to open. The 2025-11-25 and 2026-07-28 revisions accept the mode; the
2025-06-18 revision cannot carry it. The server checks only that url is a
string in URI form. Everything that makes the link safe to act on is the host's
job, and one requirement is load-bearing: the link must carry the operation the
human is approving, by an opaque staged-record id, never as a bare route. A
bare /confirm link lands the person on a page with nothing to approve,
because the only copy of the proposed action was inside the tool call that
already returned. A page that then falls back to a manual form asks the human
to reconstruct an action they never saw.
Decide first whether an operation deserves a browser gate at all. Reaching a protected tool already means holding an OAuth token, scoped to the capability, resolving to a named subject that is recorded on whatever the write produces. A second gate in a browser re-establishes none of that; it moves the human to a worse screen to repeat an assertion the token already made. Gate operations that are irreversible or that leave the building: publishing to a partner-facing site, granting an outside party access, filing a legal record. Do not gate frequent, versioned, revertible internal edits; the friction drives people around the tool. Where a gate is warranted, stage the approval with the helpers below so the safety properties hold by construction:
- the staged action is bound to the subject it was minted for, so the link is an invitation to its author rather than to whoever holds it;
- the fields the approval binds are the exact fields the call executes with;
- the record is single use and expires;
- an expired, consumed, or foreign link renders an explicit dead end, never a fallback form;
- staged records are swept, because they hold what the reviewer had to read.
Whether a principal can act on a browser link is a host decision. A machine principal can legitimately receive a URL elicitation when the client relays it to a person, and an agent acting for a person on a machine token is a valid topology, so the library does not refuse the mode by principal type. Decide it in the handler before staging.
AttestoMCP.Server.API.stage_url_elicitation/5 stores a record bound to a
SHA-256 digest of the handler context's :principal_binding (the binding
itself is never persisted), the action name, and the exact fields, with a TTL
that defaults to ten minutes and may be set between one second and 24 hours.
It returns an opaque 256-bit id to embed in the URL. The stored fields must be
a JSON object within the server's max_json_bytes budget. Staging fails with
{:error, :principal_binding_required} when the context has no binding, which
is the case for a transport that supplies none.
def publish(arguments, context) do
fields = %{"site" => arguments["site"], "revision" => arguments["revision"]}
case AttestoMCP.Server.API.stage_url_elicitation(
MyApp.MCPServer,
context,
"publish_site",
fields,
ttl_ms: 600_000
) do
{:ok, %{id: id}} ->
{:input_required,
%{
"approve" => %{
"method" => "elicitation/create",
"params" => %{
"mode" => "url",
"message" => "Approve publishing #{fields["site"]} at #{fields["revision"]}.",
"url" => "https://app.example/approvals/#{id}"
}
}
}}
{:error, reason} ->
{:error, reason}
end
endThe approval page resolves the record with the identity the browser session
established, renders the staged action and fields for review, and consumes the
record only when the person confirms. The action then runs with the fields
returned by consume_url_elicitation/3, never with values taken from the
browser request. resolve_url_elicitation/3 is read-only and returns
{:error, :foreign} before revealing anything when the identity does not
match; {:error, :consumed}, {:error, :expired}, and {:error, :not_found}
(unknown or malformed id) are the other dead ends. Render each as a terminal
message. consume_url_elicitation/3 is atomic: under concurrent confirmations
exactly one succeeds and the rest see {:error, :consumed}.
def show(conn, %{"id" => id}) do
binding = conn.assigns.current_user.id
case AttestoMCP.Server.API.resolve_url_elicitation(MyApp.MCPServer, id, binding) do
{:ok, %{action: action, fields: fields}} -> render(conn, :review, action: action, fields: fields)
{:error, reason} -> render(conn, :dead_end, reason: reason)
end
end
def confirm(conn, %{"id" => id}) do
binding = conn.assigns.current_user.id
case AttestoMCP.Server.API.consume_url_elicitation(MyApp.MCPServer, id, binding) do
{:ok, %{action: "publish_site", fields: fields}} -> MyApp.Publishing.run(fields)
{:error, reason} -> render(conn, :dead_end, reason: reason)
end
endThe browser-side binding must be derived from the same source as the
transport's :principal_binding; otherwise every link resolves as foreign.
Records live in the server's url_elicitation_store. The default is an
in-process store owned by the server, suitable for a single node. It defaults
to at most 10,000 live records and 64 MiB of approximate external-term data;
configure those bounds with url_elicitation_max_entries: and
url_elicitation_max_bytes:. When either limit is reached, staging fails
closed instead of evicting an unexpired approval. Before reporting capacity,
the store synchronously scans at most its configured entry bound and reclaims
as many expired records as the new record needs; periodic cleanup remains
limited to 1,000 records per sweep. These options apply only to the built-in
store. Clustered deployments configure
url_elicitation_store: {AttestoMCP.Server.UrlElicitationStore.Ecto, store}
with a handle from AttestoMCP.Server.UrlElicitationStore.Ecto.new/1
(repo:, namespace: matching session_namespace, optional
schema_prefix:). mix attesto_mcp_server.gen.migration generates the
attesto_mcp_url_elicitations migration alongside the session table, and an
existing host that reruns the task receives only the missing file. Expired
records are swept on the server's periodic cleanup tick, reported by the
[:attesto_mcp_server, :url_elicitation_store, :cleanup] start and stop
events; a failing store emits
[:attesto_mcp_server, :url_elicitation_store, :failure] and never affects
tool results.
Request metadata, guidance, and cache hints
Request metadata
Handlers and per-request callbacks receive context.request_meta, an
immutable snapshot of the request's params["_meta"] object with its JSON
string keys and values unchanged. A session-bound request without _meta
produces %{}. HTTP, stdio, and AttestoMCP.Server.Test dispatch expose the
same value.
handler: fn _arguments, context ->
purpose = context.request_meta["com.example/request-purpose"]
{:ok, "recorded for #{purpose || "unspecified"}"}
endThe snapshot is client input. It is never merged into principal,
principal_binding, tenant, scopes, claims, sender constraints, or
host_context, and the server never derives an issuer, audience,
authorization decision, or outbound destination from it. Protocol keys such as
io.modelcontextprotocol/protocolVersion appear exactly as sent; the server's
validated interpretation stays in context.protocol_version,
context.trace_context, and context.logging_level.
AttestoMCP.Server.RequestMeta.application/1 returns only keys outside the
reserved MCP names.
The complete _meta object must encode within max_request_meta_bytes
(65,536 bytes by default, configurable from 512 bytes up to max_json_bytes)
and the existing JSON depth and node bounds. Larger metadata returns invalid
params with reason "request_meta_too_large"; it is never truncated. Metadata
values are not added to logs, telemetry, error payloads, or response metadata.
handler_task_init runs before request validation and does not receive the
snapshot.
Server guidance
instructions remains a static string. An instructions_provider callback
returns guidance for the current authenticated caller in modern
server/discover and session-bound initialize:
instructions_provider: fn context ->
if "reports.write" in context.scopes,
do: {:ok, "Use draft_report before publish_report."},
else: {:ok, "Reports are read-only for this account."}
endThe provider returns {:ok, instructions} (a non-empty UTF-8 string of at most
65,536 bytes), :omit to send none, or {:error, reason}. When configured it
supplies the instructions; the static string is used only without a provider,
and a failing provider never falls back to it. Errors, exceptions, exits,
throws, invalid values, and the request timeout return an internal error with
reason "instructions_provider_failure". Session-bound initialize runs the
provider and checks the final reply size, including the stdio frame limit,
before committing the negotiated revision. A failure leaves the session
unnegotiated so a later valid initialize can succeed. Guidance is not an
authorization mechanism; every catalog and invocation check still applies.
Tool presentation
tool_presentation customizes descriptors for tools the caller can already
see. It receives the tool's public descriptor and the request context:
tool_presentation: fn tool, context ->
case tool["name"] do
"search" when context.tenant == "acct-eu" ->
{:ok, %{"description" => "Search EU records.", "_meta" => %{"com.example/region" => "eu"}}}
_other ->
:default
end
endOverrides may set "title", "description", "icons", and application
"_meta" keys, which merge over the registered _meta. Names, schemas,
annotations, execution, handlers, scopes, authorization callbacks, reserved
_meta keys, and unknown fields fail the whole list with
"tool_presentation_failure"; no partial catalog is returned. Scope clauses,
authorize, and HTTP definition policies run first, so hidden tools never
reach the callback, and tools/call repeats its own eligibility checks. The
callback runs for every visible tool on each tools/list page because the
customized catalog is part of the cursor fingerprint. Keep it cheap and
deterministic; a cursor stops working when the presentation it was issued for
changes. Registered definitions are never modified.
Cache hints
Modern complete results from server/discover, tools/list, prompts/list,
resources/list, resources/templates/list, and resources/read carry
ttlMs and cacheScope. The server does not cache responses. Without
cache_policy or a selected definition's cache: policy, cache_ttl_ms
(default 30_000) and a private scope apply,
as in 2.3. prompts/get keeps emitting the same extra fields; it is not a
standard cacheable operation.
cache_policy: [
methods: %{
"server/discover" => [ttl_ms: 300_000],
"tools/list" => [ttl_ms: 60_000, scope: :public],
"resources/read" => [ttl_ms: 5_000]
},
resolver: {MyApp.MCPCache, :policy, []},
max_ttl_ms: 600_000
]A resource or resource template may carry cache: [ttl_ms: 1_000] or
cache: [scope: :private], even when no server-level cache_policy is set.
Precedence is the global default, then the method
policy, the definition policy, the optional resolver, and valid handler hints
on resources/read. Handler hints can shorten ttlMs or select
"private"; they cannot lengthen freshness or select "public". The resolver
receives %{method:, definition:, policy:} and the request context and returns
{:ok, policy} or :default. A resolver failure produces ttlMs: 0 with a
private scope for that response.
These constraints apply with or without cache_policy:
- Interim
input_requiredresults carry no hints, and otherwise cacheable complete results from a multi-round retry usettlMs: 0and"private". Completed tool retries discard authored hint extras; tool calls are not standard cacheable operations. "public"requiresallow_public_cache: true, an explicit public choice, and a caller-independent response. A list is caller-independent only when no definition of that type has scope clauses or anauthorizecallback, no HTTP definition policy applies, and no presentation callback is configured. Paginated catalogs stay private on every page because their cursors bind the caller. A resource read requires an unrestricted selected definition and no potentially personalized result/content metadata. An authored implementation identity equal to the configured constant does not count as personalized; another identity or other handler metadata does. A method-level scope requirement does not make a result caller-dependent; a public hint lets shared caches serve it to callers without that scope, so choose public only for content you would publish.- Output that includes
instructions_providerortool_presentationresults is private, withttlMs: 0unless a method policy, definition policy, or resolver supplies a TTL. - Potentially personalized resource metadata is private and uses
ttlMs: 0unless a method policy, definition policy, or resolver supplies a TTL. - A list page with
nextCursoris never fresher than the cursor lifetime, and a private hint is never fresher than the verified access token. - Every page of one list request has the same scope, and a continuation is rejected if the effective scope changes.
- Session-bound results never carry
ttlMsorcacheScope.
MCP cache hints do not authorize HTTP caching. Protected HTTP responses keep
Cache-Control: private, no-store. The cache/choice telemetry event reports
only the method, the chosen scope, and the policy source.
Server identity and icons
server_name, server_version, and server_icons define the implementation
value sent in modern discovery, the io.modelcontextprotocol/serverInfo
stamp on modern results, and session-bound initialize:
server_icons: [
%{src: "https://example.com/icons/catalog.png", mime_type: "image/png", sizes: ["48x48"]},
%{src: "https://example.com/icons/catalog-dark.svg", mime_type: "image/svg+xml", theme: "dark"}
]Each configured icon src must be an absolute HTTP/HTTPS URL or a Base64 data: URI with
an image/* media type. mimeType must be an image/* type, sizes entries
are "WxH" or "any", and theme is "light" or "dark". At most 16
icons are accepted, with a 131,072-byte encoded budget. Empty icon and size
arrays are accepted, and size entries are bounded to 128 bytes. Icon URLs are never
fetched or resolved. 2025-06-18 initialization omits icons. A handler may
author its own serverInfo in a modern result's _meta; it is kept only when
every field satisfies the bounded MCP wire contract (name, version,
title, description, websiteUrl, and icons), and is otherwise replaced
by the configured value. Authored optional text and icon/size arrays may be
empty; authored MIME types and sizes do not acquire the stricter configured
image/size syntax rules. Implementation identity is display
metadata only. Registered component icons keep their 2.3 checks.
JSON Schema dialects
Tool schemas without $schema are validated as JSON Schema 2020-12. Explicit
2020-12 and draft-07 declarations are supported
(AttestoMCP.Server.Schema.supported_dialects/0); other dialects are rejected
at registration. export_schema_dialect: true inserts
"$schema" => "https://json-schema.org/draft/2020-12/schema" at the root of
exported inputSchema and outputSchema objects that have none. It does not
change validation, nested schemas, data values, boolean schemas, or registered
definitions. The export applies to 2026-07-28 and 2025-11-25; the
2025-06-18 Tool type has no $schema member. The exported catalog is checked
against max_json_bytes and included in pagination fingerprints. MCP defines
no capability for negotiating schema dialects.
Era separation
The JSON-RPC decoder rejects batches, invalid UTF-8, fractional/null IDs, oversized or over-deep messages, and malformed response objects. Duplicate JSON member names are outside this package's accepted protocol contract; the decoder delegates their handling to Jason and does not promise an ordering policy. Producers must not send duplicates; hosts requiring rejection should reject those bytes before dispatch.
Modern requests carry _meta.io.modelcontextprotocol/protocolVersion and
clientCapabilities per request and use POST-only request-scoped responses.
_meta must be a JSON object when present; non-object values return a
correlated protocol error without terminating the transport.
Host-published catalog notifications accept only type and optional object
_meta; resource-update notifications additionally require a valid uri.
Unknown fields and malformed metadata are rejected before either delivery
path.
The earlier, session-bound 2025-11-25 and 2025-06-18 revisions start with
initialize, then notifications/initialized, and may use an expiring principal-bound
Mcp-Session-Id. The server echoes and retains the exact accepted revision;
modern requests never use one of these sessions.
Session-bound GET is a standing incremental SSE stream with bounded keepalive and session-owner delivery. DELETE closes the authenticated session and its streams. Clustered mode coordinates live peers but does not replicate stream processes or event history. Last-Event-ID resumption is not supported; a Last-Event-ID GET is rejected rather than replayed.
Session-bound initialization advertises the server's resources.subscribe capability;
clients do not need to self-declare that server capability. After
notifications/initialized, negotiated sampling, elicitation, and roots
client capabilities permit corresponding server-originated requests on the
SSE/stdio route, with typed JSON-RPC responses correlated to the waiting
handler. During HTTP connection startup, a server-originated request waits for
the session's owned standing stream for at most one second or the configured
client-request timeout, whichever is shorter; it then fails closed as not
ready.
Hosts may provide initialize_callback: fn context, params -> :ok end to
reject session-bound initialization before negotiated state is committed. Callback
exceptions and arbitrary rejection terms are converted to a generic correlated
JSON-RPC internal error; no callback reason is sent to clients, and the Plug
endpoint remains available for later requests.
Task profiles
The optional modern and session-bound task profiles are disabled in this release. No
task capability is advertised, modern tasks/* methods return
method-not-found, the legacy_tasks opt-in is ignored, and the supervised task
boundary fails closed. The modern_tasks and legacy_tasks options cannot
enable the incomplete in-memory implementation; a future release must provide
a durable store contract before advertising either profile.
Telemetry
Events use the [:attesto_mcp_server, ...] prefix. Metadata is filtered to
protocol version, method, transport, status, duration, outcome, and opaque
correlation values. Request/auth/handler/stream/progress/subscription/task and
protocol error events are safe to attach to an application reporter. The
stable event contract is:
http_requestandstdio:start,stop, andexception.request,handler, andstream:start,stop,exception, withtimeout,open,close, andbackpressurewhere applicable.auth/authenticate:start,stop, andexceptionaround every HTTP authentication.auth/refusal,auth/policy_failure,protocol/error,cancellation/request,cancellation/stop, andprogress/emitorprogress/reject.principal_binding/exceptionandcontext_builder/exception.mrtr/round,subscription/open,subscription/close,subscription/suppressed, andsubscription/backpressure.cache/choice,cache/invalidation,session/open,session/close,session_store/failure,session_store/cleanup/start,session_store/cleanup/stop,client_ip/exception, andsupervision/restart.
auth/authenticate/stop reports the elapsed duration, an outcome from a
bounded set, the refusal's HTTP status, and for an authenticated request its
binding (:bearer, :dpop, or :mtls). The outcomes are :ok,
:missing_credentials, :invalid_token, :token_expired,
:token_not_yet_valid, :invalid_audience, :invalid_issuer,
:sender_constraint_mismatch, :invalid_dpop_proof, :dpop_replay,
:dpop_nonce_required, :step_up_required, :policy_rejected,
:insufficient_scope, :unauthenticated, :verifier_unavailable,
:verifier_failure, :principal_binding_failure, and :other. The span
covers credential verification, assign canonicalization, and the principal
binding; mount policy and route-scope decisions keep their own events. Tokens,
claims, principals, and error descriptions are never attached.
auth/policy_failure can report the :mount_policy category. A failed
principal_binding callback emits principal_binding/exception, and a failed
context_builder callback emits context_builder/exception; both remain
value-free and use the configured exception_reporter when applicable.
auth/policy_failure identifies the failing boundary only through a safe
principal_policy, mount_policy, or verifier category and an atom failure
kind; it never includes the callback reason, token claims, or principal.
session_store/failure identifies the failed operation only through its bounded
source atom. The outcome is unavailable, or corrupt_discarded when the
bundled Ecto adapter removes a structurally corrupt persisted row so later
bounded passes can continue. Adapter reasons, session identifiers, records, and
exception text are never included.
Each periodic expiry pass emits session_store/cleanup/start and
session_store/cleanup/stop. The stop measurement contains a non-negative
duration for the store cleanup call and its bounded return normalization,
failure reporting, and namespace filtering; it excludes start telemetry, local
stream closing, and a clustered close broadcast. It also reports the bounded
number of sessions reaped for this server's namespace in count (at most
1,000); metadata reports outcome: :success or outcome: :unavailable. A
custom adapter response over 1,000 keys, or an otherwise malformed response, is
rejected as unavailable without truncating or processing any returned keys.
Adapters must batch larger backlogs across later passes. A failed pass still emits
session_store/failure, and the cleanup implementation adds no keys, records,
principals, tenants, or adapter error details. Explicitly configured trusted
telemetry_metadata remains attached to these events.
Credential, proof, request-state, baggage, private content, and arbitrary callback values are removed before Telemetry emission.
W3C trace context
Request _meta may carry traceparent, tracestate, and baggage. The core
syntax-validates traceparent; tracestate and baggage are bounded opaque
forwarding values. Each field is limited to 4096 bytes and accepted values are
passed to handlers as context.trace_context. Baggage is available to the
handler only; it is never included in logs or Telemetry.
Stdio interop
elixir examples/stdio.exs launches the line-delimited adapter with no
credentials embedded. During cold installation it temporarily assigns Mix's
group leader to standard error (and also uses Mix.Shell.Quiet with
verbose: false), then restores the protocol stdout before starting the
adapter. Compilation and dependency diagnostics therefore stay off stdout;
stdout
contains protocol frames only. The preferred modern 2026 flow uses discovery and
per-request _meta protocol-version/capability metadata; it does not send an
initialize request. The adapter also accepts the 2025-11-25 and
2025-06-18 session-bound initialize/initialized flows on stdin, writes only compact
JSON-RPC messages to stdout, and exits on EOF. Its default bounded frame limit
is 64,000 bytes; larger limits must be explicit. A host may instead call
AttestoMCP.Server.Stdio.run/2 with its own supervised server and context.
AttestoMCP.Server.Stdio.main/1 accepts the adapter-only identity, input,
server-request, and EOF controls too; it removes those controls before starting
the owned server so core unknown-option validation stays strict. run/2 returns
one of these startup error tuples: {:error, :session_store_unavailable},
{:error, :nonportable_binding}, {:error, :binding_too_large},
{:error, :record_too_large}, or {:error, :session_limit}. main/1 stops its owned server after the
adapter exits and raises for a startup failure so an executable wrapper cannot
silently report a successful exit.
Protocol version compatibility
Only three frozen versions are accepted: 2026-07-28 for modern discovery and
per-request metadata, plus 2025-11-25 and 2025-06-18 for the negotiated,
session-bound lifecycle.
Hosts may narrow that set with the server protocol_versions option. It must
be a non-empty subset of those revisions. An HTTP session is bound to the
revision selected by initialize. Clients must send that revision in the
Mcp-Protocol-Version header on later POST, GET, and DELETE requests. For
backward compatibility, the server uses its authenticated session binding when
the header is absent; an invalid or changed value fails closed.
Modern discovery reports every revision the server supports. A client choosing
2026-07-28 continues with per-request metadata. A client choosing either
earlier revision must open the dated initialize/notifications/initialized
flow; a modern metadata envelope cannot carry a session-bound revision.
Revision-specific output is filtered before it reaches the client.
2025-06-18 catalogs, resource content, and implementation identity omit the
later icons field, and its tool schemas never receive an exported $schema. That
revision cannot send an explicit elicitation mode or sampling
tools/toolChoice fields; form elicitation remains available by omitting
mode. A server-side attempt to use one of those later fields returns
{:error, :unsupported}.
For session-bound requests, handler context exposes the session's exact negotiated
revision as context.protocol_version. This is 2025-11-25 or 2025-06-18,
not a generic marker, so handlers can make revision-aware decisions
without re-reading transport headers.