# `ConduitMcp.Client.Transport.StreamableHTTP`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/transport/streamable_http.ex#L1)

A `ConduitMcp.Client.Transport` for the MCP Streamable HTTP transport, in
both protocol eras. Needs the optional `:finch` dependency
(`{:finch, "~> 0.21"}`); without it the client raises
`ConduitMcp.OptionalDependencyError` when it starts.

    {ConduitMcp.Client,
     name: :docs,
     transport:
       {ConduitMcp.Client.Transport.StreamableHTTP,
        url: "https://mcp.example.com/mcp",
        auth: {:bearer, ConduitMcp.Client.Secret.new(System.fetch_env!("DOCS_TOKEN"))}}}

`kind/0` is `:http`: era discovery follows the HTTP rules of
`ConduitMcp.Client.Negotiation` and `:probe_timeout` defaults to 5 s.

## Processes

The transport's callbacks run in the connection and never wait on the
network. Each request runs in its own process, an *exchange*, under the
client's `Task.Supervisor` and monitored by the connection: it POSTs the
message, reads the answer (a JSON body or an event stream), sends the
response straight to the caller and tells the connection it is done.
Notifications and (legacy) server requests found on any stream go to
the connection, which routes them. A legacy session also gets one
*listener* process for the standalone GET stream.

The HTTP pool is Finch: the one named by `:finch`, else one started under
the client's supervisor (`child_specs/2`) and stopped with it. With
`server_url_policy: :public_only` each request vets the URL in its own
process and connects to the vetted address, through a pool per address
started in that Finch instance (see the option, and the guide
`client_security.md`).

A bearer token must be visible ASCII (0x21-0x7E): one an `:auth`
function or a token store returns with any other byte - a trailing
newline, a control byte - fails the request with `kind: :auth, reason:
:invalid_token`, an error that holds no part of the token.

## Requests

Every POST carries `accept: application/json, text/event-stream`,
`content-type: application/json` and `accept-encoding: identity`.

  * Modern (`2026-07-28`): `MCP-Protocol-Version` equal to the request's
    `_meta` version, `Mcp-Method`, `Mcp-Name` on `tools/call`,
    `resources/read`, `prompts/get` and the tasks extension's
    `tasks/get|update|cancel` (`ConduitMcp.Headers`), and the
    `Mcp-Param-*` headers `ConduitMcp.Client.call_tool/4` computes from
    the cached tool definition.
  * Legacy: `MCP-Protocol-Version` once negotiated, and `Mcp-Session-Id`
    once the server issued one. A session id must be 1 to 1024 visible
    ASCII characters (0x21-0x7E), else the handshake fails
    (`:invalid_session_id`); it is held as a `ConduitMcp.Client.Secret`.

Answers: `202`/`204` complete a notification or response; a `401`, or a
`403`, fails the request with `kind: :auth` and `data: %{status:,
challenges:}` (`ConduitMcp.Client.OAuth.Challenge`); a `429` with
`kind: :rate_limited` and `data: %{retry_after: seconds | nil}`; a 4xx
carrying a JSON-RPC error is delivered as that error (so a modern `400`
`-32022` or `404` `-32601` is a typed protocol error); a legacy `404` to a
request that carried a session id means the session expired: the
connection initializes a new session and sends that request once more.
`5xx`, redirects (see `:redirect`) and network failures are `kind:
:transport`.

## Streams

An event-stream answer may carry notifications and, in the legacy era,
server requests before the response. When a legacy stream ends early
after an event id, the exchange waits the stream's `retry:` (default
1 s, at most 30 s) and resumes it with a GET carrying `Last-Event-ID`,
up to five times. A modern stream that ends without a response fails the
request (`:stream_closed`); it is never replayed.

The legacy standalone GET stream is opened once a session is ready; a
`405`, or a `200` that is not an event stream, turns it off for the
session. It is reopened (resuming with `Last-Event-ID`) when it drops.
Modern `subscriptions/listen` streams are requests like any other (see
`ConduitMcp.Client.Connection`); they get `:listen_idle_timeout` instead
of a total deadline.

## Cancellation

Modern: the exchange is stopped, which closes its socket - the server
sees the disconnect as cancellation. Legacy: `notifications/cancelled` is
POSTed first. `close/2` sends a legacy session's `DELETE` (bounded by
1 s; a `405` is fine).

## Options

* `:url` - Required. The server's MCP endpoint, an absolute `http` or `https` URL
  (no user info, no fragment). Every request is POSTed to it.

* `:server_url_policy` - `:public_only` applies `ConduitMcp.Client.URLPolicy` to the
  URL before every request, in the request's own process:
  `https` only, and the host is resolved (A and AAAA) and
  refused if any address is private, loopback, link-local or
  otherwise internal (unless `:allow_private` admits it). The
  request then connects to the first vetted address - the name
  is never resolved a second time, so a DNS answer that changes
  between the check and the connect (rebinding) cannot redirect
  it - with TLS SNI, certificate verification and the `Host`
  header all bound to the URL's host name. Such connections
  come from pools the transport starts in the Finch instance
  (yours with `:finch`), one per vetted address, configured by
  `:pool_size` and `:transport_opts`. Refused together with
  `:insecure_skip_tls_verify`. For a URL that comes from end
  users rather than operator configuration. `:any` trusts the
  URL and lets Finch resolve it. The default value is `:any`.

* `:allow_private` (list of `t:term/0`) - CIDRs that `server_url_policy: :public_only` admits anyway. The default value is `[]`.

* `:resolver` - The DNS resolver `server_url_policy: :public_only` uses, a
  `t:ConduitMcp.Client.URLPolicy.resolver/0`; called in the
  request's process, never the connection. Default: the system
  resolver. The default value is `nil`.

* `:headers` - Extra headers for every request, as `{name, value}` pairs; a
  value may be a `ConduitMcp.Client.Secret`. Headers the
  transport sets itself (`accept`, `authorization`,
  `content-type`, `mcp-*`, `last-event-id`, hop-by-hop ones) are
  refused: use `:auth` for credentials. The default value is `[]`.

* `:auth` - Credentials for every request: `{:bearer, token}` (a string or
  a `ConduitMcp.Client.Secret`); a one-arity function called
  in the request's own process before each request with
  `%{url: url, method: method}` and returning `{:ok, token}`,
  `{:ok, nil}` (no credentials) or `{:error, reason}` (the
  request fails with `kind: :auth`); `{:oauth, opts}` for the
  OAuth 2.1 authorization-code flow, or `{:client_credentials,
  opts}` for machine-to-machine OAuth (see
  `ConduitMcp.Client.OAuth` and `ConduitMcp.Client.OAuth.Config`).
  Never sent over `http` to a host other than loopback: such a
  configuration raises. The default value is `nil`.

* `:credential_id` (`t:term/0`) - With an `:auth` function only: any term naming whose tokens
  the function returns (a user id, a service name). Results the
  server marks `cacheScope: "private"` are cached for this
  identity (HT-11, see `ConduitMcp.Client.Cache`). Without it,
  the function may return anyone's token from one request to
  the next, so private results fetched with it are never served
  from the cache. The default value is `nil`.

* `:finch` - The name of a `Finch` pool you started. Default: a pool the
  client starts under its own supervisor (HTTP/1, `:pool_size`
  connections, TLS peer verification against the system CA
  store), named from a hash of the client's name. Give the
  library's pool to no other code. The default value is `nil`.

* `:pool_size` (`t:pos_integer/0`) - Connections in the library-started pool. Each open event
  stream (a streamed response, a listen) holds one. The default value is `100`.

* `:transport_opts` (`t:keyword/0`) - TLS options for the library-started pool, and with
  `server_url_policy: :public_only` for the pinned pools (Mint
  `:transport_opts`, for example `cacertfile:`). Anything that
  turns certificate verification off (`verify:` other than
  `:verify_peer`, or a `verify_fun:`) is refused; see
  `:insecure_skip_tls_verify`. The default value is `[]`.

* `:insecure_skip_tls_verify` (`t:boolean/0`) - Do not verify the server's TLS certificate. For local
  development only: a warning is logged whenever the transport
  starts. Needs the library-started pool, and is refused with
  `server_url_policy: :public_only`. The default value is `false`.

* `:redirect` - `false` refuses every redirect (the request fails).
  `:same_origin` follows 307 and 308 redirects to the same
  scheme, host and port (at most 5), re-sending the request and
  its credentials; any other redirect still fails. The default value is `false`.

* `:max_message_bytes` (`t:pos_integer/0`) - The largest JSON body, and the largest event of an event
  stream (also each line of one), in bytes. More fails the
  request with `kind: :message_too_large`. Bodies are never
  decompressed: a response with a `content-encoding` fails. The default value is `8388608`.

* `:request_timeout` (`t:pos_integer/0`) - The total time, in milliseconds, of a request the connection
  sends on its own (handshake, health check). Caller requests
  are bounded by their own `:timeout` and `:max_total_timeout`.
  Bytes arriving slowly never extend either. The default value is `60000`.

* `:listen_idle_timeout` (`t:pos_integer/0`) - Milliseconds a long-lived stream (a modern
  `subscriptions/listen`, the legacy GET stream) may stay silent
  - keep-alive comments count as traffic - before it is dropped
  and reopened. The default value is `120000`.

# `child_specs`

The children the client starts before its connection: the Finch pool,
unless `:finch` names one of yours, and for `auth: {:oauth, ...}` without
a `:store` the default in-memory `ConduitMcp.Client.OAuth.TokenStore.ETS`.

---

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