Current section
Files
Jump to
Current section
Files
lib/phantom/tool_json_schema.ex
defmodule Phantom.Tool.JSONSchema do
@moduledoc """
JSON Schema for tool input and output schemas.
Provides an Ecto-like DSL for defining input schemas that both generate
JSON Schema for clients AND validate/coerce incoming params at dispatch time.
## Router usage
When defining tools in a `Phantom.Router`, use a `do` block with `field` declarations:
tool :search, description: "Search for stuff" do
field :query, :string, required: true
field :limit, :integer, default: 10
field :tags, {:array, :string}
end
This generates the equivalent JSON Schema sent to clients **and** validates
incoming arguments at dispatch time, returning structured errors for invalid
input.
You can still use the map-based `input_schema` option for full control:
tool :search,
description: "Search for stuff",
input_schema: %{
required: ~w[query],
properties: %{
query: %{type: "string", description: "Search query"},
limit: %{type: "integer", description: "Max results"}
}
}
The map-based form skips server-side validation — it only advertises the
schema to clients.
## Type system
| DSL type | Elixir guard | JSON Schema type |
|----------|-------------|-----------------|
| `:string` | `is_binary` | `"string"` |
| `:integer` | `is_integer` | `"integer"` |
| `:number` | `is_number` | `"number"` |
| `:boolean` | `is_boolean` | `"boolean"` |
| `{:array, subtype}` | `is_list` + validate each | `"array"` with `items` |
| `:map` with `do` block | `is_map` + validate nested | `"object"` with `properties` |
| `ModuleName` | delegate to module schema | delegate to module schema |
## Field options
Every `field` accepts the following options:
| Option | Type | Description |
|--------|------|-------------|
| `:required` | `boolean` | Mark the field as required (default: `false`) |
| `:default` | `any` | Default value injected when the field is absent |
| `:description` | `string` | Description sent to clients in the JSON Schema |
| `:enum` or `:in` | `list` | Restrict to an allowed set of values |
| `:exclusion` | `list` | Reject specific values |
| `:minimum` or `:greater_than_or_equal_to` | `number` | Minimum value (inclusive) |
| `:maximum` or `:less_than_or_equal_to` | `number` | Maximum value (inclusive) |
| `:greater_than` | `number` | Exclusive minimum |
| `:less_than` | `number` | Exclusive maximum |
| `:min_length` | `integer` | Minimum string length or list length |
| `:max_length` | `integer` | Maximum string length or list length |
| `:length` | `integer` | Exact string length or list length |
| `:pattern` or `:format` | `string` or `Regex` | Regex pattern the string must match |
| `:message` | `string` | Custom error message on validation failure |
| `:validate` | `function` | Custom validator (see below) |
Options like `:in`, `:greater_than_or_equal_to`, `:less_than_or_equal_to`,
and `:format` are Ecto-style aliases for their JSON Schema equivalents.
## Nested objects
Use a `do` block on a `:map` field to define nested properties:
tool :search, description: "Search" do
field :query, :string, required: true
field :filters, :map do
field :category, :string, in: ~w[books movies music]
field :min_price, :number, minimum: 0
end
end
## Custom validators
The `:validate` option accepts an anonymous function, a local function name,
or an MFA tuple. The function receives the field value and must return
`:ok` or `{:error, reason}`:
tool :create_user, description: "Create a user" do
field :email, :string,
required: true,
pattern: ~r/@/,
validate: fn value ->
if String.contains?(value, "@"),
do: :ok,
else: {:error, "must be a valid email"}
end
# Or reference a local function by name:
field :username, :string, required: true, validate: :validate_username
end
def validate_username(value) do
if String.length(value) >= 3, do: :ok, else: {:error, "too short"}
end
## Reusable schemas
Define reusable schemas as modules with `use Phantom.Tool.JSONSchema`:
defmodule MyApp.MCP.Schemas.Filters do
use Phantom.Tool.JSONSchema
input_schema do
field :category, :string
field :min_price, :number, minimum: 0
end
end
Then reference the module as a field type:
tool :search, description: "Search" do
field :query, :string, required: true
field :filters, MyApp.MCP.Schemas.Filters
end
"""
import Phantom.Utils
@type field :: %{
name: atom(),
type: atom() | {:array, atom()} | module(),
required: boolean(),
default: any(),
description: String.t() | nil,
message: String.t() | nil,
validate:
(any() -> :ok | {:error, String.t()}) | {module(), atom(), list()} | atom() | nil,
enum: list() | nil,
minimum: number() | nil,
maximum: number() | nil,
greater_than: number() | nil,
less_than: number() | nil,
min_length: non_neg_integer() | nil,
max_length: non_neg_integer() | nil,
length: non_neg_integer() | nil,
pattern: String.t() | nil,
exclusion: list() | nil,
children: [field()] | nil
}
@type t :: %__MODULE__{
required: [String.t()],
type: String.t(),
properties: map(),
fields: [field()] | nil
}
@type json :: %{
required(:type) => String.t(),
optional(:required) => [String.t()],
optional(:properties) => map()
}
defstruct required: [], type: "object", properties: %{}, fields: nil
@callback __input_schema__() :: t()
defmacro __using__(_opts) do
quote do
@behaviour Phantom.Tool.JSONSchema
import Phantom.Tool.JSONSchema, only: [input_schema: 1]
@before_compile Phantom.Tool.JSONSchema
end
end
defmacro __before_compile__(env) do
input_schema = Module.get_attribute(env.module, :phantom_input_schema)
quote do
def __input_schema__ do
unquote(Macro.escape(input_schema))
end
end
end
@doc """
Define an input schema. Returns a `%JSONSchema{}` struct with validation
metadata that is consumed by the following `tool` macro via the
`@phantom_input_schema` attribute.
input_schema do
field :query, :string, required: true
field :limit, :integer, default: 10
end
tool :search, description: "Search"
"""
defmacro input_schema(do: block) do
{defs, fields} = transform_block(block)
quote do
unquote_splicing(defs)
@phantom_input_schema Phantom.Tool.JSONSchema.build_from_fields(unquote(fields))
end
end
@doc false
def transform_block({:__block__, _, exprs}) do
{fields, defs} =
Enum.map_reduce(exprs, [], fn expr, acc ->
{field_ast, new_defs} = transform_field(expr)
{field_ast, acc ++ new_defs}
end)
{defs, fields}
end
@doc false
def transform_block(single_expr) do
{field_ast, defs} = transform_field(single_expr)
{defs, [field_ast]}
end
@doc false
def transform_field({:field, _meta, [name, type]}) do
ast =
quote do
Phantom.Tool.JSONSchema.build_field(unquote(name), unquote(type), [])
end
{ast, []}
end
@doc false
def transform_field({:field, meta, [name, type, opts, block_opts]})
when is_list(opts) and is_list(block_opts) do
transform_field({:field, meta, [name, type, opts ++ block_opts]})
end
@doc false
def transform_field({:field, _meta, [name, type, opts]}) when is_list(opts) do
{block, opts} = Keyword.pop(opts, :do)
{validate_ast, opts} = Keyword.pop(opts, :validate)
if block do
{nested_defs, nested_fields} = transform_block(block)
field_ast =
quote do
Phantom.Tool.JSONSchema.build_field(
unquote(name),
:map,
Keyword.put(unquote(opts), :children, unquote(nested_fields))
)
end
{field_ast, nested_defs}
else
build_field_ast(name, type, opts, validate_ast)
end
end
@doc false
def build_field_ast(name, type, opts, nil) do
ast =
quote do
Phantom.Tool.JSONSchema.build_field(unquote(name), unquote(type), unquote(opts))
end
{ast, []}
end
@doc false
def build_field_ast(name, type, opts, validate_ast) do
case classify_validator(validate_ast) do
:anon_function ->
{_, [{:counter, counter} | _], _} = Macro.unique_var(:v, __MODULE__)
func_name = :"__phantom_validate_#{counter}__"
def_ast =
quote do
@doc false
def unquote(func_name)(value), do: unquote(validate_ast).(value)
end
field_ast =
quote do
Phantom.Tool.JSONSchema.build_field(
unquote(name),
unquote(type),
[{:validate, {__MODULE__, unquote(func_name), []}} | unquote(opts)]
)
end
{field_ast, [def_ast]}
:local_function ->
ast =
quote do
Phantom.Tool.JSONSchema.build_field(
unquote(name),
unquote(type),
[{:validate, {__MODULE__, unquote(validate_ast), []}} | unquote(opts)]
)
end
{ast, []}
:passthrough ->
ast =
quote do
Phantom.Tool.JSONSchema.build_field(
unquote(name),
unquote(type),
[{:validate, unquote(validate_ast)} | unquote(opts)]
)
end
{ast, []}
end
end
@doc false
def classify_validator({:fn, _, _}), do: :anon_function
def classify_validator({:&, _, [{:/, _, [{{:., _, _}, _, _}, _]}]}), do: :passthrough
def classify_validator({:&, _, _}), do: :anon_function
def classify_validator(atom) when is_atom(atom), do: :local_function
def classify_validator(_), do: :passthrough
def build(nil), do: nil
def build(%__MODULE__{} = schema), do: schema
def build(attrs), do: struct!(__MODULE__, attrs)
@doc "Build a `%JSONSchema{}` from a list of field maps, pre-computing JSON Schema properties."
def build_from_fields(fields) when is_list(fields) do
{properties, required} = fields_to_json_schema(fields)
%__MODULE__{
type: "object",
required: required,
properties: properties,
fields: fields
}
end
@doc "Build a field map from name, type, and opts."
def build_field(name, type, opts \\ []) do
opts = Keyword.new(opts)
children =
case Keyword.get(opts, :children) do
nil -> nil
%__MODULE__{fields: fields} -> fields
list when is_list(list) -> list
end
# Ecto alias: format: ~r// or format: string → pattern:
{format, opts} = Keyword.pop(opts, :format)
pattern =
case {Keyword.fetch(opts, :pattern), format} do
{{:ok, explicit}, _} -> explicit
{:error, %Regex{} = r} -> Regex.source(r)
{:error, s} when is_binary(s) -> s
{:error, nil} -> nil
end
# Ecto alias: in: → enum:
{in_values, opts} = Keyword.pop(opts, :in)
enum = Keyword.get(opts, :enum, in_values)
# Ecto alias: greater_than_or_equal_to: → minimum:
{gte, opts} = Keyword.pop(opts, :greater_than_or_equal_to)
minimum = Keyword.get(opts, :minimum, gte)
# Ecto alias: less_than_or_equal_to: → maximum:
{lte, opts} = Keyword.pop(opts, :less_than_or_equal_to)
maximum = Keyword.get(opts, :maximum, lte)
%{
name: name,
type: type,
required: Keyword.get(opts, :required, false),
default: Keyword.get(opts, :default),
description: Keyword.get(opts, :description),
message: Keyword.get(opts, :message),
validate: Keyword.get(opts, :validate),
enum: enum,
minimum: minimum,
maximum: maximum,
greater_than: Keyword.get(opts, :greater_than),
less_than: Keyword.get(opts, :less_than),
min_length: Keyword.get(opts, :min_length),
max_length: Keyword.get(opts, :max_length),
length: Keyword.get(opts, :length),
pattern: pattern,
exclusion: Keyword.get(opts, :exclusion),
children: children
}
end
def to_json(nil), do: %{required: [], type: "object", properties: %{}}
def to_json(%__MODULE__{} = schema) do
remove_nils(%{
required: schema.required,
type: schema.type,
properties: schema.properties
})
end
@doc "Passthrough when no schema is present, or when schema has no DSL fields."
def maybe_validate(nil, params), do: {:ok, params}
def maybe_validate(%__MODULE__{fields: nil}, params), do: {:ok, params}
def maybe_validate(%__MODULE__{fields: fields}, params) when is_list(fields),
do: validate(fields, params)
@doc """
Validate params against the field definitions. Returns `{:ok, params_with_defaults}`
or `{:error, [error_messages]}`.
"""
def validate(schema_or_fields, params, handler \\ nil)
def validate(%__MODULE__{fields: fields}, params, handler) when is_list(fields),
do: validate(fields, params, handler)
def validate(fields, params, handler) when is_list(fields) do
{params, errors} = validate_fields(fields, params, "", handler)
case errors do
[] -> {:ok, params}
errors -> {:error, Enum.reverse(errors)}
end
end
defp validate_fields(fields, params, prefix, handler) do
Enum.reduce(fields, {params, []}, fn field, {params, errors} ->
key = to_string(field.name)
field_path = if prefix == "", do: key, else: "#{prefix}.#{key}"
case Map.fetch(params, key) do
{:ok, value} ->
case validate_field(field, value, field_path, handler) do
:ok -> {params, errors}
{:error, new_errors} -> {params, new_errors ++ errors}
end
:error ->
cond do
field.required ->
{params, ["Missing required field: #{field_path}" | errors]}
field.default != nil ->
{Map.put(params, key, field.default), errors}
true ->
{params, errors}
end
end
end)
end
defp validate_field(field, value, path, handler) do
case do_validate_field(field, value, path, handler) do
:ok ->
:ok
{:error, _} when not is_nil(field.message) ->
{:error, ["Field #{path}: #{field.message}"]}
error ->
error
end
end
defp do_validate_field(field, value, path, handler) do
with :ok <- check_type(field.type, value, path, handler) do
[]
|> check_enum(field.enum, value, path)
|> check_exclusion(field.exclusion, value, path)
|> check_minimum(field.minimum, value, path)
|> check_maximum(field.maximum, value, path)
|> check_greater_than(field.greater_than, value, path)
|> check_less_than(field.less_than, value, path)
|> check_length(field.length, value, path)
|> check_min_length(field.min_length, value, path)
|> check_max_length(field.max_length, value, path)
|> check_pattern(field.pattern, value, path)
|> check_nested(field, value, path)
|> check_validate(field.validate, value, path, handler)
|> case do
[] -> :ok
errors -> {:error, Enum.reverse(errors)}
end
end
end
defp check_type(:string, value, _path, _handler) when is_binary(value), do: :ok
defp check_type(:string, value, path, _handler),
do: {:error, ["Field #{path}: expected string, got #{inspect(value)}"]}
defp check_type(:integer, value, _path, _handler) when is_integer(value), do: :ok
defp check_type(:integer, value, path, _handler),
do: {:error, ["Field #{path}: expected integer, got #{inspect(value)}"]}
defp check_type(:number, value, _path, _handler) when is_number(value), do: :ok
defp check_type(:number, value, path, _handler),
do: {:error, ["Field #{path}: expected number, got #{inspect(value)}"]}
defp check_type(:boolean, value, _path, _handler) when is_boolean(value), do: :ok
defp check_type(:boolean, value, path, _handler),
do: {:error, ["Field #{path}: expected boolean, got #{inspect(value)}"]}
defp check_type({:array, subtype}, value, path, handler) when is_list(value) do
errors =
value
|> Enum.with_index()
|> Enum.reduce([], fn {elem, idx}, errors ->
elem_path = "#{path}[#{idx}]"
case check_type(subtype, elem, elem_path, handler) do
:ok -> errors
{:error, new_errors} -> new_errors ++ errors
end
end)
case errors do
[] -> :ok
errors -> {:error, Enum.reverse(errors)}
end
end
defp check_type({:array, _subtype}, value, path, _handler),
do: {:error, ["Field #{path}: expected array, got #{inspect(value)}"]}
defp check_type(:map, value, _path, _handler) when is_map(value), do: :ok
defp check_type(:map, value, path, _handler),
do: {:error, ["Field #{path}: expected map, got #{inspect(value)}"]}
defp check_type(nil, _value, _path, _handler), do: :ok
defp check_type(module, value, path, handler) when is_atom(module) do
if function_exported?(module, :__input_schema__, 0) do
schema = module.__input_schema__()
do_validate_field(
build_field(:_, nil, children: schema.fields),
value,
path,
handler
)
else
{:error, ["Field #{path}: unknown type #{inspect(module)}"]}
end
end
defp check_enum(errors, nil, _value, _path), do: errors
defp check_enum(errors, allowed, value, path) do
if value in allowed do
errors
else
[
"Field #{path}: value #{inspect(value)} not in allowed values: #{inspect(allowed)}"
| errors
]
end
end
defp check_exclusion(errors, nil, _value, _path), do: errors
defp check_exclusion(errors, excluded, value, path) do
if value in excluded do
["Field #{path}: must not be one of #{inspect(excluded)}" | errors]
else
errors
end
end
defp check_minimum(errors, nil, _value, _path), do: errors
defp check_minimum(errors, min, value, path) when is_number(value) do
if value >= min do
errors
else
["Field #{path}: value #{inspect(value)} is less than minimum #{min}" | errors]
end
end
defp check_minimum(errors, _min, _value, _path), do: errors
defp check_maximum(errors, nil, _value, _path), do: errors
defp check_maximum(errors, max, value, path) when is_number(value) do
if value <= max do
errors
else
["Field #{path}: value #{inspect(value)} is greater than maximum #{max}" | errors]
end
end
defp check_maximum(errors, _max, _value, _path), do: errors
defp check_greater_than(errors, nil, _, _), do: errors
defp check_greater_than(errors, threshold, value, path) when is_number(value) do
if value > threshold do
errors
else
["Field #{path}: must be greater than #{threshold}" | errors]
end
end
defp check_greater_than(errors, _, _, _), do: errors
defp check_less_than(errors, nil, _, _), do: errors
defp check_less_than(errors, threshold, value, path) when is_number(value) do
if value < threshold do
errors
else
["Field #{path}: must be less than #{threshold}" | errors]
end
end
defp check_less_than(errors, _, _, _), do: errors
defp check_length(errors, nil, _, _), do: errors
defp check_length(errors, exact, value, path) when is_binary(value) do
len = String.length(value)
if len == exact do
errors
else
["Field #{path}: length must be exactly #{exact}, got #{len}" | errors]
end
end
defp check_length(errors, exact, value, path) when is_list(value) do
len = length(value)
if len == exact do
errors
else
["Field #{path}: length must be exactly #{exact}, got #{len}" | errors]
end
end
defp check_length(errors, _, _, _), do: errors
defp check_min_length(errors, nil, _value, _path), do: errors
defp check_min_length(errors, min, value, path) when is_binary(value) do
len = String.length(value)
if len >= min do
errors
else
["Field #{path}: string length #{len} is less than minimum #{min}" | errors]
end
end
defp check_min_length(errors, min, value, path) when is_list(value) do
len = length(value)
if len >= min do
errors
else
["Field #{path}: length #{len} is less than minimum #{min}" | errors]
end
end
defp check_min_length(errors, _min, _value, _path), do: errors
defp check_max_length(errors, nil, _value, _path), do: errors
defp check_max_length(errors, max, value, path) when is_binary(value) do
len = String.length(value)
if len <= max do
errors
else
["Field #{path}: string length #{len} is greater than maximum #{max}" | errors]
end
end
defp check_max_length(errors, max, value, path) when is_list(value) do
len = length(value)
if len <= max do
errors
else
["Field #{path}: length #{len} is greater than maximum #{max}" | errors]
end
end
defp check_max_length(errors, _max, _value, _path), do: errors
defp check_pattern(errors, nil, _value, _path), do: errors
defp check_pattern(errors, pattern, value, path) when is_binary(value) do
case Regex.compile(pattern) do
{:ok, regex} ->
if Regex.match?(regex, value) do
errors
else
["Field #{path}: value does not match pattern #{pattern}" | errors]
end
_ ->
errors
end
end
defp check_pattern(errors, _pattern, _value, _path), do: errors
defp check_nested(errors, %{children: children}, value, path)
when is_list(children) and is_map(value) do
{_params, nested_errors} = validate_fields(children, value, path, nil)
nested_errors ++ errors
end
defp check_nested(errors, _field, _value, _path), do: errors
defp check_validate(errors, nil, _value, _path, _handler), do: errors
defp check_validate(errors, fun, value, path, _handler) when is_function(fun, 1) do
case fun.(value) do
:ok -> errors
{:error, reason} -> ["Field #{path}: #{reason}" | errors]
end
end
defp check_validate(errors, {m, f, a}, value, path, _handler) do
case apply(m, f, [value | a]) do
:ok -> errors
{:error, reason} -> ["Field #{path}: #{reason}" | errors]
end
end
defp check_validate(errors, atom, value, path, handler)
when is_atom(atom) and not is_nil(handler) do
case apply(handler, atom, [value]) do
:ok -> errors
{:error, reason} -> ["Field #{path}: #{reason}" | errors]
end
end
defp check_validate(errors, _atom, _value, _path, _handler), do: errors
defp fields_to_json_schema(fields) do
Enum.reduce(fields, {%{}, []}, fn field, {props, required} ->
prop = field_to_json_property(field)
props = Map.put(props, field.name, prop)
required =
if field.required do
required ++ [to_string(field.name)]
else
required
end
{props, required}
end)
end
defp field_to_json_property(field) do
base = type_to_json(field.type, field)
base
|> maybe_put(:description, field.description)
|> maybe_put(:enum, field.enum)
|> maybe_put(:minimum, field.minimum)
|> maybe_put(:maximum, field.maximum)
|> maybe_put(:exclusiveMinimum, field.greater_than)
|> maybe_put(:exclusiveMaximum, field.less_than)
|> put_length_constraints(field)
|> maybe_put(:pattern, field.pattern)
end
defp put_length_constraints(json, field) do
case field.type do
{:array, _} ->
json
|> maybe_put(:minItems, field.length || field.min_length)
|> maybe_put(:maxItems, field.length || field.max_length)
_ ->
json
|> maybe_put(:minLength, field.length || field.min_length)
|> maybe_put(:maxLength, field.length || field.max_length)
end
end
defp type_to_json(:string, _field), do: %{type: "string"}
defp type_to_json(:integer, _field), do: %{type: "integer"}
defp type_to_json(:number, _field), do: %{type: "number"}
defp type_to_json(:boolean, _field), do: %{type: "boolean"}
defp type_to_json({:array, subtype}, _field) do
%{type: "array", items: type_to_json(subtype, nil)}
end
defp type_to_json(:map, %{children: children}) when is_list(children) do
{properties, required} = fields_to_json_schema(children)
result = %{type: "object", properties: properties}
if required == [], do: result, else: Map.put(result, :required, required)
end
defp type_to_json(:map, _field), do: %{type: "object"}
defp type_to_json(module, _field) when is_atom(module) do
if function_exported?(module, :__input_schema__, 0) do
schema = module.__input_schema__()
to_json(schema)
else
%{type: "object"}
end
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, []), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end