Packages
Official-style Elixir SDK for the Model Context Protocol (MCP) — client and server with stdio and Streamable HTTP transports.
Current section
Files
Jump to
Current section
Files
lib/mcp/transport/streamable_http/plug.ex
defmodule MCP.Transport.StreamableHTTP.Plug do
@moduledoc """
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:
1. `POST` an `initialize` request — the response carries the
`MCP-Session-Id` header identifying the new session.
2. `POST` a `notifications/initialized` notification on that session
(include the `MCP-Session-Id` header). The server returns `202 Accepted`
and transitions to `:ready`.
3. Only then `POST` `tools/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) — the `MCP.Server.Handler` module. This is the
**public** Plug option (not `:handler`, which is internal to `MCP.Server`).
* `:server_opts` — options to pass to `MCP.Server.start_link/1`
(only `:server_info`, `:capabilities`, and `:instructions` are forwarded)
* `:handler_opts` — options passed to the handler's `c:MCP.Server.Handler.init/1`
for each session. Either a static keyword list, or a factory function
`(Plug.Conn.t() -> keyword())` evaluated once per session at `initialize`
(default: `[]`). See "Request-scoped handler options" below.
* `:session_id_generator` — function that generates session IDs
(default: `UUID.uuid4/0`). Pass `nil` for stateless mode.
* `:enable_json_response` — if true, return `application/json` instead
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
`c: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 to `init/1`.
* **Factory** — `handler_opts: fn conn -> [identity: conn.assigns.identity] end`.
Evaluated **once per session, at the `initialize` POST**, against that
request's `conn`. 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}
end
Security: 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.
"""
@behaviour Plug
require Logger
alias MCP.Transport.SSE
alias MCP.Transport.StreamableHTTP.Server, as: HTTPTransport
@protocol_version "2025-11-25"
@typedoc """
Options threaded into the handler's `c: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.
"""
@type handler_opts :: keyword() | (Plug.Conn.t() -> keyword())
defstruct [
:server_mod,
:server_opts,
:handler_opts,
:session_id_generator,
:enable_json_response,
:protocol_version,
:sessions
]
@doc """
Creates a new Plug configuration.
Returns a tuple `{MCP.Transport.StreamableHTTP.Plug, opts}` suitable
for passing to Bandit or other HTTP servers.
"""
def new(opts) do
{__MODULE__, opts}
end
# --- Plug callbacks ---
@impl Plug
def init(%__MODULE__{} = config), do: config
def init(opts) do
server_mod = Keyword.fetch!(opts, :server_mod)
server_opts = Keyword.get(opts, :server_opts, [])
handler_opts = validate_handler_opts!(Keyword.get(opts, :handler_opts, []))
session_id_generator =
case Keyword.get(opts, :session_id_generator, :default) do
:default -> fn -> UUID.uuid4() end
nil -> nil
fun when is_function(fun, 0) -> fun
end
enable_json_response = Keyword.get(opts, :enable_json_response, false)
protocol_version = Keyword.get(opts, :protocol_version, @protocol_version)
# Create an ETS table to store session mappings
sessions = :ets.new(:mcp_sessions, [:set, :public])
%__MODULE__{
server_mod: server_mod,
server_opts: server_opts,
handler_opts: handler_opts,
session_id_generator: session_id_generator,
enable_json_response: enable_json_response,
protocol_version: protocol_version,
sessions: sessions
}
end
@impl Plug
def call(conn, config) do
if localhost_request?(conn) do
route_method(conn, config)
else
conn
|> Plug.Conn.put_resp_content_type("text/plain")
|> Plug.Conn.send_resp(403, "Forbidden: non-localhost origin")
end
end
defp route_method(conn, config) do
case conn.method do
"POST" -> handle_post(conn, config)
"GET" -> handle_get(conn, config)
"DELETE" -> handle_delete(conn, config)
_ -> method_not_allowed(conn)
end
end
# --- POST handler ---
defp handle_post(conn, config) do
with {:ok, body, conn} <- Plug.Conn.read_body(conn),
{:ok, message} <- Jason.decode(body) do
route_post(conn, config, message)
else
{:error, reason} ->
send_json_error(conn, 400, -32_700, "Parse error", inspect(reason))
end
end
defp route_post(conn, config, message) do
cond do
Map.get(message, "method") == "initialize" ->
handle_initialize(conn, config, message)
config.session_id_generator == nil ->
handle_stateless_request(conn, config, message)
true ->
handle_session_request(conn, config, message)
end
end
defp handle_initialize(conn, config, message) do
session_id = generate_session_id(config)
# Resolve handler_opts against this request's conn BEFORE any transport or
# server is started, so a factory failure orphans nothing (no ETS row, no
# transport, no half-started MCP.Server). Bound once, for the session's life.
case resolve_handler_opts(config.handler_opts, conn) do
{:ok, handler_opts} ->
case create_session_and_deliver(config, session_id, message, handler_opts) do
{:ok, response} ->
conn
|> maybe_set_session_header(session_id)
|> send_response(config, response)
:accepted ->
Plug.Conn.send_resp(conn, 202, "")
{:error, reason} ->
send_json_error(conn, 500, -32_603, "Internal error", inspect(reason))
end
{:error, reason} ->
# Server-side fault (server-supplied factory). Log full detail; return a
# controlled, non-leaking message — a factory closure may hold secrets.
Logger.error("MCP Plug: handler_opts factory failed: #{inspect(reason)}")
send_json_error(conn, 500, -32_603, "Internal error", "handler_opts factory error")
end
end
defp handle_session_request(conn, config, message) do
validate_protocol_version(conn, config)
with {:ok, session_id} <- require_session_id(conn),
{:ok, transport_pid} <- lookup_session(config, session_id) do
deliver_and_respond(conn, config, transport_pid, message)
else
{:error, :missing_session_id} ->
send_json_error(conn, 400, -32_600, "Bad request", "Missing MCP-Session-Id header")
:not_found ->
send_json_error(conn, 404, -32_600, "Not found", "Session not found")
end
end
defp handle_stateless_request(conn, config, message) do
case :ets.first(config.sessions) do
:"$end_of_table" ->
send_json_error(conn, 400, -32_600, "Bad request", "Not initialized")
session_id ->
case lookup_session(config, session_id) do
{:ok, transport_pid} -> deliver_and_respond(conn, config, transport_pid, message)
:not_found -> send_json_error(conn, 400, -32_600, "Bad request", "Session expired")
end
end
end
defp deliver_and_respond(conn, config, transport_pid, message) do
is_request = Map.has_key?(message, "id") && Map.has_key?(message, "method")
if is_request && !config.enable_json_response && accepts_sse?(conn) do
stream_request(conn, transport_pid, message)
else
sync_deliver(conn, config, transport_pid, message)
end
end
defp sync_deliver(conn, config, transport_pid, message) do
case HTTPTransport.deliver_message(transport_pid, message) do
{:ok, response} -> send_response(conn, config, response)
:accepted -> Plug.Conn.send_resp(conn, 202, "")
{:error, reason} -> send_json_error(conn, 500, -32_603, "Internal error", inspect(reason))
end
end
defp stream_request(conn, transport_pid, message) do
request_id = Map.get(message, "id")
# Register this Plug process as a stream endpoint
HTTPTransport.register_stream(transport_pid, request_id, self())
# Open chunked SSE response
conn =
conn
|> Plug.Conn.put_resp_content_type("text/event-stream")
|> Plug.Conn.put_resp_header("cache-control", "no-cache")
|> Plug.Conn.send_chunked(200)
# Deliver message to transport (non-blocking)
HTTPTransport.deliver_message_async(transport_pid, message)
# Enter receive loop to stream events
stream_loop(conn)
end
defp stream_loop(conn) do
receive do
{:sse_event, data} ->
case Plug.Conn.chunk(conn, data) do
{:ok, conn} -> stream_loop(conn)
{:error, _} -> conn
end
{:sse_done, data} ->
case Plug.Conn.chunk(conn, data) do
{:ok, conn} -> conn
{:error, _} -> conn
end
{:sse_error, _reason} ->
conn
after
120_000 ->
conn
end
end
defp accepts_sse?(conn) do
accept = Plug.Conn.get_req_header(conn, "accept")
Enum.any?(accept, &String.contains?(&1, "text/event-stream"))
end
# --- GET handler ---
defp handle_get(conn, config) do
with :ok <- require_sse_accept(conn),
{:ok, session_id} <- require_session_id_if_stateful(conn, config),
{:ok, _transport_pid} <- lookup_session(config, session_id) do
conn
|> Plug.Conn.put_resp_content_type("text/event-stream")
|> Plug.Conn.put_resp_header("cache-control", "no-cache")
|> Plug.Conn.send_resp(200, "")
else
{:error, :not_acceptable} ->
send_json_error(conn, 406, -32_000, "Not Acceptable", "Must accept text/event-stream")
{:error, :missing_session_id} ->
send_json_error(conn, 400, -32_600, "Bad request", "Missing MCP-Session-Id header")
:not_found ->
send_json_error(conn, 404, -32_600, "Not found", "Session not found")
end
end
# --- DELETE handler ---
defp handle_delete(conn, config) do
with {:ok, session_id} <- require_session_id(conn),
{:ok, transport_pid} <- lookup_session(config, session_id) do
HTTPTransport.close(transport_pid)
:ets.delete(config.sessions, session_id)
Plug.Conn.send_resp(conn, 200, "")
else
{:error, :missing_session_id} ->
send_json_error(conn, 400, -32_600, "Bad request", "Missing MCP-Session-Id header")
:not_found ->
Plug.Conn.send_resp(conn, 404, "")
end
end
# --- Helpers ---
defp generate_session_id(config) do
if config.session_id_generator do
config.session_id_generator.()
else
nil
end
end
defp create_session_and_deliver(config, session_id, message, handler_opts) do
case start_session(config, session_id, handler_opts) do
{:ok, transport_pid} ->
if session_id, do: :ets.insert(config.sessions, {session_id, transport_pid})
HTTPTransport.deliver_message(transport_pid, message)
{:error, reason} ->
{:error, reason}
end
end
defp start_session(config, session_id, handler_opts) do
transport_opts = [owner: self(), session_id: session_id]
case HTTPTransport.start_link(transport_opts) do
{:ok, transport_pid} ->
case start_mcp_server(config, transport_pid, handler_opts) do
{:ok, _server_pid} ->
{:ok, transport_pid}
{:error, reason} ->
HTTPTransport.close(transport_pid)
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp start_mcp_server(config, transport_pid, handler_opts) do
server_opts = [
handler: {config.server_mod, handler_opts},
transport: {MCP.Transport.StreamableHTTP.PreStarted, pid: transport_pid}
]
server_opts =
server_opts ++
Keyword.take(config.server_opts, [:server_info, :capabilities, :instructions])
MCP.Server.start_link(server_opts)
end
# Resolves `handler_opts` for a session. Static keyword lists (already
# validated at init) pass through; a factory is evaluated once against the
# `initialize` request's conn. Returns `{:error, reason}` — never raises — if
# the factory raises or returns a non-keyword, so the caller can fail the
# request cleanly without starting a session.
defp resolve_handler_opts(fun, conn) when is_function(fun, 1) do
case fun.(conn) do
result when is_list(result) ->
if Keyword.keyword?(result) do
{:ok, result}
else
{:error, {:non_keyword_result, result}}
end
other ->
{:error, {:non_keyword_result, other}}
end
rescue
exception -> {:error, {:factory_raised, exception, __STACKTRACE__}}
end
defp resolve_handler_opts(list, _conn) when is_list(list), do: {:ok, list}
# Fail-fast validation at Plug.init/1: the static form must be a keyword list,
# or the option must be a 1-arity factory. A config error surfaces at mount
# time, never per-request.
defp validate_handler_opts!(fun) when is_function(fun, 1), do: fun
defp validate_handler_opts!(list) when is_list(list) do
if Keyword.keyword?(list) do
list
else
raise ArgumentError,
"handler_opts must be a keyword list or a 1-arity function " <>
"(Plug.Conn.t() -> keyword()), got a non-keyword list: #{inspect(list)}"
end
end
defp validate_handler_opts!(other) do
raise ArgumentError,
"handler_opts must be a keyword list or a 1-arity function " <>
"(Plug.Conn.t() -> keyword()), got: #{inspect(other)}"
end
defp require_session_id(conn) do
case Plug.Conn.get_req_header(conn, "mcp-session-id") do
[session_id | _] -> {:ok, session_id}
[] -> {:error, :missing_session_id}
end
end
defp require_session_id_if_stateful(conn, config) do
if config.session_id_generator == nil do
{:ok, :ets.first(config.sessions)}
else
require_session_id(conn)
end
end
defp require_sse_accept(conn) do
accept = Plug.Conn.get_req_header(conn, "accept")
accepts_sse = Enum.any?(accept, &String.contains?(&1, "text/event-stream"))
if accepts_sse do
:ok
else
{:error, :not_acceptable}
end
end
defp validate_protocol_version(conn, config) do
case Plug.Conn.get_req_header(conn, "mcp-protocol-version") do
[] ->
:ok
[version | _] when version == config.protocol_version ->
:ok
[version | _] ->
# MCP spec says servers MAY reject unsupported versions.
# We log a warning but accept to maximize interoperability.
Logger.debug(
"MCP Plug: client sent protocol version #{version}, expected #{config.protocol_version}"
)
:ok
end
end
defp lookup_session(config, session_id) do
case :ets.lookup(config.sessions, session_id) do
[{^session_id, pid}] ->
if Process.alive?(pid) do
{:ok, pid}
else
:ets.delete(config.sessions, session_id)
:not_found
end
[] ->
:not_found
end
end
defp maybe_set_session_header(conn, nil), do: conn
defp maybe_set_session_header(conn, session_id) do
Plug.Conn.put_resp_header(conn, "mcp-session-id", session_id)
end
defp send_response(conn, config, response) do
if config.enable_json_response do
send_json_response(conn, response)
else
send_sse_response(conn, response)
end
end
defp send_json_response(conn, response) do
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(response))
end
defp send_sse_response(conn, response) do
sse_data = SSE.encode_message(response)
conn
|> Plug.Conn.put_resp_content_type("text/event-stream")
|> Plug.Conn.put_resp_header("cache-control", "no-cache")
|> Plug.Conn.send_resp(200, sse_data)
end
defp send_json_error(conn, http_status, code, message, data) do
error = %{
"jsonrpc" => "2.0",
"error" => %{"code" => code, "message" => message, "data" => data}
}
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(http_status, Jason.encode!(error))
end
defp method_not_allowed(conn) do
conn
|> Plug.Conn.put_resp_header("allow", "GET, POST, DELETE")
|> Plug.Conn.send_resp(405, "")
end
@localhost_patterns ~w(localhost 127.0.0.1 [::1])
defp localhost_request?(conn) do
origin = Plug.Conn.get_req_header(conn, "origin")
host = Plug.Conn.get_req_header(conn, "host")
origin_ok = origin == [] || Enum.any?(origin, &localhost_value?/1)
host_ok = host == [] || Enum.any?(host, &localhost_value?/1)
origin_ok && host_ok
end
defp localhost_value?(value) do
# Strip scheme prefix if present
host_part =
value
|> String.replace(~r{^https?://}, "")
|> String.split("/")
|> hd()
# Strip port suffix
host_without_port = String.replace(host_part, ~r{:\d+$}, "")
host_without_port in @localhost_patterns
end
end