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 server.ex
Raw

lib/mcp/transport/streamable_http/server.ex

defmodule MCP.Transport.StreamableHTTP.Server do
@moduledoc """
Streamable HTTP server transport for MCP.
A GenServer that implements the `MCP.Transport` behaviour, bridging
between an HTTP Plug endpoint and the MCP.Server. Incoming HTTP
requests are delivered to this transport, which forwards them to
the MCP.Server. Outgoing messages from the MCP.Server are routed
back to the appropriate HTTP response.
Supports two modes for pending requests:
* **Sync** — the Plug process blocks on `deliver_message/2` waiting
for the response. Used for simple request/response.
* **Stream** — the Plug process registers as a stream endpoint via
`register_stream/3` and receives SSE events asynchronously. Used
for tool calls that may send notifications/requests during execution.
## Options
* `:owner` (required) — pid to receive `{:mcp_message, map}` and
`{:mcp_transport_closed, reason}` messages
* `:session_id` — the session ID for this transport
"""
use GenServer
require Logger
@behaviour MCP.Transport
alias MCP.Transport.SSE
defstruct [
:owner,
:session_id,
# %{request_id => {:sync, from} | {:stream, stream_pid}}
:pending_responses,
:sse_conn
]
# --- Public API (Transport behaviour) ---
@impl MCP.Transport
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@impl MCP.Transport
def send_message(pid, message) when is_map(message) do
GenServer.call(pid, {:send_message, message, []})
end
@doc """
Sends a message with options. Supports `related_request_id` for
routing notifications and requests to the correct SSE stream.
"""
def send_message(pid, message, opts) when is_map(message) and is_list(opts) do
GenServer.call(pid, {:send_message, message, opts})
end
@impl MCP.Transport
def close(pid) do
GenServer.call(pid, :close)
catch
:exit, _ -> :ok
end
@doc """
Delivers an incoming JSON-RPC message to this transport (synchronous).
Called by the Plug when an HTTP POST arrives. For JSON-RPC requests,
the caller blocks until the MCP.Server sends a response.
Returns `{:ok, response_message}` for requests, or `:accepted` for
notifications and responses.
"""
def deliver_message(pid, message) do
GenServer.call(pid, {:deliver_message, message}, 60_000)
end
@doc """
Delivers an incoming JSON-RPC message to this transport (async).
The message is forwarded to the MCP.Server but the caller does
not block for a response. Used with `register_stream/3` for
SSE streaming mode.
"""
def deliver_message_async(pid, message) do
GenServer.cast(pid, {:deliver_message_async, message})
end
@doc """
Registers a Plug process as an SSE stream endpoint for a request.
The Plug process will receive:
* `{:sse_event, sse_encoded_data}` — intermediate SSE events
* `{:sse_done, sse_encoded_data}` — final response event (stream should close)
"""
def register_stream(pid, request_id, stream_pid) do
GenServer.call(pid, {:register_stream, request_id, stream_pid})
end
@doc """
Registers a Plug connection for SSE streaming (GET endpoint).
Server-initiated messages will be streamed to this connection.
"""
def register_sse_conn(pid, conn) do
GenServer.call(pid, {:register_sse_conn, conn})
end
# --- GenServer callbacks ---
@impl GenServer
def init(opts) do
owner = Keyword.fetch!(opts, :owner)
session_id = Keyword.get(opts, :session_id)
state = %__MODULE__{
owner: owner,
session_id: session_id,
pending_responses: %{}
}
{:ok, state}
end
@impl GenServer
def handle_call({:deliver_message, message}, from, state) do
is_request = Map.has_key?(message, "id") && Map.has_key?(message, "method")
# Forward to MCP.Server
send(state.owner, {:mcp_message, message})
if is_request do
request_id = Map.get(message, "id")
pending = Map.put(state.pending_responses, request_id, {:sync, from})
{:noreply, %{state | pending_responses: pending}}
else
{:reply, :accepted, state}
end
end
def handle_call({:send_message, message, opts}, _from, state) do
state = route_outgoing_message(message, opts, state)
{:reply, :ok, state}
end
def handle_call({:register_stream, request_id, stream_pid}, _from, state) do
pending = Map.put(state.pending_responses, request_id, {:stream, stream_pid})
{:reply, :ok, %{state | pending_responses: pending}}
end
def handle_call({:register_sse_conn, conn}, _from, state) do
{:reply, :ok, %{state | sse_conn: conn}}
end
def handle_call({:set_owner, new_owner}, _from, state) do
{:reply, :ok, %{state | owner: new_owner}}
end
def handle_call(:close, _from, state) do
# Notify any remaining stream endpoints
Enum.each(state.pending_responses, fn
{_id, {:stream, pid}} -> send(pid, {:sse_error, :transport_closed})
_ -> :ok
end)
send(state.owner, {:mcp_transport_closed, :normal})
{:stop, :normal, :ok, state}
end
@impl GenServer
def handle_cast({:deliver_message_async, message}, state) do
send(state.owner, {:mcp_message, message})
{:noreply, state}
end
@impl GenServer
def handle_info(msg, state) do
Logger.debug("MCP StreamableHTTP Server: unexpected message: #{inspect(msg)}")
{:noreply, state}
end
@impl GenServer
def terminate(_reason, _state) do
:ok
end
# --- Private helpers ---
defp route_outgoing_message(message, opts, state) do
response_id = Map.get(message, "id")
is_response = response_id != nil && !Map.has_key?(message, "method")
if is_response do
route_response(response_id, message, state)
else
route_non_response(message, opts, state)
end
end
defp route_response(response_id, message, state) do
case Map.pop(state.pending_responses, response_id) do
{nil, _pending} ->
Logger.warning(
"MCP StreamableHTTP Server: no pending request for response id=#{inspect(response_id)}"
)
state
{{:sync, from}, pending} ->
GenServer.reply(from, {:ok, message})
%{state | pending_responses: pending}
{{:stream, stream_pid}, pending} ->
sse_data = SSE.encode_message(message)
send(stream_pid, {:sse_done, sse_data})
%{state | pending_responses: pending}
end
end
defp route_non_response(message, opts, state) do
related_request_id = Keyword.get(opts, :related_request_id)
if related_request_id do
route_to_related_stream(related_request_id, message, state)
else
Logger.debug(
"MCP StreamableHTTP Server: server-initiated message (no related request): #{inspect(Map.get(message, "method", "unknown"))}"
)
state
end
end
defp route_to_related_stream(related_request_id, message, state) do
case Map.get(state.pending_responses, related_request_id) do
{:stream, stream_pid} ->
sse_data = SSE.encode_message(message)
send(stream_pid, {:sse_event, sse_data})
state
_ ->
Logger.debug(
"MCP StreamableHTTP Server: no stream for related request #{inspect(related_request_id)}"
)
state
end
end
end