ExMCP Configuration Guide

Copy Markdown View Source

This guide covers the supported configuration surfaces for ExMCP 1.0.

Dependency

Use the modern-preferred 1.0.0-rc.8 release candidate:

def deps do
  [
    {:ex_mcp, "~> 1.0.0-rc.8"}
  ]
end

The earlier 1.0.0-rc.5 package is the legacy-only characterization baseline. To preserve its connection policy after upgrading, set:

config :ex_mcp, protocol_mode: :legacy_only

Protocol Eras and Modes

ExMCP 1.0 implements two wire-incompatible MCP eras:

  • Legacy: 2024-11-05, 2025-03-26, 2025-06-18, and 2025-11-25 (the newest legacy revision).
  • Modern (latest stable): 2026-07-28, with stateless discovery and per-request context.

protocol_mode is the compatibility policy. Set it in application configuration for a deployment default:

config :ex_mcp,
  protocol_mode: :prefer_modern,
  protocol_version: "2025-11-25"

1.0.0-rc.8 defaults to :prefer_modern for the required pre-1.0 soak. Stable 1.0 will copy this behavior without another default change. Production deployments should still set the mode explicitly when rollout policy must not change with a dependency upgrade.

ModeEnabled versions, in preference orderClient establishmentServer acceptance
:legacy_only2025-11-25 → older legacyinitialize onlyLegacy only
:prefer_legacyLegacy revisions → 2026-07-28initialize, then a modern probe only after an eligible protocol failure on a live transportBoth eras
:prefer_modern2026-07-28 → legacy revisionsserver/discover, then legacy fallback only with positive compatibility evidence on a live transportBoth eras
:modern_only2026-07-28server/discover onlyModern only

The two preference modes differ when used by a client. On a server both accept either era; their ordering controls the versions advertised by server/discover. A stdio or BEAM connection pins its first valid modern request or legacy initialize and rejects mixed-era traffic afterward.

Configure one client or server independently when canarying:

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://mcp.example.com/mcp",
    protocol_mode: :prefer_modern,
    era_probe_timeout: 2_000,
    era_cache_legacy_ttl: 300_000
  )

{:ok, server} =
  MyServer.start_link(
    transport: :stdio,
    protocol_mode: :prefer_legacy
  )

# Phoenix/Plug servers accept the same option.
forward "/mcp", ExMCP.HttpPlug,
  handler: MyApp.MCPServer,
  protocol_mode: :prefer_legacy

Client mode options:

  • :era_probe_timeout bounds the side-effect-free server/discover probe; the default is 2_000 milliseconds.
  • :era_cache_legacy_ttl controls how long a legacy observation is reused; the default is 300_000 milliseconds. Modern observations do not expire and cannot be replaced by automatic fallback.
  • :reset_era_cache clears the observation for the exact transport identity before connecting. Use it after an intentional endpoint upgrade, not as an automatic retry strategy.
  • :era_cache_key supplies a stable identity for a custom transport that cannot be identified from its connected state. Never include raw secrets; ExMCP hashes the configured identity.

Fallback is deliberately narrow. A modern timeout, transport failure, recognized modern error, authentication error, or cached-modern probe failure does not trigger initialize. Similarly, :prefer_legacy probes modern only after a protocol-level legacy failure while the transport remains usable. Strict modes never fall back.

protocol_version is a legacy revision preference, not an era switch or a statement of the latest upstream MCP revision. The application-level value and the compatibility helper ExMCP.protocol_version/0 retain their rc.5 legacy semantics during the soak; use protocol_mode to enable modern negotiation. A per-client modern protocol_version is honored only when its mode enables the modern era.

For legacy Streamable HTTP, initialize negotiates the version from params.protocolVersion; it does not require an MCP-Protocol-Version HTTP header. When protocol_version_required: true, every subsequent request must carry exactly one header matching the version stored for that server-issued session. ExMCP always rejects an explicit malformed, unsupported, duplicate, or session-mismatched header, even when missing-header enforcement is disabled. Modern requests always carry matching HTTP and _meta protocol versions.

The server issues a legacy Streamable HTTP session only for initialize and atomically allows one initialization attempt. It exposes the session ID only after a successful response binds that exact negotiated version. Later POST and GET/SSE requests require the issued initialized session; failed or abandoned initialization is terminated. The deprecated 2024 HTTP+SSE endpoint uses its separate endpoint-event handshake and is unaffected by this rule.

Use the public negotiator for legacy compatibility checks:

ExMCP.Protocol.VersionNegotiator.supported?("2025-11-25")

See the migration rollout and the architecture era model before changing a production default.

OAuth Client Registration

Modern authorization uses an explicit client registration strategy in the HTTP transport's :auth map:

# Credentials established with this authorization server. Resolve secrets at
# use time rather than embedding them in application configuration.
auth: %{
  client_registration:
    {:pre_registered, "client-id", {:env, "MCP_CLIENT_SECRET"}},
  credential_issuer: "https://auth.example.com"
}

# Portable, self-hosted Client ID Metadata Document.
auth: %{
  client_registration:
    {:cimd, "https://client.example/oauth/metadata.json"},
  private_key: signing_jwk,
  signing_algorithm: "ES256",
  key_id: "client-key-1"
}

# Automatic compatibility fallback. DCR is used only when advertised.
auth: %{
  client_registration: :auto,
  client_metadata_url: "https://client.example/oauth/metadata.json",
  application_type: :native,
  redirect_port: 8080
}

Registration priority is pre-registered credentials, a configured CIMD URL when the authorization server sets client_id_metadata_document_supported: true, deprecated DCR when it exposes registration_endpoint, then an actionable error. :auto never fabricates a metadata URL. Existing client_id / client_secret keys remain accepted as 1.x compatibility aliases.

A CIMD client ID must be an exact HTTPS URL with a non-root path. The JSON document at that URL must repeat the same client_id byte-for-byte and include non-empty client_name and redirect_uris. Use ExMCP.Authorization.ClientIdMetadata.build_metadata/1 and validate/2 before publishing it. For private_key_jwt, publish jwks_uri or inline jwks and configure the matching private key locally; ExMCP will not downgrade to a weaker token authentication method if assertion construction fails.

DCR requires an explicit application_type: :native | :web and stable local redirect_port. Registration rejections retain the authorization server's error response so redirect-policy failures are actionable. ExMCP does not silently change the application type or redirect URI.

OAuth metadata network policy

CIMD, Protected Resource Metadata, OIDC/RFC 8414 authorization-server metadata, and JWKS retrieval use one fail-closed outbound fetch boundary. Metadata URLs must use HTTPS, including in local development. Each hostname is resolved on every redirect hop; a DNS answer containing any private, loopback, link-local, reserved, documentation, or otherwise non-public IPv4/IPv6 address is rejected. The connection is pinned to an approved address while the original hostname remains the TLS SNI and certificate-validation name.

Defaults can be tightened globally:

config :ex_mcp, :oauth_metadata_fetch,
  max_redirects: 3,
  max_response_bytes: 262_144,
  max_aggregate_bytes: 524_288,
  dns_timeout_ms: 1_000,
  connect_timeout_ms: 2_000,
  request_timeout_ms: 5_000,
  allowed_redirect_origins: []

Redirects remain on the current origin by default. If a provider deliberately hosts metadata on another origin, list each destination as an exact HTTPS origin such as https://metadata.example.com; wildcards and URL paths are not accepted. Every allowed destination still receives fresh DNS/IP validation.

The metadata client sends only Accept, Accept-Encoding: identity, and a non-secret user agent. It never inherits MCP transport headers, authorization, cookies, client secrets, or proxy credentials. Compressed responses are rejected and the default client enforces the byte limit while streaming.

The legacy custom metadata-client shapes get(url) and get(url, headers) are no longer accepted because they can re-resolve DNS after validation. A custom :http_client must implement:

get(uri, approved_address, options)

It must connect directly to approved_address, preserve uri.host for TLS and HTTP host validation, use only options[:request_headers], enforce options[:connect_timeout_ms], options[:request_timeout_ms], and options[:max_response_bytes] while streaming, and return {:ok, %{status: integer, headers: list, body: binary}}. Per-flow overrides go under metadata_fetch: [...]; use them only for a tighter policy or an exact provider redirect.

Issuer-bound credential persistence

For MCP 2026-07-28, pre-registered credentials require credential_issuer. ExMCP compares this value byte-for-byte with the issuer in the discovered authorization-server metadata before resolving or using the secret. A trailing slash, path change, or any other textual difference is a mismatch; issuer identifiers are not URL-normalized. During 1.x, only the legacy client_id / client_secret aliases retain their old unbound behavior for legacy protocol versions; the new explicit pre-registration strategy is always issuer-bound.

Applications that persist DCR registrations or tokens can provide an encrypted store or OS-keychain adapter implementing ExMCP.Authorization.CredentialStore:

auth: %{
  client_registration: :auto,
  application_type: :native,
  redirect_port: 8080,
  credential_store: {MyApp.MCPCredentialStore, store_state},
  credential_context: "desktop-installation-42"
}

credential_context is a stable, non-secret local index (the resource URL is the default). The adapter still stores each registration under the exact versioned issuer + client-ID key supplied to it. On an authorization-server change, the new issuer partition misses and ExMCP performs registration again; an adapter returning a credential from another issuer is rejected.

Tokens are partitioned by issuer, client ID, resource and/or audience, subject or client identity, and normalized granted scopes. Access and refresh tokens never appear in a storage key, and the credential structs redact secret fields from Inspect. ExMCP intentionally provides no plaintext file adapter.

Old records without an issuer fail with {:credential_migration_required, :registration | :token}. After verifying the original authorization server out of band, migrate them explicitly with CredentialStore.bind_legacy_registration/2 or CredentialStore.bind_legacy_token/2; never use the currently discovered issuer as an implicit migration value.

OAuth transaction retention

Every authorization-code flow started by ExMCP uses a random 256-bit state and PKCE verifier. The returned transaction is registered in a supervised, node-local single-use store before the authorization URL is returned. Callback validation consumes state atomically, and code exchange atomically binds the validated code to the exact redirect URI before making the token request. This path is shared by legacy and 2026-07-28 MCP sessions.

The default store retains up to 10,000 transaction records for 10 minutes. Both limits can be adjusted:

config :ex_mcp, ExMCP.Authorization.OAuthTransactionStore,
  ttl_ms: 600_000,
  max_entries: 10_000

Do not shorten the TTL below the time a user may reasonably spend in the browser. Capacity exhaustion fails new flows closed. The built-in loopback flow is intentionally node-local; a distributed web callback must route back to the originating node or implement its own strongly consistent end-to-end flow.

For direct use of ExMCP.Authorization, preserve the returned transaction and pass it through validation and redemption:

{:ok, authorization_url, transaction} =
  ExMCP.Authorization.start_authorization_flow(config)

{:ok, code} =
  ExMCP.Authorization.validate_authorization_response(callback, transaction)

ExMCP.Authorization.exchange_code_for_token(%{
  code: code,
  code_verifier: transaction.code_verifier,
  client_id: config.client_id,
  redirect_uri: transaction.redirect_uri,
  token_endpoint: config.token_endpoint,
  transaction: transaction
})

ExMCP does not accept caller-supplied state or reserved OAuth fields in additional_params. If a token request has an ambiguous outcome, its code remains redeemed; restart authorization instead of retrying the code.

JSON Schema Resource Policy

Every JSON Schema compiled or validated by ExMCP passes through one bounded, fail-closed policy. By default, only local fragment references (# and #/...) are accepted. HTTP(S), file, and relative cross-document $ref values are rejected before ExJsonSchema can resolve them, even if the host application configured ExJsonSchema's global :remote_schema_resolver.

The defaults are suitable for protocol schemas and can be tightened or raised for a trusted application workload:

config :ex_mcp, :json_schema,
  max_schema_bytes: 262_144,
  max_schema_depth: 64,
  max_subschemas: 1_000,
  max_composition_depth: 16,
  resolve_timeout_ms: 1_000,
  validation_timeout_ms: 100

max_subschemas conservatively counts schema object nodes, including nested property maps but excluding literal instance data in const, default, enum, and examples. Composition depth counts nesting through allOf, anyOf, oneOf, not, if, then, and else. A zero timeout or limit is valid and can be used to disable the corresponding work. Invalid values fail closed.

$schema draft identifiers are metadata and remain accepted; bundled draft meta-schemas do not require a network request. Boolean JSON Schemas (true and false) are supported.

Opt-in network references

Keep remote references disabled unless the schema publisher is part of the application's trust boundary. To opt in, provide a non-empty host allowlist and increase the outer resolution deadline enough to cover the bounded network work:

config :ex_mcp, :json_schema,
  resolve_timeout_ms: 10_000,
  network_refs: [
    enabled: true,
    allowed_hosts: ["schemas.example.com", "*.schemas.example.net"],
    trust_partition: "production-schema-publishers",
    allow_http: false,
    max_redirects: 3,
    max_documents: 16,
    max_reference_depth: 8,
    max_response_bytes: 262_144,
    max_decompressed_bytes: 262_144,
    max_aggregate_bytes: 1_048_576,
    dns_timeout_ms: 1_000,
    connect_timeout_ms: 2_000,
    request_timeout_ms: 3_000,
    proxy: :disabled
  ]

The allowlist contains hostnames, not URLs. *.example.com matches subdomains but not example.com itself. HTTPS is required unless allow_http: true is set; plain HTTP provides no publisher authentication or integrity and is not recommended. Redirects from HTTPS to HTTP are rejected even when HTTP was enabled for an explicitly HTTP reference.

Every request and redirect target is allowlisted, independently DNS-resolved, checked for public-only IPv4/IPv6 addresses, and connected to an approved IP while TLS verification and SNI use the original hostname. A mixed DNS answer containing even one loopback, link-local, private, reserved, or documentation address is rejected. URI userinfo, compressed responses, and proxies are rejected. No cookies, authorization headers, or other credentials are sent.

Fetched documents exist only inside one compilation; ExMCP does not persist or globally share a remote-schema cache. This is stronger than partitioning a persistent cache and prevents one tenant or principal from warming another's schema state. trust_partition is hashed in audit logs and establishes the partition identity for any future cache implementation.

:dns_resolver and :http_client adapter overrides exist for controlled tests. Do not replace them in production: doing so replaces the DNS revalidation, IP-pinned connection, TLS, streaming limit, and deadline enforcement that make the boundary safe.

OpenTelemetry Metadata Policy

ExMCP can carry W3C trace-context values in the MCP _meta object without taking a dependency on an OpenTelemetry SDK or mutating process-global tracing state. traceparent and tracestate are validated at every client and server metadata boundary. Baggage is validated and bounded before filtering, then only explicitly allowlisted members are retained. The default baggage allowlist is empty, so baggage is dropped unless the application opts in.

config :ex_mcp, :otel_meta,
  baggage_allowlist: ["tenant.id", "request-id"],
  max_total_bytes: 9_216,
  max_baggage_bytes: 8_192,
  max_baggage_members: 64

The fixed tracestate limits are 512 bytes and 32 unique members. Configured byte limits cannot exceed 65,536 bytes, and baggage member/allowlist counts cannot exceed 64. Invalid configuration or malformed metadata fails closed. ExMCP currently accepts the W3C version 00 traceparent wire format; values must use lowercase hexadecimal and non-zero trace and parent identifiers.

Attach a connection-level context to all modern client requests:

ExMCP.Client.start_link(
  transport: :http,
  url: "https://api.example.com/mcp",
  trace_context: %{
    traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
    tracestate: "vendor=opaque",
    baggage: "tenant.id=acme,secret=discarded"
  }
)

Per-request values may also be supplied in the request's _meta; the explicit client :trace_context wins when both sources contain the same field. On the server, handlers receive the sanitized map as ExMCP.Server.RequestContext.trace_context. Notification and result metadata go through the same policy.

Allowlist only low-cardinality routing or correlation fields. Do not propagate credentials, authorization tokens, personal data, or other secrets as baggage.

Tasks Extension

Modern Tasks is an explicit extension, not the experimental tasks capability from MCP 2025-11-25. A modern client opts in on every request by adding io.modelcontextprotocol/tasks to its configured capabilities:

task_capabilities =
  ExMCP.Tasks.Extension.put_capability(%{
    "elicitation" => %{"form" => %{}}
  })

ExMCP.Client.start_link(
  transport: :http,
  url: "https://api.example.com/mcp",
  capabilities: task_capabilities
)

After a tools/call returns resultType: "task", use ExMCP.Client.get_task/3, update_task/4, and cancel_task/3. The client rejects task results unless the extension was configured, and validates the task handle before returning it to application code.

A server must advertise the same extension from server/discover only when it has configured an appropriate task store. The bundled node-local store is enabled in a Handler with tasks: :store; ExMCP then adds the extension to discovery automatically:

defmodule MyServer do
  use ExMCP.Server.Handler, tasks: :store

  @impl ExMCP.Server.Handler
  def handle_call_tool("long_deploy", arguments, state) do
    ExMCP.Tasks.Server.create(
      "long_deploy",
      arguments,
      state,
      __task_store_options__()
    )
  end
end

Handlers without tasks: :store do not gain this capability. A custom task backend that overrides the task callbacks may advertise the extension explicitly through :server_capabilities or __server_capabilities__/0 after it has implemented equivalent durability and ownership checks.

The injected modern handle_task_get/2, handle_task_update/3, and handle_task_cancel/2 callbacks use ExMCP.Tasks. Existing callbacks remain overridable, and legacy task methods are unchanged unless the application implements them explicitly. ExMCP.Tasks.Server.create/4 inserts the task synchronously and returns a handle only after tasks/get can read it.

ExMCP.Tasks.Store.ETS is bounded and atomic on one node. It keeps tasks through client reconnects, client restarts, request-process failures, and worker failures, but not an ExMCP application or node restart. Production deployments that need that stronger guarantee should implement the ExMCP.Tasks.Store behaviour, supervise the backend in their application, and configure it globally or on the Handler:

# Optional limits for the bundled reference store:
config :ex_mcp, ExMCP.Tasks.Store.ETS,
  max_tasks: 10_000,
  max_ttl_ms: 2_592_000_000

config :ex_mcp, task_store: MyApp.Tasks.PostgresStore

# Or for one server module:
use ExMCP.Server.Handler,
  tasks: :store,
  task_store: MyApp.Tasks.PostgresStore,
  task_store_opts: [repo: MyApp.Repo]

The store binds each task to the current request's principal, tenant, and endpoint. Workers running outside a request callback must retain that owner without credentials and pass it back explicitly:

owner = ExMCP.Tasks.owner()
{:ok, task} = ExMCP.Tasks.complete(task_id, result, owner: owner)

Successful creates and wire-visible transitions publish full notifications/tasks state to matching subscriptions/listen streams. A deployment using a non-default subscription registry should pass subscription_registry: registry when a worker calls ExMCP.Tasks.complete/3, fail/3, require_input/3, mark_cancelled/2, or put_status_message/3. Set notify: false only when the host application deliberately owns publication itself.

The host application still owns worker execution and recovery. Store adapters own persistence, atomicity across serving nodes, authorization binding, and expiry. Do not advertise the extension when the configured store cannot meet the deployment's durability requirements.

ExMCP.Tasks.Task.to_map/1 retains the legacy 2025-11-25 keys. to_map/2 with :modern or "2026-07-28" emits ttlMs, pollIntervalMs, inputRequests, and error without removing the public 1.x struct aliases. tasks/list, tasks/result, and notifications/tasks/status remain legacy-only.

Client Configuration

You can pass options directly to ExMCP.Client.start_link/1:

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
    protocol_mode: :prefer_modern,
    use_sse: true,
    request_timeout: 30_000
  )

Or build a reusable config with ExMCP.ClientConfig:

config =
  ExMCP.ClientConfig.new(:production)
  |> ExMCP.ClientConfig.put_transport(:http, url: "https://api.example.com/mcp")
  |> ExMCP.ClientConfig.put_auth(:bearer, token: System.fetch_env!("MCP_TOKEN"))
  |> ExMCP.ClientConfig.put_retry_policy(max_attempts: 3, base_interval: 500)

{:ok, client} = ExMCP.connect(config)

stdio

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :stdio,
    command: ["node", "server.js"],
    protocol_mode: :prefer_modern,
    cd: "/path/to/project",
    env: [{"NODE_ENV", "production"}],
    timeout: 30_000
  )

Supported options:

  • :command
  • :cd
  • :env
  • :environment_policy (:isolated by default; :inherit is an explicit compatibility opt-in)
  • :timeout

The isolated policy passes a small runtime baseline and the explicitly supplied :env entries. It prevents unrelated API, cloud, and session credentials from being inherited by a third-party MCP or ACP subprocess; it does not provide a filesystem or network sandbox.

Streamable HTTP

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
    protocol_mode: :prefer_modern,
    use_sse: true,
    headers: [{"Authorization", "Bearer #{token}"}],
    request_timeout: 30_000,
    stream_handshake_timeout: 15_000,
    stream_idle_timeout: 60_000,
    max_retry_delay: 60_000,
    dns_timeout_ms: 1_000,
    max_request_bytes: 8_388_608,
    max_response_bytes: 8_388_608,
    max_stream_buffer_bytes: 1_048_576
  )

Supported options include:

  • :url
  • :endpoint
  • :headers
  • :protocol_mode
  • :use_sse
  • :session_id
  • :protocol_version
  • :timeout
  • :request_timeout
  • :stream_handshake_timeout
  • :stream_idle_timeout
  • :max_retry_delay
  • :dns_timeout_ms (DNS lookup deadline; defaults to 1_000)
  • :dns_resolver (custom resolver for controlled testing)
  • :allowed_private_hosts (exact internal hostnames allowed to resolve to RFC 1918/IPv6 ULA addresses; no wildcards)
  • :max_request_bytes
  • :max_response_bytes
  • :max_stream_buffer_bytes (maximum delimiter-free/incomplete SSE data)
  • :security
  • :auth
  • :auth_provider

Every POST, GET/SSE, retry, and DELETE resolves the destination, validates the entire answer set, and pins the connection to an approved address. The original hostname remains the Host/SNI/certificate name. Public destinations are allowed by default, as are literal/named loopback destinations for local MCP servers. Internal destinations require an exact :allowed_private_hosts entry; link-local, reserved, and mixed public/private answers remain forbidden.

use_sse controls the legacy standalone GET stream. It may remain true on a dual-era client: once server/discover succeeds, ExMCP disables that stream, clears legacy session state, and uses JSON or POST-owned SSE for each modern request. subscriptions/listen opens its own POST response stream.

BEAM-Local

{:ok, server} = MyServer.start_link(transport: :beam)  # works when using DSL; otherwise use HandlerServer.start_link(handler: MyServer, ...)

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :beam,
    server: server,
    timeout: 5_000
  )

transport: :beam is local to the current VM and requires a server PID. Keep any pooling, service discovery, or process selection in your application layer.

Server Configuration

Servers (DSL or raw handlers) can be started with:

MyServer.start_link(transport: :beam, protocol_mode: :prefer_legacy)
MyServer.start_link(transport: :stdio, protocol_mode: :prefer_legacy)
MyServer.start_link(transport: :http, port: 4000, protocol_mode: :prefer_legacy)

# For a raw handler module (no DSL):
ExMCP.Server.HandlerServer.start_link(handler: MyHandler, transport: :beam)
# or the convenience:
ExMCP.start_server(handler: MyHandler, transport: :stdio)

HandlerServer-based BEAM/test servers and stdio servers retain request IDs for the lifetime of the server process so a client cannot execute the same JSON-RPC request ID twice. The retained set is bounded to 10,000 IDs by default; set max_request_ids: positive_integer on server startup to choose a deployment-specific fail-closed bound.

Phoenix/Plug applications usually mount ExMCP.HttpPlug:

forward "/mcp", ExMCP.HttpPlug,
  handler: MyApp.MCPServer,
  server_info: %{name: "my-app", version: "1.0.0"},
  protocol_mode: :prefer_legacy,
  handler_call_timeout: 10_000,
  cors_enabled: true

:handler_call_timeout is the server-side deadline for each call from ExMCP.HttpPlug into the Handler process (default 10_000 milliseconds). It is separate from client-side :timeout, :request_timeout, :stream_handshake_timeout, and :stream_idle_timeout settings.

The MCP 2024-11-05 HTTP+SSE transport is deprecated and disabled by default. Existing servers may retain it during ExMCP 1.x with legacy_http_sse: true. sse_enabled: true remains an rc.5-compatible alias until ExMCP 2.0. Optional legacy_http_sse_path and legacy_http_sse_post_path settings default to /sse and /message. Neither dual-era preference mode enables this transport. :modern_only disables it even when the compatibility option or its rc.5 alias is present.

OAuth protected-resource metadata

When oauth_enabled: true, ExMCP.HttpPlug requires the canonical HTTPS resource identifier and at least one HTTPS authorization-server issuer. Mount the plug so the RFC 9728 path-specific metadata URL is reachable:

forward "/", ExMCP.HttpPlug,
  endpoint: "/mcp",
  handler: MyApp.MCPServer,
  oauth_enabled: true,
  resource: "https://mcp.example.com/mcp",
  authorization_servers: ["https://auth.example.com"],
  auth_config: %{
    introspection_endpoint: "https://auth.example.com/introspect",
    client_id: System.fetch_env!("MCP_RESOURCE_CLIENT_ID"),
    client_secret: System.fetch_env!("MCP_RESOURCE_CLIENT_SECRET"),
    expected_issuer: "https://auth.example.com",
    expected_audience: "https://mcp.example.com/mcp"
  }

This serves /.well-known/oauth-protected-resource/mcp; bearer challenges point clients to that metadata document. Custom MCP methods also need an explicit :scope_mapper returning a non-empty list of scopes. Unmapped or invalid policies are denied rather than sharing a catch-all scope.

Legacy session storage is bounded to 10,000 active sessions by default. Each session also retains at most 10,000 distinct request IDs, preventing duplicate execution without allowing unbounded replay state. Set deployment-specific limits when supervising ExMCP.SessionManager directly:

{ExMCP.SessionManager,
 max_sessions: 2_000,
 max_request_ids: 5_000,
 max_events_per_session: 500,
 max_event_bytes: 1_048_576,
 max_replay_bytes_per_session: 8_388_608,
 session_ttl_seconds: 900}

At capacity, new session allocation returns HTTP 503 with Retry-After; existing active sessions continue to work. When a session's request-ID bound is reached, new IDs fail closed with HTTP 429; duplicates return JSON-RPC Invalid Request. Terminated entries and their request IDs are reclaimed before allocating a replacement.

Replay retention also fails closed for any single JSON-encoded event larger than :max_event_bytes. The per-session replay window evicts its oldest events when either :max_events_per_session (default 1,000) or :max_replay_bytes_per_session (default 8 MiB) would be exceeded. The default single-event cap is 1 MiB. Counts and byte totals are reset when a session is terminated or expires.

The resource server authenticates to introspection with :client_secret_basic by default; :client_secret_post is available through :introspection_auth_method. An active token is still rejected unless its issuer, audience/resource, exp, and optional nbf satisfy this configuration. Only migration deployments should use legacy_unbound_tokens: true.

Multi Round-Trip Requests (MCP 2026-07-28)

MRTR lets tools/call, resources/read, and prompts/get pause for client elicitation, sampling, or roots input. Configure a runtime AES-256 key ring and declare mrtr: true so server startup validates it:

# runtime.exs — load the secret from your runtime secret manager/environment.
key = System.fetch_env!("MCP_REQUEST_STATE_KEY") |> Base.decode64!()

config :ex_mcp, :request_state,
  active_key_id: "2026-08",
  keys: %{"2026-08" => key},
  ttl_seconds: 300,
  max_ttl_seconds: 900,
  clock_skew_seconds: 30

For a rolling rotation, first distribute %{"old" => old_key, "new" => new_key} to every node with active_key_id: "old"; then roll only active_key_id to "new"; finally remove "old" after the maximum token TTL plus clock skew. Install each complete key-ring snapshot atomically. Use revoked_key_ids: ["old"] for emergency invalidation, accepting that any in-flight token sealed by that key must restart.

MyServer.start_link(
  transport: :stdio,
  protocol_mode: :modern_only,
  mrtr: true
)

Handlers can return either MRTR tuple, or use the DSL builder:

{:input_required, input_requests, state}
{:input_required, input_requests, application_request_state, state}

ToolResult.input_required(input_requests, %{"workflowStep" => 1})

On the retry, unchanged callback arities read verified data from ExMCP.Server.Context.input_responses/0 and ExMCP.Server.Context.request_state/0. Application request state must be JSON encodable and is size-bounded before encryption.

Client operation options default to 8 rounds, 16 input requests per round, and 1 MiB of serialized MRTR input/output. Override them with :max_mrtr_rounds, :max_input_requests, and :max_mrtr_bytes. One overall :timeout covers all rounds.

Input callbacks run sequentially in deterministic request-ID order by default. A stateless client handler can explicitly opt into bounded parallel dispatch by implementing mrtr_input_concurrency/0 and returning an integer from 2 through

  1. Every parallel callback receives the same handler state and must return it unchanged; ExMCP rejects a parallel callback that attempts to update the state.

For resumptions that may cause side effects, enable atomic single-use enforcement:

MyServer.start_link(
  mrtr: true,
  replay_cache: ExMCP.Server.ReplayCache.ETS,
  require_replay_protection: true
)

The bundled cache is node-local. Clustered deployments must implement ExMCP.Server.ReplayCache over a shared, strongly consistent store. Without a replay cache, verified retry context explicitly reports delivery_semantics: :at_least_once.

HTTP deployments may provide :principal_id and :tenant_id as strings or resolver functions. OAuth token sub and tenant_id claims are used by default when available; these identities are authenticated into the sealed state without embedding bearer tokens.

Modern subscriptions (MCP 2026-07-28)

Open an immutable notification stream with ExMCP.Client.listen/3. The call returns only after notifications/subscriptions/acknowledged; events are sent to the subscribing process with the acknowledged subscription reference:

{:ok, subscription} =
  ExMCP.Client.listen(client, %{
    "toolsListChanged" => true,
    "resourceSubscriptions" => ["file:///project/config.json"],
    "taskIds" => [task_id]
  })

receive do
  {:ex_mcp_subscription, ^subscription, method, params} ->
    handle_notification(method, params)
end

:ok = ExMCP.Client.Subscription.cancel(subscription)

taskIds is defined by the io.modelcontextprotocol/tasks extension. The client must declare that extension in its configured capabilities. Servers using tasks: :store automatically authorize every requested ID against the same principal, tenant, endpoint, and task store used by tasks/get; IDs that are missing or not authorized are omitted from the acknowledged filter. A server with a custom task backend must provide :authorize_subscription_filter and must not acknowledge an ID until it has performed the equivalent access check.

subscribe_resource/3 and unsubscribe_resource/3 retain their legacy RPC behavior before 2026-07-28. On a modern connection they maintain one ref-counted desired URI set. Changes open and acknowledge an immutable replacement stream before cancelling the old stream; only the committed subscription ID delivers compatibility events:

{:ok, _subscription} = ExMCP.Client.subscribe_resource(client, uri)

receive do
  {:ex_mcp_resource_updated, ^uri, params} -> handle_update(params)
end

After reconnect, subscriptions are opened with fresh JSON-RPC IDs. ExMCP refetches each affected list, resource, and task, then emits {:ex_mcp_subscription_resync, subscription, {:complete, snapshot}} for a generic subscription or {:ex_mcp_resource_resync, subscription, snapshot} for the resource compatibility wrapper before releasing queued events.

Server listener defaults are 1,000 global registrations, 100 per principal, 500 per tenant, 100 queued events per listener, a 1 MiB encoded-message cap, an 8 MiB aggregate queue cap, a one-hour maximum lifetime, 256 resource URIs, 256 task IDs, and a 64 KiB filter. Configure the registry child directly with :max_queue, :max_message_bytes, and :max_queue_bytes, or pass the corresponding server options (:subscription_max_queue, :subscription_max_message_bytes, :subscription_max_queue_bytes, :subscription_max_lifetime_ms, :authorize_subscription_filter, and :authorize_subscription_publication). A message that exceeds its individual cap, or a slow consumer that exhausts either queue bound, is closed fail-safe. Publication authorization is checked again for every event; denial gracefully closes the stream.

For clustered HTTP, start one named subscription registry per node after the application's PubSub process and route every MCP server on that node to it:

children = [
  {Phoenix.PubSub, name: MyApp.PubSub},
  {ExMCP.Server.Subscriptions,
   name: MyApp.MCPSubscriptions,
   adapter:
     {ExMCP.Server.Subscriptions.PubSub,
      pubsub_server: MyApp.PubSub,
      topic: "my_app:mcp:subscriptions:v1"}},
  {MyApp.MCPServer,
   subscription_registry: MyApp.MCPSubscriptions}
]

ExMCP.Server.Subscriptions.PubSub has no hard Phoenix dependency. Its :pubsub_module defaults to Phoenix.PubSub and may be replaced by any module implementing subscribe/2 and broadcast_from/4. Registrations and listener processes stay node-local; untargeted publications fan out and each receiving listener rechecks authorization. Publications carrying a :transport_ref stay on the owning node. publish/3 counts describe synchronous work in the called registry, not eventual work on peers.

The bundled ETS storage makes global/principal/tenant limits per-node. If a deployment requires cluster-wide quotas, supply a storage adapter backed by a shared, atomic data store via the PubSub adapter's :storage_adapter option. That adapter must still return only entries whose listener processes belong to the current registry; use the shared store for reservation/accounting rather than attempting to call remote listener PIDs as local registrations.

Over modern Streamable HTTP, each subscriptions/listen call is a dedicated POST response stream. Cancelling ExMCP.Client.Subscription closes that HTTP response; it does not POST notifications/cancelled. An unexpected response close opens a new listen request with a fresh JSON-RPC ID and runs the resync flow described above. The server sends an SSE comment keepalive every 15 seconds by default so quiet disconnects are detected and intermediaries do not expire an otherwise healthy stream:

forward "/mcp", ExMCP.HttpPlug,
  handler: MyApp.MCPServer,
  protocol_mode: :modern_only,
  subscription_keepalive_interval_ms: 15_000,
  subscription_max_lifetime_ms: :timer.hours(1)

Set :subscription_keepalive_interval_ms to a positive integer or :infinity. Disabling keepalives delays detection of a quiet peer disconnect until the next notification or server-initiated closure.

Modern Streamable HTTP headers (MCP 2026-07-28)

After a connection settles on MCP 2026-07-28, the HTTP client is stateless: it neither sends nor retains Mcp-Session-Id or Last-Event-ID. Every POST mirrors the body protocol version and method into MCP-Protocol-Version and Mcp-Method; tools/call, resources/read, and prompts/get also send Mcp-Name. Unsafe UTF-8, leading/trailing whitespace, control characters, and values shaped like the Base64 sentinel are encoded automatically.

Tool input properties may opt into routing headers:

%{
  "type" => "object",
  "properties" => %{
    "region" => %{
      "type" => "string",
      "x-mcp-header" => "Region"
    }
  }
}

After tools/list, a modern HTTP tools/call mirrors a present non-null argument as Mcp-Param-Region. String, integer, and boolean properties are supported, including nested property paths. Invalid, duplicate, unreachable, or unsupported annotations cause the server to omit that tool from a modern list response. On a -32020 header mismatch the client refreshes tools/list and retries the tool call exactly once inside the original timeout.

The ExMCP DSL returns its complete tool set and therefore needs no cursor coordination. A raw handler that paginates a dynamic tool set must filter and sort the full source collection before it slices the requested page:

def handle_list_tools(cursor, state) do
  tools =
    state.dynamic_tools
    |> ExMCP.Server.ResultNormalizer.prepare_tools_list()

  {page, next_cursor} = MyApp.Cursor.page(tools, cursor)
  {:ok, page, next_cursor, state}
end

Result normalization repeats this validation as a defensive boundary, but it cannot correct a handler-owned cursor calculated from invalid definitions.

The server validates standard and annotated headers against the body before tool dispatch. Custom raw Mcp-Method, Mcp-Name, Mcp-Session-Id, Last-Event-ID, and Mcp-Param-* values supplied through the client's :headers option are removed and replaced by protocol-derived values on modern requests.

Treat all Mcp-Param-* values as sensitive routing data. ExMCP does not attach raw request headers to its Plug/client debug logs or telemetry. Configure reverse proxies, load balancers, APM agents, and access-log middleware to redact Mcp-Param-* just as they redact Authorization and cookies; those systems observe headers before ExMCP can sanitize their logs.

At a reverse proxy or load balancer, preserve individual request-header field instances through the upstream hop or reject duplicates at the edge. Do not collapse duplicate MCP-Protocol-Version, Mcp-Method, Mcp-Name, or Mcp-Param-* fields by selecting one value: once discarded, the application cannot distinguish an ambiguous request from a valid one. It is safe for an intermediary to apply a smaller header-size limit and reject early.

For response streams, use an HTTP/1.1-or-newer upstream connection, disable request and response buffering, disable transformation/caching, preserve Content-Type: text/event-stream and X-Accel-Buffering: no, and set the proxy idle timeout above :subscription_keepalive_interval_ms. The automated proxy-boundary matrix sends literal HTTP bytes through two Cowboy connections and a normalizing, buffering forwarding hop. Deployment CI should run the same valid/duplicate/oversized/stream cases against the exact Nginx, HAProxy, Envoy, ingress, or managed load-balancer configuration used in production.

Modern result cache hints

MCP 2026-07-28 requires ttlMs and cacheScope on complete results from server/discover, tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. ExMCP supplies conservative defaults when a handler omits them:

%{
  ttlMs: 0,
  cacheScope: :private
}

ttlMs: 0 means immediately stale; private prevents reuse across authorization contexts. A handler may return ttl_ms / cache_scope or the wire keys ttlMs / cacheScope to override those defaults. TTL must be a non-negative integer and scope must be :public, :private, "public", or "private". Only use public when the result is safe to share across users, including on authenticated endpoints. Each paginated response page carries its own hints, and ExMCP removes cache hints from input_required results.

Modern clients reject missing or invalid required hints. With the default :struct response format they are available as response.ttlMs and response.cacheScope; format: :map preserves the wire keys. ExMCP currently parses and validates these hints but does not store or reuse responses. This is the deliberate 1.0 scope: a client cache would add authorization partitioning, invalidation races, memory bounds, and MRTR exclusion to the final release candidate. Repeated calls therefore still reach the transport, even for a positive public TTL, and a later operation never reuses an earlier requestState. Cache storage remains a post-1.0 additive feature.

Pass request-local context into a handler with :handler_opts. The option can be a static term, a one-arity function called with the Plug.Conn, a two-arity function called with the Plug.Conn and decoded JSON-RPC request, or an MFA tuple called as apply(module, function, [conn, request | extra_args]).

forward "/mcp", ExMCP.HttpPlug,
  handler: MyApp.MCPServer,
  handler_opts: fn conn ->
    [current_user: conn.assigns[:current_user]]
  end,
  server_info: %{name: "my-app", version: "1.0.0"}

Resilience

Retries:

  ExMCP.Client.start_link(
    transport: :http,
    url: "https://api.example.com/mcp",
  retry_policy: [max_attempts: 3, initial_delay: 100, max_delay: 2_000]
)

Circuit breaker and health checks:

ExMCP.Client.start_link(
  transport: :http,
  url: "https://api.example.com/mcp",
  reliability: [
    circuit_breaker: [failure_threshold: 5, reset_timeout: 30_000],
    health_check: [check_interval: 60_000]
  ]
)

Observability

Operational telemetry and alerts

The MCP 2026-07-28 migration emits bounded operational events for era selection, fallback and downgrade observations; unsupported-version retries; MRTR rounds, failures and replay rejection; subscription reconnect and queue pressure; and ambiguous HTTP reissue. See ExMCP.Telemetry for the event list and exact metadata shapes. Client response-cache hit/miss events are absent in 1.0 because response storage/reuse is deliberately deferred.

Build deployment alerts from counts and rates, not raw payload dimensions:

  • alert on any sustained :downgrade_attempt, and investigate even a single unexpected event for an endpoint previously pinned modern;
  • alert immediately on MRTR :request_state_key_unknown or :request_state_key_revoked, and rate-alert on :replay_rejected or replay cache failures;
  • watch the ratio of subscription queue :closed to :coalesced, plus reconnect attempts that repeatedly fail to reach phase: :complete;
  • investigate spikes in unsupported-version retries, legacy fallbacks, or ambiguous HTTP reissues during a rollout.

Choose thresholds from normal traffic volume and rollout policy. Event metadata never includes tool arguments, _meta, inputResponses, resource contents or URIs, Mcp-Param-*, requestState, key IDs, credentials, raw subscription IDs, filters, principals, or tenants. Preserve that boundary in custom telemetry handlers and exporters.

Logging

This section configures application/runtime logging. The MCP wire-level Logging feature (logging/setLevel, per-request log levels, and notifications/message) is deprecated as of MCP 2026-07-28 but remains available throughout ExMCP 1.x. New observability integrations should use stderr for stdio diagnostics or OpenTelemetry for structured telemetry.

For stdio servers, stdout must contain only JSON-RPC messages. ExMCP configures stdio logging when stdio mode starts. Send ad hoc diagnostics to stderr:

IO.puts(:stderr, "debug")

Security-boundary logs describe payload types and sizes and use short hashes for opaque session, progress, and origin identifiers. OAuth failures and HTTP handler results are not rendered verbatim. Preserve that rule in custom handlers: record a correlation ID and safe error class locally, and return a stable generic error across the wire.

For HTTP and BEAM-local development:

Logger.configure(level: :debug)

Security

HTTP clients can use headers:

ExMCP.Client.start_link(
  transport: :http,
  url: "https://api.example.com/mcp",
  headers: [{"Authorization", "Bearer #{token}"}]
)

For server-side HTTP concerns, compose Plug/Phoenix pipelines before ExMCP.HttpPlug.