Custom Tools

View Source

Build custom tools to extend Claude's capabilities with your own Elixir functionality.

Official Documentation: This guide is based on the official Claude Agent SDK documentation. Examples are adapted for Elixir.

Custom tools allow you to extend Claude Code's capabilities through MCP (Model Context Protocol) servers defined in your application code. The Elixir SDK supports two approaches:

  1. In-process tools -- Define tools with ClaudeCode.MCP.Server that run in your BEAM VM, with full access to application state (Ecto repos, GenServers, caches)
  2. Hermes MCP servers -- Define tools as Hermes MCP components that run as a separate subprocess

For connecting to external MCP servers, configuring permissions, and authentication, see the MCP guide.

Prerequisites

The hermes_mcp dependency is optional. Add it to your mix.exs to enable custom tool integration:

defp deps do
  [
    {:claude_code, "~> 0.20"},
    {:hermes_mcp, "~> 0.14"}  # Required for custom tool integration
  ]
end

Then run mix deps.get.

You can check availability at runtime with ClaudeCode.MCP.available?/0.

Creating custom tools

In-process tools

Use ClaudeCode.MCP.Server to define tools that run in the same BEAM process as your application. This is the recommended approach when your tools need access to application state:

defmodule MyApp.Tools do
  use ClaudeCode.MCP.Server, name: "my-tools"

  tool :get_weather, "Get current temperature for a location using coordinates" do
    field :latitude, :float, required: true
    field :longitude, :float, required: true

    def execute(%{latitude: lat, longitude: lon}) do
      url = "https://api.open-meteo.com/v1/forecast?latitude=#{lat}&longitude=#{lon}&current=temperature_2m&temperature_unit=fahrenheit"

      case Req.get(url) do
        {:ok, %{body: %{"current" => %{"temperature_2m" => temp}}}} ->
          {:ok, "Temperature: #{temp}F"}

        {:error, reason} ->
          {:error, "Failed to fetch weather: #{inspect(reason)}"}
      end
    end
  end
end

How it works

The tool macro generates Hermes MCP Server.Component modules under the hood. Each tool block becomes a nested module (e.g., MyApp.Tools.GetWeather) with a schema, JSON Schema definition, and an execute/2 Hermes callback -- all derived from the field declarations and your execute function. You write execute/1 (params only) and the macro wraps it into the full Hermes callback automatically. Write execute/2 if you need access to session-specific context via the Hermes frame (see Passing session context with assigns).

When passed to a session via :mcp_servers, the SDK detects in-process tool servers and emits type: "sdk" in the MCP configuration. The CLI routes JSONRPC messages through the control protocol instead of spawning a subprocess, and the SDK dispatches them to your tool modules via ClaudeCode.MCP.Router.

Schema definition

Use the Hermes field DSL inside each tool block. Hermes handles conversion to JSON Schema automatically:

tool :search, "Search for items" do
  field :query, :string, required: true
  field :limit, :integer, default: 10
  field :category, :string

  def execute(%{query: query} = params) do
    limit = Map.get(params, :limit, 10)
    {:ok, "Results for #{query} (limit: #{limit})"}
  end
end

Return values

Tool handlers return simple values. The macro wraps them into the MCP response format automatically:

Handler returnsBehavior
{:ok, text} when text is a binaryReturned as text content
{:ok, data} when data is a map or listReturned as JSON content
{:error, message}Returned as error content (is_error: true)

Accessing application state

In-process tools can call into your application directly -- Ecto repos, GenServers, caches, and any other running processes. This is the primary advantage over subprocess-based tools:

defmodule MyApp.Tools do
  use ClaudeCode.MCP.Server, name: "app-tools"

  tool :query_user, "Look up a user by email" do
    field :email, :string, required: true

    def execute(%{email: email}) do
      case MyApp.Repo.get_by(MyApp.User, email: email) do
        nil -> {:error, "User not found"}
        user -> {:ok, "#{user.name} (#{user.email})"}
      end
    end
  end

  tool :cache_stats, "Get cache statistics" do
    def execute(_params) do
      stats = MyApp.Cache.stats()
      {:ok, stats}
    end
  end
end

Passing session context with assigns

When tools need per-session context (e.g., the current user's scope in a LiveView), pass :assigns in the server configuration. Assigns are set on the Hermes frame and available via execute/2:

# LiveView mount -- pass current_scope into the tool's assigns
def mount(_params, _session, socket) do
  scope = socket.assigns.current_scope

  {:ok, session} = ClaudeCode.start_link(
    mcp_servers: %{
      "my-tools" => %{module: MyApp.Tools, assigns: %{scope: scope}}
    },
    allowed_tools: ["mcp__my-tools__*"]
  )

  {:ok, assign(socket, claude_session: session)}
end
defmodule MyApp.Tools do
  use ClaudeCode.MCP.Server, name: "my-tools"

  tool :my_projects, "List the current user's projects" do
    def execute(_params, frame) do
      scope = frame.assigns.scope
      projects = MyApp.Projects.list_projects(scope)
      {:ok, projects}
    end
  end

  tool :search_docs, "Search documentation" do
    field :query, :string, required: true

    def execute(%{query: query}) do
      # Tools that don't need session context can still use execute/1
      results = MyApp.Docs.search(query)
      {:ok, results}
    end
  end
end

Tools that don't need session context continue to use execute/1. Mix both forms freely in the same server module.

Hermes MCP servers

For tools that don't need application state access, or when you want a full Hermes MCP server with resources and prompts, define tools as Hermes server components. Each tool is a module that uses Hermes.Server.Component with a schema block and an execute/2 callback:

defmodule MyApp.WeatherTool do
  @moduledoc "Get current temperature for a location using coordinates"
  use Hermes.Server.Component, type: :tool

  alias Hermes.MCP.Error
  alias Hermes.Server.Response

  schema do
    field :latitude, :float, required: true, description: "Latitude coordinate"
    field :longitude, :float, required: true, description: "Longitude coordinate"
  end

  @impl true
  def execute(%{latitude: lat, longitude: lon}, frame) do
    url = "https://api.open-meteo.com/v1/forecast?latitude=#{lat}&longitude=#{lon}&current=temperature_2m&temperature_unit=fahrenheit"

    case Req.get(url) do
      {:ok, %{body: %{"current" => %{"temperature_2m" => temp}}}} ->
        {:reply, Response.text(Response.tool(), "Temperature: #{temp}F"), frame}

      {:error, reason} ->
        {:error, Error.execution("Failed to fetch weather: #{inspect(reason)}"), frame}
    end
  end
end

The schema block uses the same field DSL as ClaudeCode.MCP.Server and auto-generates JSON Schema for the tool's input parameters. The tool description comes from @moduledoc.

Register tools on a Hermes.Server module using the component macro:

defmodule MyApp.MCPServer do
  use Hermes.Server,
    name: "my-custom-tools",
    version: "1.0.0",
    capabilities: [:tools]

  component MyApp.WeatherTool
end

When passed to :mcp_servers, the SDK auto-generates a stdio command configuration that spawns the Hermes server as a subprocess.

Pass custom environment variables to Hermes subprocesses with the %{module: ..., env: ...} form:

{:ok, session} = ClaudeCode.start_link(
  mcp_servers: %{
    "db-tools" => %{
      module: MyApp.DBTools,
      env: %{"DATABASE_URL" => System.get_env("DATABASE_URL")}
    }
  }
)

Using custom tools

Both in-process and Hermes tools are passed to a session via the :mcp_servers option and work identically from Claude's perspective.

Tool name format

When MCP tools are exposed to Claude, their names follow the pattern mcp__<server-name>__<tool-name>. For example, a tool named get_weather in server "my-tools" becomes mcp__my-tools__get_weather.

Configuring allowed tools

Use :allowed_tools to control which custom tools Claude can use:

# Allow all tools from an in-process server
{:ok, result} = ClaudeCode.query("What's the weather in San Francisco?",
  mcp_servers: %{"my-tools" => MyApp.Tools},
  allowed_tools: ["mcp__my-tools__*"]
)

# Allow specific tools only
{:ok, result} = ClaudeCode.query("Look up alice@example.com",
  mcp_servers: %{"my-tools" => MyApp.Tools},
  allowed_tools: ["mcp__my-tools__query_user"]
)

# Hermes server works the same way
{:ok, result} = ClaudeCode.query("What's the weather in San Francisco?",
  mcp_servers: %{"weather" => MyApp.MCPServer},
  allowed_tools: ["mcp__weather__get_weather"]
)

Multiple tools

When your server has multiple tools, you can selectively allow them:

defmodule MyApp.Utilities do
  use ClaudeCode.MCP.Server, name: "utilities"

  tool :calculate, "Perform calculations" do
    field :expression, :string, required: true
    def execute(%{expression: expr}), do: {:ok, "#{Code.eval_string(expr) |> elem(0)}"}
  end

  tool :translate, "Translate text" do
    field :text, :string, required: true
    field :target_lang, :string, required: true
    def execute(%{text: text, target_lang: lang}), do: {:ok, "Translated #{text} to #{lang}"}
  end

  tool :search_web, "Search the web" do
    field :query, :string, required: true
    def execute(%{query: query}), do: {:ok, "Results for: #{query}"}
  end
end

{:ok, result} = ClaudeCode.query(
  "Calculate 5 + 3 and translate 'hello' to Spanish",
  mcp_servers: %{"utilities" => MyApp.Utilities},
  allowed_tools: [
    "mcp__utilities__calculate",   # Allow calculator
    "mcp__utilities__translate"    # Allow translator
    # mcp__utilities__search_web is NOT allowed
  ]
)

For details on :allowed_tools, wildcards, and alternative permission modes, see MCP > Allow MCP tools.

Error handling

Handle errors gracefully in your tool handlers. Return {:error, message} to provide meaningful feedback to Claude.

In-process tools

tool :fetch_data, "Fetch data from an API endpoint" do
  field :endpoint, :string, required: true

  def execute(%{endpoint: endpoint}) do
    case Req.get(endpoint) do
      {:ok, %{status: status, body: body}} when status in 200..299 ->
        {:ok, body}

      {:ok, %{status: status}} ->
        {:error, "API error: HTTP #{status}"}

      {:error, reason} ->
        {:error, "Failed to fetch data: #{inspect(reason)}"}
    end
  end
end

Hermes MCP tools

@impl true
def execute(%{endpoint: endpoint}, frame) do
  alias Hermes.MCP.Error
  alias Hermes.Server.Response

  case Req.get(endpoint) do
    {:ok, %{status: status, body: body}} when status in 200..299 ->
      {:reply, Response.json(Response.tool(), body), frame}

    {:ok, %{status: status}} ->
      {:error, Error.execution("API error: HTTP #{status}"), frame}

    {:error, reason} ->
      {:error, Error.execution("Failed to fetch data: #{inspect(reason)}"), frame}
  end
end

Claude sees the error message and can adjust its approach or report the issue to the user. Unhandled exceptions in in-process tools are caught automatically and returned as error content.

For connection-level errors (server failed to start, timeouts), see the MCP error handling section.

Testing

Test in-process tool modules directly

Generated modules are standard Hermes components that can be tested without a running session:

test "add tool returns correct result" do
  frame = Hermes.Server.Frame.new()
  assert {:reply, response, _frame} = MyApp.Tools.Add.execute(%{x: 3, y: 4}, frame)
  # response contains Hermes Response struct with text "7"
end

Test the router in isolation

test "tools/list returns all registered tools" do
  message = %{"jsonrpc" => "2.0", "id" => 1, "method" => "tools/list"}
  response = ClaudeCode.MCP.Router.handle_request(MyApp.Tools, message)

  assert %{"result" => %{"tools" => tools}} = response
  assert Enum.any?(tools, &(&1["name"] == "get_weather"))
end

test "tools/call dispatches to the right tool" do
  message = %{
    "jsonrpc" => "2.0", "id" => 2,
    "method" => "tools/call",
    "params" => %{"name" => "add", "arguments" => %{"x" => 5, "y" => 3}}
  }

  response = ClaudeCode.MCP.Router.handle_request(MyApp.Tools, message)
  assert %{"result" => %{"content" => [%{"type" => "text", "text" => "8"}]}} = response
end

Example tools

Database query tool

defmodule MyApp.DBTools do
  use ClaudeCode.MCP.Server, name: "database-tools"

  tool :query_users, "Search users by name or email" do
    field :search, :string, required: true

    def execute(%{search: search}) do
      import Ecto.Query

      users =
        from(u in MyApp.User,
          where: ilike(u.name, ^"%#{search}%") or ilike(u.email, ^"%#{search}%"),
          limit: 10,
          select: map(u, [:id, :name, :email])
        )
        |> MyApp.Repo.all()

      {:ok, %{count: length(users), users: users}}
    end
  end
end

API gateway tool

defmodule MyApp.APITools do
  use ClaudeCode.MCP.Server, name: "api-gateway"

  tool :api_request, "Make authenticated API requests to external services" do
    field :service, :string, required: true
    field :endpoint, :string, required: true
    field :method, :string, required: true

    def execute(%{service: service, endpoint: endpoint, method: method}) do
      config = %{
        "github" => %{base_url: "https://api.github.com", key_env: "GITHUB_TOKEN"},
        "slack" => %{base_url: "https://slack.com/api", key_env: "SLACK_TOKEN"}
      }

      case Map.fetch(config, service) do
        {:ok, %{base_url: base_url, key_env: key_env}} ->
          url = base_url <> endpoint
          headers = [{"authorization", "Bearer #{System.get_env(key_env)}"}]
          http_method = method |> String.downcase() |> String.to_existing_atom()

          case Req.request(method: http_method, url: url, headers: headers) do
            {:ok, %{body: data}} -> {:ok, data}
            {:error, reason} -> {:error, "API request failed: #{inspect(reason)}"}
          end

        :error ->
          {:error, "Unknown service: #{service}. Available: github, slack"}
      end
    end
  end
end

Calculator tool

defmodule MyApp.Calculator do
  use ClaudeCode.MCP.Server, name: "calculator"

  tool :calculate, "Perform mathematical calculations" do
    field :expression, :string, required: true
    field :precision, :integer

    def execute(%{expression: expr} = params) do
      precision = Map.get(params, :precision, 2)

      try do
        {result, _} = Code.eval_string(expr)
        formatted = :erlang.float_to_binary(result / 1, decimals: precision)
        {:ok, "#{expr} = #{formatted}"}
      rescue
        e -> {:error, "Invalid expression: #{Exception.message(e)}"}
      end
    end
  end

  tool :compound_interest, "Calculate compound interest for an investment" do
    field :principal, :float, required: true
    field :rate, :float, required: true
    field :time, :float, required: true
    field :n, :integer

    def execute(%{principal: principal, rate: rate, time: time} = params) do
      n = Map.get(params, :n, 12)
      amount = principal * :math.pow(1 + rate / n, n * time)
      interest = amount - principal

      {:ok, """
      Investment Analysis:
      Principal: $#{:erlang.float_to_binary(principal, decimals: 2)}
      Rate: #{:erlang.float_to_binary(rate * 100, decimals: 2)}%
      Time: #{time} years
      Compounding: #{n} times per year

      Final Amount: $#{:erlang.float_to_binary(amount, decimals: 2)}
      Interest Earned: $#{:erlang.float_to_binary(interest, decimals: 2)}
      Return: #{:erlang.float_to_binary(interest / principal * 100, decimals: 2)}%\
      """}
    end
  end
end
  • MCP -- Connect to MCP servers, configure permissions, authentication, and troubleshooting
  • Permissions -- Control tool access and permission modes
  • Streaming Output -- Stream tool call progress in real-time