Packages

Model Context Protocol (MCP) implementation in Elixir with Phoenix integration

Current section

Files

Jump to
backplane_mcp_protocol lib backplane mcp_protocol transport websocket.ex
Raw

lib/backplane/mcp_protocol/transport/websocket.ex

if Code.ensure_loaded?(:gun) do
defmodule Backplane.McpProtocol.Transport.WebSocket do
@moduledoc """
A transport implementation that uses WebSockets for bidirectional communication
with the MCP server.
> ## Notes {: .info}
>
> For initialization and setup, check our [Installation & Setup](./installation.html) and
> the [Transport options](./transport_options.html) guides for reference.
"""
@behaviour Backplane.McpProtocol.Transport.Behaviour
use GenServer
use Backplane.McpProtocol.Logging
import Peri
alias Backplane.McpProtocol.Telemetry
alias Backplane.McpProtocol.Transport.Behaviour, as: Transport
@type t :: GenServer.server()
@typedoc """
The options for the MCP server.
- `:base_url` - The base URL of the MCP server (e.g. http://localhost:8000) (required).
- `:base_path` - The base path of the MCP server (e.g. /mcp).
- `:ws_path` - The path to the WebSocket endpoint (e.g. /mcp/ws) (default `:base_path` + `/ws`).
"""
@type server ::
Enumerable.t(
{:base_url, String.t()}
| {:base_path, String.t()}
| {:ws_path, String.t()}
)
@type params_t :: Enumerable.t(option)
@typedoc """
The options for the WebSocket transport.
- `:name` - The name of the transport process, respecting the `GenServer` "Name Registration" section.
- `:client` - The client to send the messages to, respecting the `GenServer` "Name Registration" section.
- `:server` - The server configuration.
- `:headers` - The headers to send with the HTTP requests.
- `:transport_opts` - The underlying transport options to pass to Gun.
"""
@type option ::
{:name, GenServer.name()}
| {:client, GenServer.server()}
| {:server, server}
| {:headers, map()}
| {:transport_opts, keyword}
| GenServer.option()
defschema(:options_schema, %{
name: {{:custom, &Backplane.McpProtocol.genserver_name/1}, {:default, __MODULE__}},
client:
{:required,
{:oneof,
[
{:custom, &Backplane.McpProtocol.genserver_name/1},
:pid,
{:tuple, [:atom, :any]}
]}},
server: [
base_url: {:required, {:string, {:transform, &URI.new!/1}}},
base_path: {:string, {:default, "/"}},
ws_path: {:string, {:default, "/ws"}}
],
headers: {:map, {:default, %{}}},
transport_opts: {:any, {:default, []}}
})
@impl Transport
@spec start_link(params_t) :: GenServer.on_start()
def start_link(opts \\ []) do
opts = options_schema!(opts)
GenServer.start_link(__MODULE__, Map.new(opts), name: opts[:name])
end
@impl Transport
def send_message(pid, message, opts) when is_binary(message) do
GenServer.call(pid, {:send, message}, Keyword.get(opts, :timeout, 5000))
end
@impl Transport
def shutdown(pid) do
GenServer.cast(pid, :close_connection)
end
@impl Transport
def supported_protocol_versions, do: :all
@impl GenServer
def init(%{} = opts) do
server_url = URI.append_path(opts.server[:base_url], opts.server[:base_path])
ws_url = URI.append_path(server_url, opts.server[:ws_path])
state = Map.merge(opts, %{ws_url: ws_url, gun_pid: nil, stream_ref: nil})
metadata = %{
transport: :websocket,
ws_url: URI.to_string(ws_url),
client: opts.client
}
Telemetry.execute(
Telemetry.event_transport_init(),
%{system_time: System.system_time()},
metadata
)
{:ok, state, {:continue, :connect}}
end
@impl GenServer
def handle_continue(:connect, state) do
uri = URI.parse(state.ws_url)
protocol = if uri.scheme == "https", do: :https, else: :http
port = uri.port || if protocol == :https, do: 443, else: 80
state =
Map.merge(state, %{
host: uri.host,
port: port,
protocol: protocol
})
metadata = %{
transport: :websocket,
ws_url: URI.to_string(state.ws_url),
host: uri.host,
port: port,
protocol: protocol
}
Telemetry.execute(
Telemetry.event_transport_connect(),
%{system_time: System.system_time()},
metadata
)
gun_opts = %{
protocols: [:http],
http_opts: %{keepalive: :infinity}
}
gun_opts = Map.merge(gun_opts, Map.new(state.transport_opts))
case open_connection(uri.host, port, gun_opts) do
{:ok, gun_pid} ->
handle_connection_established(gun_pid, uri, state)
{:error, reason} ->
Logging.transport_event("gun_open_failed", %{reason: reason}, level: :error)
Telemetry.execute(
Telemetry.event_transport_error(),
%{system_time: System.system_time()},
Map.put(metadata, :error, reason)
)
{:stop, {:gun_open_failed, reason}, state}
end
end
defp open_connection(host, port, gun_opts) do
:gun.open(to_charlist(host), port, gun_opts)
end
defp handle_connection_established(gun_pid, uri, state) do
Logging.transport_event("gun_opened", %{host: uri.host, port: uri.port})
Process.monitor(gun_pid)
case :gun.await_up(gun_pid, 5000) do
{:ok, _protocol} ->
initiate_websocket_upgrade(gun_pid, uri, state)
{:error, reason} ->
Logging.transport_event("gun_await_up_failed", %{reason: reason}, level: :error)
{:stop, {:gun_await_up_failed, reason}, state}
end
end
defp initiate_websocket_upgrade(gun_pid, uri, state) do
headers =
state.headers
|> Map.to_list()
|> Enum.map(fn {k, v} -> {to_charlist(k), to_charlist(v)} end)
path = uri.path || "/"
path = if uri.query, do: "#{path}?#{uri.query}", else: path
stream_ref = :gun.ws_upgrade(gun_pid, to_charlist(path), headers)
Logging.transport_event("ws_upgrade_requested", %{path: path})
{:noreply, %{state | gun_pid: gun_pid, stream_ref: stream_ref}}
end
@impl GenServer
def handle_call({:send, message}, _from, %{gun_pid: pid, stream_ref: stream_ref} = state)
when not is_nil(pid) and not is_nil(stream_ref) do
metadata = %{
transport: :websocket,
message_size: byte_size(message)
}
Telemetry.execute(
Telemetry.event_transport_send(),
%{system_time: System.system_time()},
metadata
)
:ok = :gun.ws_send(pid, stream_ref, {:text, message})
Logging.transport_event("ws_message_sent", String.slice(message, 0, 100))
{:reply, :ok, state}
rescue
e ->
Logging.transport_event("ws_send_failed", %{error: Exception.message(e)}, level: :error)
{:reply, {:error, :send_failed}, state}
end
def handle_call({:send, _message}, _from, state) do
{:reply, {:error, :not_connected}, state}
end
@impl GenServer
def handle_info(
{:gun_ws, pid, stream_ref, {:text, data}},
%{gun_pid: pid, stream_ref: stream_ref, client: client} = state
) do
Logging.transport_event("ws_message_received", String.slice(data, 0, 100))
Telemetry.execute(
Telemetry.event_transport_receive(),
%{system_time: System.system_time()},
%{
transport: :websocket,
message_size: byte_size(data)
}
)
GenServer.cast(client, {:response, data})
{:noreply, state}
end
def handle_info({:gun_ws, pid, stream_ref, :close}, %{gun_pid: pid, stream_ref: stream_ref} = state) do
Logging.transport_event("ws_closed", "Connection closed by server", level: :warning)
Telemetry.execute(
Telemetry.event_transport_disconnect(),
%{system_time: System.system_time()},
%{
transport: :websocket,
reason: :normal_close
}
)
{:stop, :normal, state}
end
def handle_info({:gun_ws, pid, stream_ref, {:close, code, reason}}, %{gun_pid: pid, stream_ref: stream_ref} = state) do
Logging.transport_event("ws_closed", %{code: code, reason: reason}, level: :warning)
Telemetry.execute(
Telemetry.event_transport_disconnect(),
%{system_time: System.system_time()},
%{
transport: :websocket,
code: code,
reason: reason
}
)
{:stop, {:ws_closed, code, reason}, state}
end
def handle_info(
{:gun_upgrade, pid, stream_ref, ["websocket"], _headers},
%{gun_pid: pid, stream_ref: stream_ref, client: client} = state
) do
Logging.transport_event(
"ws_upgrade_success",
"WebSocket connection established"
)
GenServer.cast(client, :initialize)
{:noreply, state}
end
def handle_info({:gun_response, pid, stream_ref, _, status, headers}, %{gun_pid: pid, stream_ref: stream_ref} = state) do
Logging.transport_event(
"ws_upgrade_rejected",
%{status: status, headers: headers},
level: :error
)
{:stop, {:ws_upgrade_rejected, status}, state}
end
def handle_info({:gun_error, pid, stream_ref, reason}, %{gun_pid: pid, stream_ref: stream_ref} = state) do
Logging.transport_event("gun_error", %{reason: reason}, level: :error)
{:stop, {:gun_error, reason}, state}
end
def handle_info({:DOWN, _ref, :process, pid, reason}, %{gun_pid: pid} = state) do
Logging.transport_event("gun_down", %{reason: reason}, level: :error)
Telemetry.execute(
Telemetry.event_transport_error(),
%{system_time: System.system_time()},
%{
transport: :websocket,
error: :connection_down,
reason: reason
}
)
{:stop, {:gun_down, reason}, state}
end
def handle_info(msg, state) do
Logging.transport_event("unexpected_message", %{message: msg})
{:noreply, state}
end
@impl GenServer
def handle_cast(:close_connection, %{gun_pid: pid} = state) when not is_nil(pid) do
Telemetry.execute(
Telemetry.event_transport_disconnect(),
%{system_time: System.system_time()},
%{
transport: :websocket,
reason: :client_closed
}
)
:ok = :gun.close(pid)
{:stop, :normal, state}
end
def handle_cast(:close_connection, state) do
Telemetry.execute(
Telemetry.event_transport_disconnect(),
%{system_time: System.system_time()},
%{
transport: :websocket,
reason: :client_closed_before_connected
}
)
{:stop, :normal, state}
end
@impl GenServer
def terminate(reason, %{gun_pid: pid} = _state) when not is_nil(pid) do
Telemetry.execute(
Telemetry.event_transport_terminate(),
%{system_time: System.system_time()},
%{
transport: :websocket,
reason: reason
}
)
Telemetry.execute(
Telemetry.event_transport_disconnect(),
%{system_time: System.system_time()},
%{
transport: :websocket,
reason: reason
}
)
:gun.close(pid)
:ok
end
def terminate(_reason, _state), do: :ok
end
end