diff --git a/lib/mcp_registry/probe.ex b/lib/mcp_registry/probe.ex index 8873a24..f228046 100644 --- a/lib/mcp_registry/probe.ex +++ b/lib/mcp_registry/probe.ex @@ -145,6 +145,14 @@ defmodule McpRegistry.Probe do end end + # Nothing on the other end of this is under our control. A server may answer + # with far more items than anyone would page through, or with an entry long + # enough to be a document rather than a name, and either would be stored on + # every row and rendered on every page. These are generous enough that no + # honest server meets them -- the largest seen is 155 resources. + @max_items 500 + @max_length 2_000 + # A resource is named by uri, a tool and a prompt by name. defp names(items) do items @@ -154,7 +162,12 @@ defmodule McpRegistry.Probe do _ -> nil end) |> Enum.reject(&(&1 in [nil, ""])) + # Dropped, not truncated: half a URI is not a shorter URI, it is a wrong + # one, and a page built on it would send the reader somewhere that does + # not exist. + |> Enum.reject(&(String.length(&1) > @max_length)) |> Enum.uniq() + |> Enum.take(@max_items) end # Streamable HTTP may answer as JSON or as a one-event SSE stream, and the diff --git a/lib/mcp_registry/probe/runner.ex b/lib/mcp_registry/probe/runner.ex index bd06a66..d2865a9 100644 --- a/lib/mcp_registry/probe/runner.ex +++ b/lib/mcp_registry/probe/runner.ex @@ -135,5 +135,14 @@ defmodule McpRegistry.Probe.Runner do Logger.warning("Probe could not record #{server.name}: #{inspect(changeset.errors)}") server end + rescue + # `Repo.update/1` returns a changeset for a validation failure but raises + # for a database one, and the raise leaves through `Task.async_stream` and + # takes the whole batch with it. One resource URI longer than the column + # cost the other 399 probes in its batch that way. What one endpoint + # answers is not under our control, so this must not be fatal to the rest. + error -> + Logger.warning("Probe could not record #{server.name}: #{Exception.message(error)}") + server end end diff --git a/lib/mcp_registry/registry.ex b/lib/mcp_registry/registry.ex index 8381c3f..14be800 100644 --- a/lib/mcp_registry/registry.ex +++ b/lib/mcp_registry/registry.ex @@ -213,6 +213,37 @@ defmodule McpRegistry.Registry do |> where([s], fragment("cardinality(?) > 0", s.tools)) end + @doc """ + A page of active listings that have at least one skill, with their skills. + + The same shape as `servers_with_tools/2` and for the same reason: a partial + struct, because `article_content` alone runs to tens of kilobytes. + """ + def servers_with_skills(page, per_page) when page >= 1 do + Server + |> with_skills() + |> order_by([s], asc: s.id) + |> offset(^((page - 1) * per_page)) + |> limit(^per_page) + |> select([s], { + struct(s, [:name, :transport, :remote_url, :package_registry, :package_identifier]), + s.prompts, + s.updated_at + }) + |> Repo.all() + end + + @doc "How many active listings offer at least one skill." + def count_servers_with_skills do + Server |> with_skills() |> Repo.aggregate(:count) + end + + defp with_skills(query) do + query + |> where([s], s.status == "active") + |> where([s], fragment("cardinality(?) > 0", s.prompts)) + end + @doc """ The `n` active listings added most recently, newest first, for the feed. Cached like the catalogue figures, so the sync and every approval refresh it. diff --git a/lib/mcp_registry/registry/skill.ex b/lib/mcp_registry/registry/skill.ex new file mode 100644 index 0000000..4c3690e --- /dev/null +++ b/lib/mcp_registry/registry/skill.ex @@ -0,0 +1,51 @@ +defmodule McpRegistry.Registry.Skill do + @moduledoc """ + What can be said about a skill when its name is all we have. + + A skill here is an MCP **prompt**: a named, invocable capability a server + offers, which a user picks deliberately rather than the model calling it on + its own. That is the primitive closest to what people mean by a skill, and + the distinction from a tool is the one worth drawing on the page — a tool is + something the model reaches for, a prompt is something the user invokes. + + As with `McpRegistry.Registry.Tool`, only names are stored. Prompts carry + arguments and a description over the wire, and neither is kept, so nothing + here asserts more than the name supports: the gloss is the name with its + punctuation removed, and that is all. + + ## Where the names come from + + Nothing declares prompts. `server.json` has no field for them, so unlike + tools there is no publisher claim — every name here was read from a live + server by `McpRegistry.Probe`. A listing with no skills page is a listing + that answered and had none, or was never reachable. + """ + + @doc """ + The name as words: `review_pull_request` becomes `review pull request`. + + A pure transformation, so it adds readability without asserting anything the + registry does not know. + """ + def gloss(name) when is_binary(name) do + name + |> String.replace(~r/[_\-.\/]+/, " ") + |> String.replace(~r/([a-z0-9])([A-Z])/, "\\1 \\2") + |> String.downcase() + |> String.trim() + end + + @doc "A skill name turned into a URL segment." + def slug(name) when is_binary(name), do: name |> String.downcase() |> URI.encode() + + @doc """ + Finds the skill on a server whose slug matches, or `nil`. + + Matching on the slug rather than the raw name means a URL stays valid + whatever casing the publisher used. + """ + def find(skills, slug) when is_list(skills) and is_binary(slug) do + wanted = String.downcase(slug) + Enum.find(skills, fn skill -> String.downcase(skill) == wanted end) + end +end diff --git a/lib/mcp_registry_web/controllers/sitemap_controller.ex b/lib/mcp_registry_web/controllers/sitemap_controller.ex index 949cf82..a5e838e 100644 --- a/lib/mcp_registry_web/controllers/sitemap_controller.ex +++ b/lib/mcp_registry_web/controllers/sitemap_controller.ex @@ -23,6 +23,9 @@ defmodule McpRegistryWeb.SitemapController do @servers_per_tool_file 150 # Twelve or so agent pages a listing, so 3,000 listings is about 36,000 URLs. @servers_per_agent_file 3_000 + # Skills run far fewer per listing than tools -- a handful rather than + # eighteen -- so more listings fit in a file at the same URL budget. + @servers_per_skill_file 500 # `/live` is LiveView's transport, not content. Its long-poll fallback carries # a fresh CSRF token in the query string, so every fetch mints a URL that has @@ -57,6 +60,10 @@ defmodule McpRegistryWeb.SitemapController do 0 -> [] n -> Enum.map(1..n, &"tools-#{&1}.xml") end ++ + case skill_files() do + 0 -> [] + n -> Enum.map(1..n, &"skills-#{&1}.xml") + end ++ Enum.map(1..agent_files(), &"agents-#{&1}.xml") [ @@ -100,7 +107,7 @@ defmodule McpRegistryWeb.SitemapController do def show(conn, %{"file" => "tools-" <> file}) do with {page, ".xml"} <- Integer.parse(file), - true <- page in 1..tool_files() do + true <- within(page, tool_files()) do base = McpRegistryWeb.Endpoint.url() page @@ -121,6 +128,32 @@ defmodule McpRegistryWeb.SitemapController do end end + def show(conn, %{"file" => "skills-" <> file}) do + with {page, ".xml"} <- Integer.parse(file), + true <- within(page, skill_files()) do + base = McpRegistryWeb.Endpoint.url() + + page + |> Registry.servers_with_skills(@servers_per_skill_file) + |> Enum.flat_map(fn {server, skills, updated_at} -> + clients = Clients.ids(server) + + [{base <> Routes.skills_path(server.name), updated_at}] ++ + Enum.flat_map(skills, fn skill -> + [{base <> Routes.skill_path(server.name, skill), updated_at}] ++ + Enum.map( + clients, + &{base <> Routes.skill_client_path(server.name, skill, &1), updated_at} + ) + end) + end) + |> urlset() + |> send_xml(conn) + else + _ -> not_found(conn) + end + end + def show(conn, %{"file" => "servers-" <> file}) do with {page, ".xml"} <- Integer.parse(file), true <- page in 1..server_files() do @@ -138,6 +171,11 @@ defmodule McpRegistryWeb.SitemapController do def show(conn, _params), do: not_found(conn) + # Not `page in 1..count`: when count is 0 that range descends (Elixir gives + # `1..0` a step of -1), so `1 in 1..0` is true and the file is served as an + # empty 200 rather than a 404. + defp within(page, count), do: page >= 1 and page <= count + defp server_files, do: max(ceil(Registry.count_servers() / @per_file), 1) defp agent_files, @@ -146,6 +184,9 @@ defmodule McpRegistryWeb.SitemapController do defp tool_files, do: ceil(Registry.count_servers_with_tools() / @servers_per_tool_file) + defp skill_files, + do: ceil(Registry.count_servers_with_skills() / @servers_per_skill_file) + defp urlset(entries) do [ ~s(\n), diff --git a/lib/mcp_registry_web/live/server_live/show.ex b/lib/mcp_registry_web/live/server_live/show.ex index dd29c16..55aa4e3 100644 --- a/lib/mcp_registry_web/live/server_live/show.ex +++ b/lib/mcp_registry_web/live/server_live/show.ex @@ -403,6 +403,21 @@ defmodule McpRegistryWeb.ServerLive.Show do
++ <.link + navigate={skills_path(@server)} + class="group inline-flex items-center gap-1.5 font-mono text-xs text-brand" + > + {length(@server.prompts)} {if length(@server.prompts) == 1, + do: "skill", + else: "skills"} you invoke yourself + <.icon + name="hero-arrow-right-micro" + class="size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" + /> + +
+
Mutating
and Read-only
diff --git a/lib/mcp_registry_web/live/server_live/skills.ex b/lib/mcp_registry_web/live/server_live/skills.ex
new file mode 100644
index 0000000..03aec8c
--- /dev/null
+++ b/lib/mcp_registry_web/live/server_live/skills.ex
@@ -0,0 +1,482 @@
+defmodule McpRegistryWeb.ServerLive.Skills do
+ @moduledoc """
+ The skills silo under a listing: an index, a page per skill, and a page per
+ skill per client.
+
+ * `:index` — `/servers/:namespace/:name/skills`
+ * `:show` — `/servers/:namespace/:name/skills/:skill`
+ * `:client` — `/servers/:namespace/:name/skills/:skill/:client`
+
+ A skill is an MCP **prompt**: something the user invokes deliberately, as
+ against a tool, which the model reaches for on its own. The pages lean on
+ that difference, because it is the part a reader arriving from a search has
+ usually not understood.
+
+ ## This silo exists only where the data does
+
+ A page is generated only for a listing that actually has prompts, and the
+ registry knows that only because it asked. Roughly one remote server in ten
+ has any — so this silo covers a small slice of the catalogue by design, and
+ a listing with none gets no page rather than an empty one. Thin pages at
+ catalogue scale are the doorway pattern search engines penalise.
+
+ Client pages are limited to the clients that can actually invoke a prompt.
+ That is a narrower set than for tools: a prompt is surfaced in a client's
+ own UI (Claude Code's slash commands, Claude Desktop's attachment menu), so
+ a client with no such surface gets no page, however well it runs the server.
+ """
+ use McpRegistryWeb, :live_view
+
+ alias McpRegistry.Registry
+ alias McpRegistry.Registry.{Clients, Server, Skill}
+
+ @impl true
+ def mount(%{"namespace" => namespace, "name" => name}, _session, socket) do
+ server = Registry.get_server!(namespace <> "/" <> name)
+
+ {:ok,
+ socket
+ |> assign(:server, server)
+ |> assign(:short_name, Server.short_name(server))
+ |> assign(:namespace, namespace)
+ |> assign(:clients, Clients.configs(server))
+ |> assign(:skills, server.prompts)
+ |> assign(:noindex, server.status != "active")}
+ end
+
+ @impl true
+ def handle_params(params, _uri, socket) do
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ # A listing with no prompts has no skills page. This is the whole reason the
+ # prober was extended -- without a real count, every listing would get one.
+ defp apply_action(socket, :index, _params) do
+ if socket.assigns.skills == [] do
+ push_navigate(socket, to: server_path(socket.assigns.server))
+ else
+ server = socket.assigns.server
+ title = "#{server.title} MCP Skills"
+
+ socket
+ |> assign(:page_title, title)
+ |> assign(:heading, title)
+ |> assign(
+ :meta_description,
+ "Every skill the #{server.title} MCP server offers: #{skill_sentence(socket.assigns.skills)}. " <>
+ "What each one does and how to invoke it from Claude Code, Claude Desktop, Cursor or VS Code."
+ )
+ |> assign(:canonical_url, absolute(skills_path(server)))
+ end
+ end
+
+ defp apply_action(socket, :show, %{"skill" => slug}) do
+ server = socket.assigns.server
+
+ case Skill.find(socket.assigns.skills, slug) do
+ nil ->
+ push_navigate(socket, to: server_path(server))
+
+ skill ->
+ title = "#{skill} — #{server.title} MCP Skill"
+
+ socket
+ |> assign(:skill, skill)
+ |> assign(:page_title, title)
+ |> assign(:heading, title)
+ |> assign(
+ :meta_description,
+ "#{skill} is a skill on the #{server.title} MCP server (#{Skill.gloss(skill)}). " <>
+ "How to connect the server and invoke #{skill} from Claude Code, Claude Desktop, Cursor or VS Code."
+ )
+ |> assign(:canonical_url, absolute(skill_path(server, skill)))
+ end
+ end
+
+ defp apply_action(socket, :client, %{"skill" => slug, "client" => client_id}) do
+ server = socket.assigns.server
+ skill = Skill.find(socket.assigns.skills, slug)
+ client = Enum.find(socket.assigns.clients, &(&1.id == client_id))
+
+ cond do
+ is_nil(skill) ->
+ push_navigate(socket, to: server_path(server))
+
+ is_nil(client) ->
+ push_navigate(socket, to: skill_path(server, skill))
+
+ true ->
+ # Client first, as on the tool pages: this page exists to answer
+ # "
+ {@server.title} offers {skill_count(length(@skills))}. A skill is a prompt you invoke
+ yourself, rather than a tool the model calls on your behalf — each has its own page with
+ the configuration for every client that can run it.
+ {Skill.gloss(skill)}
+ by client
+ {if @client.kind == :cli,
+ do: "Run this in your project directory.",
+ else: "Open #{@client.path} and merge this in. Keep any servers already there."}
+ {step}
+ <.icon name="hero-information-circle" class="mt-px size-3.5 shrink-0" />
+
+ {@client.note}
+
+ {@client.label} docs
+
+
+
+ {@server.title} will not start until these are set, so {@skill} never appears in {@client.label}.
+
+ {@server.title} also exposes
+ <.link navigate={tools_path(@server)} class={link_class()}>
+ {length(@server.tools)} {if length(@server.tools) == 1, do: "tool", else: "tools"}
+
+ — those the model calls by itself, where the skills above are ones you invoke.
+
+ <.icon name="hero-check-badge" class="mt-px size-3.5 shrink-0 text-success" />
+
+ Read from the server itself, by connecting to it and calling {@heading}
+
+
+
+ <.also_tools server={@server} />
+ <.provenance server={@server} />
+ <.back_to_server server={@server} />
+
+ {skill}
+
+ {@heading}
+ {@skill}
+ ({Skill.gloss(@skill)}) is one of {skill_count(length(@skills))} on the
+ <.link navigate={server_path(@server)} class={link_class()}>{@server.title}
+ MCP server. Connect the server and it appears in your client, ready to invoke.
+
+ How to invoke {@skill} from your client
+
+
+
+ {@heading}
+
+ How to: {@client.label} {@server.title} {@skill}
+
+
+ Add {@server.title} to {@client.label}
+
+
+
+
+
+ <.code_block
+ id="skill-client-config"
+ code={@client.code}
+ copy_label={if @client.kind == :cli, do: "Copy command", else: "Copy config"}
+ max_height="max-h-96"
+ />
+
+
+
+
+
+ <.icon name="hero-shield-check" class="size-3.5 text-brand" /> Set these first
+
+
+
+
+ {var}
+
+
+ Same skill, other clients
+
+
+
+
+ Other skills on this server
+
+
+
+ prompts/list{probed_phrase(
+ @server.probed_at
+ )}.
+ Nothing declares prompts in a listing, so this is the only way to know them — and it is
+ what the server actually offers, not what its listing claims. The registry stores names
+ only; connect the server for each skill's arguments.
+
+ ]*>\s*Cursor Weather Skill/review_diff\s*
}
+ assert html =~ "How to: Cursor Weather review_diff"
+ end
+
+ test "carries that client's real configuration, not a generic one", %{conn: conn} do
+ server = with_skills()
+
+ {:ok, _view, vscode} = live(conn, "/servers/#{server.name}/skills/review_diff/vscode")
+ assert vscode =~ ""servers""
+ refute vscode =~ "mcpServers"
+ end
+
+ test "says where the prompt actually surfaces in that client", %{conn: conn} do
+ server = with_skills()
+
+ {:ok, _view, code} = live(conn, "/servers/#{server.name}/skills/review_diff/claude-code")
+ assert code =~ "slash command"
+ end
+
+ test "an unknown client falls back to the skill page", %{conn: conn} do
+ server = with_skills()
+
+ assert {:error, {:live_redirect, %{to: to}}} =
+ live(conn, "/servers/#{server.name}/skills/review_diff/gptbot")
+
+ assert to == "/servers/#{server.name}/skills/review_diff"
+ end
+ end
+
+ test "an unknown listing still redirects home with a 301", %{conn: conn} do
+ conn = get(conn, "/servers/io.github.nobody/nothing/skills")
+
+ assert redirected_to(conn, 301) == "/"
+ end
+
+ test "the skills sitemap carries index, skill and client URLs", %{conn: conn} do
+ server = with_skills(~w(review_diff))
+
+ xml = conn |> get("/sitemaps/skills-1.xml") |> response(200)
+
+ assert xml =~ "/servers/#{server.name}/skills"
+ assert xml =~ "/servers/#{server.name}/skills/review_diff"
+ assert xml =~ "/skills/review_diff/cursor"
+
+ assert conn |> get("/sitemap.xml") |> response(200) =~ "/sitemaps/skills-1.xml"
+ end
+
+ test "no skills anywhere means no skills file in the index", %{conn: conn} do
+ server_fixture(%{prompts: []})
+
+ refute conn |> get("/sitemap.xml") |> response(200) =~ "skills-1.xml"
+ assert conn |> get("/sitemaps/skills-1.xml") |> response(404) == ""
+ end
+
+ test "every page in the silo is indexable", %{conn: conn} do
+ server = with_skills()
+
+ for path <- ["/skills", "/skills/review_diff", "/skills/review_diff/cursor"] do
+ html = conn |> get("/servers/#{server.name}#{path}") |> html_response(200)
+ refute html =~ "noindex", "#{path} should be indexable"
+ end
+ end
+
+ test "the listing page links into the silo only when there are skills", %{conn: conn} do
+ with_skills()
+ without = server_fixture(%{prompts: [], name: "io.github.acme/quiet"})
+
+ {:ok, _view, quiet} = live(conn, "/servers/#{without.name}")
+ refute quiet =~ "/skills"
+ end
+end
diff --git a/test/support/fixtures/registry_fixtures.ex b/test/support/fixtures/registry_fixtures.ex
index 398f823..7e0c066 100644
--- a/test/support/fixtures/registry_fixtures.ex
+++ b/test/support/fixtures/registry_fixtures.ex
@@ -22,7 +22,24 @@ defmodule McpRegistry.RegistryFixtures do
def server_fixture(attrs \\ %{}) do
status = Map.get(attrs, :status, "active")
- {:ok, server} = McpRegistry.Registry.create_server(valid_server_attrs(attrs), status: status)
- server
+ probed = Map.take(attrs, [:prompts, :resources])
+
+ {:ok, server} =
+ attrs
+ |> Map.drop([:prompts, :resources])
+ |> valid_server_attrs()
+ |> McpRegistry.Registry.create_server(status: status)
+
+ # Prompts and resources are deliberately not castable: the skills pages say
+ # these names were read from the server itself, and a publisher able to
+ # submit them would make that claim false. The probe writes them straight
+ # through, so a fixture has to as well.
+ if probed == %{} do
+ server
+ else
+ server
+ |> Ecto.Changeset.change(probed)
+ |> McpRegistry.Repo.update!()
+ end
end
end