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

Streamable HTTP transport for MCP (recommended).

Provides a single POST endpoint for bidirectional communication.
This is the modern replacement for SSE transport.

Everything this transport shares with `ConduitMcp.Transport.SSE` — the plug
pipeline, CORS headers, auth, rate limiting, the JSON-RPC POST dispatch,
`GET /health`, the RFC 9728 metadata endpoint and the catch-alls — lives in
`ConduitMcp.Transport.Shared`.

## Endpoint

- `POST /` — JSON-RPC messages. A request is answered with its JSON-RPC
  response; a notification or a JSON-RPC response with `202 Accepted` and
  no body. The `mcp-protocol-version` response header carries the version
  the connection negotiated (see
  `ConduitMcp.Transport.Shared.put_protocol_version_header/2`).
- `GET /` — with `Accept: text/event-stream`, **405** with an `allow`
  header: this server offers no server-to-client SSE stream. Any other GET
  gets a JSON info document.
- `DELETE /` — ends the session named by `Mcp-Session-Id` (200). It is
  validated exactly like a POST: an unknown session id is 404, a missing
  one 400. With sessions off it is **405**.

A legacy request (POST or DELETE) whose `Mcp-Session-Id` names a session
recorded for a different principal (`ConduitMcp.Principal.id/1`) gets the
same 404 as an unknown id: a session is usable, cancellable into and
endable only by the principal that created it.

## Protocol eras

`POST /` serves both eras; the request body decides
(`ConduitMcp.Era.classify/2`), after authentication and before the
message rate limit:

- **Modern** (`2026-07-28`, `_meta` carries the protocol version and
  client capabilities): `ConduitMcp.Plugs.HeaderValidation` checks
  `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` and `Mcp-Param-*`
  against the body; every rejection is HTTP 400 and is not counted by
  `:message_rate_limit`. No session is ever minted or required, and
  `Mcp-Session-Id` and `Last-Event-ID` are ignored — except that a session
  id naming a live session bound to another principal is rejected (400).
  The response echoes the request's version in `mcp-protocol-version` and
  is sent at `ConduitMcp.Protocol.Modern.http_status/1`. A `tools/call`
  whose `_meta` carries a `progressToken` or a `logLevel`, from a client
  whose `Accept` includes `text/event-stream`, may be answered as a
  Server-Sent Events stream: `notifications/progress` and
  `notifications/message` (`ConduitMcp.Progress`, `ConduitMcp.Log`), `:\r\n`
  keep-alive comments, then the response. Closing that stream cancels
  the request (`ConduitMcp.Cancellation`). A `subscriptions/listen` is
  answered with a long-lived stream of the change notifications it opted
  in to (`ConduitMcp.Subscriptions`); it needs `text/event-stream` in
  `Accept` (else 406).
- **Legacy** (`2025-11-25`, `2025-06-18`): sessions, `initialize`, and
  JSON responses as before. A request whose `MCP-Protocol-Version` header
  names a version this server does not support is answered **400**
  (`initialize` excepted); a request without the header is served.

GET and DELETE carry no `_meta` and keep legacy semantics.

## Differences from `ConduitMcp.Transport.SSE`

- **Sessions are Streamable-HTTP-only, by design.** `Mcp-Session-Id` is
  defined by the Streamable HTTP transport in the MCP specification; the
  legacy SSE transport has no session concept, so `:session` is accepted
  here and nowhere else. For the same reason `DELETE /` exists only here,
  and so does `DELETE` in the default `:cors_methods`.

## Options

- `:server_module` (required) — the MCP server module to route requests to
- `:server_name` — advertised server name in the `initialize` response (falls
  back to the module's `__endpoint_config__/0` if defined)
- `:server_version` — advertised server version (same fallback behavior)
- `:instructions` — instructions returned by `initialize` and
  `server/discover`; overrides the server module's own (`use
  ConduitMcp.Server, instructions:` or the Endpoint's `:instructions`)
- `:auth` — authentication plug configuration. See `ConduitMcp.Plugs.Auth`
  and `ConduitMcp.Plugs.OAuth`. Resolved once, in `init/1`.
- `:rate_limit` — HTTP-level rate limit configuration. See `ConduitMcp.Plugs.RateLimit`.
- `:message_rate_limit` — per-message rate limit configuration. See
  `ConduitMcp.Plugs.MessageRateLimit`.
- `:keep_alive_interval` — milliseconds between `:\r\n` keep-alive
  comments on a streamed response (default: 15 000).
- `:max_streams` — maximum concurrent streamed responses (default:
  1 000), `subscriptions/listen` streams included. A `tools/call` response
  that would open a stream past the cap is sent as plain JSON instead,
  without its notifications; a `subscriptions/listen` past the cap is
  refused with HTTP 503.
- `:max_stream_lifetime` — milliseconds after which a streamed request is
  cancelled (default: 1 hour); its response is then `-32603` unless the
  tool returns within `:stream_cancel_timeout`. A `subscriptions/listen`
  stream is ended gracefully at this age, or earlier at its OAuth bearer
  token's `exp`.
- `:stream_cancel_timeout` — milliseconds a cancelled streamed request's
  tool (client disconnect or `:max_stream_lifetime`) gets to notice
  `ConduitMcp.Cancellation.cancelled?/1` and return before it is killed
  (default: 5 000).
- `:max_listens_per_principal` — maximum concurrent `subscriptions/listen`
  streams per principal (`ConduitMcp.Principal.rate_limit_key/1`: the
  authenticated id, else the client's address) (default: 4). The next is
  admitted and the principal's oldest stream is ended gracefully (see
  `ConduitMcp.Subscriptions`); it is never refused with HTTP 429.
- `:listen_idle_timeout` — milliseconds a `subscriptions/listen` stream
  may go without delivering a notification before the server ends it
  gracefully (default: 30 minutes). Keep-alives do not count. Checked on
  each keep-alive tick, so it fires up to `:keep_alive_interval` late.

  These six must be positive integers; anything else raises
  `ArgumentError` at `init/1`.
- `:session` — session-store configuration, a keyword list. **Sessions are
  off unless configured**: omitting `:session` (or passing `session: false`)
  means no `Mcp-Session-Id` is issued or checked. Pass `session: []` to
  enable them with `ConduitMcp.Session.EtsStore`, or `session: [store: MyStore]`
  for another store. See `ConduitMcp.Session`. Add `require_session: true` to
  reject non-`initialize` POSTs that omit the `Mcp-Session-Id` header (HTTP
  400), per the MCP specification's session requirements. A `:session` that
  is not a keyword list, `false` or `nil` (`true`, a map) raises
  `ArgumentError` at `init/1`.
- `:allowed_origins` — allowlist for the `Origin` header. Accepts a list of
  strings, a bare string, a `Regex`, or `"*"`; any other value raises
  `ArgumentError` at `init/1`. A `Regex` is matched unanchored, so anchor it:
  `~r/\Ahttps:\/\/example\.com\z/`.
  **Unset fails closed**: any request carrying an `Origin` is rejected with
  403, because a warning does not stop a browser from reaching a loopback
  server. Requests *without* an `Origin` always pass — native MCP clients
  are not browsers and don't send one. Pass `allowed_origins: "*"` to allow
  all origins explicitly. See `ConduitMcp.Plugs.OriginValidation`.
- `:cors_origin` — value for `access-control-allow-origin`. **Unset means no
  CORS headers are emitted at all**, so a page on another origin cannot read
  the response. Set it (e.g. `"https://myapp.example"`, or `"*"`) to opt in.
- `:cors_methods` — CORS allow-methods header (default:
  `"GET, POST, DELETE, OPTIONS"`; only emitted when `:cors_origin` is set)
- `:cors_headers` — CORS allow-headers header (default:
  `"content-type, authorization, mcp-session-id, mcp-protocol-version, mcp-method, mcp-name"`;
  only emitted when `:cors_origin` is set). A server whose tools take
  `Mcp-Param-*` headers must list those names here itself:
  `access-control-allow-headers` has no wildcard that holds for
  credentialed requests. Whenever `:cors_origin` is set,
  `access-control-expose-headers: mcp-session-id, mcp-protocol-version` is
  emitted too, so a browser client can read its session id.

When used via `ConduitMcp.Endpoint`, the `:auth`, `:rate_limit`, and
`:message_rate_limit` options are auto-extracted from the endpoint config
unless overridden here.

## Example

    {Bandit,
     plug: {ConduitMcp.Transport.StreamableHTTP,
            server_module: MyApp.MCPServer,
            cors_origin: "https://myapp.com",
            cors_methods: "POST, OPTIONS",
            cors_headers: "content-type"},
     port: 4001}

## With Authentication

    {Bandit,
     plug: {ConduitMcp.Transport.StreamableHTTP,
            server_module: MyApp.MCPServer,
            auth: [
              enabled: true,
              strategy: :bearer_token,
              token: "my-secret-token"
            ]},
     port: 4001}

Or with custom verification:

    {Bandit,
     plug: {ConduitMcp.Transport.StreamableHTTP,
            server_module: MyApp.MCPServer,
            auth: [
              strategy: :function,
              verify: &MyApp.Auth.verify_token/1
            ]},
     port: 4001}

---

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