Skip to content

feat(skills): serve the MCP Skills extension - #36

Merged
dbernheisel merged 4 commits into
mainfrom
dbern/skills-mcp
Oct 5, 2026
Merged

dbernheisel merged 4 commits into
mainfrom
dbern/skills-mcp

Conversation

@dbernheisel

@dbernheisel dbernheisel commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Add the MCP Skills extension (io.modelcontextprotocol/skills). server/discover and initialize declare it with directoryRead: true.
  • Add Phantom.Router.skill/2,3. The macro routes a skill:// path to an action that returns a Phantom.Skill. Each route is a resource template in the generated ResourceRouter.Skill (lib/phantom/router.ex).
  • Add Phantom.Skill (lib/phantom/skill.ex): new/2, dynamic/1, with_cache/2, and embed_skills/1. embed_skills/1 compiles skill directories. EEx files render only when a method needs them.
  • Serve skills/list, skills/get, resources/read, and resources/directory/read from the action result (lib/phantom/router/skills.ex, lib/phantom/resource_plug.ex). Digests come from the bytes that resources/read serves.
  • A nested skill route owns the files below its path. A parent manifest includes the files of its nested skills. A session can use a route when the route or a parent skill route is in its allow-list.
  • The router callback list_skills/2 decides which skill URIs skills/list returns. The default lists each route that has no path params. Phantom makes each entry the same way as skills/get.
  • Results are private and not cached, unless the action calls with_cache/2. Non-canonical skill URIs return -32602.
  • Add Phantom.Cache.add_skill/2 to add skills at runtime. It takes one spec or a list.
  • Add :yamerl as an optional dependency. embed_skills/1 uses it to parse SKILL.md frontmatter.
  • Deferred: when a nested skill returns nil and a deeper skill exists below it, the parent listing shows the directory, but a directory read of it fails. Digests are not cached between calls.

Evidence

The script starts Bandit with Phantom.Plug and sends MCP 2026-07-28 requests with Req. It uses the skill fixtures in test/support/skills. Run it from the repository root with mix run evidence.exs.

iex
defmodule Demo.Skills do
  use Phantom.Skill

  embed_skills "test/support/skills/{refunds,git-workflow}"

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

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

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

  skill "git-workflow", Demo.Skills
  skill "acme/billing/refunds", Demo.Skills, :refunds

  def connect(session, _info), do: {:ok, Phantom.Session.assign(session, :user, "Ada")}
end

defmodule Demo.Endpoint do
  use Plug.Router

  plug :match
  plug Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: JSON
  plug :dispatch

  forward "/mcp", to: Phantom.Plug, init_opts: [router: Demo.MCP, validate_origin: false]
end

{:ok, _} = Bandit.start_link(plug: Demo.Endpoint, port: 4999, startup_log: false)

meta = %{
  "io.modelcontextprotocol/protocolVersion" => "2026-07-28",
  "io.modelcontextprotocol/clientInfo" => %{"name" => "evidence", "version" => "1.0.0"},
  "io.modelcontextprotocol/clientCapabilities" => %{}
}

call = fn method, params ->
  headers =
    [
      {"accept", "application/json, text/event-stream"},
      {"mcp-protocol-version", "2026-07-28"},
      {"mcp-method", method}
    ] ++ if(uri = params["uri"], do: [{"mcp-name", uri}], else: [])

  body = %{jsonrpc: "2.0", id: 1, method: method, params: Map.put(params, "_meta", meta)}
  resp = Req.post!("http://localhost:4999/mcp", json: body, headers: headers, decode_body: false)

  resp.body
  |> String.split("\n")
  |> Enum.find_value(resp.body, fn
    "data: " <> data -> data
    _ -> nil
  end)
  |> JSON.decode!()
end

show = fn label, value -> IO.puts("\n# #{label}\n" <> inspect(value, pretty: true, limit: :infinity)) end

discover = call.("server/discover", %{})
show.("server/discover capabilities.extensions", discover["result"]["capabilities"]["extensions"])

list = call.("skills/list", %{})
show.("skills/list", list["result"])

read = call.("resources/read", %{"uri" => "skill://acme/billing/refunds/SKILL.md"})
[content] = read["result"]["contents"]
IO.puts("\n# resources/read skill://acme/billing/refunds/SKILL.md\n" <> content["text"])

listed =
  list["result"]["skills"]
  |> Enum.find(&(&1["uri"] == "skill://acme/billing/refunds/SKILL.md"))
  |> Map.fetch!("resources")
  |> Enum.find(&(&1["uri"] == "skill://acme/billing/refunds/SKILL.md"))

digest = "sha256:" <> Base.encode16(:crypto.hash(:sha256, content["text"]), case: :lower)
show.("digest of bytes read == listed digest", digest == listed["digest"] and byte_size(content["text"]) == listed["size"])

show.(
  "resources/directory/read skill://acme/billing/refunds",
  call.("resources/directory/read", %{"uri" => "skill://acme/billing/refunds"})["result"]
)

show.(
  "skills/get skill://acme/billing/chargebacks/SKILL.md",
  call.("skills/get", %{"uri" => "skill://acme/billing/chargebacks/SKILL.md"})["error"]
)
# server/discover capabilities.extensions
%{"io.modelcontextprotocol/skills" => %{"directoryRead" => true}}

# skills/list
%{
  "cacheScope" => "private",
  "resultType" => "complete",
  "skills" => [
    %{
      "frontmatter" => %{
        "description" => "Process customer refund requests per company policy",
        "license" => "Apache-2.0",
        "metadata" => %{"team" => "billing"},
        "name" => "refunds"
      },
      "resources" => [
        %{
          "digest" => "sha256:0bcfa5f8db69a809b9e7a286171a21dae026e89ad9e4a844fb154d0bb1c14cac",
          "size" => 226,
          "uri" => "skill://acme/billing/refunds/SKILL.md"
        },
        %{
          "digest" => "sha256:778acc59b13150d714924274864d69fba48d9eb698821c62ef546cf31340d224",
          "size" => 1921,
          "uri" => "skill://acme/billing/refunds/assets/logo.png"
        },
        %{
          "digest" => "sha256:c85cd3c70c39accafd45dac46c151ea45bdd0ad0f78d79114dd008b90368678c",
          "size" => 24,
          "uri" => "skill://acme/billing/refunds/examples/email.md"
        },
        %{
          "digest" => "sha256:535cb2b9cfbaa01e4451cb5950cac34531b75cab4041711109ec3b4b81f7eb89",
          "size" => 89,
          "uri" => "skill://acme/billing/refunds/partial-refunds/SKILL.md"
        }
      ],
      "uri" => "skill://acme/billing/refunds/SKILL.md"
    },
    %{
      "frontmatter" => %{
        "description" => "Follow this team's Git conventions: branching and commits",
        "name" => "git-workflow"
      },
      "resources" => [
        %{
          "digest" => "sha256:5e14afb41c8e149b9f2c78f7c252218fa7599b1ca8c2495ffa4bdbc356fde755",
          "size" => 138,
          "uri" => "skill://git-workflow/SKILL.md"
        }
      ],
      "uri" => "skill://git-workflow/SKILL.md"
    }
  ],
  "ttlMs" => 0
}

# resources/read skill://acme/billing/refunds/SKILL.md
---
name: "refunds"
description: "Process customer refund requests per company policy"
license: "Apache-2.0"
metadata: {"team":"billing"}
---
# Refunds

Hello Ada. Draft the reply from [the email template](examples/email.md).


# digest of bytes read == listed digest
true

# resources/directory/read skill://acme/billing/refunds
%{
  "resources" => [
    %{
      "description" => "Process customer refund requests per company policy",
      "mimeType" => "text/markdown",
      "name" => "refunds",
      "uri" => "skill://acme/billing/refunds/SKILL.md"
    },
    %{
      "mimeType" => "inode/directory",
      "name" => "assets",
      "uri" => "skill://acme/billing/refunds/assets"
    },
    %{
      "mimeType" => "inode/directory",
      "name" => "examples",
      "uri" => "skill://acme/billing/refunds/examples"
    },
    %{
      "mimeType" => "inode/directory",
      "name" => "partial-refunds",
      "uri" => "skill://acme/billing/refunds/partial-refunds"
    }
  ],
  "resultType" => "complete"
}

# skills/get skill://acme/billing/chargebacks/SKILL.md
%{
  "code" => -32602,
  "message" => "No skill is served at skill://acme/billing/chargebacks/SKILL.md"
}

Route skill:// paths to actions that return a Phantom.Skill, and serve
skills/list, skills/get, resources/read, and resources/directory/read.
A file belongs to the deepest skill route that contains it, as the
resource router decides. A nested skill that serves nothing serves no
files, and a skill's permission covers the skills nested in it.

Cache.add_skill/2 takes a list, shares its lock with
add_resource_template, logs compiler diagnostics, and waits for reads
still running in the old routes. Digests are always computed from the
served bytes, and descriptions are limited to 1024 characters.
Add the overridable list_skills/2 router callback. The default lists every
skill route without path params.
@dbernheisel
dbernheisel marked this pull request as ready for review October 5, 2026 01:35
@dbernheisel
dbernheisel merged commit ea0a8a0 into main Oct 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant