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
21 changes: 11 additions & 10 deletions lib/mcp_registry/registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -214,34 +214,35 @@ defmodule McpRegistry.Registry do
end

@doc """
A page of active listings that have at least one skill, with their skills.
A page of active listings that have at least one prompt or resource.

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.
struct, because `article_content` alone runs to tens of kilobytes and there
are thousands of these.
"""
def servers_with_skills(page, per_page) when page >= 1 do
def servers_with(kind, page, per_page) when kind in [:prompts, :resources] and page >= 1 do
Server
|> with_skills()
|> having_any(kind)
|> 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,
field(s, ^kind),
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)
@doc "How many active listings have at least one prompt, or one resource."
def count_servers_with(kind) when kind in [:prompts, :resources] do
Server |> having_any(kind) |> Repo.aggregate(:count)
end

defp with_skills(query) do
defp having_any(query, kind) do
query
|> where([s], s.status == "active")
|> where([s], fragment("cardinality(?) > 0", s.prompts))
|> where([s], fragment("cardinality(?) > 0", field(s, ^kind)))
end

@doc """
Expand Down
102 changes: 102 additions & 0 deletions lib/mcp_registry/registry/capability.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
defmodule McpRegistry.Registry.Capability do
@moduledoc """
What can be said about a prompt or a resource when its name is all we have.

The two silos share this because they share a problem: only names are
stored. Prompts carry arguments over the wire and resources carry a MIME
type and description, and none of it is kept, so nothing here asserts more
than the name supports.

## Why prompts and resources, and not "skills"

These are the names MCP uses and the names people search for. A prompt is
something the user invokes; a resource is data the client attaches as
context. Calling either a skill would invent a word the protocol does not
have and no reader is looking for.

## Where the names come from

Nothing declares them. `server.json` has no field for prompts or resources,
so unlike tools there is no publisher claim — every name was read from a
live server by `McpRegistry.Probe`.
"""

@doc """
A name turned into one URL segment.

Resources are the reason this is not `URI.encode/1`: a resource is
identified by URI, and `URI.encode/1` leaves `/` alone, so
`file:///readme.md` would have become three path segments and matched no
route. Everything outside `[a-z0-9]` collapses to a hyphen instead.

`http://` and `https://` are dropped because they carry no meaning in a
slug and cost length. Other schemes stay: `file`, `ui` and `s3` distinguish
resources that would otherwise read alike.

Two names can in principle collide — `review_diff` and `review-diff`, or
the same URL under both http and https. `find/2` then answers with the
first, which is a worse page than it could be but never a wrong one.
"""
def slug(name) when is_binary(name) do
name
|> String.downcase()
|> String.replace(~r{^https?://}, "")
|> String.replace(~r/[^a-z0-9]+/, "-")
|> String.trim("-")
|> String.slice(0, 120)
|> case do
"" -> "item"
slug -> slug
end
end

@doc """
Finds the entry whose slug matches, or `nil`.

Compares slug to slug rather than slug to raw name, so a name that the slug
rewrites — every resource URI, and any prompt with punctuation in it — is
still reachable from its own URL.
"""
def find(items, requested) when is_list(items) and is_binary(requested) do
wanted = slug(requested)
Enum.find(items, fn item -> slug(item) == wanted end)
end

@doc """
A prompt name as words: `review_pull_request` becomes `review pull request`.

A pure transformation of the name, 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 resource URI shortened for display, without misrepresenting it.

The scheme goes for `http` and `https` only, and a long URI is cut at the
end with an ellipsis so it is visibly incomplete rather than quietly wrong.
"""
def short_uri(uri) when is_binary(uri) do
trimmed = String.replace(uri, ~r{^https?://}, "")

if String.length(trimmed) > 72 do
String.slice(trimmed, 0, 71) <> "…"
else
trimmed
end
end

@doc "The scheme of a resource URI (`file`, `https`, `ui`), or `nil`."
def scheme(uri) when is_binary(uri) do
case Regex.run(~r{^([a-z][a-z0-9+.\-]*)://}i, uri) do
[_, scheme] -> String.downcase(scheme)
_ -> nil
end
end
end
51 changes: 0 additions & 51 deletions lib/mcp_registry/registry/skill.ex

This file was deleted.

16 changes: 15 additions & 1 deletion lib/mcp_registry_web/controllers/redirect_controller.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule McpRegistryWeb.RedirectController do
@moduledoc """
Catches everything the router did not match.
Catches everything the router did not match, and paths that have moved.

Browser requests are sent to the home page permanently, rather than shown a
404. The JSON API keeps answering 404, because an API client that follows a
Expand All @@ -16,6 +16,20 @@ defmodule McpRegistryWeb.RedirectController do
|> redirect(to: ~p"/")
end

@doc """
`/skills` and everything under it, to the prompts silo.

The silo shipped as "skills" and was renamed within the hour: prompts and
resources are the words MCP uses and the words people search for, and a
third word the protocol does not have served neither. Prompts are where a
reader looking for "skills" meant to end up.
"""
def skills(conn, %{"namespace" => namespace, "name" => name}) do
conn
|> put_status(:moved_permanently)
|> redirect(to: McpRegistryWeb.Routes.capabilities_path(namespace <> "/" <> name, :prompts))
end

@doc "Answers unmatched API paths with a 404 rather than a redirect."
def api_not_found(conn, _params) do
conn
Expand Down
78 changes: 44 additions & 34 deletions lib/mcp_registry_web/controllers/sitemap_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ 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
# Prompts and resources run far fewer per listing than tools -- a handful
# rather than eighteen -- so more listings fit in a file at the same budget.
# Held at 300 because the distribution is skewed: most listings have a few
# and one has 195, and a file over 50,000 URLs is rejected in silence.
@servers_per_capability_file 300

# `/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 @@ -60,10 +62,8 @@ 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 ++
capability_file_names(:prompts) ++
capability_file_names(:resources) ++
Enum.map(1..agent_files(), &"agents-#{&1}.xml")

[
Expand Down Expand Up @@ -128,31 +128,8 @@ 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" => "prompts-" <> file}), do: capability_file(conn, :prompts, file)
def show(conn, %{"file" => "resources-" <> file}), do: capability_file(conn, :resources, file)

def show(conn, %{"file" => "servers-" <> file}) do
with {page, ".xml"} <- Integer.parse(file),
Expand Down Expand Up @@ -184,8 +161,41 @@ 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 capability_file(conn, kind, file) do
with {page, ".xml"} <- Integer.parse(file),
true <- within(page, capability_files(kind)) do
base = McpRegistryWeb.Endpoint.url()

kind
|> Registry.servers_with(page, @servers_per_capability_file)
|> Enum.flat_map(fn {server, items, updated_at} ->
clients = Clients.ids(server)

[{base <> Routes.capabilities_path(server.name, kind), updated_at}] ++
Enum.flat_map(items, fn item ->
[{base <> Routes.capability_path(server.name, kind, item), updated_at}] ++
Enum.map(
clients,
&{base <> Routes.capability_client_path(server.name, kind, item, &1), updated_at}
)
end)
end)
|> urlset()
|> send_xml(conn)
else
_ -> not_found(conn)
end
end

defp capability_file_names(kind) do
case capability_files(kind) do
0 -> []
n -> Enum.map(1..n, &"#{kind}-#{&1}.xml")
end
end

defp capability_files(kind),
do: ceil(Registry.count_servers_with(kind) / @servers_per_capability_file)

defp urlset(entries) do
[
Expand Down
Loading
Loading