lib/bb/mcp/peri_schema.ex

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

defmodule BB.MCP.PeriSchema do
  @moduledoc """
  Converts BB command argument definitions into Peri schemas suitable for
  `Anubis.Server.Frame.register_tool/3`.

  Anubis takes the Peri schema we hand it, validates incoming tool calls
  against it, and re-renders it as JSON Schema on the wire. So one
  conversion suffices for both validation and discovery.

  BB types map to Peri types as follows:

      :integer | :pos_integer | :non_neg_integer -> :integer
      :float | :number                           -> :float
      :string                                    -> :string
      :boolean                                   -> :boolean
      :atom                                      -> :atom
      :map | {:map, fields}                     -> :map
      :keyword_list                              -> :keyword
      :any                                       -> :any
      module / unknown                           -> :any
  """

  alias BB.Dsl.Command
  alias BB.Dsl.Command.Argument

  @doc """
  Build a Peri schema (map keyed by argument name) for a command's arguments.
  """
  @spec for_command(Command.t()) :: map()
  def for_command(%Command{arguments: arguments}) do
    arguments
    |> Enum.flat_map(&for_argument_fields/1)
    |> Map.new()
  end

  @doc """
  Translate MCP-visible command params back to the command's original BB goal.
  """
  @spec to_goal(Command.t(), map()) :: map()
  def to_goal(%Command{arguments: arguments}, params) when is_map(params) do
    Map.new(arguments, fn %Argument{name: name} = arg -> {name, decode_argument(arg, params)} end)
    |> Map.reject(fn {_name, value} -> is_nil(value) end)
  end

  @doc """
  Pre-process incoming params before Peri validation.

  Some MCP clients interpret a flat property name like `"target.x"` as a nested
  path and send `%{"target" => %{"x" => ...}}` instead of `%{"target.x" => ...}`.
  We advertise the flat (dotted) form deliberately — nested object schemas are
  poorly supported by some clients — so we flatten any nested object back to
  the dotted form here before Peri's schema validator sees them.

  Also coerces JSON whole numbers (`0`, `1`) to floats for fields whose schema
  type is `:float` — JSON has no syntactic distinction between integer and
  float zero, but Peri's `:float` validator rejects integers.
  """
  @spec flatten_nested_params(Command.t(), map()) :: map()
  def flatten_nested_params(%Command{arguments: arguments} = command, params)
      when is_map(params) do
    arguments
    |> Enum.reduce(params, &flatten_arg_into_params/2)
    |> coerce_numeric_fields(command)
  end

  defp coerce_numeric_fields(params, %Command{} = command) do
    schema = for_command(command)

    Map.new(params, fn {key, value} ->
      {key, coerce_if_float(value, Map.get(schema, key))}
    end)
  end

  defp coerce_if_float(value, schema) when is_integer(value) do
    if float_type?(schema), do: value * 1.0, else: value
  end

  defp coerce_if_float(value, _schema), do: value

  defp float_type?(:float), do: true
  defp float_type?({:required, inner}), do: float_type?(inner)
  defp float_type?({inner, {:default, _}}) when not is_atom(inner), do: float_type?(inner)
  defp float_type?({:meta, inner, _}) when not is_atom(inner), do: float_type?(inner)
  defp float_type?(_), do: false

  defp flatten_arg_into_params(%Argument{name: name, type: {:map, _fields}}, params) do
    name_str = Atom.to_string(name)

    case Map.get(params, name_str) || Map.get(params, name) do
      nested when is_map(nested) ->
        params
        |> Map.drop([name, name_str])
        |> Map.merge(flatten_to_dotted(nested, name_str))

      _ ->
        params
    end
  end

  defp flatten_arg_into_params(_arg, params), do: params

  defp flatten_to_dotted(value, prefix) when is_map(value) do
    Enum.reduce(value, %{}, fn {key, val}, acc ->
      key_str = to_string(key)
      path = "#{prefix}.#{key_str}"

      if is_map(val) do
        Map.merge(acc, flatten_to_dotted(val, path))
      else
        Map.put(acc, path, val)
      end
    end)
  end

  @doc """
  Build a Peri field value for a single argument, applying `required` and
  `default` wrappers and a `description` meta tag where set.
  """
  @spec for_argument(Argument.t()) :: term()
  def for_argument(%Argument{type: type, doc: doc, required: required, default: default}) do
    for_field(type, required, doc, default)
  end

  defp for_field(type, required, doc, default) do
    type
    |> base_type()
    |> apply_default(default)
    |> apply_meta(doc)
    |> apply_required(required)
  end

  defp base_type(:integer), do: :integer
  defp base_type(:pos_integer), do: :integer
  defp base_type(:non_neg_integer), do: :integer
  defp base_type(:float), do: :float
  defp base_type(:number), do: :float
  defp base_type(:boolean), do: :boolean
  defp base_type(:string), do: :string
  defp base_type(:atom), do: :atom
  defp base_type(:map), do: :map
  defp base_type({:map, _fields}), do: :map
  defp base_type(:keyword_list), do: :keyword
  defp base_type(:any), do: :any
  defp base_type({:list, _inner}), do: {:list, :any}
  defp base_type(_other), do: :any

  defp decode_argument(%Argument{name: name, type: {:map, _fields}}, params) do
    cond do
      dotted_args = collect_dotted_args(params, name) -> dotted_args
      has_arg?(params, name) -> get_arg(params, name)
      true -> nil
    end
  end

  defp decode_argument(%Argument{name: name, type: :map}, params) do
    json_name = String.to_atom("#{name}_json")

    cond do
      dotted_args = collect_dotted_args(params, name) ->
        dotted_args

      has_arg?(params, json_name) ->
        decode_json_map(get_arg(params, json_name))

      has_arg?(params, name) ->
        get_arg(params, name)

      true ->
        nil
    end
  end

  defp decode_argument(%Argument{name: name}, params) do
    if has_arg?(params, name), do: get_arg(params, name)
  end

  defp decode_json_map(value) when is_binary(value) do
    case Jason.decode(value) do
      {:ok, decoded} when is_map(decoded) -> decoded
      _other -> value
    end
  end

  defp decode_json_map(value), do: value

  defp collect_dotted_args(params, name) do
    prefix = "#{name}."

    params
    |> Enum.reduce(%{}, fn
      {key, value}, acc when is_binary(key) ->
        if String.starts_with?(key, prefix) do
          key
          |> String.replace_prefix(prefix, "")
          |> String.split(".")
          |> put_nested(acc, value)
        else
          acc
        end

      _other, acc ->
        acc
    end)
    |> case do
      empty when empty == %{} -> nil
      dotted_args -> dotted_args
    end
  end

  defp for_argument_fields(%Argument{name: name, type: {:map, fields}, required: required}) do
    flatten_map_fields([Atom.to_string(name)], fields, required)
  end

  defp for_argument_fields(%Argument{name: name, type: :map} = arg) do
    json_name = String.to_atom("#{name}_json")
    json_doc = "JSON object for `#{name}`"
    [{json_name, for_argument(%{arg | type: :string, doc: json_doc})}]
  end

  defp for_argument_fields(%Argument{name: name} = arg), do: [{name, for_argument(arg)}]

  defp get_arg(params, name) when is_atom(name),
    do: Map.get(params, name, Map.get(params, Atom.to_string(name)))

  defp get_arg(params, name) when is_binary(name), do: Map.get(params, name)

  defp has_arg?(params, name) when is_atom(name),
    do: Map.has_key?(params, name) or Map.has_key?(params, Atom.to_string(name))

  defp has_arg?(params, name) when is_binary(name), do: Map.has_key?(params, name)

  defp flatten_map_fields(path, fields, parent_required) when is_list(fields) do
    Enum.flat_map(fields, fn {name, spec} ->
      flatten_map_field(path, name, spec, parent_required)
    end)
  end

  defp flatten_map_field(path, name, spec, parent_required) do
    type = field_type(spec)
    required = field_required?(spec, parent_required)

    case type do
      {:map, fields} ->
        flatten_map_fields(path ++ [Atom.to_string(name)], fields, required)

      type ->
        field_name = Enum.join(path ++ [Atom.to_string(name)], ".")
        [{field_name, field_schema(type, spec, required)}]
    end
  end

  defp field_schema(type, spec, required) when is_list(spec) do
    for_field(type, required, Keyword.get(spec, :doc), Keyword.get(spec, :default))
  end

  defp field_schema(type, _spec, required), do: for_field(type, required, nil, nil)

  defp field_type(spec) when is_list(spec), do: Keyword.fetch!(spec, :type)
  defp field_type(type), do: type

  defp field_required?(spec, parent_required) when is_list(spec),
    do: parent_required and Keyword.get(spec, :required, false)

  defp field_required?(_spec, parent_required), do: parent_required

  defp put_nested([], acc, _value), do: acc
  defp put_nested([key], acc, value), do: Map.put(acc, key, value)

  defp put_nested([key | rest], acc, value) do
    nested = Map.get(acc, key, %{})
    Map.put(acc, key, put_nested(rest, nested, value))
  end

  defp apply_default(type, nil), do: type
  defp apply_default(type, value), do: {type, {:default, value}}

  defp apply_meta(type, nil), do: type
  defp apply_meta(type, doc), do: {:meta, type, description: doc}

  defp apply_required(type, true), do: {:required, type}
  defp apply_required(type, _), do: type
end