diff --git a/lib/mcp_registry/probe.ex b/lib/mcp_registry/probe.ex index f3ccade..8873a24 100644 --- a/lib/mcp_registry/probe.ex +++ b/lib/mcp_registry/probe.ex @@ -37,11 +37,23 @@ defmodule McpRegistry.Probe do * `:unsupported` — reachable, but not answering MCP * `:unreachable` — DNS, TLS, timeout, 5xx """ + @type listing :: %{ + tools: [String.t()], + prompts: [String.t()], + resources: [String.t()] + } + @type result :: - {:ok, [String.t()]} + {:ok, listing()} | {:error, :not_remote | :unauthorized | :unsupported | :unreachable} - @doc "Asks a listing's endpoint for its tools." + @doc """ + Asks a listing's endpoint what it exposes: tools, prompts and resources. + + Returns `{:ok, %{tools: [...], prompts: [...], resources: [...]}}`. A server + that answers for tools but errors on prompts simply reports no prompts -- + the three lists are independent, and one refusal should not discard the rest. + """ @spec probe(Server.t()) :: result() def probe(%Server{} = server) do if Server.remote?(server) and is_binary(server.remote_url) do @@ -54,8 +66,15 @@ defmodule McpRegistry.Probe do defp run(url) do with {:ok, session} <- initialize(url), :ok <- initialized(url, session), - {:ok, tools} <- list_tools(url, session) do - {:ok, tools} + {:ok, tools} <- list(url, session, "tools/list", "tools") do + {:ok, + %{ + tools: tools, + # Best effort: a server that has no prompts, or refuses the call, is + # reported as having none rather than failing the whole probe. + prompts: list(url, session, "prompts/list", "prompts") |> ok_or_empty(), + resources: list(url, session, "resources/list", "resources") |> ok_or_empty() + }} end rescue # A malformed response must never take down the run that is walking the @@ -104,11 +123,14 @@ defmodule McpRegistry.Probe do :ok end - defp list_tools(url, session) do - case post(url, %{jsonrpc: "2.0", id: 2, method: "tools/list"}, session) do + defp ok_or_empty({:ok, items}), do: items + defp ok_or_empty(_), do: [] + + defp list(url, session, method, key) do + case post(url, %{jsonrpc: "2.0", id: 2, method: method}, session) do {:ok, %Req.Response{status: status, body: raw}} when status in 200..299 -> case decode(raw) do - %{"result" => %{"tools" => tools}} when is_list(tools) -> {:ok, names(tools)} + %{"result" => %{^key => items}} when is_list(items) -> {:ok, names(items)} _ -> {:error, :unsupported} end @@ -123,10 +145,12 @@ defmodule McpRegistry.Probe do end end - defp names(tools) do - tools + # A resource is named by uri, a tool and a prompt by name. + defp names(items) do + items |> Enum.map(fn %{"name" => name} when is_binary(name) -> String.trim(name) + %{"uri" => uri} when is_binary(uri) -> String.trim(uri) _ -> nil end) |> Enum.reject(&(&1 in [nil, ""])) diff --git a/lib/mcp_registry/probe/runner.ex b/lib/mcp_registry/probe/runner.ex index 2f31ec0..bd06a66 100644 --- a/lib/mcp_registry/probe/runner.ex +++ b/lib/mcp_registry/probe/runner.ex @@ -91,14 +91,22 @@ defmodule McpRegistry.Probe.Runner do defp probe_and_record(%Server{} = server) do case Probe.probe(server) do - {:ok, tools} -> + {:ok, found} -> # Only a successful probe writes tools, and only when it actually found # some: an empty list from a server that answered is still not a reason # to delete what the publisher declared. - changes = - if tools == [], + tools = + if found.tools == [], do: %{}, - else: %{tools: tools, tools_source: "probed"} + else: %{tools: found.tools, tools_source: "probed"} + + # Prompts and resources are written even when empty, and the difference + # is not an inconsistency. Nothing declares them -- there is no publisher + # claim to protect, so the only source is the probe, and "this server + # answered and has none" is the finding. Left unwritten, a server with + # no prompts would be indistinguishable from one never asked, which is + # exactly the count this is being collected to settle. + changes = Map.merge(tools, %{prompts: found.prompts, resources: found.resources}) record(server, :ok, changes) :ok diff --git a/lib/mcp_registry/registry/server.ex b/lib/mcp_registry/registry/server.ex index 110acd6..b5f931f 100644 --- a/lib/mcp_registry/registry/server.ex +++ b/lib/mcp_registry/registry/server.ex @@ -34,6 +34,8 @@ defmodule McpRegistry.Registry.Server do field :env_vars, {:array, :string}, default: [] field :tags, {:array, :string}, default: [] field :tools, {:array, :string}, default: [] + field :prompts, {:array, :string}, default: [] + field :resources, {:array, :string}, default: [] field :origin, :string, default: "local" field :source_updated_at, :utc_datetime_usec field :synced_at, :utc_datetime_usec diff --git a/priv/repo/migrations/20260924220000_add_prompts_and_resources.exs b/priv/repo/migrations/20260924220000_add_prompts_and_resources.exs new file mode 100644 index 0000000..c4e99d1 --- /dev/null +++ b/priv/repo/migrations/20260924220000_add_prompts_and_resources.exs @@ -0,0 +1,19 @@ +defmodule McpRegistry.Repo.Migrations.AddPromptsAndResources do + @moduledoc """ + Stores the other two things an MCP server can expose. + + A sample of eight live servers found prompts at zero on every one of them, + including servers that declare the prompts capability, and resources on two. + That is a sample, not a census: these columns let the prober answer the same + question across the whole catalogue, so a skills or resources silo can be + built on a real count rather than a guess about whether one is worth having. + """ + use Ecto.Migration + + def change do + alter table(:servers) do + add :prompts, {:array, :string}, default: [], null: false + add :resources, {:array, :string}, default: [], null: false + end + end +end diff --git a/test/mcp_registry/probe_test.exs b/test/mcp_registry/probe_test.exs index 86241d3..084c3b2 100644 --- a/test/mcp_registry/probe_test.exs +++ b/test/mcp_registry/probe_test.exs @@ -61,7 +61,7 @@ defmodule McpRegistry.ProbeTest do end end) - assert {:ok, ["search_docs", "get_page"]} = Probe.probe(remote_fixture()) + assert {:ok, %{tools: ["search_docs", "get_page"]}} = Probe.probe(remote_fixture()) end test "reads a Streamable HTTP server that answers with SSE" do @@ -80,7 +80,7 @@ defmodule McpRegistry.ProbeTest do |> Plug.Conn.send_resp(200, "event: message\ndata: #{Jason.encode!(payload)}\n\n") end) - assert {:ok, ["only_tool"]} = Probe.probe(remote_fixture()) + assert {:ok, %{tools: ["only_tool"]}} = Probe.probe(remote_fixture()) end test "an auth-gated endpoint is reported as such, not as broken" do @@ -95,6 +95,52 @@ defmodule McpRegistry.ProbeTest do assert {:error, :unsupported} = Probe.probe(remote_fixture()) end + test "prompts and resources are collected alongside the tools" do + 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: "search"}]}}) + + "prompts/list" -> + json(conn, %{jsonrpc: "2.0", id: 2, result: %{prompts: [%{name: "summarise"}]}}) + + "resources/list" -> + # A resource is identified by uri, not name. + json(conn, %{jsonrpc: "2.0", id: 2, result: %{resources: [%{uri: "file:///readme"}]}}) + + _ -> + Plug.Conn.send_resp(conn, 202, "") + end + end) + + assert {:ok, found} = Probe.probe(remote_fixture()) + assert found.tools == ["search"] + assert found.prompts == ["summarise"] + assert found.resources == ["file:///readme"] + end + + test "a server that refuses prompts still reports its tools" do + # Observed on live endpoints: tools/list answers, prompts/list errors. + # One refused call must not discard the rest of the probe. + 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: "a"}]}}) + "prompts/list" -> json(conn, %{jsonrpc: "2.0", id: 2, error: %{code: -32_601}}) + _ -> Plug.Conn.send_resp(conn, 202, "") + end + end) + + assert {:ok, %{tools: ["a"], prompts: [], resources: []}} = Probe.probe(remote_fixture()) + end + test "a packaged server is never executed to find out" do # No stub: reaching the network at all would be the bug. assert {:error, :not_remote} = Probe.probe(server_fixture()) @@ -159,6 +205,33 @@ defmodule McpRegistry.ProbeTest do assert Repo.one(Server).tools == ["declared_a"] end + test "having no prompts is recorded, because it is the finding" do + # Tools are protected from an empty answer because a publisher declared + # them. Nothing declares prompts, so the probe is the only source and + # "asked, and has none" has to be storable -- otherwise it is + # indistinguishable from never asked, which is the count being collected. + remote_fixture(%{tools: ["declared_a"]}) + + 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: []}}) + "resources/list" -> json(conn, %{jsonrpc: "2.0", id: 2, result: %{resources: []}}) + _ -> Plug.Conn.send_resp(conn, 202, "") + end + end) + + assert %{ok: 1} = Runner.run_batch(limit: 10) + + server = Repo.one(Server) + assert server.tools == ["declared_a"] + assert server.prompts == [] + assert server.resources == [] + assert server.probe_status == "ok" + end + test "packaged listings are never picked up by the runner" do server_fixture(%{tools: ["declared"]})