Custom Tools
View SourceBuild 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:
- In-process tools -- Define tools with
ClaudeCode.MCP.Serverthat run in your BEAM VM, with full access to application state (Ecto repos, GenServers, caches) - 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
]
endThen 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}¤t=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
endHow 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
endReturn values
Tool handlers return simple values. The macro wraps them into the MCP response format automatically:
| Handler returns | Behavior |
|---|---|
{:ok, text} when text is a binary | Returned as text content |
{:ok, data} when data is a map or list | Returned 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
endPassing 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)}
enddefmodule 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
endTools 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}¤t=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
endThe 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
endWhen 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
endHermes 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
endClaude 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"
endTest 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
endExample 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
endAPI 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
endCalculator 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
endRelated resources
- 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