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
13 changes: 13 additions & 0 deletions lib/mcp_registry/probe.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions lib/mcp_registry/probe/runner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
31 changes: 31 additions & 0 deletions lib/mcp_registry/registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions lib/mcp_registry/registry/skill.ex
Original file line number Diff line number Diff line change
@@ -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
43 changes: 42 additions & 1 deletion lib/mcp_registry_web/controllers/sitemap_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

[
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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(<?xml version="1.0" encoding="UTF-8"?>\n),
Expand Down
15 changes: 15 additions & 0 deletions lib/mcp_registry_web/live/server_live/show.ex
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,21 @@ defmodule McpRegistryWeb.ServerLive.Show do
</.link>
</p>

<p :if={@server.prompts != []} class="pt-1">
<.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"
/>
</.link>
</p>

<p :if={@tools != []} class="text-[11px] text-pretty text-dim">
<b class="font-medium text-ink">Mutating</b>
and <b class="font-medium text-ink">Read-only</b>
Expand Down
Loading
Loading