# 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