# `ExMCP.Transport.HTTP`
[🔗](https://github.com/azmaveth/ex_mcp/blob/v1.0.0/lib/ex_mcp/transport/http.ex#L1)

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/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 {: .warning}
>
> 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

# `t`

```elixir
@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()) =&gt; 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()) =&gt; [map()]},
  use_sse: boolean()
}
```

# `build_ssl_options`

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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
