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 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
endHere :input_schema is a plain JSON Schema map, so request.params.arguments
reaches 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 validate_request/2,
which validates request.params.arguments against the schema before call/3
runs. An invalid request is answered with an invalid-parameters error and
call/3 is never reached.
The validated arguments reach 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
defschemacasts them into that struct.
To validate by other means, for example casting with an Ecto embedded schema,
implement 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 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
endThe 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 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 call/3 returns {:stream, state}, the worker stays alive and every process message it then receives is
handed to handle_message/4 with that state. Each handle_message/4 call
returns the same shapes as 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
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 handle_message/4.
Slow tools
A tool that runs slow work inline answers from 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))}
endThe tool is inside 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 handle_message/4, so it acts on the disconnect immediately, and
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, call/3, handle_message/4, and
handle_close/3, also receive the request-scoped 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.
Summary
Callbacks
Returns the cache hint {scope, ttl_ms} for the tool, as {:public | :private, milliseconds}.
Runs the tool call and returns its result.
Runs cleanup when the client disconnects during a streaming call.
Handles a process message delivered to a streaming tool.
Returns the tool metadata for the given key.
Returns the schema describing the tool's accepted arguments.
Returns the schema describing the tool's structured result, or nil.
Validates, and optionally transforms, the request before call/3 runs.
Functions
Returns the cache hint {scope, ttl_ms} for a tool descriptor.
Validates the request and invokes the tool's call/3 callback.
Builds the GenMCP.MCP.V2607.Tool.t/0 entry for a tools/list response.
Normalizes a tool spec into a tool_descriptor/0.
Dispatches a client-close to the tool's optional handle_close/3 callback.
Dispatches a streaming message to the tool's handle_message/4 callback.
Types
@type arg() :: term()
@type call_result() :: {:result, GenMCP.MCP.V2607.CallToolResult.t()} | {:stream, state()} | {:input_required, %{ optional(binary()) => GenMCP.MCP.V2607.CreateMessageRequest.t() | GenMCP.MCP.V2607.ListRootsRequest.t() | GenMCP.MCP.V2607.ElicitRequest.t() }, client_state()} | {:error, String.t()}
@type client_response() :: term()
@type client_state() :: term()
@type info_key() :: :name | :title | :description | :annotations | :_meta
@type request() :: term()
@type schema() :: term()
@type state() :: term()
@type tool() :: module() | {module(), arg()} | tool_descriptor()
Callbacks
@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
@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 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}.")}
endReturn values
{:result, result}answers the call.resultis aGenMCP.MCP.V2607.CallToolResult.t/0, typically fromGenMCP.MCP.V2607.call_tool_result/1. Passerror: messageto 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.stateis carried to the nexthandle_message/4. See the "Streaming tools" section of the module doc.{:error, reason}fails the call with a protocol error.reasonis a message string.{:input_required, requests, client_state}asks the client to satisfy one or more nested requests (sampling, roots, or elicitation) before retrying.requestsis a map of request id to request struct, andclient_stateis node-portable plain data the Suite seals into the opaquerequestStateit returns, then hands back asrequest.params.requestStateon 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")}
endA 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.
@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 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
@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 call/3 (or
a previous 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 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)}
endReturn {: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
@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, ornil.:title- a short display title, ornil.:annotations- atool_annotations/0map of behaviour hints (:readOnlyHint,:destructiveHint, and so on), ornil.:_meta- a free-form metadata map passed through to the client, ornil.
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
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]
}
enduse GenMCP.Suite.Tool generates this callback from the :input_schema
option, and from the same option also generates validate_request/2 that
enforces the schema on each call.
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 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
@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 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 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 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
Functions
@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 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.
@spec call( tool_descriptor(), GenMCP.MCP.V2607.CallToolRequest.t(), GenMCP.Mux.Channel.t() ) :: call_result()
Validates the request and invokes the tool's call/3 callback.
Runs the tool's validate_request/2 first (the generated one, or a custom
one the tool defines); on failure it returns {:error, {:invalid_params, reason}} and call/3 is not called. On success it dispatches to call/3
and returns its result. GenMCP.Suite calls this when handling a tools/call
request.
@spec describe(tool()) :: GenMCP.MCP.V2607.Tool.t()
Builds the GenMCP.MCP.V2607.Tool.t/0 entry for a tools/list response.
Gathers the tool's metadata through info/2 and normalizes its
input_schema/1 and 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.
@spec expand(tool()) :: tool_descriptor()
Normalizes a tool spec into a 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 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.
@spec handle_close(tool_descriptor(), GenMCP.Mux.Channel.t(), state()) :: term()
Dispatches a client-close to the tool's optional 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 handle_close/3. The return value is ignored.
@spec handle_message(tool_descriptor(), term(), GenMCP.Mux.Channel.t(), state()) :: call_result()
Dispatches a streaming message to the tool's 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.