Streamable HTTP server transport (MCP 2025-11-25): a single MCP endpoint handling POST/GET/DELETE, mountable in Phoenix or any Plug stack:
# Phoenix router
forward "/mcp", Noizu.MCP.Transport.StreamableHTTP.Plug, server: MyApp.MCP
# standalone with Bandit
{Bandit, plug: {Noizu.MCP.Transport.StreamableHTTP.Plug, server: MyApp.MCP}, port: 4040}Behavior per spec: initialize POSTs create a session and return
Mcp-Session-Id; requests answer as application/json when the handler
produces only a response, upgrading to an SSE stream when progress,
logging, or server-initiated requests flow first; GET opens the general
SSE stream (with Last-Event-ID resumability backed by
Noizu.MCP.Server.EventStore); DELETE terminates the session.
Options
:server(required) — theuse Noizu.MCP.Servermodule:origins—:localhost(default; allows non-browser clients and localhost origins),:mcp_clients(localhost plus the known browser MCP hosts, seemcp_client_origins/0),:any, or an explicit allowlist of origins. Origin validation guards against DNS-rebinding attacks.:idle_timeout— session idle expiry in ms (default 30 minutes):request_timeout— max time to wait for a handler response (default 300_000):keepalive— SSE keepalive comment interval in ms (default 25_000):context—{module, function}invoked asfun.(conn)returning a map merged into session assigns at initialize (how plug-level auth reaches handlers):auth— resource-server enforcement, see below:cors—false(default) ortrue/keyword to answer browser preflights, see below
Authorization (:auth)
:verifier(required) —Noizu.MCP.Auth.TokenVerifiermodule or{module, opts}:resource_metadata— the RFC 9728 URL advertised in the 401 challenge. A binary,{module, function}(called with the conn),{module, function, args}, a 1-arity fun, or:derive(built from the request and the forward's mount path).:scope— scope advertised in the challenge, so a client that has never seen a token knows what to ask for
CORS (:cors)
A browser MCP client (claude.ai) cannot read the 401 challenge — and so
cannot start OAuth — unless WWW-Authenticate is exposed, and cannot
continue a session unless Mcp-Session-Id is. Enabling :cors answers
OPTIONS with 204 and adds, to every response:
access-control-allow-origin: <origin>
access-control-expose-headers: WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-VersionOnly origins that pass :origins are answered, so CORS never widens the
DNS-rebinding guard. Options: allow_headers:, max_age:.
Summary
Functions
The origins allowed by origins: :mcp_clients — the browser MCP hosts,
which :mcp_clients allows in addition to localhost.
Functions
@spec mcp_client_origins() :: [String.t()]
The origins allowed by origins: :mcp_clients — the browser MCP hosts,
which :mcp_clients allows in addition to localhost.
Extend it rather than replacing it when a host needs one more origin:
origins: ["https://mcp.internal" | Noizu.MCP.Transport.StreamableHTTP.Plug.mcp_client_origins()]