lib/bb/mcp/tools.ex

# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.MCP.Tools do
  @moduledoc """
  Shared logic used by the per-tool component modules under
  `BB.MCP.Tools.*`.

  Tools take a `robot` argument that selects which configured robot module
  they operate on. The list of configured robots is read from
  `Application.get_env(:bb_mcp, :robots, [])`.
  """

  alias Anubis.MCP.Error
  alias BB.MCP.Robots

  @doc """
  Read the configured robots map.
  """
  @spec robots() :: Robots.config()
  def robots do
    :bb_mcp
    |> Application.get_env(:robots, [])
    |> Robots.build!()
  end

  @doc """
  Resolve a `robot` argument from tool params to a robot module.

  Returns `{:ok, module}` or `{:error, Anubis.MCP.Error.t()}`.
  """
  @spec fetch_robot(map()) :: {:ok, module()} | {:error, struct()}
  def fetch_robot(params) when is_map(params) do
    case get_arg(params, :robot) do
      name when is_binary(name) ->
        case Robots.fetch(robots(), name) do
          {:ok, module} ->
            {:ok, module}

          {:error, :unknown_robot} ->
            {:error,
             Error.protocol(:invalid_request, %{
               message: "unknown robot #{inspect(name)}; available: #{available_names()}"
             })}
        end

      _other ->
        {:error,
         Error.protocol(:invalid_request, %{
           message: "missing required argument: robot (one of: #{available_names()})"
         })}
    end
  end

  def fetch_robot(_params) do
    {:error,
     Error.protocol(:invalid_request, %{
       message: "missing required argument: robot (one of: #{available_names()})"
     })}
  end

  @doc """
  Fetch a tool argument from string-keyed or atom-keyed params.
  """
  @spec get_arg(map(), atom()) :: term()
  def get_arg(params, key) when is_map(params) and is_atom(key) do
    Map.get(params, Atom.to_string(key), Map.get(params, key))
  end

  @doc """
  Comma-separated list of configured robot names, for error messages.
  """
  @spec available_names() :: String.t()
  def available_names do
    case robots() do
      empty when empty == %{} -> "(none configured)"
      map -> map |> Map.keys() |> Enum.sort() |> Enum.join(", ")
    end
  end

  @doc """
  Schema fragment for the `robot` argument, shared by every tool.
  """
  @spec robot_arg_schema() :: map()
  def robot_arg_schema do
    %{
      "type" => "string",
      "description" =>
        "Name of the robot to operate on. Use the list_robots tool to see " <>
          "configured robots."
    }
  end

  @doc """
  Parse a parameter path string (`"motion.max_speed"`) into atoms.
  """
  @spec parse_path(String.t()) :: [atom()]
  def parse_path(path) when is_binary(path) do
    path
    |> String.split(".", trim: true)
    |> Enum.map(&String.to_atom/1)
  end

  @splode_internal_fields [:splode, :bread_crumbs, :vars, :path, :stacktrace]

  @doc """
  Convert any failure value into an `Anubis.MCP.Error` for the JSON-RPC reply.

  `BB.Error` (Splode) exceptions are rendered via `Exception.message/1` with
  their user-declared fields exposed under `data`, so MCP clients see the
  actual reason (e.g. "Robot is in state `:armed`, requires one of: `:disarmed`")
  instead of a generic "Internal error". Anubis errors pass through unchanged.
  """
  @spec to_anubis_error(term()) :: Anubis.MCP.Error.t()
  def to_anubis_error(%Error{} = error), do: error

  def to_anubis_error(%module{} = error) do
    if is_exception(error) do
      Error.execution(Exception.message(error), exception_data(error, module))
    else
      Error.execution(inspect(error))
    end
  end

  def to_anubis_error(reason), do: Error.execution(inspect(reason))

  defp exception_data(error, module) do
    error
    |> Map.from_struct()
    |> Map.drop(@splode_internal_fields)
    |> Enum.reject(fn {_k, v} -> is_nil(v) or v == [] end)
    |> Map.new(fn {k, v} -> {k, jsonable(v)} end)
    |> Map.put(:error_type, inspect(module))
  end

  defp jsonable(nil), do: nil
  defp jsonable(atom) when is_atom(atom) and not is_boolean(atom), do: inspect(atom)
  defp jsonable(list) when is_list(list), do: Enum.map(list, &jsonable/1)
  defp jsonable(tuple) when is_tuple(tuple), do: tuple |> Tuple.to_list() |> jsonable()
  defp jsonable(other), do: other
end