Plug endpoint for the MCP Streamable HTTP transport.
Handles POST, GET, and DELETE HTTP methods at the MCP endpoint:
- POST — receive JSON-RPC messages from clients, route to the appropriate MCP.Server session, and return the response
- GET — open an SSE stream for server-initiated messages
- DELETE — terminate a session
Usage
Mount this Plug in your HTTP server (e.g., with Bandit):
# Create a Plug with a server factory function
plug = MCP.Transport.StreamableHTTP.Plug.new(
server_mod: MyApp.McpHandler,
server_opts: []
)
# Start Bandit with the Plug
{:ok, _} = Bandit.start_link(plug: plug, port: 8080)The public Plug option is :server_mod. Do not use :handler here —
:handler is the internal MCP.Server.start_link/1 key that this Plug
constructs for you; passing it to the Plug has no effect.
Client handshake (initialize → initialized → tools/call)
Each session's MCP.Server starts in the :waiting state and only becomes
:ready after it receives the notifications/initialized notification. A
client MUST drive the full MCP handshake, in order:
POSTaninitializerequest — the response carries theMCP-Session-Idheader identifying the new session.POSTanotifications/initializednotification on that session (include theMCP-Session-Idheader). The server returns202 Acceptedand transitions to:ready.- Only then
POSTtools/call(and other requests) on that session.
Going straight from initialize to tools/call — skipping step 2 — is the
single most common integration mistake: the server rejects the request with
"Server not initialized", which can surface as a hang or a confusing error.
MCP.Client.connect/1 performs this handshake for you; if you drive the
transport with a raw HTTP client, you must send notifications/initialized
yourself.
Options
:server_mod(required) — theMCP.Server.Handlermodule. This is the public Plug option (not:handler, which is internal toMCP.Server).:server_opts— options to pass toMCP.Server.start_link/1(only:server_info,:capabilities, and:instructionsare forwarded):handler_opts— options passed to the handler'sMCP.Server.Handler.init/1for each session. Either a static keyword list, or a factory function(Plug.Conn.t() -> keyword())evaluated once per session atinitialize(default:[]). See "Request-scoped handler options" below.:session_id_generator— function that generates session IDs (default:UUID.uuid4/0). Passnilfor stateless mode.:enable_json_response— if true, returnapplication/jsoninstead of SSE for simple request/response (default: false):protocol_version— expected protocol version (default: "2025-11-25")
Request-scoped handler options
:handler_opts threads options into the per-session handler's
MCP.Server.Handler.init/1. This is the supported seam for carrying a
request-established identity — validated by an upstream auth Plug and placed
in conn.assigns — into handler state, without forking this Plug.
Two forms:
- Static —
handler_opts: [region: "eu"]. Passed verbatim toinit/1. - Factory —
handler_opts: fn conn -> [identity: conn.assigns.identity] end. Evaluated once per session, at theinitializePOST, against that request'sconn. The returned keyword list is bound into handler state for the session's whole life; later requests on the same session reuse it and do not re-run the factory.
Example:
# Your auth Plug runs first and sets conn.assigns.identity
plug = MCP.Transport.StreamableHTTP.Plug.new(
server_mod: MyApp.McpHandler,
handler_opts: fn conn -> [identity: conn.assigns.identity] end
)
defmodule MyApp.McpHandler do
@behaviour MCP.Server.Handler
@impl true
def init(opts), do: {:ok, %{identity: Keyword.fetch!(opts, :identity)}}
@impl true
def handle_call_tool("whoami", _args, state),
# acts as the bound principal — NEVER an identity taken from tool args
do: {:ok, [%{"type" => "text", "text" => state.identity.subject}], state}
endSecurity: identity must be established server-side by the authenticated Plug
pipeline and bound at the initialize trust boundary — never supplied by the
model via tool-call arguments, which are model-controlled and spoofable. The
handler stays transport-agnostic: identity arrives through init/1 opts, and
the conn is never leaked into handle_call_tool/3,4.
The factory form requires a conn, so it is supported only on the Plug's
initialize request path. Conn-less start paths (a directly supervised
MCP.Server, stdio, MCP.Transport.StreamableHTTP.PreStarted) support the
static keyword form only.
A factory that raises or returns a non-keyword produces a clean JSON-RPC
"Internal error" (HTTP 500, code -32603) at initialize with no session
started; the detail is logged server-side and never returned to the client.
Summary
Types
Options threaded into the handler's MCP.Server.Handler.init/1.
Types
@type handler_opts() :: keyword() | (Plug.Conn.t() -> keyword())
Options threaded into the handler's MCP.Server.Handler.init/1.
Either a static keyword list, or a factory (Plug.Conn.t() -> keyword())
evaluated once per session at initialize against that request's conn.