# Tools & Schemas
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.
```elixir
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_hint` → `readOnlyHint`; also `destructive_hint`,
`idempotent_hint`, `open_world_hint`, `title`).
Per-registration overrides let you expose one module under several names:
```elixir
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](toolkits_and_discovery.md) guide.
> #### Many small tools? {: .tip}
>
> 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](toolkits_and_discovery.md) guide.
## The field DSL
| Type | Options | JSON Schema |
|------|---------|-------------|
| `:string` | `min_length`, `max_length`, `pattern`, `format` | `"string"` + constraints |
| `:integer` / `:number` | `min`, `max` | `"integer"`/`"number"` + `minimum`/`maximum` |
| `:boolean` | — | `"boolean"` |
| `:enum` | `values: [:a, :b]` (required) | `"string"` + `"enum"` |
| `:object` | `do` block of nested fields | nested object schema |
| `{:array, inner}` | `min`/`max` → `minItems`/`maxItems` | `"array"` + `"items"` |
Every field also accepts `required: true`, `description: "..."`, and
`default: value`. Nested objects and arrays of objects take a `do` block:
```elixir
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](https://hex.pm/packages/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:
```elixir
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:
```elixir
input_schema """
{"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
"""
```
## Return contract
`call/2` may return:
| Return | Result |
|--------|--------|
| `{: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 / exit | sanitized `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](https://modelcontextprotocol.io) (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:
```elixir
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](toolkits_and_discovery.md).