Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 1 addition & 27 deletions lib/mcp_registry/registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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.

Expand Down
10 changes: 5 additions & 5 deletions lib/mcp_registry_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1107,11 +1107,6 @@ defmodule McpRegistryWeb.CoreComponents do
{"0 0 24 24", :outline,
~S|<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/>|}

defp icon_data("hero-funnel"),
do:
{"0 0 24 24", :outline,
~S|<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z"/>|}

defp icon_data("hero-globe-alt"),
do:
{"0 0 24 24", :outline,
Expand Down Expand Up @@ -1172,6 +1167,11 @@ defmodule McpRegistryWeb.CoreComponents do
{"0 0 16 16", :solid,
~S|<path d="M8 1a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 1ZM10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0ZM12.95 4.11a.75.75 0 1 0-1.06-1.06l-1.062 1.06a.75.75 0 0 0 1.061 1.062l1.06-1.061ZM15 8a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 15 8ZM11.89 12.95a.75.75 0 0 0 1.06-1.06l-1.06-1.062a.75.75 0 0 0-1.062 1.061l1.061 1.06ZM8 12a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 12ZM5.172 11.89a.75.75 0 0 0-1.061-1.062L3.05 11.89a.75.75 0 1 0 1.06 1.06l1.06-1.06ZM4 8a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 4 8ZM4.11 5.172A.75.75 0 0 0 5.173 4.11L4.11 3.05a.75.75 0 1 0-1.06 1.06l1.06 1.06Z"/>|}

defp icon_data("hero-tag"),
do:
{"0 0 24 24", :outline,
~S|<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z"/>|}

defp icon_data("hero-x-mark"),
do:
{"0 0 24 24", :outline,
Expand Down
1 change: 0 additions & 1 deletion lib/mcp_registry_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ defmodule McpRegistryWeb.Layouts do
<:link_item navigate={~p"/servers?transport=streamable-http"}>
Remote servers
</:link_item>
<:link_item navigate={~p"/servers?tag=developer-tools"}>Developer tools</:link_item>
<:link_item navigate={~p"/submit"}>Submit a server</:link_item>
</.footer_column>

Expand Down
29 changes: 4 additions & 25 deletions lib/mcp_registry_web/live/server_live/home.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()))}
Expand Down Expand Up @@ -93,7 +92,7 @@ defmodule McpRegistryWeb.ServerLive.Home do
</p>

<p class="rise mt-4 max-w-2xl text-sm text-pretty text-dim" style="--d: 180ms">
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.
</p>

Expand Down Expand Up @@ -257,7 +256,7 @@ defmodule McpRegistryWeb.ServerLive.Home do

<ol class="grid gap-px overflow-hidden rounded-box border border-rule bg-rule sm:grid-cols-3">
<.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>
<.step index="02" label="install" icon="hero-command-line">
Expand All @@ -271,26 +270,6 @@ defmodule McpRegistryWeb.ServerLive.Home do
</ol>
</section>

<section :if={@tags != []} aria-labelledby="tags-heading" class="space-y-4 pt-8">
<div class="space-y-1.5">
<p class="font-mono text-[11px] tracking-wide text-dim uppercase">by category</p>
<h2 id="tags-heading" class="text-xl font-semibold tracking-tight text-balance sm:text-2xl">
Browse by tag
</h2>
</div>

<ul class="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0">
<li :for={{tag, count} <- @tags} class="shrink-0">
<.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} <span class="tabular-nums opacity-60">{count}</span>
</.link>
</li>
</ul>
</section>

<section aria-labelledby="subscribe-heading" class="pt-8">
<div class="rounded-box border border-rule bg-surface/60 p-5 sm:p-7">
<div class="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
Expand Down Expand Up @@ -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

Expand Down
73 changes: 17 additions & 56 deletions lib/mcp_registry_web/live/server_live/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -83,7 +80,7 @@ defmodule McpRegistryWeb.ServerLive.Index do
<p class="flex items-baseline gap-2 font-mono">
<span class="text-2xl font-semibold tracking-tight">{format_number(@total)}</span>
<span class="text-xs text-dim">
{if any_filter?(@q, @transport, @tag), do: "matching", else: "servers"}
{if any_filter?(@q, @transport), do: "matching", else: "servers"}
</span>
</p>
</:actions>
Expand Down Expand Up @@ -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"
>
Expand All @@ -141,45 +138,18 @@ defmodule McpRegistryWeb.ServerLive.Index do
</select>
</form>

<nav
class="-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0"
aria-label="Filter by tag"
>
<.link
patch={index_path(@q, @transport, nil)}
aria-current={is_nil(@tag) && "true"}
class={[chip_classes(), chip_tone(is_nil(@tag))]}
>
all
</.link>
<.link
:for={{tag, count} <- @tags}
patch={index_path(@q, @transport, tag)}
aria-current={@tag == tag && "true"}
class={[chip_classes(), chip_tone(@tag == tag)]}
>
{tag} <span class="opacity-60">{count}</span>
</.link>
</nav>

<div :if={any_filter?(@q, @transport, @tag)} class="flex flex-wrap items-center gap-2">
<div :if={any_filter?(@q, @transport)} class="flex flex-wrap items-center gap-2">
<span class="font-mono text-[11px] tracking-wide text-dim uppercase">filtered by</span>
<.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
Expand Down Expand Up @@ -264,7 +234,7 @@ defmodule McpRegistryWeb.ServerLive.Index do
<.icon name="hero-magnifying-glass" class="mx-auto size-6 text-dim" />

<h2 class="mt-3 text-base font-medium text-balance">
No servers {filter_phrase(@q, @transport, @tag) || "are listed yet"}
No servers {filter_phrase(@q, @transport) || "are listed yet"}
</h2>

<p class="mx-auto mt-1.5 max-w-md text-sm text-pretty text-dim">
Expand All @@ -273,10 +243,10 @@ defmodule McpRegistryWeb.ServerLive.Index do
</p>

<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
<.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>
<.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
</.button>
Expand All @@ -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()}
>
Expand All @@ -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()}
>
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions lib/mcp_registry_web/live/server_live/show.ex
Original file line number Diff line number Diff line change
Expand Up @@ -632,15 +632,15 @@ defmodule McpRegistryWeb.ServerLive.Show do

<Layouts.sponsors />

<.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">
<ul class="flex flex-wrap gap-1.5">
<li :for={tag <- @server.tags}>
<.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}
</.link>
<li
:for={tag <- @server.tags}
class="rounded-full border border-rule px-2.5 py-1 font-mono text-[11px] text-dim"
>
#{tag}
</li>
</ul>
</.panel>
Expand Down
42 changes: 42 additions & 0 deletions lib/mcp_registry_web/plugs/retired_params.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading