Streamable HTTP Deployment

Copy Markdown View Source

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

OptionDefaultMeaning
server— (required)the use Noizu.MCP.Server module
origins:localhostallowed Origin values: :localhost, :any, or a list of origins
idle_timeout30 minsession expiry with no client activity
request_timeout300 000 msper-request budget before the connection gives up
init_timeout30 000 msbudget for the initialize handshake
keepalive25 000 msSSE comment interval on the GET stream
sse_commit_after200 msgrace period before a POST response commits to SSE
contextnil{mod, fun} mapping conn → assigns map at session creation
authnilOAuth 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/json when they're quick and message-free, or upgrade to an SSE stream when the handler emits progress/logs/server-requests (or exceeds sse_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-Id issuance at initialize, 404 after expiry/termination (clients re-initialize transparently), MCP-Protocol-Version header validation, Origin allowlisting (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
end

The 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.