Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2a6da5b
Add ClaudeCode.MCP.Backend behaviour for MCP library abstraction
guess Mar 15, 2026
544c60b
Add Backend.Hermes wrapping existing Hermes MCP integration
guess Mar 15, 2026
0c27d1a
Add tests for Backend.Hermes
guess Mar 15, 2026
633414a
Refactor Router to delegate to Backend.Hermes
guess Mar 15, 2026
677961b
Use Backend.Hermes.compatible? for MCP module detection
guess Mar 15, 2026
88ebf9e
Fix Credo warnings: alias nested modules at top level
guess Mar 15, 2026
3d0f771
Add Phase 2 and Phase 3 implementation plans
guess Mar 15, 2026
678eb6c
Centralize backend detection in ClaudeCode.MCP.backend_for/1
guess Mar 15, 2026
69d6ba2
Add anubis_mcp dependency, make hermes_mcp optional
guess Mar 15, 2026
2dbaf4c
Add Backend.Anubis implementation
guess Mar 15, 2026
c2529a8
Rewrite tool/3 macro to generate standalone modules
guess Mar 15, 2026
0896d79
Switch Router to Backend.Anubis, add param validation
guess Mar 15, 2026
2e1c0d6
Add Anubis to backend_for/1, simplify Backend.Hermes tests
guess Mar 15, 2026
56d061b
Update docs: replace Hermes references with backend-agnostic language
guess Mar 15, 2026
05adca4
Fix formatting in Backend.Anubis
guess Mar 15, 2026
5ba8662
Use anubis_mcp ~> 0.17.1
guess Mar 15, 2026
588e202
Make anubis_mcp optional, guard compatible? with Code.ensure_loaded?
guess Mar 15, 2026
b6c0476
Conditionally define backend modules based on library availability
guess Mar 15, 2026
f2b3fb4
remove plans
guess Mar 15, 2026
95eb69f
fix docs
guess Mar 15, 2026
6b1a0ca
review comments
guess Mar 15, 2026
d87874b
fix format
guess Mar 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 16 additions & 84 deletions docs/guides/custom-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ Build and integrate custom tools to extend Claude Agent SDK functionality.

> **Official Documentation:** This guide is based on the [official Claude Agent SDK documentation](https://platform.claude.com/docs/en/agent-sdk/custom-tools). 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. 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](https://hex.pm/packages/hermes_mcp) components that run as a separate subprocess
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](mcp.md) guide.

Expand Down Expand Up @@ -42,13 +39,13 @@ end

#### How it works

The `tool` macro generates [Hermes MCP](https://hexdocs.pm/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](#passing-session-context-with-assigns)).
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](#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:
Use `field` declarations inside each `tool` block. The SDK handles conversion to JSON Schema automatically:

```elixir
tool :search, "Search for items" do
Expand Down Expand Up @@ -103,7 +100,7 @@ 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`:
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:

```elixir
# LiveView mount -- pass current_scope into the tool's assigns
Expand Down Expand Up @@ -147,56 +144,19 @@ 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](https://hexdocs.pm/hermes_mcp) server with resources and prompts, define tools as Hermes server components. Each tool is a module that uses [`Hermes.Server.Component`](https://hexdocs.pm/hermes_mcp/Hermes.Server.Component.html) with a `schema` block and an `execute/2` callback:

```elixir
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}
### Subprocess MCP servers

{: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`](https://hexdocs.pm/hermes_mcp/Hermes.Server.html) module using the `component` macro:
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:

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

component MyApp.WeatherTool
end
{:ok, session} = ClaudeCode.start_link(
mcp_servers: %{
"my-server" => MyApp.MCPServer
}
)
```

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:
Pass custom environment variables with the `%{module: ..., env: ...}` form:

```elixir
{:ok, session} = ClaudeCode.start_link(
Expand All @@ -211,7 +171,7 @@ Pass custom environment variables to Hermes subprocesses with the `%{module: ...

## Using Custom Tools

Pass the custom server to a session via the `:mcp_servers` option. Both in-process and Hermes tools work identically from Claude's perspective.
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

Expand All @@ -235,12 +195,6 @@ You can control which tools Claude can use via the `:allowed_tools` option:
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 Example
Expand Down Expand Up @@ -306,27 +260,6 @@ tool :fetch_data, "Fetch data from an API endpoint" do
end
```

### Hermes MCP tools

```elixir
@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](mcp.md#error-handling) section.
Expand All @@ -335,13 +268,12 @@ For connection-level errors (server failed to start, timeouts), see the [MCP err

### Test in-process tool modules directly

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

```elixir
test "get_weather tool returns temperature" do
frame = Hermes.Server.Frame.new()
assert {:reply, response, _frame} = MyApp.Tools.GetWeather.execute(%{latitude: 37.7, longitude: -122.4}, frame)
# response contains Hermes Response struct with temperature text
assert {:ok, text} = MyApp.Tools.GetWeather.execute(%{latitude: 37.7, longitude: -122.4}, %{})
assert text =~ "Temperature"
end
```

Expand Down
15 changes: 7 additions & 8 deletions docs/guides/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ MCP servers communicate with your agent using different transport protocols. Che

- If the docs give you a **command to run** (like `npx @modelcontextprotocol/server-github`), use stdio
- If the docs give you a **URL**, use HTTP or SSE
- If you're building your own tools **in Elixir**, use an [SDK MCP server](#in-process-and-hermes-mcp-servers) (see the [Custom tools](custom-tools.md) guide for details)
- If you're building your own tools **in Elixir**, use an [SDK MCP server](#in-process-mcp-servers) (see the [Custom tools](custom-tools.md) guide for details)

### stdio servers

Expand Down Expand Up @@ -177,12 +177,11 @@ Use HTTP or SSE for cloud-hosted MCP servers and remote APIs:

For HTTP (non-streaming), use `type: "http"` instead.

### In-process and Hermes MCP servers
### In-process MCP servers

The Elixir SDK supports two additional transport types for tools defined in your application code:
The Elixir SDK supports in-process tools defined with `ClaudeCode.MCP.Server`. These run inside your BEAM VM with full access to Ecto repos, GenServers, and caches. The SDK routes messages through the control protocol -- no subprocess needed.

- **In-process tools** (`ClaudeCode.MCP.Server`) -- Run inside your BEAM VM with full access to Ecto repos, GenServers, and caches. The SDK routes messages through the control protocol, no subprocess needed.
- **Hermes MCP modules** (`Hermes.Server`) -- Run as a stdio subprocess spawned automatically by the SDK. Use this for full [Hermes MCP](https://hexdocs.pm/hermes_mcp) servers with resources and prompts.
For full MCP servers with resources and prompts, pass any module with `start_link/1` (e.g., an [AnubisMCP](https://hexdocs.pm/anubis_mcp) server). The SDK auto-detects and spawns it as a stdio subprocess.

Both are passed to `:mcp_servers` the same way as external servers. See the [Custom tools](custom-tools.md) guide for implementation details.

Expand All @@ -193,7 +192,7 @@ Both are passed to `:mcp_servers` the same way as external servers. See the [Cus
allowed_tools: ["mcp__my-tools__*"]
)

# Hermes MCP module (spawns as subprocess)
# MCP module (spawns as subprocess)
{:ok, result} = ClaudeCode.query("Get the weather",
mcp_servers: %{"weather" => MyApp.MCPServer},
allowed_tools: ["mcp__weather__*"]
Expand All @@ -211,7 +210,7 @@ db_url = System.get_env("DATABASE_URL")
mcp_servers: %{
# In-process (runs in your BEAM VM)
"app-tools" => MyApp.Tools,
# Hermes module (spawns as subprocess)
# MCP module (spawns as subprocess)
"db-tools" => %{module: MyApp.DBServer, env: %{"DATABASE_URL" => db_url}},
# External Node.js server
"browser" => %{command: "npx", args: ["@playwright/mcp@latest"]},
Expand Down Expand Up @@ -292,7 +291,7 @@ Use the `env` field to pass API keys, tokens, and other credentials to the MCP s
allowed_tools: ["mcp__github__list_issues"]
)

# Hermes MCP module with custom env
# MCP module with custom env
{:ok, session} = ClaudeCode.start_link(
mcp_servers: %{
"db-tools" => %{
Expand Down
19 changes: 12 additions & 7 deletions lib/claude_code/cli/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ defmodule ClaudeCode.CLI.Command do
It is used by `ClaudeCode.Adapter.Port` for local subprocess management.
"""

alias ClaudeCode.MCP.Server
alias ClaudeCode.Session.PermissionMode

@required_flags ["--output-format", "stream-json", "--verbose", "--print"]
Expand Down Expand Up @@ -435,9 +434,9 @@ defmodule ClaudeCode.CLI.Command do
end
end

# -- Private: Hermes MCP module expansion -----------------------------------
# -- Private: MCP module subprocess expansion --------------------------------

defp expand_hermes_module(module, config) do
defp expand_subprocess_module(module, config) do
# Generate stdio command config for a Hermes MCP server module
# This allows the CLI to spawn the Elixir app with the MCP server
startup_code = "#{inspect(module)}.start_link(transport: :stdio)"
Expand All @@ -454,10 +453,16 @@ defmodule ClaudeCode.CLI.Command do
end

defp expand_mcp_module(name, module, config) do
if Server.sdk_server?(module) do
%{type: "sdk", name: name}
else
expand_hermes_module(module, config)
case ClaudeCode.MCP.backend_for(module) do
:sdk ->
%{type: "sdk", name: name}

{:subprocess, _backend} ->
expand_subprocess_module(module, config)

:unknown ->
raise ArgumentError,
"Module #{inspect(module)} passed to :mcp_servers is not a recognized MCP server module"
end
end
end
27 changes: 26 additions & 1 deletion lib/claude_code/mcp.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule ClaudeCode.MCP do
@moduledoc """
Integration with Hermes MCP (Model Context Protocol).
Integration with the Model Context Protocol (MCP).

This module provides the MCP integration layer. Custom tools are defined
with `ClaudeCode.MCP.Server` and passed via `:mcp_servers`.
Expand All @@ -26,4 +26,29 @@ defmodule ClaudeCode.MCP do

See the [Custom Tools](docs/guides/custom-tools.md) guide for details.
"""

alias ClaudeCode.MCP.Backend
alias ClaudeCode.MCP.Server

@doc """
Determines which backend handles the given MCP module.

Returns:
- `:sdk` — in-process SDK server (handled via Router, no subprocess)
- `{:subprocess, backend_module}` — subprocess server, with the backend that can expand it
- `:unknown` — unrecognized module
"""
@spec backend_for(module()) :: :sdk | {:subprocess, module()} | :unknown
def backend_for(module) when is_atom(module) do
cond do
Server.sdk_server?(module) -> :sdk
backend_compatible?(Backend.Anubis, module) -> {:subprocess, Backend.Anubis}
backend_compatible?(Backend.Hermes, module) -> {:subprocess, Backend.Hermes}
true -> :unknown
end
end

defp backend_compatible?(backend, module) do
Code.ensure_loaded?(backend) and backend.compatible?(module)
end
end
20 changes: 20 additions & 0 deletions lib/claude_code/mcp/backend.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule ClaudeCode.MCP.Backend do
@moduledoc false

@doc "Returns a list of tool definition maps for the given server module."
@callback list_tools(server_module :: module()) :: [map()]

@doc "Calls a tool by name with the given params and assigns. Returns a JSONRPC-ready result map."
@callback call_tool(
server_module :: module(),
tool_name :: String.t(),
params :: map(),
assigns :: map()
) :: {:ok, map()} | {:error, String.t()} | {:validation_error, String.t()}

@doc "Returns server info map (name, version) for the initialize response."
@callback server_info(server_module :: module()) :: map()

@doc "Returns true if the given module is compatible with this backend (for subprocess detection)."
@callback compatible?(module :: module()) :: boolean()
end
Loading
Loading