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 MCP2026-07-28. Used byPhantom.RequestStateto encrypt the multi-round-triprequestStateblob; 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 inconfig/runtime.exs, or{MyAppWeb.Endpoint, :config, [:secret_key_base]}.:request_state_salt— required with:secret_key_base; a stable string used to derive therequestStatekey.
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
@type resolved_resource() :: {String.t(), map(), Phantom.ResourceTemplate.t()}
Callbacks
@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.
@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.
@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.
@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. Thewww_authenticate_headershould reveal to the client how to authenticate. This can either be a string to represent a built header, or a map that is passed intoPhantom.Plug.www_authenticate/1to 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. Seeterminate/1.{:error, message}- The connection should be rejected for any other reason.
@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.
@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.
@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.
@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
@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
@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.
@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
@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
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
@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"
}}
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
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}
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_reviewSee Phantom.Skill for writing actions.
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}
endNested 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
endMap-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