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
5 changes: 5 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
80 changes: 63 additions & 17 deletions lib/mcp_registry/official_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
162 changes: 162 additions & 0 deletions lib/mcp_registry/registry/logo.ex
Original file line number Diff line number Diff line change
@@ -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 `<img src>` 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.<login>/...` 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/<owner>/<repo>` 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/<login>.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
2 changes: 2 additions & 0 deletions lib/mcp_registry/registry/manifest.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"] || [],
Expand Down
18 changes: 18 additions & 0 deletions lib/mcp_registry/registry/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading