Phantom.Router behaviour (phantom_mcp v0.3.2)

View Source

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

See Phantom for usage examples.

Telemetry

Telemetry is provided with these events:

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

Summary

Callbacks

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

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

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.

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.

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.

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

Callbacks

connect(t, map)

@callback connect(Phantom.Session.t(), %{
  optional(:headers) => Plug.Conn.headers(),
  optional(:params) => Plug.Conn.query_params()
}) ::
  {:ok, Phantom.Session.t()}
  | {:unauthorized | 401,
     www_authenticate_header :: Phantom.Plug.www_authenticate()}
  | {:forbidden | 403, message :: String.t()}
  | {:error, any()}

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

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.

  • {: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.

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

server_info(t)

@callback server_info(Phantom.Session.t()) ::
  {:ok, %{name: String.t(), version: 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.

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.

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}

tool(name, opts_or_handler \\ [])

(macro)

See Phantom.Router.tool/3

tool(name, handler, opts)

(macro)

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

Examples

tool :local_echo,
  description: "A test that echos your message",
  # or supply a `@description` before defining the tool
  input_schema: %{
    required: [:message],
    properties: %{
      message: %{
        type: "string",
        description: "message to echo"
      }
    }
  }

### handled by your function syncronously:

require Phantom.Tool, as: Tool
def local_echo(params, session) do
  # Maps will be JSON-encoded and also provided
  # as structured content.
  {:reply, Tool.text(params), session}
end

# Or asyncronously:

require Phantom.Tool, as: Tool
def local_echo(params, session) do
  Task.async(fn ->
    Process.sleep(1000)
    Session.respond(session, Tool.text(params))
  end)

  {:noreply, session}
end