From 2e8f02d2cad064b454d78fc8782cbeef46b0d7d2 Mon Sep 17 00:00:00 2001 From: Logan Besecker Date: Thu, 24 Sep 2026 13:58:04 -0700 Subject: [PATCH] Split the skills silo into prompts and resources "Skills" is a word MCP does not use and nobody searches for. The protocol has prompts and resources, so the URLs now say so. The split also fits the data better than the original shape: 1,075 listings have resources against 520 with prompts, so the larger silo was the one not being built. - /servers/*/prompts and /servers/*/resources, each with an index, a page per item and a page per item per client - H1 "AGENT SERVER Prompt/NAME" or "AGENT SERVER Resource/URI", H2 "How to: AGENT SERVER NAME" - /skills and everything under it 301s to /prompts - Copy differs by kind because the things differ: a prompt is invoked deliberately, a resource is context to attach. Each silo links the other so a reader who guessed wrong is one click away A resource is identified by URI, and URI.encode/1 leaves / alone, so file:///alerts.json would have been three path segments and matched no route. Slugs collapse everything outside [a-z0-9], and lookup compares slug to slug so a name the slug rewrote is still reachable from its own URL. Closes #64 --- Pages affected: - [MCP Harbor](https://ai.mcpharbor.dev/) -- registry home, search and recently added servers. - [MCP server directory](https://ai.mcpharbor.dev/servers) -- the listings both silos hang under. Co-Authored-By: Claude Opus 5 --- lib/mcp_registry/registry.ex | 21 +- lib/mcp_registry/registry/capability.ex | 102 +++ lib/mcp_registry/registry/skill.ex | 51 -- .../controllers/redirect_controller.ex | 16 +- .../controllers/sitemap_controller.ex | 78 ++- .../live/server_live/capabilities.ex | 583 ++++++++++++++++++ lib/mcp_registry_web/live/server_live/show.ex | 21 +- .../live/server_live/skills.ex | 482 --------------- lib/mcp_registry_web/router.ex | 22 +- lib/mcp_registry_web/routes.ex | 33 +- .../live/capabilities_test.exs | 211 +++++++ test/mcp_registry_web/live/skills_test.exs | 144 ----- 12 files changed, 1023 insertions(+), 741 deletions(-) create mode 100644 lib/mcp_registry/registry/capability.ex delete mode 100644 lib/mcp_registry/registry/skill.ex create mode 100644 lib/mcp_registry_web/live/server_live/capabilities.ex delete mode 100644 lib/mcp_registry_web/live/server_live/skills.ex create mode 100644 test/mcp_registry_web/live/capabilities_test.exs delete mode 100644 test/mcp_registry_web/live/skills_test.exs diff --git a/lib/mcp_registry/registry.ex b/lib/mcp_registry/registry.ex index 14be800..a81eb6a 100644 --- a/lib/mcp_registry/registry.ex +++ b/lib/mcp_registry/registry.ex @@ -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 """ diff --git a/lib/mcp_registry/registry/capability.ex b/lib/mcp_registry/registry/capability.ex new file mode 100644 index 0000000..b47e3e7 --- /dev/null +++ b/lib/mcp_registry/registry/capability.ex @@ -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 diff --git a/lib/mcp_registry/registry/skill.ex b/lib/mcp_registry/registry/skill.ex deleted file mode 100644 index 4c3690e..0000000 --- a/lib/mcp_registry/registry/skill.ex +++ /dev/null @@ -1,51 +0,0 @@ -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/redirect_controller.ex b/lib/mcp_registry_web/controllers/redirect_controller.ex index beb8da1..04e8fae 100644 --- a/lib/mcp_registry_web/controllers/redirect_controller.ex +++ b/lib/mcp_registry_web/controllers/redirect_controller.ex @@ -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 @@ -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 diff --git a/lib/mcp_registry_web/controllers/sitemap_controller.ex b/lib/mcp_registry_web/controllers/sitemap_controller.ex index a5e838e..10ecc96 100644 --- a/lib/mcp_registry_web/controllers/sitemap_controller.ex +++ b/lib/mcp_registry_web/controllers/sitemap_controller.ex @@ -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 @@ -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") [ @@ -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), @@ -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 [ diff --git a/lib/mcp_registry_web/live/server_live/capabilities.ex b/lib/mcp_registry_web/live/server_live/capabilities.ex new file mode 100644 index 0000000..6a0f92f --- /dev/null +++ b/lib/mcp_registry_web/live/server_live/capabilities.ex @@ -0,0 +1,583 @@ +defmodule McpRegistryWeb.ServerLive.Capabilities do + @moduledoc """ + The prompts and resources silos under a listing: an index, a page per item, + and a page per item per client, for each of the two kinds. + + /servers/:namespace/:name/prompts[/:item[/:client]] + /servers/:namespace/:name/resources[/:item[/:client]] + + ## Why two silos and not one + + They are different things and people search for them differently. A prompt + is something the user invokes deliberately; a resource is data the client + attaches as context. The pages say which is which, because that is the part + a reader arriving from a search has usually not sorted out. + + Both were briefly shipped as one "skills" silo. That was the wrong word: + MCP has no skills, and nobody searches for them. `/skills` now 301s here. + + ## The silos exist only where the data does + + A page is generated only for a listing that has the thing, and the registry + knows that only because it asked — roughly one remote server in ten has + prompts and one in four has resources. A listing with neither gets neither + page, rather than two empty ones. Thin pages at catalogue scale are the + doorway pattern search engines penalise. + """ + use McpRegistryWeb, :live_view + + alias McpRegistry.Registry + alias McpRegistry.Registry.{Capability, Clients, Server} + + @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(:noindex, server.status != "active")} + end + + @impl true + def handle_params(params, _uri, socket) do + action = socket.assigns.live_action + kind = kind(action) + + socket = + socket + |> assign(:kind, kind) + |> assign(:items, items(socket.assigns.server, kind)) + + {:noreply, apply_action(socket, stage(action), params)} + end + + defp kind(action) do + if action in [:prompts_index, :prompts_show, :prompts_client], do: :prompts, else: :resources + end + + defp stage(action) do + cond do + action in [:prompts_index, :resources_index] -> :index + action in [:prompts_show, :resources_show] -> :show + true -> :client + end + end + + defp items(server, :prompts), do: server.prompts + defp items(server, :resources), do: server.resources + + # An index with nothing on it is not worth a URL. This gate is the whole + # reason the prober was extended first: without a real count, all 34,000 + # listings would have had both pages with nothing on either. + defp apply_action(socket, :index, _params) do + %{server: server, kind: kind, items: items} = socket.assigns + + if items == [] do + push_navigate(socket, to: server_path(server)) + else + title = "#{server.title} MCP #{plural(kind)}" + + socket + |> assign(:page_title, title) + |> assign(:heading, title) + |> assign(:meta_description, index_description(server, kind, items)) + |> assign(:canonical_url, absolute(capabilities_path(server, kind))) + end + end + + defp apply_action(socket, :show, %{"item" => requested}) do + %{server: server, kind: kind, items: items} = socket.assigns + + case Capability.find(items, requested) do + nil -> + push_navigate(socket, to: server_path(server)) + + item -> + title = "#{display(item, kind)} — #{server.title} MCP #{singular(kind)}" + + socket + |> assign(:item, item) + |> assign(:page_title, title) + |> assign(:heading, title) + |> assign(:meta_description, show_description(server, kind, item)) + |> assign(:canonical_url, absolute(capability_path(server, kind, item))) + end + end + + defp apply_action(socket, :client, %{"item" => requested, "client" => client_id}) do + %{server: server, kind: kind, items: items} = socket.assigns + item = Capability.find(items, requested) + client = Enum.find(socket.assigns.clients, &(&1.id == client_id)) + + cond do + is_nil(item) -> + push_navigate(socket, to: server_path(server)) + + is_nil(client) -> + push_navigate(socket, to: capability_path(server, kind, item)) + + true -> + # Client first: this page exists to answer " ", + # which is the order the question gets typed in. + title = "#{client.label} #{server.title} #{singular(kind)}/#{display(item, kind)}" + + socket + |> assign(:item, item) + |> assign(:client, client) + |> assign(:page_title, title) + |> assign(:heading, title) + |> assign(:meta_description, client_description(server, kind, item, client)) + |> assign(:canonical_url, absolute(capability_client_path(server, kind, item, client.id))) + end + end + + @impl true + def render(assigns) do + ~H""" + + <:rail> + <.crumbs + server={@server} + namespace={@namespace} + short_name={@short_name} + kind={@kind} + item={assigns[:item]} + client={assigns[:client]} + /> + + + <.index_body :if={stage(@live_action) == :index} {assigns} /> + <.show_body :if={stage(@live_action) == :show} {assigns} /> + <.client_body :if={stage(@live_action) == :client} {assigns} /> + + <.provenance kind={@kind} server={@server} /> + +
+ <.button variant="soft" navigate={server_path(@server)}> + <.icon name="hero-arrow-left-micro" class="size-4" /> {@server.title} MCP server + +
+
+ """ + end + + # --- The three page bodies ------------------------------------------------- + + defp index_body(assigns) do + ~H""" +
+

{@heading}

+

+ {@server.title} offers {count(length(@items), @kind)}. {blurb(@kind)} +

+
+ +
    +
  • + <.link + navigate={capability_path(@server, @kind, item)} + class="group flex h-full flex-col gap-1.5 rounded-box border border-rule bg-surface/40 p-3.5 transition-colors hover:border-rule-strong hover:bg-surface" + > +
    + + {display(item, @kind)} + + <.badge :if={@kind == :resources and Capability.scheme(item)} tone="neutral"> + {Capability.scheme(item)} + +
    +

    {Capability.gloss(item)}

    + +
  • +
+ + <.sibling_silo server={@server} kind={@kind} /> + """ + end + + defp show_body(assigns) do + ~H""" +
+

+ {@heading} +

+

+ {display(@item, @kind)} + is one of {count(length(@items), @kind)} on the + <.link navigate={server_path(@server)} class={link_class()}>{@server.title} + MCP server. {reach_phrase(@kind)} +

+
+ +
+
+

by client

+

+ How to {verb(@kind)} {display(@item, @kind)} from your client +

+
+ +
    +
  • + <.link + navigate={capability_client_path(@server, @kind, @item, client.id)} + class="group flex h-full flex-col gap-1 rounded-box border border-rule bg-surface/40 p-3.5 transition-colors hover:border-brand/40 hover:bg-surface" + > + + {client.label} {@server.title} {singular(@kind)}/{display(@item, @kind)} + + {client.path} + +
  • +
+
+ +
1} aria-labelledby="siblings" class="space-y-3"> +

+ Other {plural(@kind) |> String.downcase()} on this server +

+
    +
  • Enum.reject(&(&1 == @item)) |> Enum.take(40)}> + <.link + navigate={capability_path(@server, @kind, other)} + class="block rounded-full border border-rule px-2.5 py-1 font-mono text-[11px] break-all text-dim transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" + > + {display(other, @kind)} + +
  • +
+
+ + <.sibling_silo server={@server} kind={@kind} /> + """ + end + + defp client_body(assigns) do + ~H""" +
+

+ {@heading} +

+

+ How to: {@client.label} {@server.title} {display(@item, @kind)} +

+
+ <.meta_chip key="client" value={@client.label} tone="brand" /> + <.meta_chip key="transport" value={@server.transport} /> + <.meta_chip + key={singular(@kind) |> String.downcase()} + value={short(@item, @kind)} + tone="accent" + /> +
+
+ +
+

+ Add {@server.title} to {@client.label} +

+ +
    +
  1. + 01 +

    + {if @client.kind == :cli, + do: "Run this in your project directory.", + else: "Open #{@client.path} and merge this in. Keep any servers already there."} +

    +
  2. +
+ + <.code_block + id="capability-client-config" + code={@client.code} + copy_label={if @client.kind == :cli, do: "Copy command", else: "Copy config"} + max_height="max-h-96" + /> + +
    +
  1. + {index} +

    {step}

    +
  2. +
+ +

+ <.icon name="hero-information-circle" class="mt-px size-3.5 shrink-0" /> + + {@client.note} + + {@client.label} docs + + +

+
+ +
+

+ <.icon name="hero-shield-check" class="size-3.5 text-brand" /> Set these first +

+

+ {@server.title} will not start until these are set, so it never reaches {@client.label}. +

+
    +
  • + + {var} + +
  • +
+
+ +
+

+ Same {singular(@kind) |> String.downcase()}, other clients +

+
    +
  • + <.link + navigate={capability_client_path(@server, @kind, @item, other.id)} + class="block rounded-full border border-rule px-2.5 py-1 font-mono text-[11px] text-dim transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" + > + {other.label} + +
  • +
+
+ """ + end + + # --- Shared pieces --------------------------------------------------------- + + attr :server, :map, required: true + attr :namespace, :string, required: true + attr :short_name, :string, required: true + attr :kind, :atom, required: true + attr :item, :string, default: nil + attr :client, :map, default: nil + + defp crumbs(assigns) do + ~H""" + + """ + end + + attr :server, :map, required: true + attr :kind, :atom, required: true + + # Each silo points at the other, so a reader who guessed wrong is one click + # from the right one rather than back at a search box. + defp sibling_silo(assigns) do + ~H""" +

+ {@server.title} also offers + <.link navigate={capabilities_path(@server, other_kind(@kind))} class={link_class()}> + {count(length(other_items(@server, @kind)), other_kind(@kind))} + + — {contrast(@kind)} +

+ """ + end + + attr :server, :map, required: true + attr :kind, :atom, required: true + + defp provenance(assigns) do + ~H""" +

+ <.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 {@kind}/list{probed_phrase( + @server.probed_at + )}. + A listing does not declare its {@kind}, so asking is the only way to know them, and this + is what the server actually offers rather than what its listing claims. Names only are + stored; connect the server for {detail_phrase(@kind)}. + +

+ """ + end + + # --- Words that differ between the two kinds ------------------------------- + + defp singular(:prompts), do: "Prompt" + defp singular(:resources), do: "Resource" + + defp plural(:prompts), do: "Prompts" + defp plural(:resources), do: "Resources" + + defp other_kind(:prompts), do: :resources + defp other_kind(:resources), do: :prompts + + defp other_items(server, kind), do: items(server, other_kind(kind)) + + defp verb(:prompts), do: "invoke" + defp verb(:resources), do: "attach" + + defp count(1, kind), do: "1 #{singular(kind) |> String.downcase()}" + defp count(n, kind), do: "#{n} #{plural(kind) |> String.downcase()}" + + # A prompt name is short enough to print; a resource URI is not. + defp display(item, :prompts), do: item + defp display(item, :resources), do: Capability.short_uri(item) + + defp short(item, :prompts), do: item + defp short(item, :resources), do: Capability.short_uri(item) + + defp blurb(:prompts), + do: + "A prompt is one 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." + + defp blurb(:resources), + do: + "A resource is data the server exposes for a client to attach as context — a file, a page, " <> + "an API response — rather than an action. Each has its own page, per client." + + defp contrast(:prompts), do: "data you attach as context, where a prompt is something you run." + defp contrast(:resources), do: "things you invoke, where a resource is context you attach." + + defp reach_phrase(:prompts), + do: "Connect the server and it appears in your client, ready to invoke." + + defp reach_phrase(:resources), + do: "Connect the server and your client can attach it as context." + + defp detail_phrase(:prompts), do: "each prompt's arguments" + defp detail_phrase(:resources), do: "each resource's type and contents" + + # Where a prompt or resource actually surfaces differs by client, and getting + # this wrong sends the reader looking for a menu their client does not have. + defp surfaced_in(:prompts, %{id: "claude-code"}), + do: "as a slash command — type / and it is in the list" + + defp surfaced_in(:prompts, %{id: "claude-desktop"}), + do: "in the attachment menu, under the server's name" + + defp surfaced_in(:prompts, %{kind: :cli}), do: "in the session's prompt list" + + defp surfaced_in(:prompts, %{kind: :ui}), + do: "in the connector's menu once the server is linked" + + defp surfaced_in(:prompts, %{kind: :code}), + do: "through the client's prompt API, fetched by name" + + defp surfaced_in(:prompts, _), do: "in the client's prompt or command menu" + + defp surfaced_in(:resources, %{id: "claude-desktop"}), + do: "in the attachment menu, where you pick it as context" + + defp surfaced_in(:resources, %{kind: :code}), + do: "through the client's resource API, read by URI" + + defp surfaced_in(:resources, _), do: "wherever your client lets you attach context" + + defp steps(%{client: client, item: item, kind: kind, server: server}) do + name = display(item, kind) + + [ + {"02", + if(client.kind == :cli, + do: "Restart your session so the server is picked up.", + else: "Save the file and restart #{client.label}." + )}, + {"03", + "#{name} is surfaced #{surfaced_in(kind, client)}. " <> + if(kind == :prompts, + do: + "Unlike a tool, you invoke it deliberately — #{client.label} will not call it for you.", + else: "It is context to attach, not an action to run." + )}, + {"04", + "If it does not appear, check that #{server.title} is connected and that you are looking " <> + "at its #{kind} rather than its tools."} + ] + end + + # --- Descriptions ---------------------------------------------------------- + + defp index_description(server, :prompts, items) do + "Every prompt the #{server.title} MCP server offers: #{sentence(items, :prompts)}. " <> + "What each one does and how to invoke it from Claude Code, Claude Desktop, Cursor or VS Code." + end + + defp index_description(server, :resources, items) do + "Every resource the #{server.title} MCP server exposes: #{sentence(items, :resources)}. " <> + "What each one holds and how to attach it as context in Claude Code, Claude Desktop, Cursor or VS Code." + end + + defp show_description(server, :prompts, item) do + "#{item} is a prompt on the #{server.title} MCP server (#{Capability.gloss(item)}). " <> + "How to connect the server and invoke #{item} from Claude Code, Claude Desktop, Cursor or VS Code." + end + + defp show_description(server, :resources, item) do + "#{Capability.short_uri(item)} is a resource on the #{server.title} MCP server. " <> + "How to connect the server and attach it as context in Claude Code, Claude Desktop, Cursor or VS Code." + end + + defp client_description(server, kind, item, client) do + "How to #{verb(kind)} #{display(item, kind)} from the #{server.title} MCP server in " <> + "#{client.label}: where the configuration lives, what to paste, and how it is surfaced once connected." + end + + defp sentence(items, kind) do + items |> Enum.take(6) |> Enum.map(&display(&1, kind)) |> Enum.join(", ") + end + + defp probed_phrase(nil), do: "" + defp probed_phrase(%DateTime{} = at), do: " on " <> Calendar.strftime(at, "%-d %B %Y") + + defp link_class, + do: + "underline decoration-rule-strong underline-offset-4 transition-colors hover:decoration-brand" + + defp absolute(path), do: McpRegistryWeb.Endpoint.url() <> path +end diff --git a/lib/mcp_registry_web/live/server_live/show.ex b/lib/mcp_registry_web/live/server_live/show.ex index 55aa4e3..626a11a 100644 --- a/lib/mcp_registry_web/live/server_live/show.ex +++ b/lib/mcp_registry_web/live/server_live/show.ex @@ -405,12 +405,27 @@ defmodule McpRegistryWeb.ServerLive.Show do

<.link - navigate={skills_path(@server)} + navigate={capabilities_path(@server, :prompts)} 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 + do: "prompt", + else: "prompts"} you invoke yourself + <.icon + name="hero-arrow-right-micro" + class="size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" + /> + +

+ +

+ <.link + navigate={capabilities_path(@server, :resources)} + class="group inline-flex items-center gap-1.5 font-mono text-xs text-brand" + > + {length(@server.resources)} {if length(@server.resources) == 1, + do: "resource", + else: "resources"} you attach as context <.icon name="hero-arrow-right-micro" class="size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" diff --git a/lib/mcp_registry_web/live/server_live/skills.ex b/lib/mcp_registry_web/live/server_live/skills.ex deleted file mode 100644 index 03aec8c..0000000 --- a/lib/mcp_registry_web/live/server_live/skills.ex +++ /dev/null @@ -1,482 +0,0 @@ -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 - # " ", which is the order it gets typed in. - title = "#{client.label} #{server.title} Skill/#{skill}" - - socket - |> assign(:skill, skill) - |> assign(:client, client) - |> assign(:page_title, title) - |> assign(:heading, title) - |> assign( - :meta_description, - "How to invoke the #{skill} skill from the #{server.title} MCP server in #{client.label}: " <> - "where the configuration lives, what to paste, and how the prompt is surfaced once connected." - ) - |> assign(:canonical_url, absolute(skill_client_path(server, skill, client.id))) - end - end - - @impl true - def render(%{live_action: :index} = assigns) do - ~H""" - - <:rail><.crumbs server={@server} namespace={@namespace} short_name={@short_name} /> - -

-

{@heading}

-

- {@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. -

-
- -
    -
  • - <.link - navigate={skill_path(@server, skill)} - class="group flex h-full flex-col gap-1.5 rounded-box border border-rule bg-surface/40 p-3.5 transition-colors hover:border-rule-strong hover:bg-surface" - > - - {skill} - -

    {Skill.gloss(skill)}

    - -
  • -
- - <.also_tools server={@server} /> - <.provenance server={@server} /> - <.back_to_server server={@server} /> - - """ - end - - def render(%{live_action: :show} = assigns) do - ~H""" - - <:rail> - <.crumbs server={@server} namespace={@namespace} short_name={@short_name} skill={@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. -

-
- -
-
-

by client

-

- How to invoke {@skill} from your client -

-
- -
    -
  • - <.link - navigate={skill_client_path(@server, @skill, client.id)} - class="group flex h-full flex-col gap-1 rounded-box border border-rule bg-surface/40 p-3.5 transition-colors hover:border-brand/40 hover:bg-surface" - > - - {client.label} {@server.title} Skill/{@skill} - - {client.path} - -
  • -
-
- - <.sibling_skills server={@server} skills={@skills} current={@skill} /> - <.also_tools server={@server} /> - <.provenance server={@server} /> - <.back_to_server server={@server} /> -
- """ - end - - def render(%{live_action: :client} = assigns) do - ~H""" - - <:rail> - <.crumbs - server={@server} - namespace={@namespace} - short_name={@short_name} - skill={@skill} - client={@client} - /> - - -
-

{@heading}

-

- How to: {@client.label} {@server.title} {@skill} -

-
- <.meta_chip key="client" value={@client.label} tone="brand" /> - <.meta_chip key="transport" value={@server.transport} /> - <.meta_chip key="skill" value={@skill} tone="accent" /> -
-
- -
-

- Add {@server.title} to {@client.label} -

- -
    -
  1. - 01 -

    - {if @client.kind == :cli, - do: "Run this in your project directory.", - else: "Open #{@client.path} and merge this in. Keep any servers already there."} -

    -
  2. -
- - <.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" - /> - -
    -
  1. - {index} -

    {step}

    -
  2. -
- -

- <.icon name="hero-information-circle" class="mt-px size-3.5 shrink-0" /> - - {@client.note} - - {@client.label} docs - - -

-
- -
-

- <.icon name="hero-shield-check" class="size-3.5 text-brand" /> Set these first -

-

- {@server.title} will not start until these are set, so {@skill} never appears in {@client.label}. -

-
    -
  • - - {var} - -
  • -
-
- -
-

- Same skill, other clients -

-
    -
  • - <.link - navigate={skill_client_path(@server, @skill, other.id)} - class="block rounded-full border border-rule px-2.5 py-1 font-mono text-[11px] text-dim transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" - > - {other.label} - -
  • -
-
- - <.provenance server={@server} /> - <.back_to_server server={@server} /> -
- """ - end - - # --- Pieces shared by the three pages -------------------------------------- - - attr :server, :map, required: true - attr :namespace, :string, required: true - attr :short_name, :string, required: true - attr :skill, :string, default: nil - attr :client, :map, default: nil - - defp crumbs(assigns) do - ~H""" - - """ - end - - attr :server, :map, required: true - attr :skills, :list, required: true - attr :current, :string, required: true - - defp sibling_skills(assigns) do - ~H""" -
1} aria-labelledby="siblings" class="space-y-3"> -

- Other skills on this server -

-
    -
  • - <.link - navigate={skill_path(@server, skill)} - class="block rounded-full border border-rule px-2.5 py-1 font-mono text-[11px] text-dim transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" - > - {skill} - -
  • -
-
- """ - end - - attr :server, :map, required: true - - defp also_tools(assigns) do - ~H""" -

- {@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. -

- """ - end - - attr :server, :map, required: true - - defp provenance(assigns) do - ~H""" -

- <.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 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. - -

- """ - end - - attr :server, :map, required: true - - defp back_to_server(assigns) do - ~H""" -
- <.button variant="soft" navigate={server_path(@server)}> - <.icon name="hero-arrow-left-micro" class="size-4" /> {@server.title} MCP server - -
- """ - end - - # --- Helpers --------------------------------------------------------------- - - # Where a prompt actually shows up differs by client, and getting this wrong - # is the difference between a page that works and one that sends the reader - # looking for a menu their client does not have. - defp surfaced_in(%{id: "claude-code"}), do: "as a slash command — type / and it is in the list" - defp surfaced_in(%{id: "claude-desktop"}), do: "in the attachment menu, under the server's name" - defp surfaced_in(%{kind: :cli}), do: "in the session's prompt list" - defp surfaced_in(%{kind: :ui}), do: "in the connector's menu once the server is linked" - defp surfaced_in(%{kind: :code}), do: "through the client's prompt API, fetched by name" - defp surfaced_in(_), do: "in the client's prompt or command menu" - - defp steps(%{client: client, skill: skill, server: server}) do - [ - {"02", - if(client.kind == :cli, - do: "Restart your session so the server is picked up.", - else: "Save the file and restart #{client.label}." - )}, - {"03", - "#{skill} is surfaced #{surfaced_in(client)}. Unlike a tool, you invoke a skill " <> - "deliberately — #{client.label} will not call it for you."}, - {"04", - "If it does not appear, check that #{server.title} is connected and that it is the " <> - "prompts list you are looking at, not the tools list."} - ] - end - - defp probed_phrase(nil), do: "" - - defp probed_phrase(%DateTime{} = at), do: " on " <> Calendar.strftime(at, "%-d %B %Y") - - defp skill_sentence(skills), do: skills |> Enum.take(6) |> Enum.join(", ") - - defp skill_count(1), do: "1 skill" - defp skill_count(n), do: "#{n} skills" - - defp link_class, - do: - "underline decoration-rule-strong underline-offset-4 transition-colors hover:decoration-brand" - - defp absolute(path), do: McpRegistryWeb.Endpoint.url() <> path -end diff --git a/lib/mcp_registry_web/router.ex b/lib/mcp_registry_web/router.ex index 5a3b7ba..60395b1 100644 --- a/lib/mcp_registry_web/router.ex +++ b/lib/mcp_registry_web/router.ex @@ -43,9 +43,25 @@ defmodule McpRegistryWeb.Router do live "/servers/:namespace/:name/tools", ServerLive.Tools, :index live "/servers/:namespace/:name/tools/:tool", ServerLive.Tools, :show live "/servers/:namespace/:name/tools/:tool/:client", ServerLive.Tools, :client - live "/servers/:namespace/:name/skills", ServerLive.Skills, :index - live "/servers/:namespace/:name/skills/:skill", ServerLive.Skills, :show - live "/servers/:namespace/:name/skills/:skill/:client", ServerLive.Skills, :client + live "/servers/:namespace/:name/prompts", ServerLive.Capabilities, :prompts_index + live "/servers/:namespace/:name/prompts/:item", ServerLive.Capabilities, :prompts_show + + live "/servers/:namespace/:name/prompts/:item/:client", + ServerLive.Capabilities, + :prompts_client + + live "/servers/:namespace/:name/resources", ServerLive.Capabilities, :resources_index + live "/servers/:namespace/:name/resources/:item", ServerLive.Capabilities, :resources_show + + live "/servers/:namespace/:name/resources/:item/:client", + ServerLive.Capabilities, + :resources_client + + # /skills was live briefly under the old name. A 301 rather than a 404, + # both for anything already crawled and because it is the word some + # readers will still try. + get "/servers/:namespace/:name/skills", RedirectController, :skills + get "/servers/:namespace/:name/skills/*rest", RedirectController, :skills live "/servers/*name", ServerLive.Show, :show end diff --git a/lib/mcp_registry_web/routes.ex b/lib/mcp_registry_web/routes.ex index cc91443..8fe03cc 100644 --- a/lib/mcp_registry_web/routes.ex +++ b/lib/mcp_registry_web/routes.ex @@ -5,7 +5,7 @@ defmodule McpRegistryWeb.Routes do these are built as plain strings rather than through `~p`. """ alias McpRegistry.Registry.Server - alias McpRegistry.Registry.Skill + alias McpRegistry.Registry.Capability alias McpRegistry.Registry.Tool def server_path(%Server{name: name}), do: server_path(name) @@ -41,20 +41,27 @@ defmodule McpRegistryWeb.Routes do def client_path(name, tool, client_id) when is_binary(name) and is_binary(client_id), do: tool_path(name, tool) <> "/" <> client_id - @doc "The skills index for a listing, where a listing has any." - def skills_path(%Server{name: name}), do: skills_path(name) - def skills_path(name) when is_binary(name), do: server_path(name) <> "/skills" + @doc """ + The prompts or resources index for a listing. + + `kind` is `:prompts` or `:resources`, and it is also the URL segment, so the + path and the database column cannot drift apart. + """ + def capabilities_path(%Server{name: name}, kind), do: capabilities_path(name, kind) + + def capabilities_path(name, kind) when is_binary(name) and kind in [:prompts, :resources], + do: server_path(name) <> "/" <> Atom.to_string(kind) - @doc "One skill's page. Percent-encoded for the same reason `tool_path/2` is." - def skill_path(%Server{name: name}, skill) when is_binary(skill), do: skill_path(name, skill) + @doc "One prompt or resource. Slugged, because a resource URI has slashes in it." + def capability_path(%Server{name: name}, kind, item), do: capability_path(name, kind, item) - def skill_path(name, skill) when is_binary(name) and is_binary(skill), - do: skills_path(name) <> "/" <> Skill.slug(skill) + def capability_path(name, kind, item) when is_binary(name) and is_binary(item), + do: capabilities_path(name, kind) <> "/" <> Capability.slug(item) - @doc "One skill, as used from one client." - def skill_client_path(%Server{name: name}, skill, client_id) when is_binary(client_id), - do: skill_client_path(name, skill, client_id) + @doc "One prompt or resource, as used from one client." + def capability_client_path(%Server{name: name}, kind, item, client_id), + do: capability_client_path(name, kind, item, client_id) - def skill_client_path(name, skill, client_id) when is_binary(name) and is_binary(client_id), - do: skill_path(name, skill) <> "/" <> client_id + def capability_client_path(name, kind, item, client_id) when is_binary(client_id), + do: capability_path(name, kind, item) <> "/" <> client_id end diff --git a/test/mcp_registry_web/live/capabilities_test.exs b/test/mcp_registry_web/live/capabilities_test.exs new file mode 100644 index 0000000..50bcc8a --- /dev/null +++ b/test/mcp_registry_web/live/capabilities_test.exs @@ -0,0 +1,211 @@ +defmodule McpRegistryWeb.ServerLive.CapabilitiesTest do + use McpRegistryWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import McpRegistry.RegistryFixtures + + alias McpRegistry.Registry.Capability + + defp with_both do + server_fixture(%{ + title: "Weather", + tools: ~w(get_forecast), + prompts: ~w(review_diff summarise_issue), + resources: ["file:///alerts.json", "https://example.test/api/stations"] + }) + end + + describe "slugging" do + test "a resource URI becomes one path segment" do + # URI.encode/1 leaves `/` alone, so file:///alerts.json would have been + # three segments and matched no route at all. + slug = Capability.slug("file:///alerts.json") + + refute slug =~ "/" + assert slug == "file-alerts-json" + end + + test "http and https are dropped, other schemes kept" do + assert Capability.slug("https://example.test/a") == "example-test-a" + assert Capability.slug("file:///a") == "file-a" + assert Capability.slug("ui://widget/x") == "ui-widget-x" + end + + test "find matches on the slug, so a rewritten name is still reachable" do + items = ["file:///alerts.json", "review_diff"] + + assert Capability.find(items, "file-alerts-json") == "file:///alerts.json" + assert Capability.find(items, "review-diff") == "review_diff" + assert Capability.find(items, "nope") == nil + end + end + + describe "the prompts silo" do + test "lists every prompt and links each one", %{conn: conn} do + server = with_both() + {:ok, _view, html} = live(conn, "/servers/#{server.name}/prompts") + + assert html =~ "Weather MCP Prompts" + assert html =~ "review_diff" + assert html =~ "/prompts/review-diff" + # The word people do not search for must not come back. + refute html =~ "MCP Skills" + end + + test "H1 and H2 on a client page name the prompt", %{conn: conn} do + server = with_both() + {:ok, _view, html} = live(conn, "/servers/#{server.name}/prompts/review-diff/cursor") + + assert html =~ ~r{]*>\s*Cursor Weather Prompt/review_diff\s*} + assert html =~ "How to: Cursor Weather review_diff" + end + + test "says a prompt is invoked, not called for you", %{conn: conn} do + server = with_both() + {:ok, _view, html} = live(conn, "/servers/#{server.name}/prompts/review-diff/claude-code") + + assert html =~ "slash command" + assert html =~ "will not call it for you" + end + + test "a listing with no prompts redirects rather than showing an empty page", %{conn: conn} do + server = server_fixture(%{prompts: [], resources: []}) + + assert {:error, {:live_redirect, %{to: to}}} = live(conn, "/servers/#{server.name}/prompts") + assert to == "/servers/#{server.name}" + end + end + + describe "the resources silo" do + test "lists every resource, shortened, with its scheme", %{conn: conn} do + server = with_both() + {:ok, _view, html} = live(conn, "/servers/#{server.name}/resources") + + assert html =~ "Weather MCP Resources" + assert html =~ "alerts.json" + # https is stripped for display; the scheme badge carries file. + assert html =~ "example.test/api/stations" + assert html =~ "/resources/file-alerts-json" + end + + test "H1 and H2 on a client page name the resource", %{conn: conn} do + server = with_both() + + {:ok, _view, html} = + live(conn, "/servers/#{server.name}/resources/file-alerts-json/cursor") + + assert html =~ ~r{]*>\s*Cursor Weather Resource/file:///alerts\.json\s*} + assert html =~ "How to: Cursor Weather file:///alerts.json" + end + + test "describes a resource as context to attach, not an action", %{conn: conn} do + server = with_both() + {:ok, _view, html} = live(conn, "/servers/#{server.name}/resources") + + assert html =~ "attach as context" + refute html =~ "will not call it for you" + end + + test "a listing with no resources redirects", %{conn: conn} do + server = server_fixture(%{prompts: ~w(only_prompt), resources: []}) + + assert {:error, {:live_redirect, %{to: to}}} = live(conn, "/servers/#{server.name}/resources") + assert to == "/servers/#{server.name}" + end + end + + test "each silo points at the other", %{conn: conn} do + server = with_both() + + {:ok, _view, prompts} = live(conn, "/servers/#{server.name}/prompts") + assert prompts =~ "/resources" + + {:ok, _view, resources} = live(conn, "/servers/#{server.name}/resources") + assert resources =~ "/prompts" + end + + test "an unknown item or client falls back rather than 404ing", %{conn: conn} do + server = with_both() + + assert {:error, {:live_redirect, %{to: to}}} = + live(conn, "/servers/#{server.name}/prompts/not_a_prompt") + + assert to == "/servers/#{server.name}" + + assert {:error, {:live_redirect, %{to: to}}} = + live(conn, "/servers/#{server.name}/prompts/review-diff/gptbot") + + assert to == "/servers/#{server.name}/prompts/review-diff" + end + + test "/skills 301s to prompts, at every depth", %{conn: conn} do + server = with_both() + + assert conn |> get("/servers/#{server.name}/skills") |> redirected_to(301) == + "/servers/#{server.name}/prompts" + + assert conn |> get("/servers/#{server.name}/skills/review_diff/cursor") |> redirected_to(301) == + "/servers/#{server.name}/prompts" + end + + test "an unknown listing still redirects home with a 301", %{conn: conn} do + assert conn |> get("/servers/io.github.nobody/nothing/prompts") |> redirected_to(301) == "/" + end + + describe "the sitemap" do + test "carries index, item and client URLs for both kinds", %{conn: conn} do + server = with_both() + + prompts = conn |> get("/sitemaps/prompts-1.xml") |> response(200) + assert prompts =~ "/servers/#{server.name}/prompts" + assert prompts =~ "/servers/#{server.name}/prompts/review-diff" + assert prompts =~ "/prompts/review-diff/cursor" + + resources = conn |> get("/sitemaps/resources-1.xml") |> response(200) + assert resources =~ "/servers/#{server.name}/resources" + assert resources =~ "/resources/file-alerts-json" + + index = conn |> get("/sitemap.xml") |> response(200) + assert index =~ "/sitemaps/prompts-1.xml" + assert index =~ "/sitemaps/resources-1.xml" + refute index =~ "skills" + end + + test "nothing anywhere means no file, and a 404 rather than an empty 200", %{conn: conn} do + server_fixture(%{prompts: [], resources: []}) + + index = conn |> get("/sitemap.xml") |> response(200) + refute index =~ "prompts-1.xml" + refute index =~ "resources-1.xml" + + assert conn |> get("/sitemaps/prompts-1.xml") |> response(404) == "" + assert conn |> get("/sitemaps/resources-1.xml") |> response(404) == "" + end + end + + test "every page in both silos is indexable", %{conn: conn} do + server = with_both() + + paths = [ + "/prompts", + "/prompts/review-diff", + "/prompts/review-diff/cursor", + "/resources", + "/resources/file-alerts-json", + "/resources/file-alerts-json/cursor" + ] + + for path <- paths 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 a silo only when it has content", %{conn: conn} do + quiet = server_fixture(%{prompts: [], resources: []}) + + {:ok, _view, html} = live(conn, "/servers/#{quiet.name}") + refute html =~ "/prompts" + refute html =~ "/resources" + end +end diff --git a/test/mcp_registry_web/live/skills_test.exs b/test/mcp_registry_web/live/skills_test.exs deleted file mode 100644 index a671e79..0000000 --- a/test/mcp_registry_web/live/skills_test.exs +++ /dev/null @@ -1,144 +0,0 @@ -defmodule McpRegistryWeb.ServerLive.SkillsTest do - use McpRegistryWeb.ConnCase, async: true - - import Phoenix.LiveViewTest - import McpRegistry.RegistryFixtures - - defp with_skills(prompts \\ ~w(review_diff summarise_issue)) do - server_fixture(%{title: "Weather", prompts: prompts, tools: ~w(get_forecast)}) - end - - describe "the skills index" do - test "lists every skill and links each one", %{conn: conn} do - server = with_skills() - {:ok, _view, html} = live(conn, "/servers/#{server.name}/skills") - - assert html =~ "Weather MCP Skills" - assert html =~ "review_diff" - assert html =~ "summarise_issue" - assert html =~ "/skills/review_diff" - end - - test "a listing with no skills redirects rather than showing an empty page", %{conn: conn} do - # The reason the prober was extended: without a count, every one of - # 34,000 listings would have had a skills page with nothing on it. - server = server_fixture(%{prompts: []}) - - assert {:error, {:live_redirect, %{to: to}}} = live(conn, "/servers/#{server.name}/skills") - assert to == "/servers/#{server.name}" - end - - test "points at the tools silo and says how the two differ", %{conn: conn} do - server = with_skills() - {:ok, _view, html} = live(conn, "/servers/#{server.name}/skills") - - assert html =~ "/tools" - assert html =~ "invoke" - end - end - - describe "a skill page" do - test "names the skill and offers every client", %{conn: conn} do - server = with_skills() - {:ok, _view, html} = live(conn, "/servers/#{server.name}/skills/review_diff") - - assert html =~ "review_diff" - - for label <- ["Claude Code", "Cursor", "VS Code"] do - assert html =~ "#{label} Weather Skill/review_diff" - end - end - - test "provenance says the names were read from the server", %{conn: conn} do - server = with_skills() - {:ok, _view, html} = live(conn, "/servers/#{server.name}/skills/review_diff") - - assert html =~ "prompts/list" - end - - test "a skill that is not on this server redirects to the listing", %{conn: conn} do - server = with_skills() - - assert {:error, {:live_redirect, %{to: to}}} = - live(conn, "/servers/#{server.name}/skills/not_a_skill") - - assert to == "/servers/#{server.name}" - end - end - - describe "a skill-and-client page" do - test "H1 and H2 follow the shapes that were asked for", %{conn: conn} do - server = with_skills() - {:ok, _view, html} = live(conn, "/servers/#{server.name}/skills/review_diff/cursor") - - assert html =~ ~r{]*>\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