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
endThe 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
endParsing 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
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.
Clients receive "resources": "dynamic" instead of a manifest, and some
clients decline to load such skills.
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{}.
@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.
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.
Split a SKILL.md into its parsed frontmatter and its body.
@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.