ExMCP Transport Guide

Copy Markdown View Source

ExMCP supports stdio, Streamable HTTP, BEAM-local, and test transports. The deprecated MCP 2024-11-05 HTTP+SSE transport is an explicit compatibility option, not a new-server default.

Overview

TransportIdentifierBest For
stdio:stdioOfficial MCP subprocess transport
Streamable HTTP:httpRemote servers and Phoenix apps
BEAM-local:beamLocal Elixir client/server pairs
Test:testIn-memory tests

stdio

The stdio transport spawns an external MCP server process and exchanges newline-delimited JSON-RPC.

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

Supported options:

  • :command - executable plus arguments as a list.
  • :cd - subprocess working directory.
  • :env - environment variables as {"KEY", "VALUE"} tuples.
  • :timeout - client operation timeout.

Streamable HTTP

The HTTP transport supports two wire shapes on one MCP POST endpoint. A protocol mode determines how the client establishes the era; it is not a choice between separate :http transport modules.

BehaviorLegacy Streamable HTTP (2025-03-26–2025-11-25)Modern Streamable HTTP (2026-07-28)
Establishmentinitialize; server may mint Mcp-Session-Idserver/discover; no protocol session
Client messagesPOST; session header after initializationA fresh stateless POST for every message
Ordinary responseJSON or request-owned SSEJSON or request-owned SSE
Independent notificationsOptional standalone GET SSE streamsubscriptions/listen POST response stream
Server needs client inputJSON-RPC request on an SSE streaminput_required result followed by a new client POST
Resume/terminationLast-Event-ID; DELETE sessionNot resumable; close the owning response stream

The client's use_sse option controls the standalone GET stream used by legacy Streamable HTTP. ExMCP disables it, clears any session ID, and stops sending Last-Event-ID after a connection settles modern. Modern request and subscription streams use SSE on the owning POST response automatically and do not require use_sse: true.

{: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,
    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 client options include:

  • :url - base URL or full MCP endpoint URL.
  • :endpoint - endpoint path when it is not included in url.
  • :headers - additional request headers.
  • :protocol_mode - :legacy_only, :prefer_legacy, :prefer_modern, or :modern_only; see the Configuration Guide.
  • :use_sse - enable the legacy Streamable HTTP GET stream, defaults to true.
  • :session_id - resume an existing streamable HTTP session.
  • :protocol_version - requested legacy revision, or a modern probe revision when the selected mode enables it.
  • :timeout - connect timeout.
  • :request_timeout - single request timeout.
  • :stream_handshake_timeout - wait for SSE stream startup.
  • :stream_idle_timeout - allowed SSE idle time.
  • :dns_timeout_ms - DNS-resolution deadline, defaults to one second.
  • :dns_resolver - injectable resolver used primarily for controlled testing.
  • :allowed_private_hosts - exact hostnames intentionally permitted to resolve to RFC 1918 or IPv6 ULA addresses. Wildcards are rejected; loopback hostnames and literals are supported without an exception for local MCP servers.
  • :max_retry_delay - cap for SSE reconnect delay.
  • :max_request_bytes - maximum encoded HTTP request body.
  • :max_response_bytes - maximum JSON or complete streamed response body.
  • :max_stream_buffer_bytes - maximum incomplete SSE data retained while waiting for a frame delimiter.
  • :security - client-side security validation configuration.
  • :auth / :auth_provider - OAuth/auth provider integration.

HTTP requests use finite absolute deadlines, identity encoding, redirect-free clients, and incremental response limits. A delimiter-free SSE slow drip cannot extend the idle deadline indefinitely; the peer must complete a frame within the configured timeout and buffer limit. Before every connection ExMCP validates the complete DNS answer and pins the socket to an approved address while keeping the original hostname for HTTP Host, TLS SNI, and certificate validation. Mixed public/private answers, link-local addresses, and reserved ranges fail closed.

For OAuth client registration, configure one explicit strategy:

auth: %{
  client_registration:
    {:pre_registered, "client-id", {:env, "MCP_CLIENT_SECRET"}},
  credential_issuer: "https://auth.example.com",
  redirect_port: 8080
}

# Or a self-hosted Client ID Metadata Document:
auth: %{
  client_registration:
    {:cimd, "https://client.example/oauth/metadata.json"}
}

:auto uses pre-existing compatibility keys first, then a configured client_metadata_url when the authorization server advertises CIMD, then deprecated DCR only when registration_endpoint is advertised. DCR requires an explicit application_type: :native | :web and a stable redirect_port; ExMCP never invents a CIMD URL or guesses the application type. A missing strategy returns an actionable registration error.

Modern pre-registered credentials must include credential_issuer, which is compared exactly with discovered AS metadata. To retain DCR credentials and tokens safely across flows, configure an adapter implementing ExMCP.Authorization.CredentialStore; registrations are issuer + client-ID bound and tokens use the complete authorization partition. See the Configuration Guide.

Modern POST shape

Every modern request is a new POST whose body contains one JSON-RPC request. The client advertises both response types:

POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: weather

{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{...}}

ExMCP derives the routing headers from the validated body. It strips custom values for reserved MCP headers before sending a modern request, so callers cannot create a header/body disagreement.

HeaderWhen sentMeaning
MCP-Protocol-VersionEvery modern requestMirrors modern _meta.protocolVersion
Mcp-MethodEvery modern requestMirrors the JSON-RPC method
Mcp-Nametools/call, resources/read, and prompts/getMirrors the addressed tool, resource, or prompt
Mcp-Param-*Annotated tools/call inputsMirrors schema-selected routing parameters

Header names are case-insensitive on the wire. Mcp-Param-* values may contain sensitive routing data; redact them in reverse-proxy, load-balancer, and APM logs as well as application logs.

The server returns one of these shapes:

  • application/json with the final JSON-RPC response;
  • text/event-stream with request-related notifications and then the final response; or
  • 202 Accepted with no body for an accepted notification POST.

A modern result always has resultType: "complete" or resultType: "input_required". For input_required, the client satisfies the embedded elicitation, sampling, or roots requests and sends the original operation again as a new POST with inputResponses and the opaque requestState. ExMCP never turns those inputs into independent server-to-client JSON-RPC requests on the HTTP stream.

Long-lived notifications use a subscriptions/listen request. Its POST response stays open as SSE, begins with the subscription acknowledgement, and then carries only the notification categories selected by that request. Closing a modern request/subscription response stream is the cancellation signal; there is no session DELETE and no resumable Last-Event-ID cursor.

A :modern_only HTTP mount returns 405 Method Not Allowed for GET or DELETE on the MCP endpoint, ignores legacy session headers, and never exposes the deprecated HTTP+SSE endpoints. A dual-era mount must retain whatever legacy GET/DELETE behavior its enabled legacy clients need, so use request metadata and the settled client era—not the mere presence of HTTP—to reason about the wire shape.

Safe era fallback

With :prefer_modern, ExMCP sends a bounded server/discover probe first. It falls back to initialize only when the response is recognized as evidence of a legacy peer and the transport is still usable. A recognized modern error, unsupported modern revision, timeout, authentication failure, or broken transport is surfaced instead of being silently downgraded. :modern_only never falls back.

Successful modern observations are pinned by endpoint and relevant transport configuration. Legacy observations expire (five minutes by default) so an upgraded endpoint is eventually probed again. Pass reset_era_cache: true for an intentional re-probe after an operator-controlled deployment change.

Phoenix/Plug Server

scope "/mcp" do
  pipe_through [:api, :mcp_auth]

  forward "/", ExMCP.HttpPlug,
    handler: MyApp.MCPServer,
    server_info: %{name: "my-app", version: "1.0.0"},
    protocol_mode: :prefer_modern,
    cors_enabled: true
end

Put HTTP concerns in Plug pipelines before ExMCP.HttpPlug: authentication, request signing, rate limiting, CORS/origin decisions, and DNS rebinding checks.

Deprecated MCP 2024-11-05 HTTP+SSE

Existing deployments can retain the old two-endpoint transport throughout ExMCP 1.x by opting in:

forward "/mcp", ExMCP.HttpPlug,
  handler: MyApp.MCPServer,
  legacy_http_sse: true

The GET endpoint defaults to /sse; its first event is endpoint, containing the POST URI (default /message) and session ID. Configure those paths with :legacy_http_sse_path and :legacy_http_sse_post_path. The rc.5 :sse_enabled option remains a deprecated alias until ExMCP 2.0. New servers should use Streamable HTTP and leave this option off. Selecting :prefer_legacy or :prefer_modern does not enable this transport; :modern_only disables it even if the compatibility flag is present.

BEAM-Local

The BEAM-local transport carries MCP-shaped maps/lists as Elixir terms between local processes. It does not JSON encode/decode in the transport, but it still uses the MCP lifecycle, request IDs, capabilities, and handler callbacks. Its protocol mode selects legacy initialize or modern server/discover plus per-request context, exactly as stdio does.

{:ok, server} =
  MyServer.start_link(
    transport: :beam,
    protocol_mode: :prefer_modern
  )

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :beam,
    server: server,
    protocol_mode: :prefer_modern
  )

Supported options:

  • server side: transport: :beam on a DSL/handler server, with optional protocol_mode.
  • client side: transport: :beam, server: pid, optional protocol_mode and timeout.

Tip: For a fast local verification of these BEAM + DSL + Client patterns (no re-installs), run mix examples.getting_started from the project root after mix compile.

BEAM-local does not provide service discovery or distributed registry behavior. If you need a pool or registry of server processes, keep that in your application supervision layer and pass the selected server PID to the client.

Test Transport

Use :test for in-memory tests where both endpoints are in the same process tree:

{:ok, server} =
  ExMCP.Server.HandlerServer.start_link(
    transport: :test,
    handler: MyServer
  )

{:ok, client} =
  ExMCP.Client.start_link(
    transport: :test,
    server: server
  )

Reliability

Client 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]
)

Transport wrapper:

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]
  ]
)

Telemetry

Transports emit connection and message telemetry. BEAM-local events use the generic transport event names with metadata.transport == :beam:

  • [:ex_mcp, :transport, :connection, :opened]
  • [:ex_mcp, :transport, :message, :sent]
  • [:ex_mcp, :transport, :message, :received]

Selection Guide

  • Use :stdio for official subprocess MCP servers.
  • Use :http for network boundaries and Phoenix integrations.
  • Use :beam for trusted local Elixir processes.
  • Use :test for tests.