- <.monogram name={@server.name} size="size-12 text-lg" />
+ <.server_logo server={@server} size="size-12 text-lg" />
<%!-- The version sits beside the
, not inside it: the heading
is the page's SEO title and stays exactly that string. --%>
diff --git a/priv/repo/migrations/20260930230000_add_icon_url.exs b/priv/repo/migrations/20260930230000_add_icon_url.exs
new file mode 100644
index 0000000..7f83d0e
--- /dev/null
+++ b/priv/repo/migrations/20260930230000_add_icon_url.exs
@@ -0,0 +1,18 @@
+defmodule McpRegistry.Repo.Migrations.AddIconUrl do
+ @moduledoc """
+ The icon a publisher declares for their server in `server.json`.
+
+ One URL rather than the whole `icons` array: the page shows one logo, and
+ `McpRegistry.Registry.Manifest` picks it at sync time so the choice is made
+ once rather than on every render. Text, not varchar(255) -- icon URLs are
+ publisher-controlled and the census already found that 255 is not enough
+ for what publishers put in a URL.
+ """
+ use Ecto.Migration
+
+ def change do
+ alter table(:servers) do
+ add :icon_url, :text
+ end
+ end
+end
diff --git a/test/mcp_registry/logo_test.exs b/test/mcp_registry/logo_test.exs
new file mode 100644
index 0000000..c4b13b1
--- /dev/null
+++ b/test/mcp_registry/logo_test.exs
@@ -0,0 +1,81 @@
+defmodule McpRegistry.Registry.LogoTest do
+ use ExUnit.Case, async: true
+
+ alias McpRegistry.Registry.Logo
+ alias McpRegistry.Registry.Server
+
+ defp server(attrs), do: struct(Server, Map.merge(%{name: "com.acme/thing"}, attrs))
+
+ describe "which logo a listing gets" do
+ test "a declared icon outranks everything" do
+ s = server(%{name: "io.github.acme/x", icon_url: "https://acme.test/logo.svg"})
+ assert Logo.for_server(s) == {:declared, "https://acme.test/logo.svg"}
+ end
+
+ test "an io.github namespace gets that verified owner's avatar" do
+ assert {:github, url} = Logo.for_server(server(%{name: "io.github.troyhunt/hibp"}))
+ assert url == "https://avatars.githubusercontent.com/troyhunt?size=96"
+ end
+
+ test "a domain namespace falls back to its repository owner" do
+ s = server(%{repository_url: "https://github.com/cloudflare/mcp-server-cloudflare"})
+ assert {:repository, url} = Logo.for_server(s)
+ assert url =~ "/cloudflare?"
+ end
+
+ test "the namespace beats the repository, because only it is verified" do
+ s = server(%{name: "io.github.alice/x", repository_url: "https://github.com/bob/x"})
+ assert {:github, url} = Logo.for_server(s)
+ assert url =~ "/alice?"
+ end
+
+ test "nothing on the record means no logo, never a guess" do
+ assert Logo.for_server(server(%{repository_url: "https://gitlab.com/acme/x"})) == nil
+ assert Logo.for_server(server(%{})) == nil
+ end
+
+ test "a login GitHub would reject is not turned into a URL" do
+ assert Logo.namespace_login("io.github.-bad/x") == nil
+ assert Logo.namespace_login("io.github.a--b/x") == nil
+ assert Logo.repository_login("https://github.com/") == nil
+ end
+ end
+
+ describe "choosing from a server.json icons array" do
+ test "SVG first, since it is sharp at any size" do
+ icons = [
+ %{"src" => "https://a.test/128.png", "sizes" => ["128x128"]},
+ %{"src" => "https://a.test/mark.svg", "mimeType" => "image/svg+xml"}
+ ]
+
+ assert Logo.pick(icons) == "https://a.test/mark.svg"
+ end
+
+ test "otherwise the smallest raster that is still big enough" do
+ icons = [
+ %{"src" => "https://a.test/512.png", "sizes" => ["512x512"]},
+ %{"src" => "https://a.test/32.png", "sizes" => ["32x32"]},
+ %{"src" => "https://a.test/180.png", "sizes" => ["180x180"]}
+ ]
+
+ assert Logo.pick(icons) == "https://a.test/180.png"
+ end
+
+ test "an icon for dark backgrounds is passed over when there is another" do
+ icons = [
+ %{"src" => "https://a.test/dark.svg", "theme" => "dark"},
+ %{"src" => "https://a.test/light.png", "sizes" => ["256x256"]}
+ ]
+
+ assert Logo.pick(icons) == "https://a.test/light.png"
+ end
+
+ test "unusable sources are dropped: http, data:, placeholders, junk" do
+ assert Logo.pick([%{"src" => "http://a.test/x.png"}]) == nil
+ assert Logo.pick([%{"src" => "data:image/png;base64,AAAA"}]) == nil
+ assert Logo.pick([%{"src" => "https://{HOST}/x.png"}]) == nil
+ assert Logo.pick([%{"nope" => 1}, "string", nil]) == nil
+ assert Logo.pick(nil) == nil
+ end
+ end
+end
diff --git a/test/mcp_registry/official_registry_test.exs b/test/mcp_registry/official_registry_test.exs
index 58b3004..4f2c953 100644
--- a/test/mcp_registry/official_registry_test.exs
+++ b/test/mcp_registry/official_registry_test.exs
@@ -276,6 +276,55 @@ defmodule McpRegistry.OfficialRegistryTest do
assert %SyncRun{status: "error", error: "interrupted"} = Repo.get!(SyncRun, stale.id)
end
+ test "a declared icon is imported, and an unusable one never costs the listing" do
+ serve([
+ [
+ entry("io.github.acme/iconic", %{
+ "icons" => [
+ %{"src" => "https://acme.test/logo-512.png", "sizes" => ["512x512"]},
+ %{"src" => "https://acme.test/logo-128.png", "sizes" => ["128x128"]}
+ ]
+ }),
+ entry("io.github.acme/plain-http", %{"icons" => [%{"src" => "http://acme.test/logo.png"}]})
+ ]
+ ])
+
+ assert {:ok, _} = OfficialRegistry.sync(mode: :full)
+
+ # The smallest raster that is still sharp at double density wins.
+ assert Registry.get_server!("io.github.acme/iconic").icon_url ==
+ "https://acme.test/logo-128.png"
+
+ # http would be blocked as mixed content, so it is dropped -- and the
+ # listing still imports rather than failing validation over its icon.
+ assert Registry.get_server!("io.github.acme/plain-http").icon_url == nil
+ end
+
+ test "re-apply reaches listings whose upstream did not change, and stays quiet" do
+ # Imported before the mapping knew about icons: same updatedAt both times.
+ serve([[entry("io.github.acme/before-icons")]])
+ assert {:ok, _} = OfficialRegistry.sync(mode: :full)
+
+ serve([
+ [
+ entry("io.github.acme/before-icons", %{"icons" => [%{"src" => "https://acme.test/i.svg"}]})
+ ]
+ ])
+
+ # A normal run skips it: upstream's updatedAt has not moved.
+ assert {:ok, %{unchanged: 1}} = OfficialRegistry.sync(mode: :full)
+ assert Registry.get_server!("io.github.acme/before-icons").icon_url == nil
+
+ # Re-apply pushes it through the mapping again.
+ assert {:ok, %{updated: 1}} = OfficialRegistry.sync(reapply: true)
+
+ assert Registry.get_server!("io.github.acme/before-icons").icon_url ==
+ "https://acme.test/i.svg"
+
+ # Nothing left to change: a second re-apply writes nothing.
+ assert {:ok, %{updated: 0, unchanged: 1}} = OfficialRegistry.sync(reapply: true)
+ end
+
test "server_url points at the official API entry" do
assert OfficialRegistry.server_url("io.github.acme/weather") ==
"https://registry.modelcontextprotocol.io/v0.1/servers/io.github.acme%2Fweather/versions/latest"
diff --git a/test/mcp_registry_web/live/server_live_test.exs b/test/mcp_registry_web/live/server_live_test.exs
index 3392aea..11c25a4 100644
--- a/test/mcp_registry_web/live/server_live_test.exs
+++ b/test/mcp_registry_web/live/server_live_test.exs
@@ -80,6 +80,55 @@ defmodule McpRegistryWeb.ServerLiveTest do
~s(and Cursor Context7 using MCP so I can use them in Claude Code and Grok Bot.")
end
+ describe "logos" do
+ test "a verified GitHub namespace shows its owner's avatar", %{conn: conn} do
+ server =
+ server_fixture(%{name: "io.github.troyhunt/hibp-#{System.unique_integer([:positive])}"})
+
+ {:ok, _view, html} = live(conn, "/servers/#{server.name}")
+
+ assert html =~ "https://avatars.githubusercontent.com/troyhunt?size=96"
+ # It removes itself if it fails, so the monogram beneath shows instead.
+ assert html =~ ~s(onerror="this.remove\(\)")
+ assert html =~ ~s(referrerpolicy="no-referrer")
+ assert html =~ "monogram"
+ end
+
+ test "a declared icon wins, drawn padded on a light tile", %{conn: conn} do
+ server =
+ server_fixture(%{
+ name: "io.github.acme/iconic-#{System.unique_integer([:positive])}",
+ icon_url: "https://acme.test/logo.svg"
+ })
+
+ {:ok, _view, html} = live(conn, "/servers/#{server.name}")
+
+ assert html =~ "https://acme.test/logo.svg"
+ refute html =~ "avatars.githubusercontent.com"
+ assert html =~ "object-contain"
+ end
+
+ test "with nothing on record, only the monogram is drawn", %{conn: conn} do
+ server =
+ server_fixture(%{
+ name: "com.acme/plain-#{System.unique_integer([:positive])}",
+ repository_url: "https://gitlab.com/acme/plain"
+ })
+
+ {:ok, _view, html} = live(conn, "/servers/#{server.name}")
+
+ refute html =~ ~s(onerror="this.remove\(\)")
+ assert html =~ "monogram"
+ end
+
+ test "the listing grid shows logos too", %{conn: conn} do
+ server_fixture(%{name: "io.github.gridowner/x-#{System.unique_integer([:positive])}"})
+ {:ok, _view, html} = live(conn, ~p"/servers")
+
+ assert html =~ "avatars.githubusercontent.com/gridowner"
+ end
+ end
+
test "pending listings are kept out of search engines", %{conn: conn} do
active = server_fixture()
pending = server_fixture(%{status: "pending"})