-
+
- {field} +
-
+
{shown_value(old)}+ + {shown_value(new)} +
+
-
+
- + + + {item} + + +
- + + − {item} + + +
2 * shown()} + class="text-[11px] text-dim" + > + Showing the first {shown()} of each. +
+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"""
+
+ 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.
+ {@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.
+ 2 * shown()}
+ class="text-[11px] text-dim"
+ >
+ Showing the first {shown()} of each.
+
+ What changed in MCP servers
+
+
+
+
+
+
+
+ {heading(@server, @kind)}
+
+
+
+
+
+
+
+
+
+ {shown_value(old)}
+
+ {shown_value(new)}
+
+
+
{@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{