The Streamable HTTP transport is a plug: Noizu.MCP.Transport.StreamableHTTP.Plug.
It requires the optional :plug dependency (and :bandit or any other Plug
server to run on).
Mounting
# Phoenix — forward from your router (outside pipelines that parse the body!)
forward "/mcp", Noizu.MCP.Transport.StreamableHTTP.Plug, server: MyApp.MCP
# Plug.Router
forward "/mcp", to: Noizu.MCP.Transport.StreamableHTTP.Plug, init_opts: [server: MyApp.MCP]
# Standalone Bandit
children = [
MyApp.MCP, # the server's supervision tree must be running
{Bandit, plug: {Noizu.MCP.Transport.StreamableHTTP.Plug, server: MyApp.MCP}, port: 4040}
]Body parsing
The plug reads the raw request body itself. Don't route it through
Plug.Parsers (in Phoenix, forward from the router rather than mounting
inside :api pipelines that already parsed JSON).
Options
| Option | Default | Meaning |
|---|---|---|
server | — (required) | the use Noizu.MCP.Server module |
origins | :localhost | allowed Origin values: :localhost, :any, or a list of origins |
idle_timeout | 30 min | session expiry with no client activity |
request_timeout | 300 000 ms | per-request budget before the connection gives up |
init_timeout | 30 000 ms | budget for the initialize handshake |
keepalive | 25 000 ms | SSE comment interval on the GET stream |
sse_commit_after | 200 ms | grace period before a POST response commits to SSE |
context | nil | {mod, fun} mapping conn → assigns map at session creation |
auth | nil | OAuth resource-server config — see Authentication |
What the plug implements
Per the 2025-11-25 spec:
- POST — JSON-RPC requests/notifications/responses. Responses come back
as
application/jsonwhen they're quick and message-free, or upgrade to an SSE stream when the handler emits progress/logs/server-requests (or exceedssse_commit_after). - GET — the general SSE stream for unsolicited server→client messages
(subscriptions, list-changed). One per session; duplicates get
409. - DELETE — explicit session termination.
Mcp-Session-Idissuance at initialize,404after expiry/termination (clients re-initialize transparently),MCP-Protocol-Versionheader validation,Originallowlisting (403).
Resumability
Messages destined for SSE streams are buffered in a per-server
Noizu.MCP.Server.EventStore (bounded ETS ring buffer, 1000 events per
session). A client that reconnects with Last-Event-ID receives everything
it missed; on gap (buffer overrun) clients should re-sync by re-listing.
The official client in this library handles both automatically.
Passing request context to handlers
forward "/mcp", Noizu.MCP.Transport.StreamableHTTP.Plug,
server: MyApp.MCP,
context: {MyApp.MCPContext, :assigns}
defmodule MyApp.MCPContext do
def assigns(conn) do
%{remote_ip: conn.remote_ip, tenant: conn.assigns[:tenant]}
end
endThe returned map is merged into ctx.assigns for every handler in that
session. (With auth: configured, verified claims arrive at
ctx.assigns.auth_claims without any extra wiring.)
Scaling notes
Sessions are node-local (Registry + ETS). Behind a load balancer you
need sticky routing on the Mcp-Session-Id header (or a single node).
Session loss on deploy is benign-by-spec: clients get 404 and
re-initialize. Distributed session stores are a post-1.0 extension point.