# `GenMCP.Suite.Tool`
[🔗](https://github.com/lud/gen_mcp/blob/main/lib/gen_mcp/suite/tool.ex#L1)

Behaviour for a tool served by a `GenMCP.Suite`.

A tool is an operation a client invokes with a `tools/call` request. You
implement this behaviour in a module, list it in a Suite's `:tools`, and the
Suite advertises the tool in `tools/list` and routes matching calls to it. A
tool declares its name and the schema of its arguments, validates incoming
arguments against that schema, and runs the call.

`use GenMCP.Suite.Tool` generates the metadata and validation callbacks from a
few options, leaving you to implement `c:call/3`. Keep the tool a thin adapter:
push the real work into a plain module with no library concern, so the tool
only validates arguments and shapes the result.

    defmodule MyApp.Calculator do
      def add(a, b) do
        a + b
      end
    end

    defmodule MyApp.AddTool do
      use GenMCP.Suite.Tool,
        name: "add",
        description: "Adds two numbers and returns the sum.",
        input_schema: %{
          type: :object,
          properties: %{
            a: %{type: :number},
            b: %{type: :number}
          },
          required: [:a, :b]
        }

      alias GenMCP.MCP.V2607, as: MCP

      @impl true
      def call(request, _channel, _arg) do
        %{"a" => a, "b" => b} = request.params.arguments
        {:result, MCP.call_tool_result(text: "#{MyApp.Calculator.add(a, b)}")}
      end
    end

Here `:input_schema` is a plain JSON Schema map, so `request.params.arguments`
reaches `c:call/3` as a map with string keys. A schema can also be a `JSV`
module that casts the arguments into a struct; see the "JSV integration"
section.

### Wiring a tool into a server

Tools are served by a `GenMCP.Suite`, which is the default `:server` for
`GenMCP.Transport.StreamableHTTP`. List the tool module in the `:tools` option
and pass the Suite options straight to the transport plug in your router:

    # In your router
    forward "/mcp", GenMCP.Transport.StreamableHTTP,
      server_name: "My App",
      server_version: "1.0.0",
      tools: [MyApp.AddTool]

Pass `{MyApp.AddTool, arg}` instead of the bare module to attach a configuration
term, handed to every callback as its last argument (see "Provider arguments").

### Validating arguments

With an `:input_schema`, `use GenMCP.Suite.Tool` generates `c:validate_request/2`,
which validates `request.params.arguments` against the schema before `c:call/3`
runs. An invalid request is answered with an invalid-parameters error and
`c:call/3` is never reached.

The validated arguments reach `c:call/3` as `request.params.arguments`. Their
form depends on the schema:

  * a plain map schema keeps them as a map with string keys;
  * a schema module built with `defschema` casts them into that struct.

To validate by other means, for example casting with an `Ecto` embedded schema,
implement `c:validate_request/2` yourself. When you do, `use GenMCP.Suite.Tool`
skips generating one, and the `:input_schema` is then used only to describe the
tool in `tools/list`.

### JSV integration

The `:input_schema` and `:output_schema` options each accept either a plain JSON
Schema map (as in the example above) or a `JSV` schema module. A schema module
built with `defschema` also defines a struct, and validation casts the arguments
into it, so `c:call/3` receives a struct instead of a string-keyed map:

    defmodule MyApp.AddTool do
      use GenMCP.Suite.Tool,
        name: "add",
        description: "Adds two numbers and returns the sum.",
        input_schema: Add

      use JSV.Schema

      alias GenMCP.MCP.V2607, as: MCP

      defschema Add,
        a: number(),
        b: number()

      @impl true
      def call(request, _channel, _arg) do
        %Add{a: a, b: b} = request.params.arguments
        {:result, MCP.call_tool_result(text: "#{MyApp.Calculator.add(a, b)}")}
      end
    end

The schema module can be named before it is defined, as above with
`input_schema: Add` written over the `defschema Add`. `use GenMCP.Suite.Tool`
builds the `JSV` root from a `@before_compile` hook, which runs after the whole
module body, so the schema module already exists by the time the root is built.

A schema module may reference other schema modules as subschemas. The schema the
Suite advertises in `tools/list` is self-contained: the referenced definitions
are inlined into it.

#### Build options

The generated `c:validate_request/2` validates against a `JSV` root built at
compile time with `formats: true` and `atoms: true`. Pass `:jsv_build_opts` to
merge options on top of those defaults. Each key you give wins over the default,
so to change `:formats` or `:atoms` set them explicitly:

    use GenMCP.Suite.Tool,
      name: "schedule_meeting",
      input_schema: Input,
      jsv_build_opts: [
        formats: [MyApp.Formats | JSV.default_format_validator_modules()]
      ]

See `JSV.build/2` for the full list of options.

### Streaming tools

A tool does not have to answer in one step. When `c:call/3` returns `{:stream,
state}`, the worker stays alive and every process message it then receives is
handed to `c:handle_message/4` with that `state`. Each `c:handle_message/4` call
returns the same shapes as `c:call/3`: `{:stream, new_state}` to keep waiting,
`{:result, result}` to finish, or `{:error, reason}` to fail. While streaming, a
tool can push interim progress to the client with `GenMCP.Mux.Channel.send_progress/4`
on the `channel` it was given. If the client disconnects first, the optional
`c:handle_close/3` runs so the tool can clean up.

This makes the tool the active streaming handler for the request. Subscribe to
your own event source (a `Phoenix.PubSub` topic, progress messages from a job
queue) before returning `{:stream, state}`, then translate the messages you
receive in `c:handle_message/4`.

### Slow tools

A tool that runs slow work inline answers from `c:call/3` like any other, and
it gets its own worker process for the request, so it can take as long as the
work takes. Open the stream first with
`GenMCP.Mux.Channel.start_stream/1` so the transport keeps writing keepalives
while the work runs, which holds the connection through proxy and client idle
timeouts:

    @impl true
    def call(request, channel, _arg) do
      :ok = Channel.start_stream(channel)
      {:result, MCP.call_tool_result(text: MyApp.Search.run(request))}
    end

The tool is inside `c:call/3` for the whole of the work, and it learns of a
client disconnect once the work returns. When the work is costly enough to be
worth abandoning the moment the client goes away, hand it to a task under your
own `Task.Supervisor` and wait for it as a streaming tool: the worker sits idle
in `c:handle_message/4`, so it acts on the disconnect immediately, and
`c:handle_close/3` can shut the task down.

### Provider arguments

Every tool callback ends with `arg`, the configuration term attached to the
module as `{module, arg}` in the Suite's `:tools` (a bare module is treated as
`{module, []}`). It lets one generic tool module behave differently in different
Suites.

The callbacks that act on a request, `c:call/3`, `c:handle_message/4`, and
`c:handle_close/3`, also receive the request-scoped `t:GenMCP.Mux.Channel.t/0`
as their second-to-last argument. It carries the read-only client `meta` and
authorization assigns, and is how a tool sends progress, logs, and
notifications. See `GenMCP.Suite` for the shared provider conventions.

# `arg`

```elixir
@type arg() :: term()
```

# `call_result`

```elixir
@type call_result() ::
  {:result, GenMCP.MCP.V2607.CallToolResult.t()}
  | {:stream, state()}
  | {:input_required,
     %{
       optional(binary()) =&gt;
         GenMCP.MCP.V2607.CreateMessageRequest.t()
         | GenMCP.MCP.V2607.ListRootsRequest.t()
         | GenMCP.MCP.V2607.ElicitRequest.t()
     }, client_state()}
  | {:error, String.t()}
```

# `client_response`

```elixir
@type client_response() :: term()
```

# `client_state`

```elixir
@type client_state() :: term()
```

# `info_key`

```elixir
@type info_key() :: :name | :title | :description | :annotations | :_meta
```

# `request`

```elixir
@type request() :: term()
```

# `schema`

```elixir
@type schema() :: term()
```

# `state`

```elixir
@type state() :: term()
```

# `tool`

```elixir
@type tool() :: module() | {module(), arg()} | tool_descriptor()
```

# `tool_annotations`

```elixir
@type tool_annotations() :: %{
  optional(:__struct__) =&gt; GenMCP.MCP.V2607.ToolAnnotations,
  optional(:destructiveHint) =&gt; boolean(),
  optional(:idempotentHint) =&gt; boolean(),
  optional(:openWorldHint) =&gt; boolean(),
  optional(:readOnlyHint) =&gt; boolean(),
  optional(:title) =&gt; String.t()
}
```

# `tool_descriptor`

```elixir
@type tool_descriptor() :: %{name: String.t(), mod: module(), arg: arg()}
```

# `cache_control`
*optional* 

```elixir
@callback cache_control(arg()) :: {:public | :private, non_neg_integer()}
```

Returns the cache hint `{scope, ttl_ms}` for the tool, as `{:public | :private,
milliseconds}`.

Optional. When implemented, the value is used as the tool's cache hint;
otherwise the no-cache default from `GenMCP.MCP.V2607.default_cache_control/0`
applies. Use `:public` for results safe to share across clients and `:private`
for per-caller results.

`use GenMCP.Suite.Tool` generates this callback from the `:cache_control`
option, given as the `{scope, ttl_ms}` tuple itself:

    use GenMCP.Suite.Tool,
      name: "list_countries",
      input_schema: %{},
      cache_control: {:public, :timer.minutes(5)}

Implement it by hand instead when the hint is computed from `arg`:

    @impl true
    def cache_control(_arg) do
      {:public, :timer.minutes(5)}
    end

# `call`

```elixir
@callback call(GenMCP.MCP.V2607.CallToolRequest.t(), GenMCP.Mux.Channel.t(), arg()) ::
  call_result()
```

Runs the tool call and returns its result.

This is the one callback every tool implements. It receives the `request` (with
arguments already validated by `c:validate_request/2`), the request-scoped
`channel`, and the configured `arg`. Build the result with the
`GenMCP.MCP.V2607` helpers, most often `GenMCP.MCP.V2607.call_tool_result/1`.

Note the return tuples carry no channel: the channel is for sending interim
output during the call, not for handing back.

    @impl true
    def call(request, _channel, _arg) do
      %{"city" => city} = request.params.arguments
      {:result, MCP.call_tool_result(text: "It is sunny in #{city}.")}
    end

### Return values

  * `{:result, result}` answers the call. `result` is a
    `t:GenMCP.MCP.V2607.CallToolResult.t/0`, typically from
    `GenMCP.MCP.V2607.call_tool_result/1`. Pass `error: message` to that helper
    to return a tool-level error the model can read, as opposed to a protocol
    error.
  * `{:stream, state}` keeps the worker alive as the request's streaming
    handler. `state` is carried to the next `c:handle_message/4`. See the
    "Streaming tools" section of the module doc.
  * `{:error, reason}` fails the call with a protocol error. `reason` is a
    message string.
  * `{:input_required, requests, client_state}` asks the client to satisfy one
    or more nested requests (sampling, roots, or elicitation) before retrying.
    `requests` is a map of request id to request struct, and `client_state` is
    node-portable plain data the Suite seals into the opaque `requestState` it
    returns, then hands back as `request.params.requestState` on the retry.

Returning a tool-level error from a successful call, rather than a protocol
error, lets the client see the message:

    def call(_request, _channel, _arg) do
      {:result, MCP.call_tool_result(error: "Upstream service unavailable")}
    end

A tool that takes a long time before it can answer calls
`GenMCP.Mux.Channel.start_stream/1` first, so the transport keeps the
connection alive while it works. See the "Slow tools" section of the module
doc.

# `handle_close`
*optional* 

```elixir
@callback handle_close(GenMCP.Mux.Channel.t(), state(), arg()) :: term()
```

Runs cleanup when the client disconnects during a streaming call.

Optional. Invoked when the client closes the connection while this tool is the
active streaming handler (after `c:call/3` returned `{:stream, state}`). The
`channel` is already closed, so nothing more can be sent; `state` is the latest
streaming state and `arg` the configured argument. The return value is ignored
and the worker stops afterward, so use it only for side-effecting cleanup such
as unsubscribing from your event source.

    @impl true
    def handle_close(_channel, _state, _arg) do
      :ok
    end

# `handle_message`
*optional* 

```elixir
@callback handle_message(term(), GenMCP.Mux.Channel.t(), state(), arg()) :: call_result()
```

Handles a process message delivered to a streaming tool.

Invoked once for every message the worker process receives after `c:call/3` (or
a previous `c:handle_message/4`) returned `{:stream, state}`. The `message` is
whatever was sent to the worker, `channel` is the request-scoped channel,
`state` is the term carried from the previous return, and `arg` is the
configured argument. Returns the same shapes as `c:call/3`.

Match on the messages your tool subscribed to. The example below forwards a job
result from the application's own queue and finishes the call:

    @impl true
    def handle_message({:job_finished, result}, _channel, _state, _arg) do
      {:result, MCP.call_tool_result(text: result)}
    end

Return `{:stream, new_state}` instead to keep waiting, accumulating progress in
`new_state`, and emit interim updates with `GenMCP.Mux.Channel.send_progress/4`:

    def handle_message({:chunk, data}, channel, acc, _arg) do
      GenMCP.Mux.Channel.send_progress(channel, length(acc) + 1, nil, "received chunk")
      {:stream, [data | acc]}
    end

    def handle_message(:done, _channel, acc, _arg) do
      {:result, MCP.call_tool_result(text: Enum.join(Enum.reverse(acc)))}
    end

# `info`

```elixir
@callback info(:name, arg()) :: String.t()
@callback info(:description, arg()) :: nil | String.t()
@callback info(:title, arg()) :: nil | String.t()
@callback info(:annotations, arg()) :: nil | tool_annotations()
@callback info(:_meta, arg()) :: nil | map()
```

Returns the tool metadata for the given key.

The Suite calls this once per metadata field to build the `tools/list` entry.
`:name` must return a non-blank string; the other keys may return `nil` when the
tool does not set them. The recognized keys are:

  * `:name` - the tool's name, used by clients to call it. Required.
  * `:description` - a human-readable description, or `nil`.
  * `:title` - a short display title, or `nil`.
  * `:annotations` - a `t:tool_annotations/0` map of behaviour hints
    (`:readOnlyHint`, `:destructiveHint`, and so on), or `nil`.
  * `:_meta` - a free-form metadata map passed through to the client, or `nil`.

`use GenMCP.Suite.Tool` generates this callback from the `:name`,
`:description`, `:title`, `:annotations`, and `:_meta` options, with a catch-all
clause returning `nil`. Implement it by hand only when the metadata is computed
rather than static:

    def info(:name, _arg), do: "search_files"
    def info(:description, _arg), do: "Searches for files matching a glob."
    def info(:annotations, _arg), do: %{readOnlyHint: true}
    def info(_key, _arg), do: nil

# `input_schema`

```elixir
@callback input_schema(arg()) :: schema()
```

Returns the schema describing the tool's accepted arguments.

The Suite normalizes this to JSON Schema and sends it as the tool's
`inputSchema` in `tools/list`, so clients know what arguments to provide. The
return may be a plain schema map or a `defschema` module:

    def input_schema(_arg) do
      %{
        type: :object,
        properties: %{query: %{type: :string}},
        required: [:query]
      }
    end

`use GenMCP.Suite.Tool` generates this callback from the `:input_schema`
option, and from the same option also generates `c:validate_request/2` that
enforces the schema on each call.

# `output_schema`
*optional* 

```elixir
@callback output_schema(arg()) :: nil | schema()
```

Returns the schema describing the tool's structured result, or `nil`.

Optional. When given, the Suite normalizes it to JSON Schema and advertises it
as the tool's `outputSchema` in `tools/list`, so clients know the shape of the
`structuredContent` to expect. It is purely descriptive and is not enforced
against the result at runtime.

`use GenMCP.Suite.Tool` generates this callback from the `:output_schema`
option. Like `c:input_schema/1`, the return may be a plain schema map or a
`defschema` module:

    def output_schema(_arg) do
      %{
        type: :object,
        properties: %{files: %{type: :array, items: %{type: :string}}}
      }
    end

# `validate_request`
*optional* 

```elixir
@callback validate_request(GenMCP.MCP.V2607.CallToolRequest.t(), arg()) ::
  {:ok, GenMCP.MCP.V2607.CallToolRequest.t()}
  | {:error, String.t() | Exception.t()}
```

Validates, and optionally transforms, the request before `c:call/3` runs.

Returns `{:ok, request}` to proceed (with a possibly rewritten request), or
`{:error, reason}` to reject the call with an invalid-parameters error, in which
case `c:call/3` is never invoked. The `reason` may be a message string, a
`JSV.ValidationError`, or any exception.

`use GenMCP.Suite.Tool` generates this callback from the `:input_schema` option:
it validates `request.params.arguments` against the schema and, on success,
replaces them with the validated value (a struct when the schema is a
`defschema` module). Defining `c:validate_request/2` yourself stops it from
generating one, which is how you validate by other means:

    def validate_request(request, _arg) do
      case request.params.arguments do
        %{"limit" => n} when n in 1..100 -> {:ok, request}
        _ -> {:error, "limit must be between 1 and 100"}
      end
    end

# `cache_control`

```elixir
@spec cache_control(tool_descriptor()) :: {:public | :private, non_neg_integer()}
```

Returns the cache hint `{scope, ttl_ms}` for a tool descriptor.

Delegates to the tool's optional `c:cache_control/1` callback, falling back to
`GenMCP.MCP.V2607.default_cache_control/0` when the tool does not implement it.
`GenMCP.Suite` uses the hint when caching the tool's response.

# `call`

```elixir
@spec call(
  tool_descriptor(),
  GenMCP.MCP.V2607.CallToolRequest.t(),
  GenMCP.Mux.Channel.t()
) ::
  call_result()
```

Validates the request and invokes the tool's `c:call/3` callback.

Runs the tool's `c:validate_request/2` first (the generated one, or a custom
one the tool defines); on failure it returns `{:error, {:invalid_params,
reason}}` and `c:call/3` is not called. On success it dispatches to `c:call/3`
and returns its result. `GenMCP.Suite` calls this when handling a `tools/call`
request.

# `describe`

```elixir
@spec describe(tool()) :: GenMCP.MCP.V2607.Tool.t()
```

Builds the `t:GenMCP.MCP.V2607.Tool.t/0` entry for a `tools/list` response.

Gathers the tool's metadata through `c:info/2` and normalizes its
`c:input_schema/1` and `c:output_schema/1` to JSON Schema. Accepts any tool spec
form (it runs `expand/1` first). `GenMCP.Suite` calls this for every tool when
answering `tools/list`.

# `expand`

```elixir
@spec expand(tool()) :: tool_descriptor()
```

Normalizes a tool spec into a `t:tool_descriptor/0`.

Accepts the three forms a Suite's `:tools` entry may take: a bare `module`, a
`{module, arg}` tuple, or an already-built descriptor. It loads the module,
reads its name via `c:info/2`, and returns `%{name: name, mod: module, arg:
arg}`, raising `ArgumentError` when the tool does not define a non-blank name.
`GenMCP.Suite` calls this to resolve every configured tool.

# `handle_close`

```elixir
@spec handle_close(tool_descriptor(), GenMCP.Mux.Channel.t(), state()) :: term()
```

Dispatches a client-close to the tool's optional `c:handle_close/3` callback.

Invoked by `GenMCP.Suite` when the client disconnects while this tool is the
active streaming handler. A no-op returning `:ok` when the tool does not
implement `c:handle_close/3`. The return value is ignored.

# `handle_message`

```elixir
@spec handle_message(tool_descriptor(), term(), GenMCP.Mux.Channel.t(), state()) ::
  call_result()
```

Dispatches a streaming message to the tool's `c:handle_message/4` callback.

Invoked by `GenMCP.Suite` for each process message the worker receives while
this tool is the active streaming handler. The `state` is the term the tool
last returned in `{:stream, state}`. Returns the callback's result.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
