Phantom.Session (phantom_mcp v0.7.3)

Copy Markdown View Source

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.

Summary

Functions

Set an allow-list of usable Prompts for the session

Set an allow-list of usable Resource Templates for the session

Set an allow-list of usable Tools for the session

Assign state to the session.

Assign state to the session.

Elicit input from the client.

Convenience to elicit a URL mode interaction. Blocks until the client responds.

Closes the connection for the session

Builds a new session with the provided session ID.

Send a notification to the client

Send a progress notification to the client

Send a ping to the client

A reference to the current request that another process, on any node, can pass to notify_progress/4. It is a JSON-safe map, so it can travel in job arguments, such as an Oban job's.

Fetch the current progress token if provided by the client

Sends response back to the stream

Send a JSON-RPC error response for a pending request.

Sets the log level for the SSE stream. Sets both for the current request for async tasks and the SSE stream

Whether the session's current request is using the MCP 2026-07-28 stateless-core protocol.

Subscribe the session to a resource.

Whether the client declared the Tasks extension on the current request, so a tool may answer it with a Phantom.Tasks. The extension requires MCP 2026-07-28, and only the current request counts.

Unsubscribe the session to a resource.

Types

t()

@type t() :: %Phantom.Session{
  acknowledged_subscriptions: MapSet.t(),
  allowed_prompts: [String.t()],
  allowed_resource_templates: [String.t()],
  allowed_tools: [String.t()],
  assigns: map(),
  client_capabilities: %{
    elicitation: false | map(),
    sampling: false | map(),
    roots: false | map(),
    ui: false | map()
  },
  client_info: map(),
  close_after_complete: boolean(),
  id: binary(),
  last_event_id: String.t() | nil,
  pending_elicit: {Phantom.Elicit.t(), term()} | nil,
  pid: pid() | nil,
  pubsub: module(),
  request: Phantom.Request.t() | nil,
  requests: map(),
  router: module(),
  state: term() | nil,
  stream_fun: fun(),
  subscription_filter: map() | nil,
  subscription_id: String.t() | integer() | nil,
  subscriptions: map(),
  tracker: term(),
  transport_pid: pid()
}

Functions

allow_prompts(session, prompts)

Set an allow-list of usable Prompts for the session

allow_resource_templates(session, resource_templates)

Set an allow-list of usable Resource Templates for the session

allow_tools(session, tools)

Set an allow-list of usable Tools for the session

assign(session, map)

@spec assign(t(), map()) :: t()

Assign state to the session.

assign(session, key, value)

@spec assign(t(), atom(), any()) :: t()

Assign state to the session.

elicit(session, elicitation, opts \\ [])

@spec elicit(t(), Phantom.Elicit.t(), keyword()) ::
  {:ok, response :: map()} | t() | :not_supported | :error | :timeout

Elicit input from the client.

Two call patterns, with protocol-aware defaults that preserve historical behavior:

  • Inline blocking (:await true, or default under legacy) — returns {:ok, response} where response is the client's JSON map ("action" and "content" keys), or :not_supported / :timeout / :error. Under legacy MCP protocols the call blocks via the open SSE stream. Under MCP 2026-07-28 the calling process waits: the request is answered with an input_required result, and the client's follow-up call, on any node, resumes the process and receives its eventual response.

  • Re-entry (:state set, or default under stateless) — returns the session struct with the pending elicit attached. The handler wraps it in the standard {:noreply, session} reply shape:

    {:noreply, Session.elicit(session, elicit, state: %{step: :got_input})}

    The dispatcher then converts to an input_required result (stateless) or runs through the SSE elicit round-trip + handler re-invocation (legacy). On resume, the handler is re-entered with session.state populated to whatever you passed as :state. Structure the handler with a function-head clause that matches on %Session{state: %{...}}.

Without :state the call blocks inline under every protocol, so code that pattern-matches {:ok, response} works unchanged under MCP 2026-07-28. The exception is a handler that runs in the request's own process, as resource handlers do: it cannot wait on itself, so it re-enters.

Pick based on style preference:

# Inline — returns the client's response.
def my_tool(_params, session) do
  {:ok, %{"action" => "accept", "content" => %{"choice" => c}}} =
    Session.elicit(session, elicit, await: true)

  {:reply, Tool.text("got #{c}"), session}
end

# Re-entry — the handler is invoked again with session.state populated
def my_tool(%{"choice" => c}, %Session{state: %{step: :got_choice}} = session) do
  {:reply, Tool.text("got #{c}"), session}
end

def my_tool(params, session) do
  {:noreply, Session.elicit(session, elicit, state: %{step: :got_choice})}
end

Options:

  • :await — true to force inline blocking
  • :state — value placed on session.state on re-entry; forces re-entry mode regardless of protocol
  • :timeout — max blocking time in ms (:await mode only; default: 5 minutes)

elicit_url(session, url, message, opts \\ [])

@spec elicit_url(t(), url :: String.t(), message :: String.t(), keyword()) ::
  {:ok, response :: map()} | :not_supported | :error | :timeout

Convenience to elicit a URL mode interaction. Blocks until the client responds.

finish(pid)

@spec finish(t() | pid()) :: :ok

Closes the connection for the session

list_resource_subscriptions(session)

new(session_id, opts \\ [])

@spec new(String.t() | nil, Keyword.t() | map()) :: t()

Builds a new session with the provided session ID.

This is used for adapters such as Phantom.Plug. If a session ID is not provided, it will generate one using UUIDv7.

notify(session, payload)

@spec notify(t() | pid(), payload :: any()) :: :ok

Send a notification to the client

notify_progress(session, progress, total \\ nil, message \\ nil)

@spec notify_progress(t() | map() | nil, number(), nil | number(), String.t() | nil) ::
  :ok

Send a progress notification to the client

the progress and total can be a integer or float, but must be ever-increasing. the total is optional.

Pass the session, or a reference from progress_ref/1 to notify from another process or node.

https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress

notify_progress(pid, progress_token, progress, total, message)

ping(pid)

@spec ping(t() | pid()) :: :ok

Send a ping to the client

progress_ref(session)

@spec progress_ref(t()) :: map() | nil

A reference to the current request that another process, on any node, can pass to notify_progress/4. It is a JSON-safe map, so it can travel in job arguments, such as an Oban job's.

Returns nil when the client did not ask for progress or the session has no PubSub, and notify_progress/4 does nothing with nil.

args = %{report_id: id, progress: Phantom.Session.progress_ref(session)}

# In the job, on any node:
Phantom.Session.notify_progress(args["progress"], 50, 100, "Halfway")

progress_token(session)

Fetch the current progress token if provided by the client

respond(session, payload)

Sends response back to the stream

This should likely be used in conjunction with:

  • Phantom.Tool.response(payload)
  • Phantom.Resource.response(payload)
  • Phantom.Prompt.response(payload)

For example:

session_pid = session.pid
request_id = request.id

Task.async(fn ->
  Session.respond(
    session_pid,
    request_id,
    Phantom.Tool.audio(
      File.read!("priv/static/game-over.wav"),
      mime_type: "audio/wav"
    )
  )
end)

respond(pid, request_id, payload)

See respond/2

respond_error(session, request_id, error)

@spec respond_error(pid() | t(), Phantom.Request.t() | String.t() | integer(), map()) ::
  :ok

Send a JSON-RPC error response for a pending request.

Used by async tool handlers (running in a Task) to finalize a request with a protocol-level error rather than a Tool.error result.

set_log_level(session, request, level)

@spec set_log_level(t(), Phantom.Request.t(), String.t()) :: :ok | :error

Sets the log level for the SSE stream. Sets both for the current request for async tasks and the SSE stream

stateless?(arg1)

Whether the session's current request is using the MCP 2026-07-28 stateless-core protocol.

subscribe_to_resource(session, resource)

@spec subscribe_to_resource(t(), Phantom.Router.resolved_resource() | String.t()) ::
  :ok | :error

Subscribe the session to a resource.

This is used by the MCP Router when the client requests to subscribe to the provided resource.

tasks_supported?(session)

Whether the client declared the Tasks extension on the current request, so a tool may answer it with a Phantom.Tasks. The extension requires MCP 2026-07-28, and only the current request counts.

unsubscribe_to_resource(session, uri)

@spec unsubscribe_to_resource(t(), string_uri :: String.t()) :: :ok | :error

Unsubscribe the session to a resource.

This is used by the MCP Router when the client requests to subscribe to the provided resource.