Custom Tools
View SourceBuild and integrate custom tools to extend Claude Agent SDK 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 with your own functionality through in-process MCP servers, enabling Claude to interact with external services, APIs, or perform specialized operations. Define tools with ClaudeCode.MCP.Server that run in your BEAM VM, with full access to application state (Ecto repos, GenServers, caches).
For connecting to external MCP servers, configuring permissions, and authentication, see the MCP guide.
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 do
description "Get current temperature for a location using coordinates"
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 tool modules under the hood. Each tool block becomes a nested module (e.g., MyApp.Tools.GetWeather) with an input_schema/0 function (JSON Schema), and an execute/2 callback -- all derived from the field declarations and your execute function. You write execute/1 (params only) and the macro wraps it automatically. Write execute/2 if you need access to session-specific context via assigns (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 field declarations inside each tool block. The SDK handles conversion to JSON Schema automatically:
tool :search do
description "Search for items"
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 do
description "Look up a user by email"
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 do
description "Get cache statistics"
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 passed to execute/2 as the second argument:
# 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 do
description "List the current user's projects"
def execute(_params, frame) do
scope = frame.assigns.scope
projects = MyApp.Projects.list_projects(scope)
{:ok, projects}
end
end
tool :search_docs do
description "Search documentation"
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.
Subprocess MCP servers
For full MCP servers with resources and prompts (beyond in-process tools), pass any module that exports start_link/1. The SDK auto-detects it and spawns it as a stdio subprocess:
{:ok, session} = ClaudeCode.start_link(
mcp_servers: %{
"my-server" => MyApp.MCPServer
}
)Pass custom environment variables 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
Pass the custom server to a session via the :mcp_servers option. Both in-process and subprocess tools work identically from Claude's perspective.
Tool Name Format
When MCP tools are exposed to Claude, their names follow a specific format:
- Pattern:
mcp__{server_name}__{tool_name} - Example: A tool named
get_weatherin server"my-tools"becomesmcp__my-tools__get_weather
Configuring Allowed Tools
You can control which tools Claude can use via the :allowed_tools option:
# 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"]
)Multiple Tools Example
When your MCP server has multiple tools, you can selectively allow them:
defmodule MyApp.Utilities do
use ClaudeCode.MCP.Server, name: "utilities"
tool :calculate do
description "Perform calculations"
field :expression, :string, required: true
def execute(%{expression: expr}), do: {:ok, "#{Code.eval_string(expr) |> elem(0)}"}
end
tool :translate do
description "Translate text"
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 do
description "Search the web"
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 to provide meaningful feedback to Claude. Return {:error, message} from your tool handlers.
In-process tools
tool :fetch_data do
description "Fetch data from an API endpoint"
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
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 tool modules can be tested without a running session:
test "get_weather tool returns temperature" do
assert {:ok, text} = MyApp.Tools.GetWeather.execute(%{latitude: 37.7, longitude: -122.4}, %{})
assert text =~ "Temperature"
endTest the router in isolation
test "tools/list returns all registered tools" do
tools = ClaudeCode.Test.mcp_list_tools(MyApp.Tools)
assert Enum.any?(tools, &(&1["name"] == "get_weather"))
end
test "tools/call dispatches to the right tool" do
result = ClaudeCode.Test.mcp_call_tool(MyApp.Tools, "get_weather", %{"latitude" => 37.7, "longitude" => -122.4})
assert %{"content" => [%{"type" => "text", "text" => text}]} = result
assert text =~ "Temperature"
endExample Tools
Database Query Tool
defmodule MyApp.DBTools do
use ClaudeCode.MCP.Server, name: "database-tools"
tool :query_users do
description "Search users by name or email"
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 do
description "Make authenticated API requests to external services"
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 do
description "Perform mathematical calculations"
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 do
description "Calculate compound interest for an investment"
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 Documentation
- MCP -- Connect to MCP servers, configure permissions, authentication, and troubleshooting
- MCP Protocol -- Model Context Protocol specification and resources
- Permissions -- Control tool access and permission modes
- Streaming Output -- Stream tool call progress in real-time