# 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