GenMCP.Suite.Tool behaviour (gen_mcp v2.0.0)

Copy Markdown View Source

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
end

Here :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 defschema casts 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
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 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))}
end

The 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

arg()

@type arg() :: term()

call_result()

@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()}

client_response()

@type client_response() :: term()

client_state()

@type client_state() :: term()

info_key()

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

request()

@type request() :: term()

schema()

@type schema() :: term()

state()

@type state() :: term()

tool()

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

tool_annotations()

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

tool_descriptor()

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

Callbacks

cache_control(arg)

(optional)
@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(t, t, arg)

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}.")}
end

Return values

  • {:result, result} answers the call. result is a 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 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(t, state, arg)

(optional)
@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

handle_message(term, t, state, arg)

(optional)
@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)}
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(atom, arg)

@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 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(arg)

@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 validate_request/2 that enforces the schema on each call.

output_schema(arg)

(optional)
@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 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(t, arg)

(optional)
@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

cache_control(tool)

@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.

call(tool, req, channel)

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.

describe(tool)

@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.

expand(tool)

@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.

handle_close(tool, channel, state)

@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.

handle_message(tool, message, channel, state)

@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.