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-Versionequal to the request's_metaversion,Mcp-Method,Mcp-Nameontools/call,resources/read,prompts/getand the tasks extension'stasks/get|update|cancel(ConduitMcp.Headers), and theMcp-Param-*headersConduitMcp.Client.call_tool/4computes from the cached tool definition. - Legacy:
MCP-Protocol-Versiononce negotiated, andMcp-Session-Idonce 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 aConduitMcp.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 absolutehttporhttpsURL (no user info, no fragment). Every request is POSTed to it.:server_url_policy-:public_onlyappliesConduitMcp.Client.URLPolicyto the URL before every request, in the request's own process:httpsonly, and the host is resolved (A and AAAA) and refused if any address is private, loopback, link-local or otherwise internal (unless:allow_privateadmits 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 theHostheader 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_sizeand:transport_opts. Refused together with:insecure_skip_tls_verify. For a URL that comes from end users rather than operator configuration.:anytrusts the URL and lets Finch resolve it. The default value is:any.:allow_private(list ofterm/0) - CIDRs thatserver_url_policy: :public_onlyadmits anyway. The default value is[].:resolver- The DNS resolverserver_url_policy: :public_onlyuses, aConduitMcp.Client.URLPolicy.resolver/0; called in the request's process, never the connection. Default: the system resolver. The default value isnil.:headers- Extra headers for every request, as{name, value}pairs; a value may be aConduitMcp.Client.Secret. Headers the transport sets itself (accept,authorization,content-type,mcp-*,last-event-id, hop-by-hop ones) are refused: use:authfor credentials. The default value is[].:auth- Credentials for every request:{:bearer, token}(a string or aConduitMcp.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 withkind: :auth);{:oauth, opts}for the OAuth 2.1 authorization-code flow, or{:client_credentials, opts}for machine-to-machine OAuth (seeConduitMcp.Client.OAuthandConduitMcp.Client.OAuth.Config). Never sent overhttpto a host other than loopback: such a configuration raises. The default value isnil.:credential_id(term/0) - With an:authfunction only: any term naming whose tokens the function returns (a user id, a service name). Results the server markscacheScope: "private"are cached for this identity (HT-11, seeConduitMcp.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 isnil.:finch- The name of aFinchpool you started. Default: a pool the client starts under its own supervisor (HTTP/1,:pool_sizeconnections, 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 isnil.:pool_size(pos_integer/0) - Connections in the library-started pool. Each open event stream (a streamed response, a listen) holds one. The default value is100.:transport_opts(keyword/0) - TLS options for the library-started pool, and withserver_url_policy: :public_onlyfor the pinned pools (Mint:transport_opts, for examplecacertfile:). Anything that turns certificate verification off (verify:other than:verify_peer, or averify_fun:) is refused; see:insecure_skip_tls_verify. The default value is[].:insecure_skip_tls_verify(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 withserver_url_policy: :public_only. The default value isfalse.:redirect-falserefuses every redirect (the request fails).:same_originfollows 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 isfalse.:max_message_bytes(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 withkind: :message_too_large. Bodies are never decompressed: a response with acontent-encodingfails. The default value is8388608.:request_timeout(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:timeoutand:max_total_timeout. Bytes arriving slowly never extend either. The default value is60000.:listen_idle_timeout(pos_integer/0) - Milliseconds a long-lived stream (a modernsubscriptions/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.
- keep-alive comments count as traffic - before it is dropped
and reopened. The default value is
Summary
Functions
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.
Functions
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.