GenMCP.Suite.Tool behaviour (gen_mcp v0.10.0)

Copy Markdown View Source

Defines the behaviour for implementing MCP tools in GenMCP.Suite.

Tools are the primary mechanism for clients to execute operations on the server. Each tool implementation provides metadata, input validation, execution logic, and optional asynchronous continuation handling.

Implementation Patterns

Use use GenMCP.Suite.Tool with options to auto-generate common callbacks:

use GenMCP.Suite.Tool,
  name: "search_files",
  description: "Searches for files matching a pattern",
  input_schema: %{
    type: :object,
    properties: %{query: %{type: :string}},
    required: [:query]
  }

Auto-generates info/2, input_schema/1, and validate_request/2 with JSON schema validation.

JSV Build Options

The auto-generated validate_request/2 validates incoming arguments against the :input_schema using a JSV root built at compile time. By default, the root is built with formats: true and atoms: true.

The :jsv_build_opts option is merged on top of those defaults and the result is passed to JSV.build/2. Any key given in :jsv_build_opts wins over the default, so to change :formats or :atoms you must set them explicitly:

use GenMCP.Suite.Tool,
  name: "MyTool",
  input_schema: Input,
  jsv_build_opts: [
    formats: [MyApp.MyFormats | JSV.default_format_validator_modules()],
    atoms: false
  ]

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

Synchronous Tool Example

defmodule MySearchTool do
  use GenMCP.Suite.Tool,
    name: "search_files",
    input_schema: %{
      type: :object,
      properties: %{
        query: %{type: :string}
      }
    }

  alias GenMCP.MCP

  @impl true
  def call(req, channel, _arg) do
    # Arguments are string keys unless using a JSV module based schema or a
    # custom validate_request function.
    %{"query" => query} = req.params.arguments

    text = generate_text_response(query)
    {:result, MCP.call_tool_result(text: text), channel}
  end
end

Asynchronous Tool Example

defmodule MyAsyncTool do
  use GenMCP.Suite.Tool,
    name: "expensive_search",
    input_schema: %{}

  alias GenMCP.MCP

  @impl true
  def call(req, channel, _arg) do
    task = Task.async(fn -> perform_expensive_search(req) end)
    {:async, {:search_task, task}, channel}
  end

  @impl true
  def continue({:search_task, {:ok, document}}, channel, _arg) do
    {:result, MCP.call_tool_result(text: document), channel}
  end
end

Summary

Callbacks

Executes the tool call and returns a result, error, or async continuation.

Continues processing after async work completes.

Returns tool metadata for the specified key.

Returns the JSON schema defining accepted tool arguments.

Returns the JSON schema defining tool result structure, or nil if unspecified.

Validates and optionally transforms the incoming call request.

Functions

Invokes the tool's call/3 callback with request validation.

Dispatches continuation logic to the tool's continue/3 callback.

Builds an MCP tool description suitable for tools/list responses.

Returns a descriptor for the given module or {module, arg} tuple.

Types

arg()

@type arg() :: term()

call_result()

@type call_result() ::
  {:result, GenMCP.MCP.CallToolResult.t(), GenMCP.Mux.Channel.t()}
  | {:async, {tag(), reference() | Task.t()}, GenMCP.Mux.Channel.t()}
  | {:error, String.t(), GenMCP.Mux.Channel.t()}

client_response()

@type client_response() :: term()

This is not supported yet

info_key()

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

request()

@type request() :: term()

schema()

@type schema() :: term()

tag()

@type tag() :: term()

tool()

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

tool_annotations()

@type tool_annotations() :: %{
  optional(:__struct__) => GenMCP.MCP.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

call(t, t, arg)

Executes the tool call and returns a result, error, or async continuation.

Receives validated request parameters if validate_request/2 is defined. When using use GenMCP.Suite.Tool, JSON schema validation is automatically implemented, ensuring req.params.arguments conforms to the schema.

Result payload

Synchronous results are returned as {:result, %MCP.CallToolResult{}, channel}. The second element must be a %MCP.CallToolResult{}, typically built with GenMCP.MCP.call_tool_result/1.

Async calls

When returning {:async, {tag, ref}, channel}, the continue/3 callback will be invoked with {tag, {:ok, value}} if the server process receives a {ref, value} message with the same ref.

This works automatically with tasks. It is possible to directly return the task struct as in {:async, {tag, task}, channel}.

Note that Task.async/1 may crash the server process, you may want to use Task.Supervisor.async_nolink/2 in which case a :DOWN message from the task will be delivered as with the same tag as {tag, {:error, reason}}.

This should also work with manually monitored processes, given the monitored process obtains the ref to send the {ref, result} value back to the calling process.

Channel and Assigns

The channel provides access to assigns copied from the Plug.Conn struct (from the HTTP request that delivered the tool call request) and can be modified via GenMCP.Mux.Channel.assign/3 to keep state before entering the continue/3 callback.

Assigning modifies the channel, so the last updated channel must always be returned from your callback.

Examples

Synchronous execution:

def call(req, channel, _arg) do
  %{"query" => query} = req.params.arguments
  entity = perform_search(query)

  # With structured output (entity is a map), mind the list wrapper
  {:result, MCP.call_tool_result([entity]), channel}

  # Without structured output
  {:result, MCP.call_tool_result(text: Jason.encode!(entity)), channel}
end

Asynchronous with Task:

def call(req, channel, _arg) do
  task = Task.async(fn -> expensive_operation(req) end)
  {:async, {:search_task, task}, channel}
end

Error handling:

def call(_req, channel, _arg) do
  {:error, "Resource not available", channel}
end

continue(tuple, t, arg)

(optional)
@callback continue(
  {tag(), {:ok, term()} | {:error, term()}},
  GenMCP.Mux.Channel.t(),
  arg()
) ::
  call_result()

Continues processing after async work completes.

Invoked when call/3 returns {:async, {tag, ref_or_task}, channel} and the task finishes.

The first tuple element contains the tag and the wrapped task result (either {:ok, result} for success or {:error, reason} for failures with non-linked tasks). Returns same result types as call/3. Can chain another async operation by returning {:async, {new_tag, new_ref}, channel}.

The tag is generally an atom to be matched on if you implement multiple conitinuation callbacks but it can be any term.

Examples

Basic continuation:

def continue({:search_task, {:ok, results}}, channel, _arg) do
  {:result, MCP.call_tool_result(text: Jason.encode!(results)), channel}
end

Error handling:

def continue({:search_task, {:error, reason}}, channel, _arg) do
  {:error, "Search failed: #{reason}", channel}
end

Chaining async operations:

def continue({:step1, {:ok, intermediate}}, channel, _arg) do
  task = Task.async(fn -> step2(intermediate) end)
  {:async, {:step2, task}, channel}
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 tool metadata for the specified key.

Invoked by describe/1 to gather tool metadata.

With use GenMCP.Suite.Tool with metadata options, this callback is auto-generated.

Examples

def info(:name, _arg), do: "search_files"
def info(:description, _arg), do: "Searches for files matching a pattern"
def info(:_meta, _arg), do: %{"ui/resourceUri" => "ui://pages/some-page"}
def info(:annotations, _arg), do: %{readOnlyHint: true}
def info(_, _), do: nil

input_schema(arg)

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

Returns the JSON schema defining accepted tool arguments.

Sent to clients in tools/list responses to describe expected parameters.

Auto-generated with use GenMCP.Suite.Tool with an :input_schema option.

Examples

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

output_schema(arg)

(optional)
@callback output_schema(arg()) :: nil | schema()

Returns the JSON schema defining tool result structure, or nil if unspecified.

Defines the structured outputs returned by the tool if any. Entirely optional and not enforced at runtime.

Auto-generated with use GenMCP.Suite.Tool with an :output_schema option.

Examples

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.CallToolRequest.t(), arg()) ::
  {:ok, GenMCP.MCP.CallToolRequest.t()} | {:error, String.t()}

Validates and optionally transforms the incoming call request.

Invoked before call/3.

Auto-generated with JSON schema validation when using use GenMCP.Suite.Tool with an :input_schema option.

Examples

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

Functions

call(tool, req, channel)

Invokes the tool's call/3 callback with request validation.

Performs optional validation via validate_request/2 before dispatching to the tool implementation. Returns {:error, {:invalid_params, reason}, channel} if validation fails. Called by GenMCP.Suite when handling CallToolRequest.

Examples

req = %GenMCP.MCP.CallToolRequest{
  params: %{name: "search_files", arguments: %{"query" => "*.ex"}}
}
Tool.call(tool_descriptor, req, channel)
#=> {:result, %GenMCP.MCP.CallToolResult{...}, channel}

# With validation error
Tool.call(tool_descriptor, invalid_req, channel)
#=> {:error, {:invalid_params, "limit must be positive"}, channel}

continue(tool, cont, channel)

@spec continue(
  tool_descriptor(),
  {tag(), client_response() | term()},
  GenMCP.Mux.Channel.t()
) ::
  call_result()

Dispatches continuation logic to the tool's continue/3 callback.

Invoked by GenMCP.Suite when an async task completes. The continuation tuple contains the tag from the original {:async, {tag, ref}, channel} return and the wrapped task result. Task results are wrapped as {:ok, result} for normal completion or {:error, reason} for failures.

Examples

Tool.continue(tool_descriptor, {:search_task, {:ok, results}}, channel)
#=> {:result, %GenMCP.MCP.CallToolResult{...}, channel}

Tool.continue(tool_descriptor, {:search_task, {:error, :timeout}}, channel)
#=> {:error, "Search timed out", channel}

describe(tool)

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

Builds an MCP tool description suitable for tools/list responses.

Gathers metadata via info/2 callbacks and normalizes input/output schemas to JSON schema format. Invoked by GenMCP.Suite when handling ListToolsRequest.

Examples

iex> Tool.describe(MySearchTool)
%GenMCP.MCP.Tool{
  name: "search_files",
  description: "Searches for files matching a pattern",
  inputSchema: %{"type" => "object", "properties" => ...},
  annotations: %{readOnlyHint: true}
}

iex> Tool.describe({MySearchTool, [repo_path: "/data"]})
%GenMCP.MCP.Tool{name: "search_files", ...}

expand(tool)

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

Returns a descriptor for the given module or {module, arg} tuple.