Usage and deployment

Copy Markdown View Source

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_request and stdio: start, stop, and exception.
  • request, handler, and stream: start, stop, exception, with timeout, open, close, and backpressure where applicable.
  • auth/refusal, protocol/error, cancellation/request, cancellation/stop, and progress/emit or progress/reject.
  • mrtr/round, subscription/open, subscription/close, subscription/suppressed, and subscription/backpressure.
  • cache/choice, cache/invalidation, session/open, session/close, and supervision/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.