Phantom.Router behaviour (phantom_mcp v0.7.3)

Copy Markdown View Source

A DSL for defining MCP servers. This module provides functions that define tools, resources, and prompts.

See Phantom for usage examples.

Options

  • :name — server name advertised to the client
  • :vsn — server version string (defaults to the OTP app version)
  • :instructions — server instructions, typically the moduledoc
  • :icons, :website_url — server metadata
  • :secret_key_base — required to support MCP 2026-07-28. Used by Phantom.RequestState to encrypt the multi-round-trip requestState blob; nodes serving the same router must share this value. Pass a {module, function, args} tuple to read it at runtime, e.g. {Application, :fetch_env!, [:my_app, :mcp_secret_key_base]} set in config/runtime.exs, or {MyAppWeb.Endpoint, :config, [:secret_key_base]}.
  • :request_state_salt — required with :secret_key_base; a stable string used to derive the requestState key.

Telemetry

Telemetry is provided with these events:

  • [:phantom, :dispatch, :start] with meta: ~w[method params request session trace_context]a
  • [:phantom, :dispatch, :stop] with meta: ~w[method params request result session trace_context]a
  • [:phantom, :dispatch, :exception] with meta: ~w[method kind reason stacktrace params request session]a

The :trace_context value is a map of the W3C traceparent, tracestate, and baggage the client provided in the request's _meta.

Summary

Callbacks

Authorize subscriptions and update notifications for resolved resources.

Authorize task status notifications for task IDs a client listens to with subscriptions/listen.

Cancel a task, such as by cancelling its job, when the client sends tasks/cancel.

When the connection is opening, this callback will be invoked. You will receive the session and the adapter's context, for example, when using Phantom.Plug, you'll get the Plug.Conn.

When the connection is closing, this callback will be invoked.

Fetch a task the client asked about with tasks/get, tasks/update, or tasks/cancel. Implementing it enables the Tasks extension (io.modelcontextprotocol/tasks); see Phantom.Tasks.

Return the instructions for the MCP server for a given session. This is retrieved when the client is initializing with the server.

List resources available to the client.

List the skills that skills/list returns, as SKILL.md URIs.

Return the server information for the MCP server for a given session. This is retrieved when the client is initializing with the server.

When the session is terminating, this callback will be invoked. Termination is when the client indicates they are finished with the MCP session.

Receive the client's responses to a task's input_requests from tasks/update, such as to resume the work.

Functions

Define a prompt that can be retrieved by the MCP client.

Reads the resource given its URI, primarily for embedded resources.

Define a resource that can be read by the MCP client.

Constructs a response map for the given resource with the provided parameters. This function is provided to your MCP Router that accepts the session instead.

Route a skill to an action that returns a Phantom.Skill.

Define a tool that can be called by the MCP client.

Types

resolved_resource()

@type resolved_resource() :: {String.t(), map(), Phantom.ResourceTemplate.t()}

Callbacks

authorize_resource_subscriptions(list, t)

@callback authorize_resource_subscriptions([resolved_resource()], Phantom.Session.t()) ::
  [resolved_resource() | String.t()] | nil

Authorize subscriptions and update notifications for resolved resources.

Each resource is represented as {uri, path_params, resource_template}. Return either the filtered resource tuples or their URI strings. Returning nil or an empty list rejects all resources. Any unresolved URI is rejected before this callback is invoked.

Phantom invokes this callback with a one-element list when a client requests resources/subscribe, and with the requested resourceSubscriptions on subscriptions/listen. It also invokes it once with all resources in an update batch before notifying a subscribed session. Applications performing bulk writes should collect their changed resource URIs and call Phantom.Tracker.notify_resources_updated/1 once so this callback can authorize them with one bulk query.

The default implementation authorizes every resolved resource.

authorize_task_subscriptions(list, t)

@callback authorize_task_subscriptions([task_id :: String.t()], Phantom.Session.t()) ::
  [String.t()] | nil

Authorize task status notifications for task IDs a client listens to with subscriptions/listen.

Return the allowed task IDs. Returning nil or an empty list, raising, or returning an invalid value rejects them all, and IDs the client did not ask for are ignored.

Phantom invokes this callback when a client listens. Override it to check many tasks with one query. Before each notifications/tasks, Phantom fetches the task with get_task/2, so revoked access takes effect on the next update and the client gets the stored task.

The default implementation allows the tasks get_task/2 returns.

cancel_task(t, t)

(optional)
@callback cancel_task(Phantom.Tasks.t(), Phantom.Session.t()) :: :ok | {:error, map()}

Cancel a task, such as by cancelling its job, when the client sends tasks/cancel.

The task is the one get_task/2 returned. Cancellation is cooperative: the task may still finish with another status. Without this callback, Phantom acknowledges the request and the task carries on.

connect(t, term)

@callback connect(Phantom.Session.t(), term()) ::
  {:ok, Phantom.Session.t()}
  | {:unauthorized | 401,
     www_authenticate_header :: Phantom.Plug.www_authenticate()}
  | {:forbidden | 403, message :: String.t()}
  | {:not_found | 404, message :: String.t()}
  | {:error, any()}

When the connection is opening, this callback will be invoked. You will receive the session and the adapter's context, for example, when using Phantom.Plug, you'll get the Plug.Conn.

This is critical for authentication and authorization.

  • {:ok, session} - The session is authenticated and authorized.
  • {:unauthorized | 401, www_authenticate_header} - The session is not authenticated. The www_authenticate_header should reveal to the client how to authenticate. This can either be a string to represent a built header, or a map that is passed into Phantom.Plug.www_authenticate/1 to build the header.

  • {:forbidden | 403, message} - The session is not authorized. For example, the user is authenticated, but lacks the account permissions to access the MCP server.

  • {:not_found | 404, message} - The session was terminated. Clients respond by starting a new session. See terminate/1.

  • {:error, message} - The connection should be rejected for any other reason.

disconnect(t)

@callback disconnect(Phantom.Session.t()) :: {:ok, Phantom.Session.t()} | any()

When the connection is closing, this callback will be invoked.

The return value is largely ignored. This is a lifecycle event that will likely happen very often. This could be helpful if you wanted to emit a side-effect when the connection closes or modify the session. Consider hooking into the [:phantom, :plug, :request, :disconnect] telemetry event instead. The telemetry event will receive the modified session if implemented.

get_task(task_id, t)

(optional)
@callback get_task(task_id :: String.t(), Phantom.Session.t()) ::
  {:ok, Phantom.Tasks.t()} | {:error, :not_found | :expired | map()}

Fetch a task the client asked about with tasks/get, tasks/update, or tasks/cancel. Implementing it enables the Tasks extension (io.modelcontextprotocol/tasks); see Phantom.Tasks.

This is where you check that the session may access the task. Return {:error, :not_found} when it may not, so the client cannot tell the task exists. Return {:error, :expired} for a task past its TTL, or a JSON-RPC error map for anything else.

instructions(t)

@callback instructions(Phantom.Session.t()) :: {:ok, String.t()}

Return the instructions for the MCP server for a given session. This is retrieved when the client is initializing with the server.

By default, it will return the compiled :instructions provided to use Phantom.Router, however if you need the instructions to be dynamic based on the session, you may implement this callback and return {:ok, "my instructions"}. Any other shape will result in no instructions.

list_resources(arg1, t)

@callback list_resources(String.t() | nil, Phantom.Session.t()) ::
  {:reply, Phantom.Resource.list_response(), Phantom.Session.t()}
  | {:noreply, Phantom.Session.t()}
  | {:error, any(), Phantom.Session.t()}

List resources available to the client.

This will expect the response to use Phantom.Resource.list/2 as the result. You may also want to leverage resource_for/3 and Phantom.Resource.resource_link/3 to construct the response. See Phantom for an exmaple.

Remember to check for allowed resources according to session.allowed_resource_templates

list_skills(arg1, t)

@callback list_skills(String.t() | nil, Phantom.Session.t()) ::
  {:reply, %{:skills => [String.t()], optional(:nextCursor) => String.t()},
   Phantom.Session.t()}
  | {:error, any(), Phantom.Session.t()}

List the skills that skills/list returns, as SKILL.md URIs.

Return Phantom.Skill.list/2 with the URIs of one page and the cursor of the next page. The cursor is opaque to Phantom. Phantom builds each entry as skills/get does, and leaves out a URI that serves no skill to the session.

The default implementation lists every skill route without path params, by path.

def list_skills(cursor, session) do
  {studies, next_cursor} = MyApp.Studies.page(session.assigns.user, cursor)
  uris = Enum.map(studies, &"skill://studies/#{&1.id}/study-review/SKILL.md")

  {:reply, Phantom.Skill.list(["skill://git-workflow/SKILL.md" | uris], next_cursor),
   session}
end

server_info(t)

@callback server_info(Phantom.Session.t()) ::
  {:ok,
   %{
     :name => String.t(),
     :version => String.t(),
     optional(:icons) => [map()],
     optional(:websiteUrl) => String.t()
   }}
  | {:error, any()}

Return the server information for the MCP server for a given session. This is retrieved when the client is initializing with the server.

By default, it will return the static :name and :vsn provided to use Phantom.Router, however if you need the instructions to be dynamic based on the session, you may implement this callback and return {:ok, %{name: "my name", version: "my version"}. Any other shape will result in no server information.

You may also include optional :icons (a list of serialized icon maps) and :websiteUrl (a URL string) in the returned map. These are part of the MCP 2025-11-25 specification for Implementation.

terminate(t)

@callback terminate(Phantom.Session.t()) :: {:ok, any()} | {:error, any()}

When the session is terminating, this callback will be invoked. Termination is when the client indicates they are finished with the MCP session.

The callback will be invoked and should return {:ok, _} or {:error, _} to indicate success or not in terminating the session. Consider hooking into the [:phantom, :plug, :request, :terminate] telemetry event for side-effects.

Phantom closes the session's open streams, but it does not remember terminated sessions. The MCP specification requires later requests with a terminated session ID to get HTTP 404, so record the session here and reject it in connect/2:

def terminate(session) do
  MyApp.Sessions.mark_terminated(session.id)
  {:ok, session}
end

def connect(session, _conn) do
  if MyApp.Sessions.terminated?(session.id),
    do: {:not_found, "Session terminated"},
    else: {:ok, session}
end

update_task(t, input_responses, t)

(optional)
@callback update_task(Phantom.Tasks.t(), input_responses :: map(), Phantom.Session.t()) ::
  :ok | {:error, map()}

Receive the client's responses to a task's input_requests from tasks/update, such as to resume the work.

The task is the one get_task/2 returned. Only responses for keys still outstanding on it are passed; Phantom acknowledges the request without calling this when none are, or when this callback is not implemented.

Functions

prompt(name, opts_or_handler \\ [])

(macro)

See Phantom.Router.prompt/3

prompt(name, handler, opts)

(macro)

Define a prompt that can be retrieved by the MCP client.

Examples

prompt :summarize,
  description: "A text prompt",
  completion_function: :summarize_complete,
  arguments: [
    %{
      name: "text",
      description: "The text to summarize",
    },
    %{
      name: "resource",
      description: "The resource to summarize",
    }
  ]
)

# ...

require Phantom.Prompt, as: Prompt
def summarize(args, _request, session) do
  {:reply, Prompt.response([
    assistant: Prompt.text("You're great"),
    user: Prompt.text("No you're great!")
  ], session}
end

def summarize_complete("text", _typed_value, session) do
  {:reply, ["many values"], session}
end

def summarize_complete("resource", _typed_value, session) do
  # list of IDs
  {:reply, ["123"], session}
end

read_resource(session, router, uri_struct)

@spec read_resource(Phantom.Session.t(), module(), URI.t()) ::
  {:ok, uri_string :: String.t(),
   Phantom.Resource.blob_content() | Phantom.Resource.text_content()}
  | {:error, error_response :: map()}

Reads the resource given its URI, primarily for embedded resources.

This is available on your router as: MyApp.MCP.Router.read_resource/3 that accepts the session, resource_name, and path params.

For example:

iex> MyApp.MCP.Router.read_resource(session, :my_resource, id: 321)
{:ok, "myapp:///resources/123", %{
  blob: "abc123"
  uri: "myapp:///resources/123",
  mimeType: "audio/wav",
  name: "Some audio",
  title: "Super audio"
}}

resource(pattern, handler)

(macro)

See Phantom.Router.resource/4

resource(pattern, handler, function_or_opts, opts \\ [])

(macro)

Define a resource that can be read by the MCP client.

Examples

resource "app:///studies/:id", MyApp.MCP, :read_study,
  description: "A study",
  mime_type: "application/json"

# ...

require Phantom.Resource, as: Resource
def read_study(%{"id" => id}, _request, session) do
  {:reply, Response.response(
    Response.text("IO.puts \"Hi\"")
  ), session}
end

resource_uri(router_or_templates, name, path_params \\ %{})

Constructs a response map for the given resource with the provided parameters. This function is provided to your MCP Router that accepts the session instead.

For example

iex> MyApp.MCP.Router.resource_uri(session, :my_resource, id: 123)
{:ok, "myapp:///my-resource/123"}

iex> MyApp.MCP.Router.resource_uri(session, :my_resource, foo: "error")
{:error, :invalid_params}

iex> MyApp.MCP.Router.resource_uri(session, :unknown, id: 123)
{:error, :router_not_found}

skill(path, handler, function \\ nil)

(macro)

Route a skill to an action that returns a Phantom.Skill.

The path locates the skill under skill:// and ends in the skill's name. Segments after the first may be path params. The function defaults to the skill's name with hyphens replaced by underscores.

skill "git-workflow", MyApp.MCP.Skills
skill "acme/billing/refunds", MyApp.MCP.Skills, :refunds
skill "studies/:study_id/study-review", MyApp.MCP.Skills, :study_review

See Phantom.Skill for writing actions.

tool(name, handler, opts, list)

(macro)

Define a tool that can be called by the MCP client.

Input schema DSL

Use a do block to define input fields with an Ecto-like syntax. This generates JSON Schema for clients and validates incoming arguments at dispatch time. See Phantom.Tool.JSONSchema for the full list of field types, options, and validators.

tool :search, description: "Search for stuff" do
  field :query, :string, required: true
  field :limit, :integer, default: 10
  field :tags, {:array, :string}
end

Nested objects are supported with a do block on :map fields:

tool :search, description: "Search" do
  field :query, :string, required: true
  field :filters, :map do
    field :category, :string, in: ~w[books movies music]
    field :min_price, :number, minimum: 0
  end
end

Map-based input schema

For full control over the JSON Schema (without server-side validation), pass :input_schema directly:

tool :echo,
  description: "Echo a message",
  input_schema: %{
    required: [:message],
    properties: %{
      message: %{type: "string", description: "message to echo"}
    }
  }

External handler module

tool :search, MyApp.MCP, description: "Search" do
  field :query, :string, required: true
end