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