ExMCP.Transport.HTTP (ex_mcp v1.0.0-rc.8)

Copy Markdown View Source

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.

BehaviorLegacy Streamable HTTPMCP 2026-07-28
Establishmentinitialize; the server may issue Mcp-Session-Idserver/discover; stateless requests
StreamingOptional standalone GET stream plus request-owned SSERequest- and subscription-owned POST responses
ResumptionLast-Event-ID and session DELETEClose/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/listen POST 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

  1. Always use HTTPS in production
  2. Enable origin validation to prevent DNS rebinding attacks
  3. Bind to localhost when possible for local servers
  4. Implement proper authentication (bearer tokens, API keys, etc.)
  5. 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

t()

@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

build_ssl_options(tls_config)

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: [...], ...]

terminate_session(state)

@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).