For a checklist that maps an existing catalog and deployment onto this package, start with the migration runbook.
Phoenix installation
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.
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.
The loaded principal is the server's security identity for ownership checks,
session and subscription isolation, and rate/concurrency accounting. Return a
term whose equality remains stable for the same identity across requests; do
not include per-load timestamps, references, or other changing values.
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 < 4.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"
)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 the MCP route and its
route-equivalent trailing slashes while leaving metadata, browser, JSON, and
form routes unchanged. 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 exact MCP 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. 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.
Bandit development server
The HTTP boundary is a normal Plug. A documented local launcher is:
Mix.install(
[{:attesto_mcp_server, path: "."}, {:bandit, "~> 1.6"}],
force: true,
verbose: false
)
{:ok, server} = AttestoMCP.Server.start_link(name: :bandit_example)
plug = {AttestoMCP.Server.Plug, server: server, path: "/mcp", auth: [config: my_attesto_config, base_url: "http://127.0.0.1:4000"]}
Bandit.start_link(plug: plug, scheme: :http, ip: {127, 0, 0, 1}, port: 4000)For an executable credential-free launcher, use elixir examples/bandit.exs.
It starts Bandit with an empty static keystore and therefore answers 401 until
the host supplies a token; it contains no hidden application module or secret.
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.
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 < 2.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, and tenant preserve the
authenticated stream's ownership snapshot; they do not re-run host policy.
Host revocation and principal callbacks run when the stream is authenticated,
not inside shared publish processes; a later host-policy change applies on the
next request or reconnect. 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 subset. 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 revision, 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(Content.text("total: #{total}"),
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 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/2 and resource/2 construct complete results.
They 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.
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(AttestoMCP.Server.Content.text(arguments["format"]))}
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.
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, and any matched resource definition scopes rather than
re-running host business policy. Apply changed host policy on the next request
or reconnect, and do not rely on authorize to suppress subscription updates.
Limits and scope policy
max_concurrency, per_principal_concurrency, request_timeout,
max_request_timeout, max_queue, stream_keepalive_ms, and
stream_queue_size are the bounded server limits. 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, 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.
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, and auth_failures; each entry is
%{burst: positive_integer, window_ms: positive_integer}. Defaults are
600/60s, 300/60s, 100/60s, and 120/60s respectively. 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 -32029, and are isolated by principal plus remote address.
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
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. 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.
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 and explicit namespace. 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 reauthorized for the subscription owner. 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.
Modern tool, resource, and prompt handlers may return {:input_required, requests} where requests is a map of unique server keys to real
elicitation/create, sampling/createMessage, or roots/list request
objects. The server emits a map of server-assigned input_N keys and an integrity-protected requestState;
retry with a new JSON-RPC ID and matching typed inputResponses: elicitation
responses use action (and accepted content), sampling responses use
role, content, model, and stopReason, and roots responses use a
roots array.
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/refusal,auth/policy_failure,protocol/error,cancellation/request,cancellation/stop, andprogress/emitorprogress/reject.mrtr/round,subscription/open,subscription/close,subscription/suppressed, andsubscription/backpressure.cache/choice,cache/invalidation,session/open,session/close,session_store/failure, andsupervision/restart.
auth/policy_failure identifies the failing boundary only through a safe
principal_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.
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}, or
{:error, :record_too_large}. 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 and resource content omit the later icons field. 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.