Packages
Add an AI brain to your Phoenix app - Auto-expose Ecto schemas as MCP tools
Current section
Files
Jump to
Current section
Files
lib/ectomancer/tool.ex
if Code.ensure_loaded?(Ecto) do
defmodule Ectomancer.Tool do
@moduledoc """
Custom tool DSL for defining MCP tools.
Tools are defined with:
- description: What the tool does
- params: Input parameters (with types and requirements)
- handle: The function that executes the tool logic
The DSL generates a tool module that integrates with Anubis MCP.
Validation is handled internally rather than by Anubis's Peri validator
to avoid JSON encoding issues with Peri's tuple-based schema format.
"""
@doc """
Defines a new tool within an Ectomancer module.
## Example
tool :greet do
description("Greet someone by name")
param(:name, :string, required: true)
handle(fn params, _actor ->
name = params["name"]
{:ok, "Hello, \#{name}!"}
end)
end
## Authorization
Tools can have authorization to control access:
# Inline function
tool :admin_only do
description("Admin only action")
authorize(fn actor, _action -> actor.role == :admin end)
handle(fn _params, _actor ->
{:ok, "Secret data"}
end)
end
# Policy module
tool :with_policy do
description("Uses policy module")
authorize(with: MyApp.Policies.MyPolicy)
handle(fn _params, _actor ->
{:ok, "Protected data"}
end)
end
# No authorization (public)
tool :public do
description("Public endpoint")
authorize(:none)
handle(fn _params, _actor ->
{:ok, "Public data"}
end)
end
"""
defmacro tool(name, do: block) do
tool_name_str = to_string(name)
{description, params, auth_handler, handler_ast} = parse_tool_block(block)
# Build Peri schema for Anubis validation (atom keys)
peri_schema = build_peri_schema(params)
# Build JSON Schema for external clients (string keys)
json_schema = build_json_schema(params)
# Infer action name from tool name for authorization
action = tool_action_from_name(tool_name_str)
quote do
tool_module_name =
Module.concat(__MODULE__, "Tool.#{Macro.camelize(unquote(tool_name_str))}")
Ectomancer.Tool.define_tool_module(
tool_module_name,
unquote(tool_name_str),
unquote(action),
unquote(description),
unquote(Macro.escape(peri_schema)),
unquote(Macro.escape(json_schema)),
unquote(auth_handler),
unquote(handler_ast)
)
require Anubis.Server
Anubis.Server.component(tool_module_name, name: unquote(tool_name_str))
end
end
@doc false
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defmacro define_tool_module(
module_name,
tool_name,
action,
description,
peri_schema,
json_schema_for_clients,
auth_handler,
handler_ast
) do
quote do
defmodule unquote(module_name) do
@moduledoc unquote(description)
@tool_name unquote(tool_name)
@action unquote(action)
# Suppress compiler warnings about dead code when handler only returns {:ok, ...}
@compile {:no_warn_unused, execute: 2}
def name, do: @tool_name
def description, do: @moduledoc
def __mcp_component_type__, do: :tool
def __description__, do: @moduledoc
# Peri schema for Anubis validation (atom keys)
def __mcp_raw_schema__, do: unquote(peri_schema)
# JSON Schema for external clients (string keys, JSON encodable)
def input_schema, do: unquote(json_schema_for_clients)
def execute(params, frame) do
actor = frame.assigns[:ectomancer_actor]
# Check authorization before executing
case check_authorization(actor, @action) do
{:ok, :scoped, scope_fn} ->
with :ok <- check_rate_limit(actor, frame) do
handler = unquote(handler_ast)
do_execute(handler, params, scope_fn, actor, frame)
end
:ok ->
with :ok <- check_rate_limit(actor, frame) do
handler = unquote(handler_ast)
do_execute(handler, params, nil, actor, frame)
end
{:error, reason} ->
error = %Anubis.MCP.Error{
code: -32_001,
message: "Unauthorized: #{reason}",
data: %{}
}
{:error, error, frame}
end
end
defp check_authorization(actor, action) do
auth_handler = unquote(auth_handler)
if auth_handler do
Ectomancer.Authorization.check(actor, action, handler: auth_handler)
else
:ok
end
end
defp check_rate_limit(actor, frame) do
case Application.get_env(:ectomancer, :rate_limit) do
nil ->
:ok
config when is_list(config) ->
max = Keyword.get(config, :max, 100)
window_ms = Keyword.get(config, :window_ms, 60_000)
per_actor = Keyword.get(config, :per_actor, false)
key = if per_actor, do: {:actor, actor}, else: :global
case Ectomancer.RateLimiter.check(max: max, window_ms: window_ms, key: key) do
:ok ->
:ok
{:error, :rate_limited, retry_after} ->
error = %Anubis.MCP.Error{
code: -32_029,
message: "Rate limited. Try again in #{retry_after}ms",
data: %{retry_after_ms: retry_after}
}
{:error, error, frame}
end
end
end
# Helper function to hide handler return type from compiler analysis
# This prevents "clause will never match" warnings when test handlers
# only return {:ok, _} - the compiler can't track types through this function
@dialyzer {:nowarn_function, do_execute: 5}
defp do_execute(handler, params, scope, actor, frame) when is_function(handler, 3) do
result = handler.(params, actor, scope)
case result do
{:ok, data} ->
response = %Anubis.Server.Response{
type: :tool,
content: [%{"type" => "text", "text" => inspect(data)}]
}
{:reply, response, frame}
{:error, reason} ->
{code, message, data} = Ectomancer.Tool.format_error(reason)
error = %Anubis.MCP.Error{code: code, message: message, data: data}
{:error, error, frame}
end
rescue
e ->
error = %Anubis.MCP.Error{
code: -32_603,
message: "Tool execution error: #{Exception.message(e)}",
data: %{
error: inspect(e),
stacktrace: Exception.format_stacktrace(__STACKTRACE__)
}
}
{:error, error, frame}
end
# Backward compatibility: custom tools with arity 2 (params, actor)
@dialyzer {:nowarn_function, do_execute: 4}
defp do_execute(handler, params, _scope, actor, frame) when is_function(handler, 2) do
result = handler.(params, actor)
case result do
{:ok, data} ->
response = %Anubis.Server.Response{
type: :tool,
content: [%{"type" => "text", "text" => inspect(data)}]
}
{:reply, response, frame}
{:error, reason} ->
{code, message, data} = Ectomancer.Tool.format_error(reason)
error = %Anubis.MCP.Error{code: code, message: message, data: data}
{:error, error, frame}
end
rescue
e ->
error = %Anubis.MCP.Error{
code: -32_603,
message: "Tool execution error: #{Exception.message(e)}",
data: %{
error: inspect(e),
stacktrace: Exception.format_stacktrace(__STACKTRACE__)
}
}
{:error, error, frame}
end
end
end
end
# Parse tool block to extract components
defp parse_tool_block(block) do
# Handle the block which is a __block__ containing the tool definitions
# Flatten nested __block__ structures
items =
case block do
{:__block__, _, inner_items} -> flatten_block_items(inner_items)
single -> [single]
end
Enum.reduce(
items,
{"", [], nil, quote(do: fn _, _ -> {:ok, nil} end)},
fn item, {desc, params, auth_handler, handler} ->
case item do
{:description, _, [text]} ->
{text, params, auth_handler, handler}
{:param, _, [name, type | rest]} ->
opts = extract_opts(rest)
{desc, [{name, type, opts} | params], auth_handler, handler}
{:authorize, _, [handler]} ->
auth_handler = parse_authorize_handler(handler)
{desc, params, auth_handler, handler}
{:handle, _, [handler_block]} ->
{desc, params, auth_handler, handler_block}
_ ->
{desc, params, auth_handler, handler}
end
end
)
end
defp parse_authorize_handler(handler) do
case handler do
:none ->
nil
[with: module] ->
module
{:with, _, [module]} ->
module
{:fn, _, _} = fn_ast ->
fn_ast
{:&, _, _} = capture_ast ->
capture_ast
handler when is_function(handler) ->
handler
_ ->
raise ArgumentError,
"Invalid authorization handler. Use: authorize(fn actor, action -> ...), authorize(with: Module), or authorize(:none)"
end
end
# Flatten nested __block__ items
defp flatten_block_items(items) do
Enum.flat_map(items, fn
{:__block__, _, inner} -> flatten_block_items(inner)
other -> [other]
end)
end
# Build Peri schema for Anubis validation (atom keys)
defp build_peri_schema(params) do
Enum.map(params, fn {name, type, opts} ->
full_type =
if opts[:required], do: {:required, type_to_peri(type)}, else: type_to_peri(type)
{name, full_type}
end)
|> Enum.into(%{})
end
# Build JSON Schema for external clients (string keys, JSON encodable)
defp build_json_schema(params) do
properties =
Enum.map(params, fn {name, type, _} ->
{to_string(name), %{"type" => type_to_json(type)}}
end)
|> Enum.into(%{})
required =
Enum.filter(params, fn {_, _, opts} -> opts[:required] end)
|> Enum.map(fn {name, _, _} -> to_string(name) end)
schema = %{"type" => "object", "properties" => properties}
if required != [], do: Map.put(schema, "required", required), else: schema
end
# Map DSL types to Peri types
defp type_to_peri(:string), do: :string
defp type_to_peri(:integer), do: :integer
defp type_to_peri(:float), do: :float
defp type_to_peri(:boolean), do: :boolean
defp type_to_peri(:list), do: {:list, :string}
defp type_to_peri(:map), do: :map
defp type_to_peri(_), do: :string
# Map DSL types to JSON Schema types
defp type_to_json(:string), do: "string"
defp type_to_json(:integer), do: "integer"
defp type_to_json(:float), do: "number"
defp type_to_json(:boolean), do: "boolean"
defp type_to_json(:list), do: "array"
defp type_to_json(:map), do: "object"
defp type_to_json(_), do: "string"
defp extract_opts([]), do: []
defp extract_opts([opts | _]), do: opts
@doc """
Defines authorization for a tool.
## Examples
# Inline function
authorize fn actor, action ->
actor.role == :admin or action in [:list, :get]
end
# Policy module
authorize with: MyApp.Policies.UserPolicy
# No authorization (public)
authorize :none
"""
defmacro authorize(handler) do
quote do
authorize(unquote(handler))
end
end
# Infer action type from tool name for authorization
defp tool_action_from_name(tool_name) do
tool_name
|> String.downcase()
|> case do
name when name in ["list", "index", "all"] -> :list
name when name in ["get", "find", "show"] -> :get
name when name in ["create", "new", "add"] -> :create
name when name in ["update", "edit", "modify"] -> :update
name when name in ["destroy", "delete", "remove"] -> :destroy
_ -> :execute
end
end
@doc """
Formats error reasons into proper MCP error format with descriptive messages.
This function is called from generated tool modules.
"""
@spec format_error(any()) :: {integer(), String.t(), map()}
def format_error(:missing_primary_key) do
{-32_602, "Invalid params: Missing required primary key", %{field: :id}}
end
def format_error(:no_primary_key) do
{-32_602, "Invalid params: Schema has no primary key defined", %{}}
end
def format_error(:repo_not_configured) do
{-32_603, "Internal error: Repository not configured",
%{
help: "Configure :ectomancer, :repo in your config files"
}}
end
def format_error(:not_found) do
{-32_002, "Resource not found", %{}}
end
def format_error(:not_soft_deletable) do
{-32_602, "Invalid params: Schema does not support soft-delete", %{}}
end
def format_error(%Ecto.Changeset{} = changeset) do
errors = map_changeset_errors(changeset)
validation_type = infer_validation_type(errors)
field_errors =
errors
|> Enum.map(fn {field, messages} ->
%{
field: format_field_name(field),
message: Enum.join(messages, ", ")
}
end)
data = %{errors: field_errors, count: length(field_errors)}
message =
case validation_type do
:presence -> "Missing required field(s)"
:format -> "Invalid format for field(s)"
:inclusion -> "Invalid value for field(s)"
:confirmation -> "Confirmation does not match"
:length -> "Invalid length for field(s)"
:comparison -> "Invalid value for field(s)"
_ -> "Validation failed"
end
{-32_602, message, data}
end
def format_error(reason) when is_binary(reason) do
cond do
field_match = extract_null_violation_field(reason) ->
format_null_violation_error(field_match, reason)
not_found_error?(reason) ->
{-32_002, "Resource not found", %{details: reason}}
foreign_key_error?(reason) ->
{-32_602, "Invalid reference: Related record does not exist", %{details: reason}}
unique_constraint_error?(reason) ->
{-32_602, "Duplicate value: Record with this value already exists", %{details: reason}}
not_null_error?(reason) ->
{-32_602, "Missing required value", %{details: reason}}
true ->
{-32_603, "Tool execution failed", %{reason: reason}}
end
end
def format_error(reason) do
{-32_603, "Tool execution failed", %{reason: inspect(reason)}}
end
# Helper functions for format_error/1
defp extract_null_violation_field(reason) when is_binary(reason) do
~r/null value in column "([^"]+)"/i
|> Regex.run(reason)
end
defp format_null_violation_error(field_match, reason) when is_list(field_match) do
field_name = Enum.at(field_match, 1)
formatted_field = format_field_name(field_name)
{-32_602, "Missing required parameter: #{formatted_field}",
%{field: field_name, details: reason}}
end
defp not_found_error?(reason) when is_binary(reason) do
String.contains?(reason, "not found")
end
defp foreign_key_error?(reason) when is_binary(reason) do
String.contains?(reason, "violates foreign key") ||
String.contains?(reason, "foreign_key_violation")
end
defp unique_constraint_error?(reason) when is_binary(reason) do
String.contains?(reason, "duplicate key") ||
String.contains?(reason, "unique_violation") ||
String.contains?(reason, "23505")
end
defp not_null_error?(reason) when is_binary(reason) do
String.contains?(reason, "not_null_violation") ||
String.contains?(reason, "23502")
end
@doc """
Maps Ecto changeset errors to a structured format suitable for MCP responses.
Returns a map where keys are field names (atoms) and values are lists of error strings.
## Examples
changeset = %Ecto.Changeset{
errors: [email: {"can't be blank", []}],
...
}
Ectomancer.Tool.map_changeset_errors(changeset)
# %{email: ["can't be blank"]}
"""
@spec map_changeset_errors(Ecto.Changeset.t()) :: %{atom() => [String.t()]}
def map_changeset_errors(%Ecto.Changeset{} = changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
@doc """
Flattens mapped changeset errors into a single map with concatenated messages.
## Examples
errors = %{email: ["can't be blank"], name: ["is invalid"]}
Ectomancer.Tool.flatten_errors(errors)
# %{email: "can't be blank", name: "is invalid"}
"""
@spec flatten_errors(%{atom() => [String.t()]}) :: %{atom() => String.t()}
def flatten_errors(errors) when is_map(errors) do
errors
|> Enum.map(fn {field, messages} ->
{field, Enum.join(messages, ", ")}
end)
|> Enum.into(%{})
end
@doc """
Formats a field name for display in error messages.
Converts snake_case to Title Case.
"""
@spec format_field_name(atom() | String.t()) :: String.t()
def format_field_name(field) do
field
|> to_string()
|> String.replace("_", " ")
|> String.capitalize()
end
@doc """
Infers the validation type from changeset errors.
Accepts either a map with list values (from traverse_errors) or a map with string values.
"""
@spec infer_validation_type(%{atom() => String.t()} | %{atom() => [String.t()]}) :: atom()
def infer_validation_type(errors) when is_map(errors) do
# Normalize errors to a single string for pattern matching
error_messages =
errors
|> Map.values()
|> List.flatten()
|> Enum.join(" ")
cond do
String.contains?(error_messages, "can't be blank") -> :presence
String.contains?(error_messages, "has invalid format") -> :format
String.contains?(error_messages, "is invalid") -> :inclusion
String.contains?(error_messages, "doesn't match confirmation") -> :confirmation
String.contains?(error_messages, "string too") -> :length
String.contains?(error_messages, "must be") -> :comparison
true -> :other
end
end
end
else
defmodule Ectomancer.Tool do
@moduledoc false
defmacro tool(_name, do: _block) do
quote do
end
end
defmacro define_tool_module(
_module_name,
_tool_name,
_action,
_description,
_peri_schema,
_json_schema,
_auth_handler,
_handler_ast
) do
quote do
end
end
defmacro authorize(_handler) do
quote do
end
end
def format_error(:missing_primary_key) do
{-32_602, "Invalid params: Missing required primary key", %{field: :id}}
end
def format_error(:no_primary_key) do
{-32_602, "Invalid params: Schema has no primary key defined", %{}}
end
def format_error(:repo_not_configured) do
{-32_603, "Internal error: Repository not configured",
%{
help: "Configure :ectomancer, :repo in your config files"
}}
end
def format_error(:not_found) do
{-32_002, "Resource not found", %{}}
end
def format_error(reason) when is_binary(reason) do
{-32_603, "Tool execution failed", %{reason: reason}}
end
def format_error(reason) do
{-32_603, "Tool execution failed", %{reason: inspect(reason)}}
end
def map_changeset_errors(_changeset), do: %{}
def flatten_errors(errors) when is_map(errors), do: errors
def format_field_name(field), do: to_string(field)
def infer_validation_type(_errors), do: :other
end
end