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
| Transport | Identifier | Best For |
|---|---|---|
| stdio | :stdio | Official MCP subprocess transport |
| Streamable HTTP | :http | Remote servers and Phoenix apps |
| BEAM-local | :beam | Local Elixir client/server pairs |
| Test | :test | In-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.
| Behavior | Legacy Streamable HTTP (2025-03-26–2025-11-25) | Modern Streamable HTTP (2026-07-28) |
|---|---|---|
| Establishment | initialize; server may mint Mcp-Session-Id | server/discover; no protocol session |
| Client messages | POST; session header after initialization | A fresh stateless POST for every message |
| Ordinary response | JSON or request-owned SSE | JSON or request-owned SSE |
| Independent notifications | Optional standalone GET SSE stream | subscriptions/listen POST response stream |
| Server needs client input | JSON-RPC request on an SSE stream | input_required result followed by a new client POST |
| Resume/termination | Last-Event-ID; DELETE session | Not 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 inurl.: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 totrue.: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.
| Header | When sent | Meaning |
|---|---|---|
MCP-Protocol-Version | Every modern request | Mirrors modern _meta.protocolVersion |
Mcp-Method | Every modern request | Mirrors the JSON-RPC method |
Mcp-Name | tools/call, resources/read, and prompts/get | Mirrors the addressed tool, resource, or prompt |
Mcp-Param-* | Annotated tools/call inputs | Mirrors 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/jsonwith the final JSON-RPC response;text/event-streamwith request-related notifications and then the final response; or202 Acceptedwith 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
endPut 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: trueThe 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: :beamon a DSL/handler server, with optionalprotocol_mode. - client side:
transport: :beam,server: pid, optionalprotocol_modeandtimeout.
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
:stdiofor official subprocess MCP servers. - Use
:httpfor network boundaries and Phoenix integrations. - Use
:beamfor trusted local Elixir processes. - Use
:testfor tests.