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 with202 Acceptedand no body. Themcp-protocol-versionresponse header carries the version the connection negotiated (seeConduitMcp.Transport.Shared.put_protocol_version_header/2).GET /— withAccept: text/event-stream, 405 with anallowheader: this server offers no server-to-client SSE stream. Any other GET gets a JSON info document.DELETE /— ends the session named byMcp-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,_metacarries the protocol version and client capabilities):ConduitMcp.Plugs.HeaderValidationchecksMCP-Protocol-Version,Mcp-Method,Mcp-NameandMcp-Param-*against the body; every rejection is HTTP 400 and is not counted by:message_rate_limit. No session is ever minted or required, andMcp-Session-IdandLast-Event-IDare ignored — except that a session id naming a live session bound to another principal is rejected (400). The response echoes the request's version inmcp-protocol-versionand is sent atConduitMcp.Protocol.Modern.http_status/1. Atools/callwhose_metacarries aprogressTokenor alogLevel, from a client whoseAcceptincludestext/event-stream, may be answered as a Server-Sent Events stream:notifications/progressandnotifications/message(ConduitMcp.Progress,ConduitMcp.Log),:\r\nkeep-alive comments, then the response. Closing that stream cancels the request (ConduitMcp.Cancellation). Asubscriptions/listenis answered with a long-lived stream of the change notifications it opted in to (ConduitMcp.Subscriptions); it needstext/event-streaminAccept(else 406). - Legacy (
2025-11-25,2025-06-18): sessions,initialize, and JSON responses as before. A request whoseMCP-Protocol-Versionheader names a version this server does not support is answered 400 (initializeexcepted); 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-Idis defined by the Streamable HTTP transport in the MCP specification; the legacy SSE transport has no session concept, so:sessionis accepted here and nowhere else. For the same reasonDELETE /exists only here, and so doesDELETEin the default:cors_methods.
Options
:server_module(required) — the MCP server module to route requests to:server_name— advertised server name in theinitializeresponse (falls back to the module's__endpoint_config__/0if defined):server_version— advertised server version (same fallback behavior):instructions— instructions returned byinitializeandserver/discover; overrides the server module's own (use ConduitMcp.Server, instructions:or the Endpoint's:instructions):auth— authentication plug configuration. SeeConduitMcp.Plugs.AuthandConduitMcp.Plugs.OAuth. Resolved once, ininit/1.:rate_limit— HTTP-level rate limit configuration. SeeConduitMcp.Plugs.RateLimit.:message_rate_limit— per-message rate limit configuration. SeeConduitMcp.Plugs.MessageRateLimit.:keep_alive_interval— milliseconds between:\r\nkeep-alive comments on a streamed response (default: 15 000).:max_streams— maximum concurrent streamed responses (default: 1 000),subscriptions/listenstreams included. Atools/callresponse that would open a stream past the cap is sent as plain JSON instead, without its notifications; asubscriptions/listenpast 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-32603unless the tool returns within:stream_cancel_timeout. Asubscriptions/listenstream is ended gracefully at this age, or earlier at its OAuth bearer token'sexp.:stream_cancel_timeout— milliseconds a cancelled streamed request's tool (client disconnect or:max_stream_lifetime) gets to noticeConduitMcp.Cancellation.cancelled?/1and return before it is killed (default: 5 000).:max_listens_per_principal— maximum concurrentsubscriptions/listenstreams 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 (seeConduitMcp.Subscriptions); it is never refused with HTTP 429.:listen_idle_timeout— milliseconds asubscriptions/listenstream 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_intervallate.These six must be positive integers; anything else raises
ArgumentErroratinit/1.:session— session-store configuration, a keyword list. Sessions are off unless configured: omitting:session(or passingsession: false) means noMcp-Session-Idis issued or checked. Passsession: []to enable them withConduitMcp.Session.EtsStore, orsession: [store: MyStore]for another store. SeeConduitMcp.Session. Addrequire_session: trueto reject non-initializePOSTs that omit theMcp-Session-Idheader (HTTP 400), per the MCP specification's session requirements. A:sessionthat is not a keyword list,falseornil(true, a map) raisesArgumentErroratinit/1.:allowed_origins— allowlist for theOriginheader. Accepts a list of strings, a bare string, aRegex, or"*"; any other value raisesArgumentErroratinit/1. ARegexis matched unanchored, so anchor it:~r/\Ahttps:\/\/example\.com\z/. Unset fails closed: any request carrying anOriginis rejected with 403, because a warning does not stop a browser from reaching a loopback server. Requests without anOriginalways pass — native MCP clients are not browsers and don't send one. Passallowed_origins: "*"to allow all origins explicitly. SeeConduitMcp.Plugs.OriginValidation.:cors_origin— value foraccess-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_originis 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_originis set). A server whose tools takeMcp-Param-*headers must list those names here itself:access-control-allow-headershas no wildcard that holds for credentialed requests. Whenever:cors_originis set,access-control-expose-headers: mcp-session-id, mcp-protocol-versionis 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}