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
mcp_elixir_sdk lib mcp transport streamable_http plug.ex
Raw

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)
## Options
* `:server_mod` (required) — the MCP.Server.Handler module
* `:server_opts` — options to pass to `MCP.Server.start_link/1`
* `: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")
"""
@behaviour Plug
require Logger
alias MCP.Transport.SSE
alias MCP.Transport.StreamableHTTP.Server, as: HTTPTransport
@protocol_version "2025-11-25"
defstruct [
:server_mod,
:server_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, [])
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,
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)
case create_session_and_deliver(config, session_id, message) 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
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) do
case start_session(config, session_id) 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) 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) 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) do
server_opts = [
handler: {config.server_mod, []},
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
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