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"})