Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ locals_without_parens = [
resource: 2,
resource: 3,
resource: 4,
skill: 2,
skill: 3,
embed_skills: 1,
input_schema: 1,
field: 2,
field: 3,
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@
only runs as a task when the client did not declare the extension.
- `Phantom.Request.missing_capability/1` accepts a `ClientCapabilities` map,
for capabilities whose names contain dots.
- New: Skills, with the MCP Skills extension (`io.modelcontextprotocol/skills`).
`Phantom.Router.skill/3` routes a `skill://` path to an action that returns a
`Phantom.Skill`, and `Phantom.Skill.embed_skills/1` compiles skill
directories, rendering `.eex` files with assigns. Phantom serves
`skills/list`, `skills/get`, `resources/read`, and `resources/directory/read`.
Files render lazily, and caching is opt-in with `Phantom.Skill.with_cache/2`.
`c:Phantom.Router.list_skills/2` decides which skills `skills/list` returns.
`Phantom.Cache.add_skill/2` adds a skill at runtime. The `skill://` scheme is
reserved for skills.

Streamable HTTP transport fixes for the protocol versions before 2026-07-28:

Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,50 @@ It may return the allowed tuples or just their URI strings. Returning `nil` or `
returning an invalid value rejects the resources. URIs that do not resolve to an available resource
template are rejected before the callback runs.

## Defining Skills

Phantom serves [Agent Skills](https://agentskills.io/) with the MCP Skills
extension. Route a skill path under `skill://` to an action, the way a
Phoenix router routes to a controller:

```elixir
defmodule MyApp.MCP.Router do
# ...

# skill://acme/billing/refunds/SKILL.md
skill "acme/billing/refunds", MyApp.MCP.Skills, :refunds
skill "studies/:study_id/study-review", MyApp.MCP.Skills, :study_review
end
```

The action returns a `Phantom.Skill`. `embed_skills/1` compiles skill
directories into functions; files ending in `.eex` render with assigns.

```elixir
defmodule MyApp.MCP.Skills do
use Phantom.Skill

# skills/refunds/SKILL.md.eex, skills/refunds/examples/email.md, ...
embed_skills "skills/*"

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

Phantom calls the action for `skills/list`, `skills/get`, `resources/read`,
and `resources/directory/read`, and computes the digests clients verify from
what it returns. A file can be a binary or a function that renders it when
needed. Caching is opt-in with `Phantom.Skill.with_cache/2`. Parsing
`SKILL.md` frontmatter needs the optional `{:yamerl, "~> 0.10"}` dependency.
Skill routes are resource templates named by their path: they share names and
allow-lists with your resource templates, and are not listed in
`resources/templates/list`. Override `list_skills/2` in your router to decide
which skills `skills/list` returns; by default it lists every skill route without
path params. To add skills at runtime, use `Phantom.Cache.add_skill/2`. See
`Phantom.Skill`.

## Eliciting input

Two helpers, picked by how the dev wants to structure the handler.
Expand Down Expand Up @@ -702,6 +746,7 @@ Phantom will implement these MCP requests on your behalf:
- `resources/read` dispatch the request to your handler. `Phantom.Resource`.
- `resources/subscribe` available if the MCP router is configured with `pubsub`. To notify of updates, prefer `Phantom.Tracker.notify_resources_updated(uris)`; `notify_resource_updated(uri)` remains available for individual changes.
- `resources/unsubscribe` see above.
- `skills/list`, `skills/get`, and `resources/directory/read` serve skills from your router. Skills are resource templates, so `allow_resource_templates/2` limits them too. Read more in `Phantom.Skill`.
- `logging/setLevel` available if the MCP router is configured with `pubsub`. Logs can be sent to client with `Session.log_{level}(session, map_content)`. [See docs](https://modelcontextprotocol.io/specification/20.6.03-26/server/utilities/logging#log-levels).
- `tools/list` list either the allowed tools as provided in the `connect/2` callback or all tools by default. To disable, return `allow_tools(session, [])` in the `connect/2` callback.
- `tools/call` dispatch the request to your handler. Read more in `Phantom.Tool`.
Expand Down
96 changes: 89 additions & 7 deletions lib/phantom/cache.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ defmodule Phantom.Cache do
to use the functions herein.
"""

require Logger

@doc """
Initialize the cache with compiled tooling for the given router.

Expand Down Expand Up @@ -63,17 +65,97 @@ defmodule Phantom.Cache do
resource_templates =
resource_template_spec |> List.wrap() |> Enum.map(&Phantom.ResourceTemplate.build/1)

existing = :persistent_term.get({Phantom, router, :resource_templates}, [])
with_lock(router, fn ->
existing = :persistent_term.get({Phantom, router, :resource_templates}, [])

resource_templates =
Enum.sort_by(Enum.uniq(resource_templates ++ existing), &{&1.scheme, &1.name})
resource_templates =
Enum.sort_by(Enum.uniq(resource_templates ++ existing), &{&1.scheme, &1.name})

validate!(resource_templates)
raise_if_duplicates(resource_templates)
:persistent_term.put({Phantom, router, :resource_templates}, resource_templates)
Phantom.Router.__create_resource_routers__(resource_templates, __CALLER__)
end)
end

@doc """
Add one skill, or a list of skills, for the given router. Each takes the same
arguments as `Phantom.Router.skill/3`:

Phantom.Cache.add_skill(MyApp.MCP.Router,
path: "acme/billing/refunds",
handler: MyApp.MCP.Skills,
function: :refunds
)

This regenerates the router's skill routes once for each call.
"""
def add_skill(router, skill_specs) do
skill_specs =
if is_map(skill_specs) or Keyword.keyword?(skill_specs),
do: [skill_specs],
else: skill_specs

skills =
Enum.map(skill_specs, fn spec ->
spec |> Map.new() |> Map.put(:router, router) |> Phantom.Router.skill_template()
end)

validate!(skills)
register(router)

with_lock(router, fn ->
existing = :persistent_term.get({Phantom, router, :resource_templates}, [])
resource_templates = Enum.sort_by(Enum.uniq(skills ++ existing), & &1.name)
raise_if_duplicates(resource_templates)

__redefine_modules__(fn ->
resource_templates
|> Enum.filter(&(&1.scheme == "skill"))
|> Phantom.Router.__create_resource_routers__(__ENV__)
end)

:persistent_term.put({Phantom, router, :resource_templates}, resource_templates)
end)

:ok
end

# The lock is local, because :persistent_term and the generated routers are local to each node.
defp with_lock(router, fun), do: :global.trans({{__MODULE__, router}, self()}, fun, [node()])

validate!(resource_templates)
raise_if_duplicates(resource_templates)
:persistent_term.put({Phantom, router, :resource_templates}, resource_templates)
Phantom.Router.__create_resource_routers__(resource_templates, __CALLER__)
@doc false
def __redefine_modules__(fun) do
{result, diagnostics} =
Code.with_diagnostics(fn ->
try do
{:ok, fun.()}
rescue
exception -> {:error, exception, __STACKTRACE__}
end
end)

for %{message: message, severity: severity} = diagnostic <- diagnostics,
not String.starts_with?(message, "redefining module") do
Logger.log(
if(severity == :error, do: :error, else: :warning),
Exception.format_file_line(
Path.relative_to_cwd(diagnostic.file || "nofile"),
line(diagnostic)
) <>
" " <> message
)
end

case result do
{:ok, value} -> value
{:error, exception, stacktrace} -> reraise exception, stacktrace
end
end

defp line(%{position: {line, _column}}), do: line
defp line(%{position: line}), do: line

@doc """
List all the entities for the given type.
"""
Expand Down
5 changes: 5 additions & 0 deletions lib/phantom/request.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,15 @@ defmodule Phantom.Request do
resources/list
resources/templates/list
resources/read
resources/directory/read
completion/complete
subscriptions/listen
notifications/cancelled
tasks/get
tasks/update
tasks/cancel
skills/list
skills/get
]

@modern_cacheable_methods ~w[
Expand All @@ -57,6 +60,8 @@ defmodule Phantom.Request do
resources/list
resources/templates/list
resources/read
skills/list
skills/get
]

import Phantom.Utils
Expand Down
7 changes: 7 additions & 0 deletions lib/phantom/resource_plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ defmodule Phantom.ResourcePlug do
)
end

def call(%{assigns: %{resource_template: %{scheme: "skill"} = template}} = fake_conn, _opts) do
%{session: session, uri: uri} = fake_conn.assigns
session = %{session | request: %{session.request | spec: template}}
result = Phantom.Router.Skills.read(template, fake_conn.path_params, uri, session)
assign(fake_conn, :result, wrap(result, uri, session))
end

def call(fake_conn, _opts) do
session = %{
fake_conn.assigns.session
Expand Down
Loading
Loading