Bandit development server
The HTTP boundary is a normal Plug. A documented local launcher is:
Mix.install([{:attesto_mcp_server, path: "."}, {:bandit, "~> 1.6"}], 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 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. The metadata
endpoint is public; protected POST/GET/DELETE traffic is not.
The package requires attesto_mcp ~> 1.2 and calls the public
ProtectResource.prepare/1, authenticate/2, and authorize/3 contract
directly. There is no sibling-path or pre-1.2 authentication fallback.
Per-delivery subscription reauthorization requires an executable %Attesto.Config{}
through auth: [config: ...]. 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. Legacy sessions 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 one RFC 6570 expression:
named path variables ({id}), reserved path variables ({+path}), prefix
modifiers ({id:3}), and query variables ({?q,limit} or {?keys*}) are
supported. Query values are strictly percent-decoded, bounded, and reject
ambiguous duplicates or decoded traversal. Unsupported multi-expression or
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.
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.
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.
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.
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.
HTTP 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 the union of the configured subscription scope(s) and the category
scopes: tools_read, prompts_read, and/or resources_read; the same union is
checked again before each delivery.
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.
Legacy starts with initialize, then notifications/initialized, and may use
an expiring principal-bound Mcp-Session-Id. Modern requests never use a
legacy session.
Legacy GET is a standing incremental SSE stream with bounded keepalive and
session-owner delivery. DELETE closes the authenticated session and its
streams. This release does not advertise cross-process replication or
Last-Event-ID resumption; a Last-Event-ID GET is rejected rather than replayed.
Legacy 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 legacy 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 legacy task profiles are disabled in this release. No
task capability is advertised, modern tasks/* methods return
method-not-found, legacy task 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,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, andsupervision/restart.
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 frozen legacy
initialize/initialized flow 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.
Only the two frozen versions are accepted: 2026-07-28 for modern discovery
and per-request metadata, and 2025-11-25 for the negotiated legacy lifecycle.