defmodule Noizu.MCP.Server.Features.Resources do
@moduledoc """
Resources feature plumbing: the helpers behind the generated
`handle_list_resources/2`, `handle_list_resource_templates/2`,
`handle_read_resource/2`, and `handle_subscribe/2` defaults.
Most servers never call this module directly. Hand-written list callbacks
that still want the registry-driven behavior can call `list_registered/5`
and `list_registered_templates/3` — e.g. with `include_hidden: true` for
session-gated visibility (see the Toolkits, Categories & Hidden Tools
guide).
Direct resources match by exact URI; templates match via
`Noizu.MCP.UriTemplate`.
"""
alias Noizu.MCP.{Error, UriTemplate}
alias Noizu.MCP.Server.Features.Pagination
alias Noizu.MCP.Types.{Resource, ResourceContents, ResourceTemplate}
# ── resources/list ────────────────────────────────────────────────────────
def list(server, params, ctx) do
cursor = (params || %{})["cursor"]
case server.handle_list_resources(cursor, ctx) do
{:ok, resources, next_cursor} ->
result = %{"resources" => Enum.map(resources, &Resource.to_map/1)}
result = if next_cursor, do: Map.put(result, "nextCursor", next_cursor), else: result
{:ok, result}
{:error, %Error{} = error} ->
{:error, error}
end
end
@doc "Returns true if the registered resource should be hidden from listings."
def hidden?({module, opts}) do
case Keyword.fetch(opts, :hidden) do
{:ok, v} ->
v == true
:error ->
try do
module.__mcp_resource__(:hidden) == true
rescue
FunctionClauseError -> false
UndefinedFunctionError -> false
end
end
end
@doc "Returns true if the registered resource template should be hidden from listings."
def hidden_template?({module, opts}) do
case Keyword.fetch(opts, :hidden) do
{:ok, v} ->
v == true
:error ->
try do
module.__mcp_resource_template__(:hidden) == true
rescue
FunctionClauseError -> false
UndefinedFunctionError -> false
end
end
end
@doc "Default `handle_list_resources`: registered resources + enumerable templates."
def list_registered(resources, templates, cursor, ctx, opts \\ []) do
include_hidden = Keyword.get(opts, :include_hidden, false)
visible_resources = if include_hidden, do: resources, else: Enum.reject(resources, &hidden?/1)
direct =
Enum.map(visible_resources, fn {module, entry_opts} -> definition(module, entry_opts) end)
visible_templates =
if include_hidden, do: templates, else: Enum.reject(templates, &hidden_template?/1)
from_templates =
Enum.flat_map(visible_templates, fn {module, _entry_opts} ->
if function_exported?(module, :list, 1) do
case module.list(ctx) do
{:ok, items} -> items
{:error, _} -> []
end
else
[]
end
end)
Pagination.paginate(direct ++ from_templates, cursor)
end
# ── resources/templates/list ──────────────────────────────────────────────
def list_templates(server, params, ctx) do
cursor = (params || %{})["cursor"]
case server.handle_list_resource_templates(cursor, ctx) do
{:ok, templates, next_cursor} ->
result = %{"resourceTemplates" => Enum.map(templates, &ResourceTemplate.to_map/1)}
result = if next_cursor, do: Map.put(result, "nextCursor", next_cursor), else: result
{:ok, result}
{:error, %Error{} = error} ->
{:error, error}
end
end
@doc "Default `handle_list_resource_templates` over registered template modules."
def list_registered_templates(templates, cursor, opts \\ []) do
include_hidden = Keyword.get(opts, :include_hidden, false)
visible = if include_hidden, do: templates, else: Enum.reject(templates, &hidden_template?/1)
definitions = Enum.map(visible, fn {module, _entry_opts} -> module.definition() end)
Pagination.paginate(definitions, cursor)
end
# ── resources/read ────────────────────────────────────────────────────────
def read(server, params, ctx) do
case (params || %{})["uri"] do
uri when is_binary(uri) ->
case server.handle_read_resource(uri, ctx) do
{:error, %Error{} = error} ->
{:error, error}
result ->
case normalize_contents(result, uri, nil) do
{:error, %Error{} = error} -> {:error, error}
contents -> {:ok, %{"contents" => Enum.map(contents, &ResourceContents.to_map/1)}}
end
end
_ ->
{:error, Error.invalid_params("resources/read requires a uri")}
end
end
@doc "Default `handle_read_resource`: exact URI match, then template match."
def dispatch_read(resources, templates, uri, ctx) do
case find(resources, templates, uri) do
{:resource, module, _opts} ->
module.read(uri, ctx) |> normalize_contents(uri, module.__mcp_resource__(:mime_type))
{:template, module, vars} ->
module.read(uri, vars, ctx)
|> normalize_contents(uri, module.__mcp_resource_template__(:mime_type))
nil ->
{:error, Error.resource_not_found(uri)}
end
end
@doc "Subscribe check for the default `handle_subscribe`: the URI must exist and be subscribable."
def check_subscribe(resources, templates, uri) do
case find(resources, templates, uri) do
{:resource, module, _opts} ->
if module.__mcp_resource__(:subscribable),
do: :ok,
else: {:error, Error.invalid_request("Resource is not subscribable: #{uri}")}
{:template, module, _vars} ->
if module.__mcp_resource_template__(:subscribable),
do: :ok,
else: {:error, Error.invalid_request("Resource is not subscribable: #{uri}")}
nil ->
{:error, Error.resource_not_found(uri)}
end
end
defp find(resources, templates, uri) do
direct =
Enum.find_value(resources, fn {module, opts} ->
if definition(module, opts).uri == uri, do: {:resource, module, opts}
end)
direct ||
Enum.find_value(templates, fn {module, _opts} ->
case UriTemplate.match(module.definition().uri_template, uri) do
{:ok, vars} -> {:template, module, vars}
:nomatch -> nil
end
end)
end
@doc "A resource module's effective definition with per-registration overrides applied."
def definition(module, opts) do
definition = module.definition()
Enum.reduce(opts, definition, fn
{:name, name}, acc -> %{acc | name: name}
{:description, description}, acc -> %{acc | description: description}
{_other, _}, acc -> acc
end)
end
# ── contents normalization ────────────────────────────────────────────────
defp normalize_contents({:ok, text}, uri, mime_type) when is_binary(text),
do: [ResourceContents.text(uri, text, mime_type: mime_type)]
defp normalize_contents({:ok, {:blob, blob}}, uri, mime_type) when is_binary(blob),
do: [ResourceContents.blob(uri, blob, mime_type: mime_type)]
defp normalize_contents({:ok, %ResourceContents{} = contents}, _uri, _mime), do: [contents]
defp normalize_contents({:ok, [%ResourceContents{} | _] = contents}, _uri, _mime),
do: contents
defp normalize_contents({:error, %Error{} = error}, _uri, _mime), do: {:error, error}
defp normalize_contents([%ResourceContents{} | _] = contents, _uri, _mime), do: contents
defp normalize_contents(other, uri, _mime) do
raise ArgumentError,
"invalid resource read return for #{uri}: #{inspect(other)} — expected " <>
"{:ok, text | {:blob, binary} | ResourceContents | [ResourceContents]} | {:error, Error}"
end
end