<.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" />
- <.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 =