diff --git a/config/config.exs b/config/config.exs index df6770e..3b9b627 100644 --- a/config/config.exs +++ b/config/config.exs @@ -114,7 +114,9 @@ config :mcp_registry, :probe, enabled: false, batch_size: 50, concurrency: 4, - recheck_days: 14, + # Weekly, so a listing's changelog is at most a week behind its server. + # About 3,000 endpoints a day, against a scheduler that can ask 28 times that. + recheck_days: 7, interval_ms: :timer.minutes(5), initial_delay_ms: :timer.minutes(3), req_options: [] diff --git a/lib/mcp_registry/changes.ex b/lib/mcp_registry/changes.ex new file mode 100644 index 0000000..a52d057 --- /dev/null +++ b/lib/mcp_registry/changes.ex @@ -0,0 +1,225 @@ +defmodule McpRegistry.Changes do + @moduledoc """ + What changed about a listing, recorded as it is noticed. + + Two processes already re-read every listing on a schedule: the official + registry sync (every six hours, for `server.json`) and the prober (for the + tools, prompts and resources a live server reports). Both used to overwrite + what they found. This keeps the difference. + + ## What is not a change + + A changelog that reports our own bookkeeping as the publisher's activity is + worse than none, so most of the rules here are about what *not* to record: + + * **Nothing is recorded on first sight.** A listing's first import, or a + server's first answer, is a baseline, not an event. + * **Tools changing from declared to probed is not recorded.** That is our + knowledge improving, not the server changing. Only probe-to-probe + differences count. + * **Prompts and resources from before #{"2026-09-24"} are not compared.** + Probes before then never asked for them, so an empty list then meant + "not asked", and comparing it to a real answer would report every + prompt the server has always had as newly added. + * **A list at the probe's 500-item cap is not compared.** When a server + with 1,045 resources adds one, another falls off our end of the list, + and that would read as a removal that never happened. + * **The sync's re-apply runs are not recorded.** They change how we read + upstream data, not the data. + """ + import Ecto.Query + + alias McpRegistry.Changes.Change + alias McpRegistry.Probe + alias McpRegistry.Registry.Server + alias McpRegistry.Repo + + # When the prober started asking for prompts and resources. See moduledoc. + @capabilities_since ~U[2026-09-24 19:47:00Z] + + # The server.json fields a reader would call a change. synced_at, + # source_updated_at and the like are ours, not the publisher's. + @tracked_fields ~w(title description version transport remote_url package_registry + package_identifier repository_url website_url icon_url env_vars + license status)a + + @labels %{ + "tools" => "Tools", + "prompts" => "Prompts", + "resources" => "Resources", + "server_json" => "server.json", + "llms_txt" => "llms.txt", + "agents_md" => "AGENTS.md" + } + + # --- Recording ------------------------------------------------------------- + + @doc """ + Records what a successful probe found that differs from what the listing + held before it. `before` is the row as it was; `found` is the probe's map of + `tools`, `prompts` and `resources`. + """ + def record_probe(%Server{} = before, %{} = found) do + [ + tools_change(before, found[:tools] || []), + list_change(before, :prompts, found[:prompts] || []), + list_change(before, :resources, found[:resources] || []) + ] + |> Enum.reject(&is_nil/1) + |> Enum.map(fn {kind, diff} -> entry(before, Atom.to_string(kind), diff, "probe") end) + |> insert_all() + end + + @doc """ + Records the publisher-visible fields a sync is about to change on an + existing listing, from the changeset that will write them. + """ + def record_sync(%Server{id: id} = before, %Ecto.Changeset{changes: changes}) + when not is_nil(id) do + fields = + for field <- @tracked_fields, + Map.has_key?(changes, field), + Map.get(before, field) != changes[field], + into: %{}, + do: {Atom.to_string(field), [Map.get(before, field), changes[field]]} + + if map_size(fields) == 0 do + [] + else + insert_all([entry(before, "server_json", %{fields: fields}, "sync")]) + end + end + + def record_sync(_before, _changeset), do: [] + + defp tools_change(%Server{tools_source: "probed", tools: old}, [_ | _] = new), + do: list_entry(%{tools: old}, :tools, new) + + defp tools_change(_before, _new), do: nil + + defp list_change(%Server{probe_status: "ok", probed_at: %DateTime{} = at} = before, kind, new) do + old = Map.fetch!(before, kind) + cap = Probe.max_items() + + cond do + DateTime.compare(at, @capabilities_since) == :lt -> nil + length(old) >= cap or length(new) >= cap -> nil + true -> list_entry(before, kind, new) + end + end + + defp list_change(_before, _kind, _new), do: nil + + # A set difference, kept in the server's own order: the order a server lists + # things in is usually the order its author thinks of them in. + defp list_entry(before, kind, new) do + old = Map.fetch!(before, kind) + old_set = MapSet.new(old) + new_set = MapSet.new(new) + + added = Enum.reject(new, &MapSet.member?(old_set, &1)) + removed = Enum.reject(old, &MapSet.member?(new_set, &1)) + + if added == [] and removed == [] do + nil + else + {kind, %{added: added, removed: removed}} + end + end + + defp entry(%Server{id: id}, kind, attrs, source) do + Map.merge( + %{ + server_id: id, + document_url: nil, + kind: kind, + added: [], + removed: [], + fields: %{}, + source: source, + inserted_at: DateTime.utc_now() + }, + attrs + ) + end + + defp insert_all([]), do: [] + + defp insert_all(rows) do + {_, inserted} = Repo.insert_all(Change, rows, returning: true) + inserted + end + + # --- Reading --------------------------------------------------------------- + + @doc "A listing's changes, newest first. `kind` narrows to one kind." + def for_server(%Server{} = server, kind \\ nil, limit \\ 200) do + server + |> subject_query() + |> then(fn q -> if kind, do: where(q, [c], c.kind == ^kind), else: q end) + |> order_by([c], desc: c.inserted_at, desc: c.id) + |> limit(^limit) + |> Repo.all() + end + + @doc "How many changes a listing has of each kind, as `%{kind => count}`." + def kinds_for_server(%Server{} = server) do + server + |> subject_query() + |> group_by([c], c.kind) + |> select([c], {c.kind, count(c.id)}) + |> Repo.all() + |> Map.new() + end + + @doc "The most recent changes across the whole registry, with their listing." + def recent(limit \\ 100) do + Change + |> where([c], not is_nil(c.server_id)) + |> order_by([c], desc: c.inserted_at, desc: c.id) + |> limit(^limit) + |> preload(server: ^from(s in Server, select: struct(s, [:id, :name, :title]))) + |> Repo.all() + end + + @doc "How many active listings have at least one recorded change." + def count_servers_with_changes do + Change + |> join(:inner, [c], s in Server, on: s.id == c.server_id and s.status == "active") + |> select([c], count(c.server_id, :distinct)) + |> Repo.one() + end + + @doc """ + A page of active listings with changes, as `{name, kinds, latest}` -- the + shape the sitemap needs, without loading any listing in full. + """ + def servers_with_changes(offset, limit) do + Change + |> join(:inner, [c], s in Server, on: s.id == c.server_id and s.status == "active") + |> group_by([c, s], [s.id, s.name]) + |> order_by([c, s], asc: s.id) + |> offset(^offset) + |> limit(^limit) + |> select([c, s], {s.name, fragment("array_agg(DISTINCT ?)", c.kind), max(c.inserted_at)}) + |> Repo.all() + end + + # Extended by the document fetcher: a listing's changelog also includes the + # documents it points at, which are recorded against their URL. + defp subject_query(%Server{id: id}), do: where(Change, [c], c.server_id == ^id) + + # --- Naming ---------------------------------------------------------------- + + @doc "How a kind is shown to a reader: `llms.txt`, `server.json`, `Tools`." + def label(kind), do: Map.fetch!(@labels, kind) + + @doc "A kind as a URL segment: `server_json` becomes `server-json`." + def slug(kind), do: String.replace(kind, "_", "-") + + @doc "The kind a URL segment names, or `nil`." + def from_slug(slug) when is_binary(slug) do + kind = String.replace(slug, "-", "_") + if Map.has_key?(@labels, kind), do: kind + end +end diff --git a/lib/mcp_registry/changes/change.ex b/lib/mcp_registry/changes/change.ex new file mode 100644 index 0000000..5258171 --- /dev/null +++ b/lib/mcp_registry/changes/change.ex @@ -0,0 +1,24 @@ +defmodule McpRegistry.Changes.Change do + @moduledoc """ + One recorded change: items added and removed from a list, or fields that + moved from one value to another. See `McpRegistry.Changes` for what is + recorded and, as importantly, what is not. + """ + use Ecto.Schema + + @kinds ~w(tools prompts resources server_json llms_txt agents_md) + + schema "changes" do + belongs_to :server, McpRegistry.Registry.Server + field :document_url, :string + field :kind, :string + field :added, {:array, :string}, default: [] + field :removed, {:array, :string}, default: [] + field :fields, :map, default: %{} + field :source, :string + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + def kinds, do: @kinds +end diff --git a/lib/mcp_registry/discovery.ex b/lib/mcp_registry/discovery.ex index ebc1056..fc86b00 100644 --- a/lib/mcp_registry/discovery.ex +++ b/lib/mcp_registry/discovery.ex @@ -32,7 +32,7 @@ defmodule McpRegistry.Discovery do @batch_size 10_000 # Pages that are not listings, for the backfill. - @static_paths ["/", "/servers", "/submit", "/book"] + @static_paths ["/", "/servers", "/submit", "/book", "/changelog"] def config, do: Application.get_env(:mcp_registry, :discovery, []) diff --git a/lib/mcp_registry/official_registry.ex b/lib/mcp_registry/official_registry.ex index a582f7b..cae7aee 100644 --- a/lib/mcp_registry/official_registry.ex +++ b/lib/mcp_registry/official_registry.ex @@ -31,6 +31,7 @@ defmodule McpRegistry.OfficialRegistry do require Logger alias McpRegistry.Analytics + alias McpRegistry.Changes alias McpRegistry.Discovery alias McpRegistry.OfficialRegistry.SyncRun alias McpRegistry.Registry.{Manifest, Server} @@ -364,7 +365,7 @@ defmodule McpRegistry.OfficialRegistry do if reapply? and not is_nil(existing) and only_bookkeeping?(changeset) do :unchanged else - write(existing, changeset) + write(existing, changeset, reapply?) end end end @@ -376,12 +377,22 @@ defmodule McpRegistry.OfficialRegistry do changeset.changes |> Map.drop([:synced_at, :source_updated_at]) |> map_size() == 0 end - defp write(existing, changeset) do + defp write(existing, changeset, reapply?) do case {existing, Repo.insert_or_update(changeset)} do - {_, {:error, _changeset}} -> :invalid - {nil, {:ok, _}} -> :inserted - {%Server{origin: "local"}, {:ok, _}} -> :replaced_pending - {_, {:ok, _}} -> :updated + {_, {:error, _changeset}} -> + :invalid + + {nil, {:ok, _}} -> + :inserted + + {%Server{origin: "local"}, {:ok, _}} -> + :replaced_pending + + {_, {:ok, _}} -> + # A re-apply run changes how we read upstream, not what the publisher + # published, so it leaves the changelog alone. + unless reapply?, do: Changes.record_sync(existing, changeset) + :updated end end diff --git a/lib/mcp_registry/probe.ex b/lib/mcp_registry/probe.ex index f228046..40acfc1 100644 --- a/lib/mcp_registry/probe.ex +++ b/lib/mcp_registry/probe.ex @@ -153,6 +153,9 @@ defmodule McpRegistry.Probe do @max_items 500 @max_length 2_000 + @doc "The most items of one kind a probe keeps. A list this long was cut short." + def max_items, do: @max_items + # A resource is named by uri, a tool and a prompt by name. defp names(items) do items diff --git a/lib/mcp_registry/probe/runner.ex b/lib/mcp_registry/probe/runner.ex index d2865a9..c548f80 100644 --- a/lib/mcp_registry/probe/runner.ex +++ b/lib/mcp_registry/probe/runner.ex @@ -21,12 +21,12 @@ defmodule McpRegistry.Probe.Runner do import Ecto.Query - alias McpRegistry.{Probe, Repo} + alias McpRegistry.{Changes, Probe, Repo} alias McpRegistry.Registry.Server @default_batch 100 @default_concurrency 6 - @default_recheck_days 14 + @default_recheck_days 7 @doc """ Probes one batch and returns a tally. @@ -108,7 +108,13 @@ defmodule McpRegistry.Probe.Runner do # exactly the count this is being collected to settle. changes = Map.merge(tools, %{prompts: found.prompts, resources: found.resources}) - record(server, :ok, changes) + # The changelog is written only once the new values are, so it can + # never describe a state the listing did not reach. `server` is still + # the row as it was before this probe. + with {:ok, _updated} <- record(server, :ok, changes) do + Changes.record_probe(server, found) + end + :ok {:error, reason} -> @@ -129,11 +135,11 @@ defmodule McpRegistry.Probe.Runner do |> Repo.update() |> case do {:ok, updated} -> - updated + {:ok, updated} {:error, changeset} -> Logger.warning("Probe could not record #{server.name}: #{inspect(changeset.errors)}") - server + {:error, server} end rescue # `Repo.update/1` returns a changeset for a validation failure but raises @@ -143,6 +149,6 @@ defmodule McpRegistry.Probe.Runner do # answers is not under our control, so this must not be fatal to the rest. error -> Logger.warning("Probe could not record #{server.name}: #{Exception.message(error)}") - server + {:error, server} end end diff --git a/lib/mcp_registry_web/controllers/sitemap_controller.ex b/lib/mcp_registry_web/controllers/sitemap_controller.ex index 625141d..bf909a9 100644 --- a/lib/mcp_registry_web/controllers/sitemap_controller.ex +++ b/lib/mcp_registry_web/controllers/sitemap_controller.ex @@ -12,6 +12,7 @@ defmodule McpRegistryWeb.SitemapController do use McpRegistryWeb, :controller alias McpRegistry.Discovery + alias McpRegistry.Changes alias McpRegistry.Registry alias McpRegistry.Registry.Clients alias McpRegistryWeb.Routes @@ -30,6 +31,9 @@ defmodule McpRegistryWeb.SitemapController do # over 50,000 is rejected in silence. 40,000 leaves room for the estimate to # be generous without approaching the limit. @urls_per_capability_file 40_000 + # A listing's changelog plus one page per kind of change -- at most seven + # URLs, so 5,000 listings stays under 35,000 whatever they hold. + @servers_per_changelog_file 5_000 # `/live` is LiveView's transport, not content. Its long-poll fallback carries # a fresh CSRF token in the query string, so every fetch mints a URL that has @@ -66,6 +70,7 @@ defmodule McpRegistryWeb.SitemapController do end ++ capability_file_names(:prompts) ++ capability_file_names(:resources) ++ + changelog_file_names() ++ Enum.map(1..agent_files(), &"agents-#{&1}.xml") [ @@ -77,6 +82,24 @@ defmodule McpRegistryWeb.SitemapController do |> send_xml(conn) end + def show(conn, %{"file" => "changelogs-" <> file}) do + with {page, ".xml"} <- Integer.parse(file), + true <- within(page, changelog_files()) do + base = McpRegistryWeb.Endpoint.url() + + ((page - 1) * @servers_per_changelog_file) + |> Changes.servers_with_changes(@servers_per_changelog_file) + |> Enum.flat_map(fn {name, kinds, latest} -> + [{base <> Routes.changelog_path(name), latest}] ++ + Enum.map(kinds, &{base <> Routes.changelog_path(name, &1), latest}) + end) + |> urlset() + |> send_xml(conn) + else + _ -> not_found(conn) + end + end + def show(conn, %{"file" => "pages.xml"}) do Discovery.static_urls() |> Enum.map(&{&1, nil}) @@ -194,6 +217,16 @@ defmodule McpRegistryWeb.SitemapController do end end + defp changelog_files, + do: ceil(Changes.count_servers_with_changes() / @servers_per_changelog_file) + + defp changelog_file_names do + case changelog_files() do + 0 -> [] + n -> Enum.map(1..n, &"changelogs-#{&1}.xml") + end + end + defp capability_file_names(kind) do case capability_files(kind) do 0 -> [] diff --git a/lib/mcp_registry_web/live/changelog_live.ex b/lib/mcp_registry_web/live/changelog_live.ex new file mode 100644 index 0000000..8f39453 --- /dev/null +++ b/lib/mcp_registry_web/live/changelog_live.ex @@ -0,0 +1,79 @@ +defmodule McpRegistryWeb.ChangelogLive do + @moduledoc """ + `/changelog`: the latest recorded changes across every listing. + + The one page on the site that is different every few hours, which is the + point -- it gives a returning reader and a crawler alike a reason to come + back, and every entry links into the listing it changed. + """ + use McpRegistryWeb, :live_view + + alias McpRegistry.Changes + alias McpRegistryWeb.ServerLive.Changelog + + @impl true + def mount(_params, _session, socket) do + changes = Changes.recent(100) + + {:ok, + socket + |> assign(:days, by_day(changes)) + |> assign(:empty?, changes == []) + |> assign(:page_title, "MCP Server Changelog — new tools, prompts, resources and versions") + |> assign( + :meta_description, + "What changed across the MCP server registry: tools added and removed, new prompts and " <> + "resources, and version and description updates, read from the servers themselves." + ) + |> assign(:canonical_url, McpRegistryWeb.Endpoint.url() <> ~p"/changelog")} + end + + @impl true + def render(assigns) do + ~H""" + +
+

+ What changed in MCP servers +

+

+ The latest changes across the registry. Every listing is re-read from the official MCP + registry every six hours, and remote servers are asked what they expose; this is what + was different from the time before. +

+
+ +

+ Nothing recorded yet. Changes appear here as listings are re-read. +

+ +
+

+ +

+ +
+
+ """ + end + + defp by_day(changes) do + changes + |> Enum.group_by(&DateTime.to_date(&1.inserted_at)) + |> Enum.sort_by(fn {day, _} -> day end, {:desc, Date}) + end +end diff --git a/lib/mcp_registry_web/live/server_live/changelog.ex b/lib/mcp_registry_web/live/server_live/changelog.ex new file mode 100644 index 0000000..5070c75 --- /dev/null +++ b/lib/mcp_registry_web/live/server_live/changelog.ex @@ -0,0 +1,253 @@ +defmodule McpRegistryWeb.ServerLive.Changelog do + @moduledoc """ + A listing's changelog: `/servers/:namespace/:name/changelog`, and one kind of + change at `/servers/:namespace/:name/changelog/:kind`. + + Built from `McpRegistry.Changes`, which records what the official sync and + the prober notice is different from last time. Like the other silos, a page + exists only where there is something on it: a listing with no recorded + change redirects to the listing, and so does a kind it has none of. + """ + use McpRegistryWeb, :live_view + + alias McpRegistry.Changes + alias McpRegistry.Registry + alias McpRegistry.Registry.Server + + # Past this many items, one change lists the first few and says how many more. + @shown 40 + + @impl true + def mount(%{"namespace" => namespace, "name" => name}, _session, socket) do + server = Registry.get_server!(namespace <> "/" <> name) + + {:ok, + socket + |> assign(:server, server) + |> assign(:short_name, Server.short_name(server)) + |> assign(:kinds, Changes.kinds_for_server(server)) + |> assign(:noindex, server.status != "active")} + end + + @impl true + def handle_params(params, _uri, socket) do + %{server: server, kinds: kinds} = socket.assigns + kind = params["kind"] && Changes.from_slug(params["kind"]) + + cond do + kinds == %{} -> + {:noreply, push_navigate(socket, to: server_path(server))} + + params["kind"] && (is_nil(kind) or not Map.has_key?(kinds, kind)) -> + {:noreply, push_navigate(socket, to: changelog_path(server))} + + true -> + changes = Changes.for_server(server, kind) + + {:noreply, + socket + |> assign(:kind, kind) + |> assign(:days, by_day(changes)) + |> assign(:total, if(kind, do: kinds[kind], else: kinds |> Map.values() |> Enum.sum())) + |> assign(:page_title, title(server, kind)) + |> assign(:meta_description, description(server, kind, changes)) + |> assign(:canonical_url, McpRegistryWeb.Endpoint.url() <> changelog_path(server, kind))} + end + end + + @impl true + def render(assigns) do + ~H""" + + <:rail> + + + +
+
+ <.server_logo server={@server} size="size-12 text-lg" /> +
+

+ {heading(@server, @kind)} +

+

{@server.name}

+
+
+

+ {count_phrase(@total)} recorded. MCP Harbor re-reads this listing from the official + registry every six hours and asks the live server what it exposes, and keeps what + changed between one look and the next. +

+ + +
+ +
+

+ +

+ +
+ +
+ <.button variant="soft" navigate={server_path(@server)}> + <.icon name="hero-arrow-left-micro" class="size-4" /> {@server.title} MCP server + +
+
+ """ + end + + attr :change, :map, required: true + attr :server, :map, required: true + + @doc false + def change_card(assigns) do + ~H""" +
+
+ <.badge tone="neutral">{Changes.label(@change.kind)} + {summary(@change)} +
+ +
+
+
{field}
+
+ {shown_value(old)} + + {shown_value(new)} +
+
+
+ + +

2 * shown()} + class="text-[11px] text-dim" + > + Showing the first {shown()} of each. +

+
+ """ + end + + defp shown, do: @shown + + defp by_day(changes) do + changes + |> Enum.group_by(&DateTime.to_date(&1.inserted_at)) + |> Enum.sort_by(fn {day, _} -> day end, {:desc, Date}) + end + + defp summary(%{fields: fields}) when map_size(fields) > 0, + do: "#{map_size(fields)} field#{if map_size(fields) == 1, do: "", else: "s"} changed" + + defp summary(%{added: added, removed: removed}) do + [ + added != [] && "+#{length(added)}", + removed != [] && "−#{length(removed)}" + ] + |> Enum.filter(& &1) + |> Enum.join(" ") + end + + defp shown_value(nil), do: "—" + + defp shown_value(list) when is_list(list), + do: if(list == [], do: "none", else: Enum.join(list, ", ")) + + defp shown_value(value), do: to_string(value) + + defp heading(server, nil), do: "#{server.title} changelog" + defp heading(server, kind), do: "#{server.title} #{Changes.label(kind)} changelog" + + defp title(server, nil), + do: "#{server.title} MCP Server Changelog — tools, prompts, resources and version history" + + defp title(server, kind), + do: "#{server.title} MCP #{Changes.label(kind)} Changelog — what was added and removed" + + defp description(server, kind, changes) do + first = changes |> List.last() |> then(&(&1 && DateTime.to_date(&1.inserted_at))) + since = if first, do: " since #{Calendar.strftime(first, "%-d %B %Y")}", else: "" + + what = + if kind, + do: "#{Changes.label(kind)} changes", + else: "tools added and removed, prompt and resource changes, and server.json updates" + + "Every recorded change to the #{server.title} MCP server#{since}: #{what}, " <> + "read from the server itself and the official MCP registry." + end + + defp count_phrase(1), do: "1 change" + defp count_phrase(n), do: "#{n} changes" + + defp chip_class(active?) do + [ + "rounded-full border px-2.5 py-1 font-mono text-[11px] transition-colors", + if(active?, + do: "border-brand/50 bg-brand/10 text-ink", + else: "border-rule text-dim hover:border-brand/40 hover:bg-surface hover:text-ink" + ) + ] + end +end diff --git a/lib/mcp_registry_web/live/server_live/show.ex b/lib/mcp_registry_web/live/server_live/show.ex index 5e5c3e5..e7e6e53 100644 --- a/lib/mcp_registry_web/live/server_live/show.ex +++ b/lib/mcp_registry_web/live/server_live/show.ex @@ -45,6 +45,7 @@ defmodule McpRegistryWeb.ServerLive.Show do active_tab: "tools", tool_query: "", tools: decorate_tools(server.tools), + change_count: McpRegistry.Changes.kinds_for_server(server) |> Map.values() |> Enum.sum(), secrets: Map.new(server.env_vars, &{&1, ""}), website_title: nil, website_description: nil, @@ -166,6 +167,13 @@ defmodule McpRegistryWeb.ServerLive.Show do v{@server.version} + <.link + :if={@change_count > 0} + navigate={changelog_path(@server)} + class="font-mono text-xs text-brand underline decoration-brand/30 underline-offset-4 transition-colors hover:decoration-brand" + > + changelog · {@change_count} +

{@server.name}

diff --git a/lib/mcp_registry_web/router.ex b/lib/mcp_registry_web/router.ex index 60395b1..9e21c40 100644 --- a/lib/mcp_registry_web/router.ex +++ b/lib/mcp_registry_web/router.ex @@ -31,6 +31,7 @@ defmodule McpRegistryWeb.Router do live "/servers", ServerLive.Index, :index live "/submit", ServerLive.New, :new live "/book", BookLive, :show + live "/changelog", ChangelogLive, :index get "/llms.txt", LlmsController, :show end @@ -40,6 +41,8 @@ defmodule McpRegistryWeb.Router do # Before the glob: `*name` would otherwise swallow /tools and everything # under it as though it were part of the listing's name. live "/servers/:namespace/:name/for/:agent", ServerLive.Agent, :show + live "/servers/:namespace/:name/changelog", ServerLive.Changelog, :index + live "/servers/:namespace/:name/changelog/:kind", ServerLive.Changelog, :kind live "/servers/:namespace/:name/tools", ServerLive.Tools, :index live "/servers/:namespace/:name/tools/:tool", ServerLive.Tools, :show live "/servers/:namespace/:name/tools/:tool/:client", ServerLive.Tools, :client diff --git a/lib/mcp_registry_web/routes.ex b/lib/mcp_registry_web/routes.ex index 8fe03cc..1abbd44 100644 --- a/lib/mcp_registry_web/routes.ex +++ b/lib/mcp_registry_web/routes.ex @@ -64,4 +64,12 @@ defmodule McpRegistryWeb.Routes do def capability_client_path(name, kind, item, client_id) when is_binary(client_id), do: capability_path(name, kind, item) <> "/" <> client_id + + @doc "A listing's changelog, or one kind of change in it (`\"tools\"`, `\"server_json\"`)." + def changelog_path(server, kind \\ nil) + def changelog_path(%Server{name: name}, kind), do: changelog_path(name, kind) + def changelog_path(name, nil) when is_binary(name), do: server_path(name) <> "/changelog" + + def changelog_path(name, kind) when is_binary(name), + do: changelog_path(name, nil) <> "/" <> McpRegistry.Changes.slug(kind) end diff --git a/lib/mix/tasks/probe.tools.ex b/lib/mix/tasks/probe.tools.ex index 1b47e54..4e058bd 100644 --- a/lib/mix/tasks/probe.tools.ex +++ b/lib/mix/tasks/probe.tools.ex @@ -26,7 +26,7 @@ defmodule Mix.Tasks.Probe.Tools do strict: [limit: :integer, concurrency: :integer, recheck_days: :integer, all: :boolean] ) - pending = Runner.pending(Keyword.get(opts, :recheck_days, 14)) + pending = Runner.pending(Keyword.get(opts, :recheck_days, 7)) Mix.shell().info("#{pending} remote listing(s) due") if Keyword.get(opts, :all, false), do: loop(opts, %{}), else: report(Runner.run_batch(opts)) diff --git a/priv/repo/migrations/20261001100000_create_changes.exs b/priv/repo/migrations/20261001100000_create_changes.exs new file mode 100644 index 0000000..862a329 --- /dev/null +++ b/priv/repo/migrations/20261001100000_create_changes.exs @@ -0,0 +1,37 @@ +defmodule McpRegistry.Repo.Migrations.CreateChanges do + @moduledoc """ + What changed about a listing, and when -- the record behind its changelog. + + A row is about exactly one subject: a listing (`server_id`), for its tools, + prompts, resources and `server.json` fields; or a document URL + (`document_url`), for an llms.txt or AGENTS.md. Documents are keyed by URL + rather than by listing because many listings share one: fifty listings under + `com.cloudflare` point at a single llms.txt, and recording its change fifty + times would put the same diff on fifty pages. The check constraint holds the + "exactly one" rule. + """ + use Ecto.Migration + + def change do + create table(:changes) do + add :server_id, references(:servers, on_delete: :delete_all) + add :document_url, :text + add :kind, :string, null: false + add :added, {:array, :text}, default: [], null: false + add :removed, {:array, :text}, default: [], null: false + # For server.json: %{"version" => [old, new], ...}. + add :fields, :map, default: %{}, null: false + add :source, :string, null: false + + timestamps(type: :utc_datetime_usec, updated_at: false) + end + + create index(:changes, [:server_id, :inserted_at]) + create index(:changes, [:document_url, :inserted_at]) + create index(:changes, [:inserted_at]) + + create constraint(:changes, :exactly_one_subject, + check: "(server_id IS NOT NULL) <> (document_url IS NOT NULL)" + ) + end +end diff --git a/test/mcp_registry/changes_test.exs b/test/mcp_registry/changes_test.exs new file mode 100644 index 0000000..4f74d15 --- /dev/null +++ b/test/mcp_registry/changes_test.exs @@ -0,0 +1,133 @@ +defmodule McpRegistry.ChangesTest do + use McpRegistry.DataCase, async: true + + import McpRegistry.RegistryFixtures + + alias McpRegistry.Changes + alias McpRegistry.Changes.Change + alias McpRegistry.Repo + + # After the census, so prompts and resources were genuinely asked for. + @asked ~U[2026-09-28 12:00:00.000000Z] + + defp probed(attrs) do + server = server_fixture(Map.drop(attrs, [:probe_status, :probed_at, :tools_source])) + + server + |> Ecto.Changeset.change( + Map.merge( + %{probe_status: "ok", probed_at: @asked, tools_source: "probed"}, + Map.take(attrs, [:probe_status, :probed_at, :tools_source]) + ) + ) + |> Repo.update!() + end + + defp found(attrs), do: Map.merge(%{tools: [], prompts: [], resources: []}, attrs) + + describe "from a probe" do + test "tools a server added and dropped are recorded, in its own order" do + server = probed(%{tools: ~w(a b c)}) + + assert [change] = Changes.record_probe(server, found(%{tools: ~w(d a c e)})) + assert change.kind == "tools" + assert change.added == ~w(d e) + assert change.removed == ~w(b) + assert change.source == "probe" + assert change.server_id == server.id + end + + test "the same answer twice records nothing" do + server = probed(%{tools: ~w(a b), prompts: ~w(p)}) + assert Changes.record_probe(server, found(%{tools: ~w(b a), prompts: ~w(p)})) == [] + end + + test "declared tools giving way to probed ones is our knowledge changing, not the server" do + server = probed(%{tools: ~w(declared), tools_source: nil}) + assert Changes.record_probe(server, found(%{tools: ~w(real)})) == [] + end + + test "prompts are compared once they were actually being asked for" do + server = probed(%{prompts: ~w(old)}) + + assert [change] = Changes.record_probe(server, found(%{prompts: ~w(old new)})) + assert change.kind == "prompts" + assert change.added == ~w(new) + end + + test "an empty list from before the census meant 'not asked', so it is not compared" do + server = probed(%{prompts: [], probed_at: ~U[2026-09-01 00:00:00.000000Z]}) + + # Otherwise every prompt this server always had would read as new. + assert Changes.record_probe(server, found(%{prompts: ~w(long_standing)})) == [] + end + + test "a list at the probe's cap is not compared, because its tail is not ours to see" do + cap = McpRegistry.Probe.max_items() + full = Enum.map(1..cap, &"r#{&1}") + server = probed(%{resources: full}) + + # One new item at the top pushes the last off our end -- not a removal. + shifted = ["r0" | Enum.take(full, cap - 1)] + assert Changes.record_probe(server, found(%{resources: shifted})) == [] + end + + test "a server whose last probe failed is not compared against stale lists" do + server = probed(%{prompts: ~w(a), probe_status: "unauthorized"}) + assert Changes.record_probe(server, found(%{prompts: ~w(a b)})) == [] + end + end + + describe "from a sync" do + test "publisher-visible fields are recorded as old → new" do + server = server_fixture(%{version: "1.0.0", description: "The original description."}) + + changeset = + McpRegistry.Registry.Server.changeset(server, %{ + version: "1.1.0", + description: "A better description." + }) + + assert [change] = Changes.record_sync(server, changeset) + assert change.kind == "server_json" + assert change.fields["version"] == ["1.0.0", "1.1.0"] + + assert change.fields["description"] == [ + "The original description.", + "A better description." + ] + end + + test "our own bookkeeping is not a change" do + server = server_fixture() + + changeset = + server + |> Ecto.Changeset.change( + synced_at: DateTime.utc_now(), + source_updated_at: DateTime.utc_now() + ) + + assert Changes.record_sync(server, changeset) == [] + end + end + + test "a change belongs to exactly one subject" do + # The check constraint, not just the code paths: a row about nothing, or + # about both a listing and a document, cannot be stored. + assert_raise Postgrex.Error, ~r/exactly_one_subject/, fn -> + Repo.insert_all(Change, [ + %{kind: "tools", source: "probe", inserted_at: DateTime.utc_now()} + ]) + end + end + + test "kind slugs round-trip, and unknown ones are refused" do + for kind <- Change.kinds() do + assert kind |> Changes.slug() |> Changes.from_slug() == kind + end + + assert Changes.slug("server_json") == "server-json" + assert Changes.from_slug("gptbot") == nil + end +end diff --git a/test/mcp_registry/official_registry_test.exs b/test/mcp_registry/official_registry_test.exs index 4f2c953..0814590 100644 --- a/test/mcp_registry/official_registry_test.exs +++ b/test/mcp_registry/official_registry_test.exs @@ -325,6 +325,41 @@ defmodule McpRegistry.OfficialRegistryTest do assert {:ok, %{updated: 0, unchanged: 1}} = OfficialRegistry.sync(reapply: true) end + test "a publisher's version bump lands in the changelog; a re-apply does not" do + serve([[entry("io.github.acme/bumped")]]) + assert {:ok, _} = OfficialRegistry.sync(mode: :full) + # First import is a baseline, not a change. + assert Repo.all(McpRegistry.Changes.Change) == [] + + serve([ + [ + entry("io.github.acme/bumped", %{"version" => "1.1.0"}, %{ + "updatedAt" => "2026-09-20T00:00:00Z" + }) + ] + ]) + + assert {:ok, %{updated: 1}} = OfficialRegistry.sync(mode: :full) + assert [change] = Repo.all(McpRegistry.Changes.Change) + assert change.kind == "server_json" + assert change.fields["version"] == ["1.0.0", "1.1.0"] + assert change.source == "sync" + + # Re-apply with a newly read field: ours, not the publisher's. + serve([ + [ + entry( + "io.github.acme/bumped", + %{"version" => "1.1.0", "icons" => [%{"src" => "https://acme.test/i.svg"}]}, + %{"updatedAt" => "2026-09-20T00:00:00Z"} + ) + ] + ]) + + assert {:ok, %{updated: 1}} = OfficialRegistry.sync(reapply: true) + assert Repo.aggregate(McpRegistry.Changes.Change, :count) == 1 + 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/probe_test.exs b/test/mcp_registry/probe_test.exs index 71a97b3..f9bacec 100644 --- a/test/mcp_registry/probe_test.exs +++ b/test/mcp_registry/probe_test.exs @@ -283,6 +283,53 @@ defmodule McpRegistry.ProbeTest do assert %{ok: 1} = Runner.run_batch(limit: 10) end + test "a probe that finds different tools leaves a changelog entry" do + server = remote_fixture(%{tools: ~w(old_tool kept_tool)}) + + server + |> Ecto.Changeset.change( + tools_source: "probed", + probe_status: "ok", + # Stale enough to be due again. + probed_at: ~U[2026-09-01 00:00:00.000000Z] + ) + |> Repo.update!() + + stub(fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + case Jason.decode!(body)["method"] do + "initialize" -> + json(conn, %{jsonrpc: "2.0", id: 1, result: %{}}) + + "tools/list" -> + json(conn, %{ + jsonrpc: "2.0", + id: 2, + result: %{tools: [%{name: "kept_tool"}, %{name: "new_tool"}]} + }) + + _ -> + Plug.Conn.send_resp(conn, 202, "") + end + end) + + assert %{ok: 1} = Runner.run_batch(limit: 10) + + assert [change] = Repo.all(McpRegistry.Changes.Change) + assert change.kind == "tools" + assert change.added == ["new_tool"] + assert change.removed == ["old_tool"] + end + + test "a failed probe records no change, whatever it did not see" do + remote_fixture(%{tools: ~w(a b)}) + stub(fn conn -> Plug.Conn.send_resp(conn, 401, "") end) + + assert %{unauthorized: 1} = Runner.run_batch(limit: 10) + assert Repo.all(McpRegistry.Changes.Change) == [] + end + test "packaged listings are never picked up by the runner" do server_fixture(%{tools: ["declared"]}) diff --git a/test/mcp_registry_web/live/changelog_test.exs b/test/mcp_registry_web/live/changelog_test.exs new file mode 100644 index 0000000..6d1c8e2 --- /dev/null +++ b/test/mcp_registry_web/live/changelog_test.exs @@ -0,0 +1,114 @@ +defmodule McpRegistryWeb.ChangelogTest do + use McpRegistryWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import McpRegistry.RegistryFixtures + + alias McpRegistry.Changes.Change + alias McpRegistry.Repo + + defp change!(server, attrs) do + Repo.insert!( + struct( + Change, + Map.merge(%{server_id: server.id, source: "probe", added: [], removed: []}, attrs) + ) + ) + end + + defp with_history do + server = server_fixture(%{title: "Weather"}) + change!(server, %{kind: "tools", added: ~w(get_radar), removed: ~w(get_legacy)}) + + change!(server, %{ + kind: "server_json", + source: "sync", + fields: %{"version" => ["1.0.0", "1.1.0"]} + }) + + server + end + + test "a listing's changelog shows each change, newest first", %{conn: conn} do + server = with_history() + {:ok, _view, html} = live(conn, "/servers/#{server.name}/changelog") + + assert html =~ ~r{]*>\s*Weather changelog\s*} + assert html =~ "get_radar" + assert html =~ "get_legacy" + assert html =~ "1.0.0" + assert html =~ "1.1.0" + assert html =~ "/changelog/server-json" + end + + test "one kind of change has its own page", %{conn: conn} do + server = with_history() + {:ok, _view, html} = live(conn, "/servers/#{server.name}/changelog/tools") + + assert html =~ "Weather Tools changelog" + assert html =~ "get_radar" + refute html =~ "1.1.0" + end + + test "no history means no page, not an empty one", %{conn: conn} do + server = server_fixture() + + assert {:error, {:live_redirect, %{to: to}}} = live(conn, "/servers/#{server.name}/changelog") + assert to == "/servers/#{server.name}" + end + + test "a kind the listing has none of, or no such kind, goes to its changelog", %{conn: conn} do + server = with_history() + + for kind <- ["prompts", "gptbot"] do + assert {:error, {:live_redirect, %{to: to}}} = + live(conn, "/servers/#{server.name}/changelog/#{kind}") + + assert to == "/servers/#{server.name}/changelog" + end + end + + test "the listing links to its changelog only when there is one", %{conn: conn} do + with = with_history() + without = server_fixture() + + {:ok, _view, html} = live(conn, "/servers/#{with.name}") + assert html =~ "/servers/#{with.name}/changelog" + assert html =~ "changelog · 2" + + {:ok, _view, html} = live(conn, "/servers/#{without.name}") + refute html =~ "/changelog" + end + + test "the site-wide changelog lists recent changes and links into each listing", %{conn: conn} do + server = with_history() + {:ok, _view, html} = live(conn, "/changelog") + + assert html =~ "What changed in MCP servers" + assert html =~ "get_radar" + assert html =~ "/servers/#{server.name}/changelog" + end + + test "the sitemap lists changelogs for listings that have them", %{conn: conn} do + server = with_history() + _quiet = server_fixture() + + index = conn |> get("/sitemap.xml") |> response(200) + assert index =~ "/sitemaps/changelogs-1.xml" + + xml = conn |> get("/sitemaps/changelogs-1.xml") |> response(200) + assert xml =~ "/servers/#{server.name}/changelog" + assert xml =~ "/servers/#{server.name}/changelog/tools" + assert xml =~ "/servers/#{server.name}/changelog/server-json" + + pages = conn |> get("/sitemaps/pages.xml") |> response(200) + assert pages =~ "/changelog" + end + + test "no history anywhere means no changelog file", %{conn: conn} do + server_fixture() + + refute conn |> get("/sitemap.xml") |> response(200) =~ "changelogs-1.xml" + assert conn |> get("/sitemaps/changelogs-1.xml") |> response(404) == "" + end +end