Ectomancer.Tool (Ectomancer v1.7.0)

Copy Markdown View Source

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.

Summary

Functions

Defines authorization for a tool.

Flattens mapped changeset errors into a single map with concatenated messages.

Formats error reasons into proper MCP error format with descriptive messages. This function is called from generated tool modules.

Formats a field name for display in error messages. Converts snake_case to Title Case.

Infers the validation type from changeset errors.

Maps Ecto changeset errors to a structured format suitable for MCP responses.

Normalizes tool-call parameter keys to strings.

Defines a new tool within an Ectomancer module.

Functions

authorize(handler)

(macro)

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

flatten_errors(errors)

@spec flatten_errors(%{required(atom()) => [String.t()]}) :: %{
  required(atom()) => String.t()
}

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"}

format_error(changeset)

@spec format_error(any()) :: {integer(), String.t(), map()}

Formats error reasons into proper MCP error format with descriptive messages. This function is called from generated tool modules.

format_field_name(field)

@spec format_field_name(atom() | String.t()) :: String.t()

Formats a field name for display in error messages. Converts snake_case to Title Case.

infer_validation_type(errors)

@spec infer_validation_type(
  %{required(atom()) => String.t()}
  | %{required(atom()) => [String.t()]}
) ::
  atom()

Infers the validation type from changeset errors.

Accepts either a map with list values (from traverse_errors) or a map with string values.

map_changeset_errors(changeset)

@spec map_changeset_errors(Ecto.Changeset.t()) :: %{required(atom()) => [String.t()]}

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"]}

normalize_param_keys(params)

@spec normalize_param_keys(map() | nil) :: map()

Normalizes tool-call parameter keys to strings.

Anubis validates against its Peri schema and delivers params with atom keys, but handlers (batch records/ids, dynamic include) read string keys. Normalizing once at the execution funnel makes every generated tool behave identically regardless of whether the client sent atom or string keys.

tool(name, list)

(macro)

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