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
42 changes: 33 additions & 9 deletions lib/mcp_registry/probe.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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, ""]))
Expand Down
16 changes: 12 additions & 4 deletions lib/mcp_registry/probe/runner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/mcp_registry/registry/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions priv/repo/migrations/20260924220000_add_prompts_and_resources.exs
Original file line number Diff line number Diff line change
@@ -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
77 changes: 75 additions & 2 deletions test/mcp_registry/probe_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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())
Expand Down Expand Up @@ -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"]})

Expand Down
Loading