A tool is a module using Noizu.MCP.Server.Tool, registered on the server with tool MyModule. The use options describe the tool; the input/output blocks define schemas; call/2 does the work.

defmodule MyApp.Tools.Search do
  use Noizu.MCP.Server.Tool,
    name: "search_docs",                       # defaults to the module-derived snake_case name
    description: "Full-text search over project documentation",
    annotations: [read_only_hint: true, idempotent_hint: true]

  input do
    field :query, :string, required: true, min_length: 2,
      description: "Search terms"
    field :limit, :integer, min: 1, max: 50, default: 10
    field :scope, :enum, values: [:all, :guides, :api], default: :all
  end

  @impl true
  def call(%{query: query, limit: limit, scope: scope}, _ctx) do
    {:ok, "#{length(run_search(query, limit, scope))} hits"}
  end
end

Annotations are written snake_case and emitted camelCase on the wire (read_only_hintreadOnlyHint; also destructive_hint, idempotent_hint, open_world_hint, title).

Per-registration overrides let you expose one module under several names:

tool MyApp.Tools.Search
tool MyApp.Tools.Search, name: "search", description: "Alias for search_docs"

Two more use options round out the metadata: category: "Docs" attaches a grouping label that rides on the wire in _meta.category, and hidden: true omits the tool from tools/list while leaving it callable by name. Both also work as registration-level overrides — see the Toolkits, Categories & Hidden Tools guide.

Many small tools?

One module per tool is ceremony for a bundle of one-liners. Noizu.MCP.Server.Toolkit defines several tools in one module via @mcp function annotations, with schemas as plain data — see the Toolkits, Categories & Hidden Tools guide.

The field DSL

TypeOptionsJSON Schema
:stringmin_length, max_length, pattern, format"string" + constraints
:integer / :numbermin, max"integer"/"number" + minimum/maximum
:boolean"boolean"
:enumvalues: [:a, :b] (required)"string" + "enum"
:objectdo block of nested fieldsnested object schema
{:array, inner}min/maxminItems/maxItems"array" + "items"

Every field also accepts required: true, description: "...", and default: value. Nested objects and arrays of objects take a do block:

input do
  field :filters, :object do
    field :tags, {:array, :string}, max: 16
    field :authors, {:array, :object} do
      field :name, :string, required: true
    end
  end
end

The schema is compiled at compile time to JSON Schema 2020-12 and validated on every call with JSV. Your handler receives arguments that are:

  • atom-keyed — only field names you declared are atomized (safe),
  • default-applied — absent optional fields get their default,
  • enum-cast"loud" arrives as :loud.

Raw schema escape hatch

When the DSL can't express your schema (e.g. oneOf, dynamic shapes), pass JSON Schema directly. Raw-schema tools receive string-keyed arguments, validated but otherwise untouched:

use Noizu.MCP.Server.Tool, name: "raw", description: "..."

input_schema %{
  "type" => "object",
  "properties" => %{"query" => %{"type" => "string", "minLength" => 2}},
  "required" => ["query"]
}

@impl true
def call(%{"query" => query}, _ctx), do: {:ok, "found: #{query}"}

output_schema %{...} is the equivalent for structured output.

Both macros also accept the schema as raw JSON text, decoded at compile time (malformed JSON is a compile error) — handy when pasting a schema block from the spec or another tool's definition:

input_schema """
{"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
"""

Return contract

call/2 may return:

ReturnResult
{:ok, binary}one text content block
{:ok, map}structuredContent (validated against output) + JSON text block
{:ok, [%Content{}]} or {:ok, %Content{}}the given content blocks
{:ok, %Noizu.MCP.Types.ToolResult{}}passed through verbatim
{:error, binary}execution error: isError: true text result
{:error, %Noizu.MCP.Error{}}JSON-RPC protocol error
raise / exitsanitized isError: true result (details go to Logger)

Build richer content with Noizu.MCP.Types.Content (:text, :image, :audio, :resource_link, embedded :resource) and Noizu.MCP.Types.ToolResult.ok/structured/error.

Validation failures are results, not errors

Per SEP-1303 (2025-11-25), arguments that fail schema validation produce an isError: true tool result describing the violation — visible to the model so it can self-correct — rather than a -32602 protocol error. Calling a tool that doesn't exist is still -32602.

Dynamic tools (no DSL)

The macros compile down to two callbacks you can write by hand — useful when the tool list is computed at runtime:

defmodule MyApp.DynamicMCP do
  use Noizu.MCP.Server, name: "dyn", version: "1.0.0"

  @impl true
  def handle_list_tools(_cursor, ctx) do
    tools =
      for plugin <- MyApp.Plugins.for_tenant(ctx.assigns.tenant) do
        %Noizu.MCP.Types.Tool{
          name: plugin.slug,
          description: plugin.description,
          input_schema: plugin.json_schema
        }
      end

    {:ok, tools, nil}
  end

  @impl true
  def handle_call_tool(name, args, ctx),
    do: MyApp.Plugins.dispatch(name, args, ctx)
end

Hand-written handle_call_tool/3 receives raw string-keyed arguments — no validation is applied unless you do it yourself (Noizu.MCP.Schema exposes the same JSV plumbing the DSL uses). See examples/no_dsl_server for a complete behaviour-only server.

A middle ground: keep the DSL registrations and hand-write only the list callback over the registry helpers (Noizu.MCP.Server.Features.Tools) — e.g. for session-gated visibility. That pattern, along with multi-tool modules and discovery, lives in Toolkits, Categories & Hidden Tools.