From c452dd13f75de1f095e69ebabbc7615a48b41c9f Mon Sep 17 00:00:00 2001 From: Logan Besecker Date: Thu, 1 Oct 2026 12:43:50 -0700 Subject: [PATCH] Remove browse by tag The most-used tag covers 8 of about 37,000 listings, so every tag page was close to empty. This removes the browsing and keeps the tags. - Gone: the directory's tag chip row and ?tag= filter, the home page's "Browse by tag" section, the footer's tag link, and top_tags/1, which had no other caller - A listing's own tags stay, as text instead of links - ?tag= URLs 301 to the same page without it, keeping any other query; every listing page linked there, so crawlers and bookmarks hold them - Search still matches tags, and the JSON API keeps its tag filter Closes #85 --- Pages affected: - [MCP server directory](https://ai.mcpharbor.dev/servers) -- no tag filter row. - [MCP Harbor](https://ai.mcpharbor.dev/) -- no browse-by-tag section. Co-Authored-By: Claude Opus 5.5 --- lib/mcp_registry/registry.ex | 28 +------ .../components/core_components.ex | 10 +-- lib/mcp_registry_web/components/layouts.ex | 1 - lib/mcp_registry_web/live/server_live/home.ex | 29 +------- .../live/server_live/index.ex | 73 +++++-------------- lib/mcp_registry_web/live/server_live/show.ex | 16 ++-- lib/mcp_registry_web/plugs/retired_params.ex | 42 +++++++++++ lib/mcp_registry_web/router.ex | 1 + .../live/server_live_test.exs | 45 ++++++++++++ 9 files changed, 123 insertions(+), 122 deletions(-) create mode 100644 lib/mcp_registry_web/plugs/retired_params.ex diff --git a/lib/mcp_registry/registry.ex b/lib/mcp_registry/registry.ex index d9ddfd6..3909067 100644 --- a/lib/mcp_registry/registry.ex +++ b/lib/mcp_registry/registry.ex @@ -116,8 +116,7 @@ defmodule McpRegistry.Registry do @doc """ Drops the cached catalogue figures. - Called after anything that changes what `stats/0` or `top_tags/1` would - answer, so the day-long TTL is a ceiling rather than a staleness guarantee. + Called after anything that changes what `stats/0` would answer, so the day-long TTL is a ceiling rather than a staleness guarantee. The official-registry sync calls this once at the end of a run rather than per row. """ @@ -303,31 +302,6 @@ defmodule McpRegistry.Registry do end) end - @doc """ - The most-used tags among active servers, as `{tag, count}` pairs. - - Counted in Postgres. The previous version selected `unnest(tags)` and tallied - in Elixir, which moved one row per tag per listing -- roughly ninety thousand - of them -- across the wire on every catalogue and landing page view. - """ - def top_tags(n \\ 12) do - Cache.fetch({:top_tags, n}, fn -> - %{rows: rows} = - Repo.query!( - """ - SELECT tag, count(*) AS uses - FROM (SELECT unnest(tags) AS tag FROM servers WHERE status = 'active') AS tags - GROUP BY tag - ORDER BY uses DESC, tag ASC - LIMIT $1 - """, - [n] - ) - - Enum.map(rows, fn [tag, uses] -> {tag, uses} end) - end) - end - @doc """ Catalogue-wide counters for the landing page and the catalogue header. diff --git a/lib/mcp_registry_web/components/core_components.ex b/lib/mcp_registry_web/components/core_components.ex index 6bb016b..7beacca 100644 --- a/lib/mcp_registry_web/components/core_components.ex +++ b/lib/mcp_registry_web/components/core_components.ex @@ -1107,11 +1107,6 @@ defmodule McpRegistryWeb.CoreComponents do {"0 0 24 24", :outline, ~S||} - defp icon_data("hero-funnel"), - do: - {"0 0 24 24", :outline, - ~S||} - defp icon_data("hero-globe-alt"), do: {"0 0 24 24", :outline, @@ -1172,6 +1167,11 @@ defmodule McpRegistryWeb.CoreComponents do {"0 0 16 16", :solid, ~S||} + defp icon_data("hero-tag"), + do: + {"0 0 24 24", :outline, + ~S||} + defp icon_data("hero-x-mark"), do: {"0 0 24 24", :outline, diff --git a/lib/mcp_registry_web/components/layouts.ex b/lib/mcp_registry_web/components/layouts.ex index 5aa992d..fc8b11c 100644 --- a/lib/mcp_registry_web/components/layouts.ex +++ b/lib/mcp_registry_web/components/layouts.ex @@ -122,7 +122,6 @@ defmodule McpRegistryWeb.Layouts do <:link_item navigate={~p"/servers?transport=streamable-http"}> Remote servers - <:link_item navigate={~p"/servers?tag=developer-tools"}>Developer tools <:link_item navigate={~p"/submit"}>Submit a server diff --git a/lib/mcp_registry_web/live/server_live/home.ex b/lib/mcp_registry_web/live/server_live/home.ex index 3b0dd1c..a63d9e4 100644 --- a/lib/mcp_registry_web/live/server_live/home.ex +++ b/lib/mcp_registry_web/live/server_live/home.ex @@ -14,7 +14,7 @@ defmodule McpRegistryWeb.ServerLive.Home do # Query params that used to belong to this route. Anything here means the # visitor wanted the catalogue, not the landing page. - @catalogue_params ~w(q tag transport page) + @catalogue_params ~w(q transport page) @impl true def mount(_params, _session, socket) do @@ -30,7 +30,6 @@ defmodule McpRegistryWeb.ServerLive.Home do |> assign(:canonical_url, McpRegistryWeb.Endpoint.url() <> "/") |> assign(:mcp_url, McpRegistryWeb.Endpoint.url() <> "/mcp") |> assign(:stats, stats) - |> assign(:tags, Registry.top_tags(12)) |> assign(:featured, Registry.list_servers(limit: 6)) |> assign(:subscribed?, false) |> assign(:form, to_form(Subscribers.change_subscriber()))} @@ -93,7 +92,7 @@ defmodule McpRegistryWeb.ServerLive.Home do

- Search every listing by name, tag, transport or the exact tool you need. The official + Search every listing by name, transport or the exact tool you need. The official MCP Registry is mirrored in and kept in sync; anything submitted here lands alongside it.

@@ -257,7 +256,7 @@ defmodule McpRegistryWeb.ServerLive.Home do
    <.step index="01" label="find" icon="hero-magnifying-glass"> - Filter by tag, transport or tool name. Every listing shows what it exposes and what it + Search by name, transport or tool name. Every listing shows what it exposes and what it needs before you install anything. <.step index="02" label="install" icon="hero-command-line"> @@ -271,26 +270,6 @@ defmodule McpRegistryWeb.ServerLive.Home do
-
-
-

by category

-

- Browse by tag -

-
- -
    -
  • - <.link - navigate={~p"/servers?tag=#{tag}"} - class="flex items-center gap-2 rounded-full border border-rule bg-surface/50 px-3 py-1.5 font-mono text-xs text-dim transition-colors hover:border-brand/40 hover:bg-surface hover:text-ink" - > - {tag} {count} - -
  • -
-
-
@@ -404,7 +383,7 @@ defmodule McpRegistryWeb.ServerLive.Home do end defp meta_description(stats) do - "Search #{format_number(stats.servers)} Model Context Protocol servers by tag, transport or " <> + "Search #{format_number(stats.servers)} Model Context Protocol servers by name, transport or " <> "tool name. Copy the install command, or connect your agent to the registry's own MCP endpoint." end diff --git a/lib/mcp_registry_web/live/server_live/index.ex b/lib/mcp_registry_web/live/server_live/index.ex index d3c1927..e65fb9c 100644 --- a/lib/mcp_registry_web/live/server_live/index.ex +++ b/lib/mcp_registry_web/live/server_live/index.ex @@ -22,10 +22,9 @@ defmodule McpRegistryWeb.ServerLive.Index do |> assign( :meta_description, "Search the MCP Registry: an open directory of Model Context Protocol servers. " <> - "Filter by tag, tool and transport, then connect one to Claude, Cursor or any MCP client." + "Search by name or tool, filter by transport, then connect one to Claude, Cursor or any MCP client." ) |> assign(:canonical_url, McpRegistryWeb.Endpoint.url() <> "/servers") - |> assign(:tags, Registry.top_tags(14)) |> assign(:stats, Registry.stats()) |> assign(:transports, Server.transports()) |> assign(:mcp_url, McpRegistryWeb.Endpoint.url() <> "/mcp")} @@ -35,8 +34,7 @@ defmodule McpRegistryWeb.ServerLive.Index do def handle_params(params, _uri, socket) do q = String.trim(params["q"] || "") transport = blank_to_nil(params["transport"]) - tag = blank_to_nil(params["tag"]) - filters = [q: q, transport: transport, tag: tag] + filters = [q: q, transport: transport] total = Registry.count_servers(filters) last_page = max(div(total + @per_page - 1, @per_page), 1) @@ -53,7 +51,6 @@ defmodule McpRegistryWeb.ServerLive.Index do assign(socket, q: q, transport: transport, - tag: tag, servers: servers, page: page, last_page: last_page, @@ -66,7 +63,7 @@ defmodule McpRegistryWeb.ServerLive.Index do @impl true def handle_event("search", %{"q" => q} = params, socket) do transport = Map.get(params, "transport", socket.assigns.transport) - {:noreply, push_patch(socket, to: index_path(q, transport, socket.assigns.tag))} + {:noreply, push_patch(socket, to: index_path(q, transport))} end @impl true @@ -83,7 +80,7 @@ defmodule McpRegistryWeb.ServerLive.Index do

{format_number(@total)} - {if any_filter?(@q, @transport, @tag), do: "matching", else: "servers"} + {if any_filter?(@q, @transport), do: "matching", else: "servers"}

@@ -117,7 +114,7 @@ defmodule McpRegistryWeb.ServerLive.Index do /> <.link :if={@q != ""} - patch={index_path("", @transport, @tag)} + patch={index_path("", @transport)} aria-label="Clear the search" class="flex size-6 shrink-0 items-center justify-center rounded-full text-dim transition-colors hover:bg-sunken hover:text-ink" > @@ -141,45 +138,18 @@ defmodule McpRegistryWeb.ServerLive.Index do - - -
+
filtered by <.filter_pill :if={@q != ""} label={@q} - remove={index_path("", @transport, @tag)} + remove={index_path("", @transport)} remove_label={"Clear the search term #{@q}"} /> - <.filter_pill - :if={@tag} - label={"##{@tag}"} - remove={index_path(@q, @transport, nil)} - remove_label={"Remove the #{@tag} tag filter"} - /> <.filter_pill :if={@transport} label={@transport} - remove={index_path(@q, nil, @tag)} + remove={index_path(@q, nil)} remove_label={"Remove the #{@transport} transport filter"} /> <.link @@ -264,7 +234,7 @@ defmodule McpRegistryWeb.ServerLive.Index do <.icon name="hero-magnifying-glass" class="mx-auto size-6 text-dim" />

- No servers {filter_phrase(@q, @transport, @tag) || "are listed yet"} + No servers {filter_phrase(@q, @transport) || "are listed yet"}

@@ -273,10 +243,10 @@ defmodule McpRegistryWeb.ServerLive.Index do

- <.button :if={@q != ""} variant="soft" patch={index_path("", @transport, @tag)}> + <.button :if={@q != ""} variant="soft" patch={index_path("", @transport)}> <.icon name="hero-x-mark" class="size-4" /> Clear the search - <.button :if={any_filter?(@q, @transport, @tag)} variant="soft" patch={~p"/servers"}> + <.button :if={any_filter?(@q, @transport)} variant="soft" patch={~p"/servers"}> <.icon name="hero-squares-2x2" class="size-4" /> Browse all {format_number(@stats.servers)} servers @@ -294,7 +264,7 @@ defmodule McpRegistryWeb.ServerLive.Index do > <.link :if={@page > 1} - patch={index_path(@q, @transport, @tag, @page - 1)} + patch={index_path(@q, @transport, @page - 1)} rel="prev" class={page_button_classes()} > @@ -314,7 +284,7 @@ defmodule McpRegistryWeb.ServerLive.Index do <.link :if={@page < @last_page} - patch={index_path(@q, @transport, @tag, @page + 1)} + patch={index_path(@q, @transport, @page + 1)} rel="next" class={page_button_classes()} > @@ -369,34 +339,25 @@ defmodule McpRegistryWeb.ServerLive.Index do """ end - defp chip_classes, - do: "shrink-0 rounded-field border px-2.5 py-1 font-mono text-xs transition-colors" - - defp chip_tone(true), do: "border-brand bg-brand text-brand-ink" - - defp chip_tone(false), - do: "border-rule text-dim hover:border-rule-strong hover:bg-surface hover:text-ink" - defp page_button_classes, do: "inline-flex min-h-10 items-center gap-1.5 rounded-field border border-rule bg-surface px-3.5 font-mono text-xs transition-colors hover:border-rule-strong hover:bg-sunken" - defp index_path(q, transport, tag, page \\ 1) do + defp index_path(q, transport, page \\ 1) do params = - [q: q, transport: transport, tag: tag, page: if(page > 1, do: page)] + [q: q, transport: transport, page: if(page > 1, do: page)] |> Enum.reject(fn {_key, value} -> value in [nil, ""] end) ~p"/servers?#{params}" end - defp any_filter?(q, transport, tag), do: q != "" or transport != nil or tag != nil + defp any_filter?(q, transport), do: q != "" or transport != nil # Reads back the active filters as a sentence fragment, so an empty result # says what was actually asked for rather than "no matches". - defp filter_phrase(q, transport, tag) do + defp filter_phrase(q, transport) do [ q != "" && ~s(matching "#{q}"), - tag && "tagged ##{tag}", transport && "on #{transport}" ] |> Enum.filter(& &1) diff --git a/lib/mcp_registry_web/live/server_live/show.ex b/lib/mcp_registry_web/live/server_live/show.ex index e7e6e53..d7a85a3 100644 --- a/lib/mcp_registry_web/live/server_live/show.ex +++ b/lib/mcp_registry_web/live/server_live/show.ex @@ -632,15 +632,15 @@ defmodule McpRegistryWeb.ServerLive.Show do - <.panel :if={@server.tags != []} title="Tags" icon="hero-funnel"> + <%!-- Plain text, not links: browsing by tag was removed while too few + listings carry tags for a tag page to be worth landing on. --%> + <.panel :if={@server.tags != []} title="Tags" icon="hero-tag">
    -
  • - <.link - navigate={~p"/servers?tag=#{tag}"} - 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" - > - #{tag} - +
  • + #{tag}
diff --git a/lib/mcp_registry_web/plugs/retired_params.ex b/lib/mcp_registry_web/plugs/retired_params.ex new file mode 100644 index 0000000..209e62a --- /dev/null +++ b/lib/mcp_registry_web/plugs/retired_params.ex @@ -0,0 +1,42 @@ +defmodule McpRegistryWeb.Plugs.RetiredParams do + @moduledoc """ + Permanently redirects a page request that carries a query parameter the site + no longer uses to the same URL without it. + + `?tag=` drove browse-by-tag, which was removed because too few listings carry + tags for a tag page to be worth landing on. Every listing page, the home page + and the footer linked to `/servers?tag=…`, so crawlers and bookmarks hold + those URLs. Left alone, each would now render a copy of `/servers` under its + own URL; a 301 folds them back into the page they actually show. + + Only page requests pass through here. The JSON API keeps its `tag` filter: + it is a query parameter clients may rely on, not a page anyone browses. + """ + @behaviour Plug + + import Plug.Conn + + @retired ~w(tag) + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(%Plug.Conn{method: "GET"} = conn, _opts) do + conn = fetch_query_params(conn) + + if Enum.any?(@retired, &Map.has_key?(conn.query_params, &1)) do + kept = Map.drop(conn.query_params, @retired) + query = if kept == %{}, do: "", else: "?" <> Plug.Conn.Query.encode(kept) + + conn + |> put_resp_header("location", conn.request_path <> query) + |> send_resp(:moved_permanently, "") + |> halt() + else + conn + end + end + + def call(conn, _opts), do: conn +end diff --git a/lib/mcp_registry_web/router.ex b/lib/mcp_registry_web/router.ex index 9e21c40..f080734 100644 --- a/lib/mcp_registry_web/router.ex +++ b/lib/mcp_registry_web/router.ex @@ -3,6 +3,7 @@ defmodule McpRegistryWeb.Router do pipeline :browser do plug :accepts, ["html"] + plug McpRegistryWeb.Plugs.RetiredParams plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {McpRegistryWeb.Layouts, :root} diff --git a/test/mcp_registry_web/live/server_live_test.exs b/test/mcp_registry_web/live/server_live_test.exs index f36fd51..7324d6e 100644 --- a/test/mcp_registry_web/live/server_live_test.exs +++ b/test/mcp_registry_web/live/server_live_test.exs @@ -80,6 +80,51 @@ defmodule McpRegistryWeb.ServerLiveTest do ~s(and Cursor Context7 using MCP so I can use them in Claude Code and Grok Bot.") end + describe "browsing by tag is gone" do + test "neither the directory nor the home page offers it", %{conn: conn} do + server_fixture(%{tags: ["weather"]}) + + {:ok, _view, directory} = live(conn, "/servers") + refute directory =~ "Filter by tag" + refute directory =~ "?tag=" + + {:ok, _view, home} = live(conn, "/") + refute home =~ "Browse by tag" + refute home =~ "?tag=" + end + + test "a listing still shows its tags, but as text rather than links", %{conn: conn} do + server = server_fixture(%{tags: ["weather", "forecasts"]}) + {:ok, _view, html} = live(conn, "/servers/#{server.name}") + + assert html =~ "#weather" + refute html =~ "?tag=" + end + + test "old tag URLs 301 to the same page without the tag", %{conn: conn} do + assert conn |> get("/servers?tag=weather") |> redirected_to(301) == "/servers" + assert conn |> get("/?tag=weather") |> redirected_to(301) == "/" + + # Anything else in the query survives. + assert conn |> get("/servers?tag=weather&q=radar&transport=stdio") |> redirected_to(301) == + "/servers?q=radar&transport=stdio" + end + + test "the JSON API keeps its tag filter, which clients may depend on", %{conn: conn} do + tagged = server_fixture(%{tags: ["weather"]}) + _other = server_fixture(%{tags: ["notes"]}) + + names = + conn + |> get("/api/v0/servers?tag=weather") + |> json_response(200) + |> Map.fetch!("servers") + |> Enum.map(& &1["server"]["name"]) + + assert names == [tagged.name] + end + end + describe "logos" do test "a verified GitHub namespace shows its owner's avatar", %{conn: conn} do server =