Phantom.Skill (phantom_mcp v0.7.3)

Copy Markdown View Source

A skill served with the MCP Skills extension (io.modelcontextprotocol/skills).

A skill is a directory of files with a SKILL.md at its root, as defined by the Agent Skills specification. Route a skill path to an action in your Phantom.Router, and return a %Phantom.Skill{} from it, much like a Phoenix controller renders a template:

defmodule MyApp.MCP.Router do
  use Phantom.Router, name: "MyApp", vsn: "1.0"

  # skill://git-workflow/SKILL.md → MyApp.MCP.Skills.git_workflow/2
  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
end

The final segment of the path is the skill's name, and must equal the name in its frontmatter. Earlier segments organize skills and may contain path params, except the first. The router's Phantom.Router.list_skills/2 decides which skills skills/list returns; by default, every route without path params. Clients can get any served skill by URI.

Each skill is a resource template named by its path, so skill paths share names with resource templates, and Phantom.Session.allow_resource_templates/2 limits skills too. Skill routes are not listed in resources/templates/list.

Actions

An action receives the path params and the session, and returns {:reply, %Phantom.Skill{}, session}. Return {:reply, nil, session} when no skill is served at that path. Actions are synchronous.

Phantom calls the same action for skills/list, skills/get, resources/read, and resources/directory/read, and serves the part each method asks for. Digests are computed from what the action returns, so the action must return the same files for the same session. If it cannot, mark the skill with dynamic/1.

Nested skills

A skill routed under another skill's path is nested in it. Each file belongs to the deepest skill whose directory contains it: the parent's manifest and directory listings take each nested skill's files from its own action, and leave out the parent's own files in that directory. A nested action that returns nil or an error serves no files; one that raises fails the parent's skills/get and is left out of skills/list. A session allowed a skill may use every skill nested in it.

Rendering

A file's contents are either a binary or a function that returns iodata. A function is only called when Phantom needs that file: resources/read renders the one file read, while skills/list and skills/get render every file for their digests.

Caching

skills/list and skills/get results are private and uncached unless the action calls with_cache/2. Only declare scope: :public when the skill is the same for every user who can see it.

{:reply, git_workflow(%{}) |> Phantom.Skill.with_cache(ttl_ms: 300_000, scope: :public),
 session}

Embedding skills from files

embed_skills/1 compiles skill directories into functions, the way Phoenix.Component.embed_templates/1 compiles templates. Files ending in .eex are rendered with assigns; the frontmatter of SKILL.md is parsed at compile time and must not contain EEx.

defmodule MyApp.MCP.Skills do
  use Phantom.Skill

  # skills/refunds/SKILL.md.eex, skills/refunds/examples/email.md
  # → refunds(assigns)
  embed_skills "skills/*"

  def refunds(_params, session) do
    {:reply, refunds(%{user: session.assigns.user}), session}
  end

  def git_workflow(_params, session) do
    {:reply,
     Phantom.Skill.new(
       %{name: "git-workflow", description: "Follow this team's Git conventions"},
       %{"SKILL.md" => "# Git workflow\n\nBranch from `main`.\n"}
     ), session}
  end
end

Parsing frontmatter requires the optional :yamerl dependency:

{:yamerl, "~> 0.10"}

Summary

Functions

The skill's files as served, with the frontmatter written ahead of SKILL.md. Renders every file.

Mark a skill as generated such that its files can't have stable digests.

Compile every skill directory matching pattern into a function.

The result of Phantom.Router.list_skills/2: the SKILL.md URIs to list, and the cursor of the next page, or nil on the last page.

Build a skill from its frontmatter and files.

Split a SKILL.md into its parsed frontmatter and its body.

Let clients cache the skill's skills/list and skills/get entries.

Types

t()

@type t() :: %Phantom.Skill{
  cache: nil | [ttl_ms: non_neg_integer(), scope: :public | :private],
  dynamic: boolean(),
  files: %{required(String.t()) => binary() | (-> iodata())},
  frontmatter: %{required(String.t()) => term()}
}

Functions

contents(skill)

@spec contents(t()) :: %{required(String.t()) => binary()}

The skill's files as served, with the frontmatter written ahead of SKILL.md. Renders every file.

dynamic(skill)

@spec dynamic(t()) :: t()

Mark a skill as generated such that its files can't have stable digests.

Clients receive "resources": "dynamic" instead of a manifest, and some clients decline to load such skills.

embed_skills(pattern)

(macro)

Compile every skill directory matching pattern into a function.

The pattern is relative to the current file. A directory with a SKILL.md or SKILL.md.eex becomes a function named after the directory, with hyphens replaced by underscores, that takes assigns and returns a %Phantom.Skill{}.

list(uris, next_cursor)

@spec list([String.t()], String.t() | nil) :: %{
  :skills => [String.t()],
  optional(:nextCursor) => String.t()
}

The result of Phantom.Router.list_skills/2: the SKILL.md URIs to list, and the cursor of the next page, or nil on the last page.

new(frontmatter, files)

@spec new(map(), %{required(String.t()) => iodata() | (-> iodata())}) :: t()

Build a skill from its frontmatter and files.

frontmatter must contain name and description; every other field passes through to clients as written. files maps paths relative to the skill's root to their contents, and must include "SKILL.md" without its frontmatter. Contents are iodata, or a function that returns iodata when the file is needed.

parse(skill_md)

@spec parse(String.t()) :: {:ok, map(), String.t()} | {:error, String.t()}

Split a SKILL.md into its parsed frontmatter and its body.

with_cache(skill, opts)

@spec with_cache(t(), ttl_ms: non_neg_integer(), scope: :public | :private) :: t()

Let clients cache the skill's skills/list and skills/get entries.

Both :ttl_ms and :scope (:public or :private) are required, with the meaning of Phantom.Request.with_cache/2.