Current section
Files
Jump to
Current section
Files
lib/anubis/server/transport/streamable_http/plug.ex
if Code.ensure_loaded?(Plug) do
defmodule Anubis.Server.Transport.StreamableHTTP.Plug do
@moduledoc """
A Plug implementation for the Streamable HTTP transport.
This plug handles the MCP Streamable HTTP protocol as specified in MCP 2025-03-26.
It provides a single endpoint that supports both GET and POST methods:
- GET: Opens an SSE stream for server-to-client communication
- POST: Handles JSON-RPC messages from client to server
- DELETE: Closes a session
## Usage in Phoenix Router
pipeline :mcp do
plug :accepts, ["json"]
end
scope "/mcp" do
pipe_through :mcp
forward "/", to: Anubis.Server.Transport.StreamableHTTP.Plug, server: :your_server_name
end
## Configuration Options
- `:server` - The server process name (required)
- `:session_header` - Custom header name for session ID (default: "mcp-session-id")
- `:request_timeout` - Request timeout in milliseconds (default: 30000)
"""
@behaviour Plug
use Anubis.Logging
import Plug.Conn
alias Anubis.MCP.Error
alias Anubis.MCP.ID
alias Anubis.MCP.Message
alias Anubis.Server.Authorization
alias Anubis.Server.Registry
alias Anubis.Server.Session
alias Anubis.Server.Supervisor, as: ServerSupervisor
alias Anubis.Server.Transport.StreamableHTTP
alias Anubis.SSE.Streaming
alias Anubis.Telemetry
alias Plug.Conn.Unfetched
require Message
@default_session_header "mcp-session-id"
@default_timeout 30_000
# Plug callbacks
@impl Plug
def init(opts) do
server = Keyword.fetch!(opts, :server)
session_header = Keyword.get(opts, :session_header, @default_session_header)
request_timeout = Keyword.get(opts, :request_timeout, @default_timeout)
%{
server: server,
session_header: session_header,
timeout: request_timeout
}
end
@impl Plug
def call(conn, opts) do
opts = resolve_runtime_config(opts)
if conn.request_path == "/.well-known/oauth-protected-resource" do
handle_well_known(conn, opts)
else
case authorize(conn, opts) do
{:ok, conn, claims} ->
opts
|> Map.put(:auth_claims, claims)
|> then(&handle_request(conn, &1))
{:halt, conn} ->
conn
end
end
end
defp handle_request(conn, opts) do
case conn.method do
"GET" -> handle_get(conn, opts)
"POST" -> handle_post(conn, opts)
"DELETE" -> handle_delete(conn, opts)
_ -> send_error(conn, 405, "Method not allowed")
end
end
defp resolve_runtime_config(%{server: server} = opts) do
session_config = ServerSupervisor.get_session_config(server)
auth_config = ServerSupervisor.get_authorization_config(server)
Map.merge(opts, %{
registry_mod: session_config.registry_mod,
registry_name: Registry.registry_name(server),
transport: Registry.transport_name(server, :streamable_http),
authorization: auth_config
})
end
# GET request handler - establishes SSE connection
defp handle_get(conn, %{transport: transport, session_header: session_header} = opts) do
if wants_sse?(conn) do
session_id = get_or_create_session_id(conn, session_header)
case StreamableHTTP.register_sse_handler(transport, session_id) do
:ok ->
start_sse_streaming(conn, Map.put(opts, :session_id, session_id))
{:error, reason} ->
Logging.transport_event("sse_registration_failed", %{reason: reason}, level: :error)
send_error(conn, 500, "Could not establish SSE connection")
end
else
send_error(conn, 406, "Accept header must include text/event-stream")
end
end
# POST request handler - processes MCP messages directly to Session
defp handle_post(conn, %{session_header: session_header} = opts) do
with :ok <- validate_accept_header(conn),
{:ok, body, conn} <- maybe_read_request_body(conn, opts),
{:ok, [message]} <- maybe_parse_messages(body) do
session_id = determine_session_id(conn, session_header, message)
context = build_request_context(conn, Map.get(opts, :auth_claims))
Logging.transport_event("parsed_messages", %{
message: message,
session_id: session_id
})
process_message(conn, message, session_id, context, opts)
else
{:error, :invalid_accept_header} ->
send_error(
conn,
406,
"Not Acceptable: Client must accept application/json"
)
{:error, :invalid_json} ->
send_jsonrpc_error(
conn,
Error.protocol(:parse_error, %{message: "Invalid JSON"}),
nil
)
{:error, reason} ->
Logging.transport_event("request_error", %{reason: reason}, level: :error)
send_jsonrpc_error(
conn,
Error.protocol(:parse_error, %{reason: reason}),
nil
)
end
end
defp process_message(conn, message, session_id, context, opts) do
cond do
Message.is_notification(message) ->
handle_notification_message(conn, message, session_id, context, opts)
Message.is_response(message) or Message.is_error(message) ->
handle_response_message(conn, message, session_id, context, opts)
Message.is_request(message) ->
handle_request_message(conn, message, session_id, context, opts)
true ->
send_jsonrpc_error(
conn,
Error.protocol(:invalid_request, %{message: "Invalid message type"}),
nil
)
end
end
defp handle_notification_message(conn, message, session_id, context, opts) do
case find_session(opts, session_id) do
{:ok, session_pid} ->
GenServer.cast(session_pid, {:mcp_notification, message, context})
conn
|> put_resp_content_type("application/json")
|> send_resp(202, "{}")
{:error, :not_found} ->
send_error(conn, 404, "Session not found")
end
end
defp handle_response_message(conn, message, session_id, context, opts) do
case find_session(opts, session_id) do
{:ok, session_pid} ->
GenServer.cast(session_pid, {:mcp_response, message, context})
conn
|> put_resp_content_type("application/json")
|> send_resp(202, "{}")
{:error, :not_found} ->
send_error(conn, 404, "Session not found")
end
end
defp handle_request_message(conn, message, session_id, context, opts) do
case find_or_create_session(opts, session_id, message) do
{:ok, session_pid} ->
if wants_sse?(conn) do
handle_sse_request(conn, session_pid, message, session_id, context, opts)
else
handle_json_request(conn, session_pid, message, session_id, context, opts)
end
{:error, reason} ->
send_jsonrpc_error(
conn,
Error.protocol(:internal_error, %{reason: reason}),
extract_request_id(message)
)
end
end
defp handle_json_request(conn, session_pid, message, session_id, context, %{session_header: session_header} = opts) do
case GenServer.call(session_pid, {:mcp_request, message, context}, opts.timeout) do
{:ok, response} when is_binary(response) ->
conn
|> put_resp_content_type("application/json")
|> maybe_add_session_header(session_header, session_id)
|> send_resp(200, response)
{:ok, nil} ->
conn
|> put_resp_content_type("application/json")
|> maybe_add_session_header(session_header, session_id)
|> send_resp(200, "{}")
{:error, error} ->
handle_request_error(conn, error, message)
end
catch
:exit, reason ->
Logging.transport_event("session_call_failed", %{reason: reason}, level: :error)
send_jsonrpc_error(
conn,
Error.protocol(:internal_error, %{message: "Server unavailable"}),
extract_request_id(message)
)
end
defp handle_sse_request(conn, session_pid, message, session_id, context, opts) do
%{session_header: session_header} = opts
case GenServer.call(session_pid, {:mcp_request, message, context}, opts.timeout) do
{:ok, response} when is_binary(response) ->
stream_response_on_conn(conn, response, session_id, session_header)
{:ok, nil} ->
conn
|> put_resp_content_type("application/json")
|> maybe_add_session_header(session_header, session_id)
|> send_resp(200, "{}")
{:error, error} ->
handle_request_error(conn, error, message)
end
catch
:exit, reason ->
Logging.transport_event("session_call_failed", %{reason: reason}, level: :error)
send_jsonrpc_error(
conn,
Error.protocol(:internal_error, %{message: "Server unavailable"}),
extract_request_id(message)
)
end
# Per MCP 2025-06-18 Streamable HTTP: a POST that opts into SSE response
# gets its OWN stream on its OWN HTTP connection, scoped to that request.
# Stream the response chunk on this conn and let Plug finalize the chunked
# response. Never reuse the session-wide SSE handler (GET stream).
defp stream_response_on_conn(conn, response, session_id, session_header) do
conn = put_resp_header(conn, session_header, session_id)
conn = Streaming.prepare_connection(conn)
case Streaming.send_event(conn, response, 0) do
{:ok, conn} ->
conn
{:error, reason} ->
Logging.transport_event(
"sse_post_send_failed",
%{session_id: session_id, reason: inspect(reason)},
level: :warning
)
conn
end
end
defp handle_delete(conn, %{transport: transport, session_header: session_header} = opts) do
case get_req_header(conn, session_header) do
[session_id] when is_binary(session_id) and session_id != "" ->
StreamableHTTP.unregister_sse_handler(transport, session_id)
delete_session_from_store(session_id)
stop_session_process(opts, session_id)
conn
|> put_resp_content_type("application/json")
|> send_resp(200, "{}")
_ ->
send_error(conn, 400, "Session ID required")
end
end
# Session management
defp find_session(%{registry_mod: mod, registry_name: name}, session_id) do
mod.lookup_session(name, session_id)
end
defp find_or_create_session(opts, session_id, message) do
case find_session(opts, session_id) do
{:ok, pid} ->
{:ok, pid}
{:error, :not_found} when Message.is_initialize(message) ->
start_new_session(opts, session_id)
{:error, :not_found} ->
start_and_auto_initialize_session(opts, session_id)
end
end
defp start_and_auto_initialize_session(opts, session_id) do
case start_new_session(opts, session_id) do
{:ok, pid} ->
case Session.auto_initialize(pid) do
:ok ->
Logging.transport_event("session_auto_reinitialized", %{
session_id: session_id
})
{:ok, pid}
{:error, reason} ->
Logging.transport_event("session_auto_reinitialize_failed", %{
session_id: session_id,
reason: inspect(reason)
})
stop_session_process(opts, session_id)
{:error, reason}
end
error ->
error
end
end
defp start_new_session(%{server: server, registry_mod: registry_mod, registry_name: registry_name} = opts, session_id) do
session_config = ServerSupervisor.get_session_config(server)
session_name = Registry.resolve_session_name(registry_mod, registry_name, session_id)
session_opts = [
session_id: session_id,
server_module: server,
name: session_name,
transport: session_config.transport,
session_idle_timeout: session_config.session_idle_timeout || 1_800_000,
timeout: opts.timeout,
task_supervisor: session_config.task_supervisor,
task_store: Map.get(session_config, :task_store)
]
case ServerSupervisor.start_session(server, session_opts) do
{:ok, pid} ->
registry_mod.register_session(registry_name, session_id, pid)
{:ok, pid}
{:error, {:already_started, pid}} ->
{:ok, pid}
{:error, reason} ->
{:error, reason}
end
end
# Helper functions
defp wants_sse?(conn) do
conn
|> get_req_header("accept")
|> List.first("")
|> String.contains?("text/event-stream")
end
defp validate_accept_header(conn) do
accept_header =
conn
|> get_req_header("accept")
|> List.first("")
if String.contains?(accept_header, "application/json") do
:ok
else
{:error, :invalid_accept_header}
end
end
defp get_or_create_session_id(conn, session_header) do
case get_req_header(conn, session_header) do
[session_id] when is_binary(session_id) and session_id != "" ->
session_id
_ ->
ID.generate_session_id()
end
end
defp determine_session_id(conn, session_header, message) when Message.is_initialize(message) do
case get_req_header(conn, session_header) do
[session_id] when is_binary(session_id) and session_id != "" ->
session_id
_ ->
ID.generate_session_id()
end
end
defp determine_session_id(conn, session_header, _message) do
get_or_create_session_id(conn, session_header)
end
defp maybe_parse_messages(body) when is_binary(body) do
case Message.decode(body) do
{:ok, messages} ->
{:ok, messages}
{:error, reason} ->
Logging.transport_event(
"parse_error",
%{body: body, reason: inspect(reason)},
level: :error
)
{:error, :invalid_json}
end
end
defp maybe_parse_messages(body) when is_map(body) do
case Message.validate_message(body) do
{:ok, message} -> {:ok, [message]}
{:error, _} -> {:error, :invalid_json}
end
end
defp maybe_add_session_header(conn, session_header, session_id) do
if get_req_header(conn, session_header) == [] do
put_resp_header(conn, session_header, session_id)
else
conn
end
end
defp maybe_read_request_body(%{body_params: %Unfetched{aspect: :body_params}} = conn, %{timeout: timeout}) do
case Plug.Conn.read_body(conn, read_timeout: timeout) do
{:ok, body, conn} -> {:ok, body, conn}
{:error, reason} -> {:error, reason}
end
end
defp maybe_read_request_body(%{body_params: body} = conn, _), do: {:ok, body, conn}
defp send_error(conn, status, message) do
data = %{data: %{message: message, http_status: status}}
mcp_error =
case status do
404 -> Error.protocol(:invalid_request, data)
405 -> Error.protocol(:method_not_found, data)
406 -> Error.protocol(:invalid_request, data)
_ -> Error.protocol(:internal_error, data)
end
{:ok, error_response} = Error.to_json_rpc(mcp_error, ID.generate_error_id())
conn
|> put_resp_content_type("application/json")
|> send_resp(status, error_response)
end
defp send_jsonrpc_error(conn, %Error{} = error, id) do
error_id = id || ID.generate_error_id()
{:ok, encoded_error} = Error.to_json_rpc(error, error_id)
conn
|> put_resp_content_type("application/json")
|> send_resp(400, encoded_error)
end
defp handle_request_error(conn, %Error{} = error, body) do
send_jsonrpc_error(conn, error, extract_request_id(body))
end
defp handle_request_error(conn, reason, body) do
Logging.transport_event("request_error", %{reason: reason}, level: :error)
send_jsonrpc_error(
conn,
Error.protocol(:internal_error, %{reason: reason}),
extract_request_id(body)
)
end
defp extract_request_id(%{"id" => request_id}), do: request_id
defp extract_request_id(_), do: nil
defp build_request_context(conn, auth_claims) do
%{
assigns: conn.assigns,
type: :http,
req_headers: conn.req_headers,
query_params: fetch_query_params_safe(conn),
remote_ip: conn.remote_ip,
scheme: conn.scheme,
host: conn.host,
port: conn.port,
request_path: conn.request_path,
auth: auth_claims
}
end
defp handle_well_known(conn, %{authorization: nil}) do
send_error(conn, 404, "Not found")
end
defp handle_well_known(conn, %{authorization: auth_config}) do
metadata = Authorization.build_resource_metadata(auth_config)
conn
|> put_resp_content_type("application/json")
|> send_resp(200, JSON.encode!(metadata))
end
defp authorize(conn, %{authorization: nil}), do: {:ok, conn, nil}
defp authorize(conn, %{authorization: auth_config}) do
case extract_bearer_token(conn) do
{:ok, token} ->
validate_bearer_token(conn, token, auth_config)
{:error, :missing_token} ->
www_auth = Authorization.build_www_authenticate(auth_config, :unauthorized)
conn =
conn
|> put_resp_header("www-authenticate", www_auth)
|> put_resp_content_type("application/json")
|> send_resp(401, JSON.encode!(%{"error" => "unauthorized"}))
|> halt()
{:halt, conn}
end
end
defp validate_bearer_token(conn, token, auth_config) do
{validator_mod, validator_opts} = auth_config.validator
_ = validator_opts
Telemetry.execute(
[:server, :authorization, :validate],
%{system_time: System.system_time()},
%{validator: validator_mod}
)
case validator_mod.validate_token(token, auth_config) do
{:ok, raw_claims} ->
claims = Authorization.normalize_claims(raw_claims)
with :ok <- Authorization.validate_expiry(claims),
:ok <- Authorization.validate_audience(claims, auth_config) do
{:ok, conn, claims}
else
{:error, :token_expired} ->
send_auth_error(conn, auth_config, 401, :unauthorized)
{:error, :invalid_audience} ->
send_auth_error(conn, auth_config, 401, :unauthorized)
end
{:error, _reason} ->
send_auth_error(conn, auth_config, 401, :unauthorized)
end
end
defp send_auth_error(conn, auth_config, 401, :unauthorized) do
www_auth = Authorization.build_www_authenticate(auth_config, :unauthorized)
conn =
conn
|> put_resp_header("www-authenticate", www_auth)
|> put_resp_content_type("application/json")
|> send_resp(401, JSON.encode!(%{"error" => "unauthorized"}))
|> halt()
{:halt, conn}
end
defp extract_bearer_token(conn) do
conn
|> get_req_header("authorization")
|> List.first()
|> parse_bearer_header()
end
defp parse_bearer_header(header) when is_binary(header) do
case String.split(header, ~r/\s+/, parts: 2) do
[scheme, token] ->
if String.downcase(scheme) == "bearer" and token != "" do
{:ok, String.trim(token)}
else
{:error, :missing_token}
end
_ ->
{:error, :missing_token}
end
end
defp parse_bearer_header(_), do: {:error, :missing_token}
defp fetch_query_params_safe(conn) do
case conn.query_params do
%Unfetched{} -> nil
params -> params
end
end
defp start_sse_streaming(conn, params) do
%{transport: transport, session_id: session_id, session_header: session_header} = params
handler_pid = self()
conn
|> put_resp_header(session_header, session_id)
|> Streaming.prepare_connection()
|> Streaming.start(transport, session_id,
on_close: fn ->
StreamableHTTP.unregister_sse_handler(transport, session_id, handler_pid)
end
)
end
defp delete_session_from_store(session_id) do
if store = Anubis.get_session_store_adapter() do
store.delete(session_id, [])
end
end
defp stop_session_process(%{server: server, registry_mod: registry_mod}, session_id) do
ServerSupervisor.stop_session(server, registry_mod, session_id)
end
end
end