From 981cf86fa255887705d1c651cd19cf2c14ec1bf4 Mon Sep 17 00:00:00 2001 From: Logan Besecker Date: Thu, 1 Oct 2026 00:09:13 -0700 Subject: [PATCH] Show each MCP server's logo, from sources the publisher declared Listings drew a two-letter monogram. Most can show a real logo from something the publisher already put on the public record: - The icon declared in server.json, preferring SVG, then the smallest raster still sharp at double density; http and data: are dropped - The GitHub avatar of an io.github.* owner -- verified by the official registry, and two listings in three have one - The repository owner's avatar for domain namespaces, ranked last since the repo URL is the publisher's own claim The image sits over the monogram and removes itself if it fails to load, so a dead icon never shows a broken-image glyph. Nothing is guessed from a name: a wrong logo implies an affiliation that is not there. sync(reapply: true) pushes unchanged upstream entries through the mapping again so existing listings get icons. It announces nothing, so 37,000 rows do not reach IndexNow. Closes #71 --- Pages affected: - [MCP Harbor](https://ai.mcpharbor.dev/) -- registry home and newest servers. - [MCP server search](https://ai.mcpharbor.dev/servers) -- every card in the grid now carries a logo. - [HIBP MCP server](https://ai.mcpharbor.dev/servers/io.github.troyhunt/hibp) -- logo from a verified GitHub owner. Co-Authored-By: Claude Opus 5.5 --- assets/css/app.css | 5 + lib/mcp_registry/official_registry.ex | 80 +++++++-- lib/mcp_registry/registry/logo.ex | 162 ++++++++++++++++++ lib/mcp_registry/registry/manifest.ex | 2 + lib/mcp_registry/registry/server.ex | 18 ++ .../components/core_components.ex | 51 ++++++ .../components/layouts/root.html.heex | 1 + .../live/server_live/agent.ex | 2 +- lib/mcp_registry_web/live/server_live/home.ex | 2 +- .../live/server_live/index.ex | 2 +- lib/mcp_registry_web/live/server_live/show.ex | 2 +- .../20260930230000_add_icon_url.exs | 18 ++ test/mcp_registry/logo_test.exs | 81 +++++++++ test/mcp_registry/official_registry_test.exs | 49 ++++++ .../live/server_live_test.exs | 49 ++++++ 15 files changed, 503 insertions(+), 21 deletions(-) create mode 100644 lib/mcp_registry/registry/logo.ex create mode 100644 priv/repo/migrations/20260930230000_add_icon_url.exs create mode 100644 test/mcp_registry/logo_test.exs diff --git a/assets/css/app.css b/assets/css/app.css index 5d13857..9f7c50c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -32,6 +32,9 @@ color-scheme: light; --canvas: oklch(99% 0.003 250); + /* Behind a publisher's logo. Light in both themes: most logos are drawn for + white, and a transparent mark on a dark tile can vanish entirely. */ + --logo-tile: oklch(100% 0 0); --surface: oklch(97.2% 0.005 250); --sunken: oklch(95.2% 0.008 253); --ink: oklch(21% 0.028 258); @@ -56,6 +59,8 @@ color-scheme: dark; --canvas: oklch(13.2% 0.032 264); + /* Off-white rather than white, so a row of logos does not glare. */ + --logo-tile: oklch(93% 0.006 250); --surface: oklch(20.4% 0.038 264); --sunken: oklch(10.5% 0.028 264); --ink: oklch(96.5% 0.008 250); diff --git a/lib/mcp_registry/official_registry.ex b/lib/mcp_registry/official_registry.ex index 41d1db8..a582f7b 100644 --- a/lib/mcp_registry/official_registry.ex +++ b/lib/mcp_registry/official_registry.ex @@ -66,14 +66,24 @@ defmodule McpRegistry.OfficialRegistry do Runs one sync. `mode:` is `:auto` (the default: incremental after a recent full sync, otherwise full), `:full` or `:incremental`. + Pass `reapply: true` to push every upstream entry through the mapping again, + including the ones whose `updatedAt` has not moved. Normal runs skip those -- + correctly, since nothing upstream changed -- but that also means a field the + mapping newly reads, such as `icons`, would never reach existing listings. + A re-apply run is always full, writes only rows whose mapped fields actually + differ, and announces nothing: it changes how we read the data, not the data. + Returns `{:ok, stats}`, `{:error, reason, stats}` or `{:error, :already_running}`. """ def sync(opts \\ []) do + reapply? = Keyword.get(opts, :reapply, false) + mode = if reapply?, do: :full, else: Keyword.get(opts, :mode, :auto) + Repo.checkout( fn -> if locked?() do try do - run(resolve_mode(Keyword.get(opts, :mode, :auto))) + run(resolve_mode(mode), reapply?) after Repo.query!("SELECT pg_advisory_unlock($1)", [@lock_key]) end @@ -114,7 +124,7 @@ defmodule McpRegistry.OfficialRegistry do |> Repo.one() end - defp run(mode) do + defp run(mode, reapply?) do started_at = DateTime.utc_now() # We hold the lock, so any run still marked running was interrupted. @@ -146,7 +156,11 @@ defmodule McpRegistry.OfficialRegistry do outcome = try do - pages(nil, params, Map.merge(empty, %{run_at: started_at, seen: MapSet.new()})) + pages( + nil, + params, + Map.merge(empty, %{run_at: started_at, seen: MapSet.new(), reapply?: reapply?}) + ) rescue exception -> {:error, Exception.message(exception), empty} end @@ -178,6 +192,9 @@ defmodule McpRegistry.OfficialRegistry do # What a failed run wrote before it stopped is live too, so announce it # either way. New pages go first: they are the ones no crawler has seen. %{new: new, updated: updated, removed: removed} = state.touched + # A re-apply run touches listings whose upstream did not change, so its + # "updated" are ours, not the publishers' -- not worth a crawler's visit. + updated = if reapply?, do: [], else: updated Discovery.announce(new ++ removed ++ updated, feed: new != []) if status == "ok", do: {:ok, stats}, else: {:error, error, stats} @@ -289,28 +306,42 @@ defmodule McpRegistry.OfficialRegistry do {:duplicate, nil} else existing = Repo.get_by(Server, name: name) - {decide(existing, json, upstream_status, updated_at, state.run_at), name} + {decide(existing, json, upstream_status, updated_at, state.run_at, state.reapply?), name} end end defp apply_entry(_entry, _state), do: {:invalid, nil} - defp decide(%Server{origin: "official"} = server, _json, "deleted", _updated_at, _run_at) do + defp decide(%Server{origin: "official"} = server, _json, "deleted", _updated_at, _run_at, _re) do Repo.delete!(server) :deleted end - defp decide(_existing, _json, "deleted", _updated_at, _run_at), do: :ignored_deleted - - defp decide(%Server{origin: "local", status: "active"}, _json, _status, _updated_at, _run_at), - do: :kept_local - - defp decide(%Server{origin: origin, source_updated_at: same}, _json, status, same, _run_at) + defp decide(_existing, _json, "deleted", _updated_at, _run_at, _re), do: :ignored_deleted + + defp decide( + %Server{origin: "local", status: "active"}, + _json, + _status, + _updated_at, + _run_at, + _re + ), + do: :kept_local + + defp decide( + %Server{origin: origin, source_updated_at: same}, + _json, + status, + same, + _run_at, + false + ) when origin in ["official", "seed"] and not is_nil(same) and status in ["active", "deprecated"], do: :unchanged - defp decide(existing, json, upstream_status, updated_at, run_at) do + defp decide(existing, json, upstream_status, updated_at, run_at, reapply?) do attrs = json |> Manifest.from_map() @@ -330,15 +361,30 @@ defmodule McpRegistry.OfficialRegistry do |> Ecto.Changeset.put_change(:source_updated_at, updated_at) |> Ecto.Changeset.put_change(:synced_at, run_at) - case {existing, Repo.insert_or_update(changeset)} do - {_, {:error, _changeset}} -> :invalid - {nil, {:ok, _}} -> :inserted - {%Server{origin: "local"}, {:ok, _}} -> :replaced_pending - {_, {:ok, _}} -> :updated + if reapply? and not is_nil(existing) and only_bookkeeping?(changeset) do + :unchanged + else + write(existing, changeset) end end end + # Under re-apply, a row whose mapped fields all came out the same needs no + # write. synced_at and source_updated_at always "change" -- they are stamped + # on every pass -- so they do not count as a difference. + defp only_bookkeeping?(changeset) do + changeset.changes |> Map.drop([:synced_at, :source_updated_at]) |> map_size() == 0 + end + + defp write(existing, changeset) do + case {existing, Repo.insert_or_update(changeset)} do + {_, {:error, _changeset}} -> :invalid + {nil, {:ok, _}} -> :inserted + {%Server{origin: "local"}, {:ok, _}} -> :replaced_pending + {_, {:ok, _}} -> :updated + end + end + # The official format has no tags, tool names or license, and its title is # optional. Keep what we already have rather than blanking or guessing. defp keep_curated_fields(attrs, nil, _json), do: attrs diff --git a/lib/mcp_registry/registry/logo.ex b/lib/mcp_registry/registry/logo.ex new file mode 100644 index 0000000..c673bb5 --- /dev/null +++ b/lib/mcp_registry/registry/logo.ex @@ -0,0 +1,162 @@ +defmodule McpRegistry.Registry.Logo do + @moduledoc """ + Which image stands for a listing, and where it came from. + + In order of preference: + + 1. **The icon the publisher declared** in `server.json`. It is the logo + they chose for this server specifically, so nothing outranks it. + 2. **The GitHub avatar of an `io.github.*` namespace owner.** The official + registry only lets someone publish under `io.github.acme` after they + have proved they are `acme` on GitHub, so this is a verified identity + rather than a claim. Two listings in three have one. + 3. **The GitHub avatar of the repository owner**, for listings under a + domain namespace that point at a GitHub repository. Weaker: the + repository URL is the publisher's own say-so. It is still their public + profile image and never someone else's, so it is used, last. + 4. Nothing, in which case the page draws the name's monogram. + + Every source here is something the publisher put on the public record. Logos + are never guessed from a name or scraped from a website, because a wrong logo + is worse than none: it asserts an affiliation that is not there. + """ + + alias McpRegistry.Registry.Server + + # GitHub's own rule for a login: alphanumerics and single hyphens, up to 39. + @github_login ~r/\A[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}\z/i + + # Twice the largest size a logo is drawn at, for high-density screens. Avatars + # at this size are around 2 KB. + @avatar_px 96 + + @doc """ + The logo for a listing as `{source, url}`, or `nil` when there is none. + + `source` is `:declared`, `:github` or `:repository` -- see the moduledoc for + what each means and why they rank as they do. + """ + def for_server(%Server{} = server) do + cond do + usable_src?(server.icon_url) -> {:declared, server.icon_url} + login = namespace_login(server.name) -> {:github, avatar(login)} + login = repository_login(server.repository_url) -> {:repository, avatar(login)} + true -> nil + end + end + + @doc """ + Chooses one icon from a `server.json` `icons` array, or `nil`. + + The array follows the MCP `Icon` shape: `src`, and optionally `mimeType`, + `sizes` and `theme`. Preference goes to a scalable SVG, then to the smallest + raster that is still at least #{@avatar_px}px -- a 512px PNG drawn at 48px is + a large download for no visible gain. An icon meant for dark backgrounds is + passed over when another exists, because logos are drawn on a light tile. + + Only icons that `usable_src?/1` accepts are considered, so a bad icon is + dropped here rather than failing the listing's import. + """ + def pick(icons) when is_list(icons) do + icons + |> Enum.filter(&(is_map(&1) and usable_src?(&1["src"]))) + |> Enum.sort_by(&rank/1) + |> List.first() + |> case do + %{"src" => src} -> src + nil -> nil + end + end + + def pick(_), do: nil + + @doc """ + Whether a URL can be used as an `` on this site. + + https only: the pages are served over https, so a plain-http image is + blocked as mixed content. No `data:` URIs either -- they would put + publisher-supplied payloads of any size straight into our HTML. + """ + def usable_src?(src) when is_binary(src) do + byte_size(src) <= 2048 and + match?( + %URI{scheme: "https", host: host} when is_binary(host) and host != "", + URI.parse(src) + ) and + not String.contains?(src, ["{", "}", " "]) + end + + def usable_src?(_), do: false + + @doc "The GitHub login an `io.github./...` name was verified for, or `nil`." + def namespace_login("io.github." <> rest) do + rest |> String.split("/", parts: 2) |> List.first() |> valid_login() + end + + def namespace_login(_), do: nil + + @doc "The owner of a `https://github.com//` URL, or `nil`." + def repository_login(url) when is_binary(url) do + case URI.parse(url) do + %URI{host: host, path: "/" <> path} when host in ["github.com", "www.github.com"] -> + path |> String.split("/", parts: 2) |> List.first() |> valid_login() + + _ -> + nil + end + end + + def repository_login(_), do: nil + + defp valid_login(login) when is_binary(login) do + if Regex.match?(@github_login, login), do: String.downcase(login), else: nil + end + + defp valid_login(_), do: nil + + # avatars.githubusercontent.com answers a login directly with the image, no + # redirect -- github.com/.png costs an extra round trip per logo. + defp avatar(login), do: "https://avatars.githubusercontent.com/#{login}?size=#{@avatar_px}" + + # Lower sorts first. Dark-theme icons last; then SVG; then the smallest + # raster that is big enough; then any raster; unknown sizes in the middle. + defp rank(icon) do + dark = if icon["theme"] == "dark", do: 1, else: 0 + {dark, size_rank(icon)} + end + + defp size_rank(icon) do + svg? = + icon["mimeType"] == "image/svg+xml" or + String.ends_with?(String.downcase(icon["src"]), ".svg") or + "any" in List.wrap(icon["sizes"]) + + px = largest_px(icon["sizes"]) + + cond do + svg? -> 0 + # Big enough: the smaller the better, so a 128px beats a 512px. + is_integer(px) and px >= @avatar_px -> 1 + px / 10_000 + is_nil(px) -> 2 + # Too small to look sharp, but better than nothing; larger first. + true -> 3 + 1 / max(px, 1) + end + end + + defp largest_px(sizes) when is_list(sizes) do + sizes + |> Enum.flat_map(fn + size when is_binary(size) -> + case Regex.run(~r/\A(\d+)x(\d+)\z/, size) do + [_, w, h] -> [min(String.to_integer(w), String.to_integer(h))] + _ -> [] + end + + _ -> + [] + end) + |> Enum.max(fn -> nil end) + end + + defp largest_px(_), do: nil +end diff --git a/lib/mcp_registry/registry/manifest.ex b/lib/mcp_registry/registry/manifest.ex index b726cfa..20fdfe0 100644 --- a/lib/mcp_registry/registry/manifest.ex +++ b/lib/mcp_registry/registry/manifest.ex @@ -24,6 +24,7 @@ defmodule McpRegistry.Registry.Manifest do } |> put_if("repository", repository(server.repository_url)) |> put_if("websiteUrl", server.website_url) + |> put_if("icons", server.icon_url && [%{"src" => server.icon_url}]) |> put_if("packages", packages(server)) |> put_if("remotes", remotes(server)) |> Map.put( @@ -67,6 +68,7 @@ defmodule McpRegistry.Registry.Manifest do "package_identifier" => package && present(package["identifier"]), "repository_url" => present(get_in(json, ["repository", "url"])), "website_url" => present(json["websiteUrl"]), + "icon_url" => McpRegistry.Registry.Logo.pick(json["icons"]), "license" => meta[@meta_prefix <> "license"] || json["license"], "env_vars" => package |> env_vars(), "tags" => meta[@meta_prefix <> "tags"] || json["tags"] || [], diff --git a/lib/mcp_registry/registry/server.ex b/lib/mcp_registry/registry/server.ex index b5f931f..ac2bfad 100644 --- a/lib/mcp_registry/registry/server.ex +++ b/lib/mcp_registry/registry/server.ex @@ -30,6 +30,7 @@ defmodule McpRegistry.Registry.Server do field :package_identifier, :string field :repository_url, :string field :website_url, :string + field :icon_url, :string field :license, :string field :env_vars, {:array, :string}, default: [] field :tags, {:array, :string}, default: [] @@ -116,6 +117,7 @@ defmodule McpRegistry.Registry.Server do :package_identifier, :repository_url, :website_url, + :icon_url, :license, :article_content, :article_generated_at | @list_fields @@ -138,6 +140,7 @@ defmodule McpRegistry.Registry.Server do |> validate_length(:remote_url, max: 4096) |> validate_length(:repository_url, max: 4096) |> validate_length(:website_url, max: 4096) + |> validate_icon_url() |> validate_length(:package_identifier, max: 1024) |> validate_distribution() |> validate_length(:tags, max: 12) @@ -196,6 +199,21 @@ defmodule McpRegistry.Registry.Server do ) end + # Stricter than the other URLs on purpose: an icon is fetched by every + # visitor's browser from a page served over https, so a plain-http source is + # blocked as mixed content, and a placeholder like {HOST} can never resolve. + # `McpRegistry.Registry.Manifest` applies the same rule before import, so an + # unusable icon is dropped there rather than invalidating the whole listing. + defp validate_icon_url(changeset) do + changeset + |> validate_length(:icon_url, max: 2048) + |> validate_change(:icon_url, fn :icon_url, value -> + if McpRegistry.Registry.Logo.usable_src?(value), + do: [], + else: [icon_url: "must be an https URL"] + end) + end + # A remote transport needs an endpoint; a stdio server needs a package; and a # package registry and identifier only make sense together. defp validate_distribution(changeset) do diff --git a/lib/mcp_registry_web/components/core_components.ex b/lib/mcp_registry_web/components/core_components.ex index 4324d7c..6bb016b 100644 --- a/lib/mcp_registry_web/components/core_components.ex +++ b/lib/mcp_registry_web/components/core_components.ex @@ -731,6 +731,57 @@ defmodule McpRegistryWeb.CoreComponents do """ end + @doc """ + A listing's logo, falling back to its monogram. + + The source is chosen by `McpRegistry.Registry.Logo` -- the publisher's + declared icon, else a verified GitHub owner's avatar -- and when there is + none the monogram is drawn instead. + + The image sits on top of the monogram rather than replacing it. If it fails + to load (a publisher moves their icon, a host goes down) it removes itself + and the monogram underneath is what the reader sees: never a broken-image + glyph, and no layout shift either way, because the tile's size is fixed + before anything loads. + + `referrerpolicy="no-referrer"` keeps the page a visitor was reading out of + third-party logs. Declared icons are drawn with padding on a light tile, + since most are transparent marks designed for white; avatars are opaque + squares and fill the tile. + + ## Examples + + <.server_logo server={@server} size="size-12 text-lg" /> + """ + attr :server, :map, required: true + attr :size, :string, default: "size-10" + attr :class, :any, default: nil + + def server_logo(assigns) do + assigns = assign(assigns, :logo, McpRegistry.Registry.Logo.for_server(assigns.server)) + + ~H""" + + <.monogram name={@server.name} size={@size} /> + + + """ + end + @doc """ The identity tile for a listing: two initials on a hue derived from the name. diff --git a/lib/mcp_registry_web/components/layouts/root.html.heex b/lib/mcp_registry_web/components/layouts/root.html.heex index 75da3ba..c9868ae 100644 --- a/lib/mcp_registry_web/components/layouts/root.html.heex +++ b/lib/mcp_registry_web/components/layouts/root.html.heex @@ -73,6 +73,7 @@ --color-canvas: var(--canvas); --color-surface: var(--surface); --color-sunken: var(--sunken); + --color-logo-tile: var(--logo-tile); --color-ink: var(--ink); --color-dim: var(--dim); --color-rule: var(--rule); diff --git a/lib/mcp_registry_web/live/server_live/agent.ex b/lib/mcp_registry_web/live/server_live/agent.ex index c28f539..98a6804 100644 --- a/lib/mcp_registry_web/live/server_live/agent.ex +++ b/lib/mcp_registry_web/live/server_live/agent.ex @@ -128,7 +128,7 @@ defmodule McpRegistryWeb.ServerLive.Agent do
- <.monogram name={@server.name} size="size-12 text-lg" /> + <.server_logo server={@server} size="size-12 text-lg" />

{@server.title} for {@client.label} diff --git a/lib/mcp_registry_web/live/server_live/home.ex b/lib/mcp_registry_web/live/server_live/home.ex index 99f9c8a..3b0dd1c 100644 --- a/lib/mcp_registry_web/live/server_live/home.ex +++ b/lib/mcp_registry_web/live/server_live/home.ex @@ -215,7 +215,7 @@ defmodule McpRegistryWeb.ServerLive.Home do class="group flex h-full flex-col gap-2 rounded-box border border-rule bg-surface/50 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:border-rule-strong hover:bg-surface hover:shadow-md" >
- <.monogram name={server.name} size="size-8 text-[11px]" /> + <.server_logo server={server} size="size-8 text-[11px]" />
diff --git a/lib/mcp_registry_web/live/server_live/index.ex b/lib/mcp_registry_web/live/server_live/index.ex index 839151a..d3c1927 100644 --- a/lib/mcp_registry_web/live/server_live/index.ex +++ b/lib/mcp_registry_web/live/server_live/index.ex @@ -223,7 +223,7 @@ defmodule McpRegistryWeb.ServerLive.Index do navigate={server_path(server)} class="group/row -mx-3 flex gap-3.5 px-3 py-4 transition-colors hover:bg-surface focus-visible:bg-surface" > - <.monogram name={server.name} size="size-9 text-xs" class="mt-0.5" /> + <.server_logo server={server} size="size-9 text-xs" class="mt-0.5" />
diff --git a/lib/mcp_registry_web/live/server_live/show.ex b/lib/mcp_registry_web/live/server_live/show.ex index 626a11a..5e5c3e5 100644 --- a/lib/mcp_registry_web/live/server_live/show.ex +++ b/lib/mcp_registry_web/live/server_live/show.ex @@ -154,7 +154,7 @@ defmodule McpRegistryWeb.ServerLive.Show do
- <.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"})