# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0
defmodule BB.MCP.JsonSchema do
@moduledoc """
Converts BB command argument definitions into JSON Schema fragments.
Used to advertise command argument types to MCP clients via the
`list_commands` tool, so agents can know what to pass to `invoke_command`.
Maps Spark `:type` values to JSON Schema types:
:integer -> %{"type" => "integer"}
:float, :number -> %{"type" => "number"}
:boolean -> %{"type" => "boolean"}
:string -> %{"type" => "string"}
:atom -> %{"type" => "string", "description" => "atom"}
:map, {:map, fields} -> %{"type" => "object"}
:keyword_list -> %{"type" => "object"}
:any -> %{}
module -> %{"description" => "complex type: \#{inspect(module)}"}
"""
alias BB.Dsl.Command
alias BB.Dsl.Command.Argument
@doc """
Build a JSON Schema object describing a command's arguments.
"""
@spec for_command(Command.t()) :: map()
def for_command(%Command{arguments: arguments}) do
properties =
arguments
|> Enum.flat_map(&for_argument_properties/1)
|> Map.new()
required =
arguments
|> Enum.flat_map(&required_properties/1)
base = %{"type" => "object", "properties" => properties}
if required == [], do: base, else: Map.put(base, "required", required)
end
@doc """
Build a JSON Schema fragment for a single command argument, including
description and default value where present.
"""
@spec for_argument(Argument.t()) :: map()
def for_argument(%Argument{type: type, doc: doc, default: default}) do
for_field(type, doc, default)
end
defp for_field(type, doc, default) do
type
|> base_schema()
|> maybe_put("description", doc)
|> maybe_put("default", default)
end
defp base_schema(:integer), do: %{"type" => "integer"}
defp base_schema(:non_neg_integer), do: %{"type" => "integer", "minimum" => 0}
defp base_schema(:pos_integer), do: %{"type" => "integer", "minimum" => 1}
defp base_schema(:float), do: %{"type" => "number"}
defp base_schema(:number), do: %{"type" => "number"}
defp base_schema(:boolean), do: %{"type" => "boolean"}
defp base_schema(:string), do: %{"type" => "string"}
defp base_schema(:atom), do: %{"type" => "string"}
defp base_schema(:map), do: %{"type" => "object"}
defp base_schema({:map, _fields}), do: %{"type" => "object"}
defp base_schema(:keyword_list), do: %{"type" => "object"}
defp base_schema({:list, inner}), do: %{"type" => "array", "items" => base_schema(inner)}
defp base_schema(:any), do: %{}
defp base_schema(module) when is_atom(module),
do: %{"description" => "complex type: #{inspect(module)}"}
defp base_schema(_other), do: %{}
defp for_argument_properties(%Argument{name: name, type: {:map, fields}, required: required}) do
flatten_map_fields([Atom.to_string(name)], fields, required)
end
defp for_argument_properties(%Argument{name: name, type: :map} = arg) do
json_name = "#{name}_json"
json_doc = "JSON object for `#{name}`"
[{json_name, for_argument(%{arg | type: :string, doc: json_doc})}]
end
defp for_argument_properties(%Argument{name: name} = arg) do
[{Atom.to_string(name), for_argument(arg)}]
end
defp required_properties(%Argument{name: name, type: {:map, fields}, required: true}) do
flatten_required_map_fields([Atom.to_string(name)], fields, true)
end
defp required_properties(%Argument{name: name, type: :map, required: true}),
do: ["#{name}_json"]
defp required_properties(%Argument{name: name, required: true}), do: [Atom.to_string(name)]
defp required_properties(_arg), do: []
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)}]
end
end
defp field_schema(type, spec) when is_list(spec) do
for_field(type, Keyword.get(spec, :doc), Keyword.get(spec, :default))
end
defp field_schema(type, _spec), do: for_field(type, 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 flatten_required_map_fields(path, fields, parent_required) when is_list(fields) do
Enum.flat_map(fields, fn {name, spec} ->
flatten_required_map_field(path, name, spec, parent_required)
end)
end
defp flatten_required_map_field(path, name, spec, parent_required) do
type = field_type(spec)
required = field_required?(spec, parent_required)
case type do
{:map, fields} ->
flatten_required_map_fields(path ++ [Atom.to_string(name)], fields, required)
_type when required ->
[Enum.join(path ++ [Atom.to_string(name)], ".")]
_type ->
[]
end
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end