Streamable HTTP client transport for both supported MCP wire eras.
MCP 2026-07-28 and the legacy 2025-03-26 through 2025-11-25 revisions use
the same MCP endpoint but have different lifecycle rules. Select the
compatibility policy with :protocol_mode; do not infer the wire shape
from transport: :http alone.
| Behavior | Legacy Streamable HTTP | MCP 2026-07-28 |
|---|---|---|
| Establishment | initialize; the server may issue Mcp-Session-Id | server/discover; stateless requests |
| Streaming | Optional standalone GET stream plus request-owned SSE | Request- and subscription-owned POST responses |
| Resumption | Last-Event-ID and session DELETE | Close/reissue the owning POST; no session cursor |
Modern requests always use a fresh POST. ExMCP derives
MCP-Protocol-Version, Mcp-Method, Mcp-Name, and annotated
Mcp-Param-* routing headers from the JSON-RPC body. Modern SSE is selected
by the response to the owning POST and does not depend on :use_sse.
Features
- Dual-era negotiation: Modern discovery with explicit, safe legacy fallback
- Request-owned streaming: JSON or SSE responses on modern POST requests
- Modern subscriptions: Long-lived
subscriptions/listenPOST streams - Legacy compatibility: Session IDs, GET SSE, event resumption, and DELETE
- Configurable endpoint: Customize the MCP endpoint path
- Protocol routing: Validated version, method, name, and parameter headers
Security Features
The Streamable HTTP transport supports comprehensive security features:
- Authentication: Bearer tokens, API keys, basic auth
- Origin Validation: Prevent DNS rebinding attacks (recommended to enable)
- CORS Headers: Cross-origin resource sharing
- Security Headers: XSS protection, frame options, etc.
- TLS/SSL: Secure connections with certificate validation
Modern-preferred example
{:ok, client} = ExMCP.Client.start_link(
transport: :http,
url: "https://api.example.com/mcp",
protocol_mode: :prefer_modern,
security: %{
auth: {:bearer, "your-token"},
validate_origin: true,
allowed_origins: ["https://app.example.com"]
}
)With :prefer_modern, ExMCP probes with server/discover and falls back only
when a live peer provides positive legacy compatibility evidence. Use
:modern_only when fallback is not allowed.
Legacy session compatibility
:session_id, :use_sse, Mcp-Session-Id, Last-Event-ID, the standalone
GET stream, and DELETE termination apply only after a connection settles on
a legacy revision. For example:
{:ok, legacy_client} = ExMCP.Client.start_link(
transport: :http,
url: "https://api.example.com",
protocol_mode: :legacy_only,
protocol_version: "2025-11-25",
session_id: "existing-session",
use_sse: true
)Setting use_sse: false disables the legacy standalone GET stream. It does
not disable a modern request-owned SSE response.
Security Best Practices
- Always use HTTPS in production
- Enable origin validation to prevent DNS rebinding attacks
- Bind to localhost when possible for local servers
- Implement proper authentication (bearer tokens, API keys, etc.)
- Set restrictive CORS policies for cross-origin requests
Summary
Functions
Builds SSL options from TLS configuration.
Terminates the server-side session by sending DELETE to the endpoint.
Types
@type t() :: %ExMCP.Transport.HTTP{ access_token: term(), allowed_private_hosts: [String.t()], auth_completed: term(), auth_config: term(), auth_provider: module() | nil, auth_provider_state: any(), base_url: String.t(), dns_resolver: module() | function(), dns_timeout_ms: pos_integer(), endpoint: String.t(), headers: [{String.t(), String.t()}], http_client: module(), last_event_id: String.t() | nil, last_response: map() | nil, max_request_bytes: pos_integer(), max_response_bytes: pos_integer(), max_retry_delay: term(), max_stream_buffer_bytes: pos_integer(), modern_streams: %{optional(ExMCP.Types.request_id()) => pid()}, origin: String.t() | nil, protocol_era: :legacy | :modern | :unknown, protocol_version: String.t(), retry_delay: term(), security: ExMCP.Security.Validation.security_config() | nil, session_id: String.t() | nil, sse_deferred_attempted: boolean(), sse_pid: pid() | nil, timeouts: map(), tool_headers: %{optional(String.t()) => [map()]}, use_sse: boolean() }
Functions
Builds SSL options from TLS configuration.
Always returns a flat :ssl option list. Callers passing the result to
:httpc must wrap it themselves, e.g. [{:ssl, ssl_opts} | http_opts] —
passing the flat list unwrapped makes :httpc silently ignore every
TLS option.
The defaults enable peer verification, the OS trust store, TLS 1.2/1.3,
and HTTPS hostname matching with wildcard support
(:customize_hostname_check). Each default can be overridden through the
TLS configuration map.
Examples
tls_config = %{
verify: :verify_peer,
versions: [:"tlsv1.2", :"tlsv1.3"],
cert: "client.pem",
key: "client.key"
}
ssl_opts = ExMCP.Transport.HTTP.build_ssl_options(tls_config)
# => [verify: :verify_peer, cacerts: [...], versions: [...], ...]
@spec terminate_session(t()) :: :ok
Terminates the server-side session by sending DELETE to the endpoint.
Per the MCP spec, clients SHOULD send a DELETE request with the session ID to allow the server to clean up session state. This is best-effort — errors are logged but don't prevent client shutdown.
Returns :ok regardless of server response (fire-and-forget).