ConduitMcp.Client.Transport.StreamableHTTP (ConduitMCP v0.11.0)

Copy Markdown View Source

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 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 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 (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 (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 (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 (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 (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 (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 (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.

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

child_specs(opts, key)

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.