ConduitMcp.Transport.StreamableHTTP (ConduitMCP v0.11.0)

Copy Markdown View Source

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}