Current section

Files

Jump to
phantom_mcp lib phantom session.ex
Raw

lib/phantom/session.ex

defmodule Phantom.Session do
@moduledoc """
Represents the state of the MCP session. This is the state across the conversation
and is the bridge between the various transports (HTTP, stdio) to persistence,
even if stateless.
"""
require Logger
alias Phantom.Request
@enforce_keys [:id]
defstruct [
:id,
:last_event_id,
:pid,
:prompts,
:pubsub,
:resource_templates,
:router,
:stream_fun,
:tools,
:tracker,
assigns: %{},
close_after_complete: true,
subscribed: [],
requests: %{}
]
@type t :: %__MODULE__{
assigns: map(),
last_event_id: String.t() | nil,
close_after_complete: boolean(),
id: binary(),
pid: pid() | nil,
prompts: [atom()],
pubsub: module(),
requests: map(),
resource_templates: [atom()],
router: module(),
stream_fun: fun(),
subscribed: map(),
tools: [atom()]
}
@spec new(String.t() | nil, Keyword.t() | map) :: t()
def new(session_id, opts \\ []) do
struct!(__MODULE__, [id: session_id || UUIDv7.generate()] ++ opts)
end
def list do
Phoenix.Tracker.list(Phantom.Tracker, "sessions")
end
@doc "Get the PID of the SSE stream for the session id"
def get_sse_pid(session_id) do
case Phoenix.Tracker.get_by_key(Phantom.Tracker, "sessions", session_id) do
[{pid, _} | _] -> pid
_ -> nil
end
end
@spec assign(t(), atom(), any()) :: t()
def assign(session, key, value) do
%{session | assigns: Map.put(session.assigns, key, value)}
end
@spec assign(t(), map()) :: t()
def assign(session, map) do
%{session | assigns: Map.merge(session.assigns, Map.new(map))}
end
@doc false
# not ready
def subscribe_to_resource(%__MODULE__{pubsub: nil}, _uri), do: :error
def subscribe_to_resource(session, uri) do
case get_sse_pid(session.id) do
nil -> :error
pid -> GenServer.cast(pid, {:resource_subscribe, uri})
end
end
@doc """
Sets the log level for the SSE stream.
Sets both for the current request for async tasks and the SSE stream
"""
@spec set_log_level(Session.t(), Request.t(), String.t()) :: :ok
def set_log_level(%__MODULE__{id: id, pid: pid}, request, level) do
GenServer.cast(pid, {:set_log_level, request, level})
case get_sse_pid(id) do
nil -> :error
pid -> GenServer.cast(pid, {:set_log_level, request, level})
end
end
@doc "Closes the SSE stream for the session"
@spec finish(Session.t()) :: :ok
def finish(%__MODULE__{pid: pid}), do: finish(pid)
def finish(pid) when is_pid(pid), do: GenServer.cast(pid, :finish)
@doc """
Sends response back to the SSE stream
This should likely be used in conjunction with:
- `Phantom.Tool.response(payload)`
- `Phantom.ResourceTemplate.response(payload, resource_template, uri)`
- `Phantom.Prompt.response(payload, prompt)`
For example:
```elixir
session_pid = session.pid
request_id = request.id
Task.async(fn ->
Session.respond(
session_pid,
request_id,
Phantom.Tool.response(%{
type: :audio,
data: File.read!("test/support/game-over.wav"),
mime_type: "audio/wav"
})
)
end)
```
"""
def respond(%__MODULE__{pid: pid}, request_id, payload), do: respond(pid, request_id, payload)
def respond(pid, request_id, payload) when is_pid(pid) do
GenServer.cast(
pid,
{:respond, request_id,
%{
id: request_id,
jsonrpc: "2.0",
result: payload
}}
)
end
@log_grades [
emergency: 1,
alert: 2,
critical: 3,
error: 4,
warning: 5,
notice: 6,
info: 7,
debug: 8
]
@doc false
defp do_log(%__MODULE__{pubsub: nil}, _level_num, _name, _domain, _payload), do: :ok
defp do_log(%__MODULE__{id: id}, level_num, level_name, domain, payload) do
case Phoenix.Tracker.get_by_key(Phantom.Tracker, "sessions", id) do
[{pid, _} | _] ->
GenServer.cast(pid, {:log, level_num, level_name, domain, payload})
_ ->
:ok
end
end
@type log_level ::
:emergency | :alert | :critical | :error | :warning | :notice | :info | :debug
@spec log(Session.t(), log_level, String.t(), structured_log :: map()) :: :ok
for {name, level} <- @log_grades do
def log(session, unquote(name), domain, payload) do
do_log(session, unquote(level), unquote(name), domain, payload)
end
@doc "Notify the client with a log at level \"#{name}\""
@spec unquote(:"log_#{name}")(Session.t(), String.t(), structured_log :: map()) :: :ok
def unquote(:"log_#{name}")(session, domain, payload) do
do_log(session, unquote(level), unquote(name), domain, payload)
end
def handle_cast({:set_log_level, request, unquote(to_string(name))}, state) do
state = state.stream_fun.(state, request.id, "message", %{})
{:noreply, %{state | log_level: unquote(level)}}
end
end
@doc false
def start_loop(opts) do
session = Keyword.fetch!(opts, :session)
timeout = Keyword.fetch!(opts, :timeout)
{cb, opts} = Keyword.pop(opts, :continue_fun)
timer = Process.send_after(self(), :inactivity, timeout)
Process.set_label({Phantom.Session, session.id})
:gen_server.enter_loop(
__MODULE__,
[],
Map.new(
opts ++
[log_level: nil, timeout: timeout, last_activity: System.system_time(), timer: timer]
),
self(),
{:continue, cb}
)
end
@doc false
def handle_continue(cb, state) when is_function(cb, 1) do
maybe_finish(cb.(state))
end
@doc false
def handle_cast(:finish, state) do
state = state.stream_fun.(state, nil, "closed", "finished")
{:stop, {:shutdown, :closed}, state}
end
@doc false
def handle_cast({:log, level, level_name, domain, payload}, state)
when state.log_level and level <= state.log_level do
{:noreply,
state.stream_fun.(
state,
nil,
"message",
Request.notify(%{level: level_name, logger: domain, data: payload})
)}
end
def handle_cast({:log, _level, _domain, _payload}, state) do
{:noreply, state}
end
def handle_cast({:respond, request_id, payload}, state) do
cancel_inactivity(state)
state = state.stream_fun.(state, request_id, "message", payload)
requests = Map.delete(state.session.requests, request_id)
state = put_in(state.session.requests, requests)
maybe_finish(state)
end
def handle_cast({:resource, uri}, state) do
if uri not in state.subscribed do
Phoenix.PubSub.subscribe(state.session.pubsub, "phantom:resources")
{:noreply, put_in(state.subscribed, [uri | state.subscribed])}
else
{:noreply, state}
end
end
defp maybe_finish(state) do
if Enum.any?(Map.keys(state.session.requests)) or not state.session.close_after_complete do
{:noreply, state |> set_activity() |> schedule_inactivity()}
else
handle_cast(:finish, state)
end
end
@doc false
# eat this message since we send once the stream loop is over
def handle_info({:plug_conn, :sent}, state), do: {:noreply, state}
def handle_info(:inactivity, state) do
if System.system_time() - state.last_activity > state.timeout do
state = state.stream_fun.(state, nil, "closed", "inactivity")
{:stop, {:shutdown, :closed}, state}
else
{:noreply, state}
end
end
def handle_info({:resource_subscribe, uri}, state) do
if uri in state.subscribed do
{:noreply, state.stream_fun.(state, nil, "message", Phantom.ResourceTemplate.updated(uri))}
else
{:noreply, state}
end
end
def handle_info(what, state) do
Logger.warning(inspect(what))
{:noreply, state}
end
# Phoenix.PubSub.broadcast(Test.PubSub, "phantom:resource", "anything")
defp cancel_inactivity(%{timer: ref}) when is_reference(ref), do: Process.cancel_timer(ref)
defp cancel_inactivity(_), do: :ok
defp set_activity(state), do: %{state | last_activity: System.system_time()}
defp schedule_inactivity(state) do
%{state | timer: Process.send_after(self(), :inactivity, state.timeout)}
end
end