diff --git a/docs/guides/custom-tools.md b/docs/guides/custom-tools.md index 431018b2..072be15c 100644 --- a/docs/guides/custom-tools.md +++ b/docs/guides/custom-tools.md @@ -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. @@ -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 @@ -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 @@ -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}¤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} +### 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( @@ -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 @@ -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 @@ -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. @@ -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 ``` diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index a9fd968c..0355c20e 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -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 @@ -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. @@ -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__*"] @@ -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"]}, @@ -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" => %{ diff --git a/lib/claude_code/cli/command.ex b/lib/claude_code/cli/command.ex index 635c7ffe..e68ecad4 100644 --- a/lib/claude_code/cli/command.ex +++ b/lib/claude_code/cli/command.ex @@ -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"] @@ -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)" @@ -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 diff --git a/lib/claude_code/mcp.ex b/lib/claude_code/mcp.ex index 0e63d318..80edf70c 100644 --- a/lib/claude_code/mcp.ex +++ b/lib/claude_code/mcp.ex @@ -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`. @@ -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 diff --git a/lib/claude_code/mcp/backend.ex b/lib/claude_code/mcp/backend.ex new file mode 100644 index 00000000..e6fb3b43 --- /dev/null +++ b/lib/claude_code/mcp/backend.ex @@ -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 diff --git a/lib/claude_code/mcp/backend/anubis.ex b/lib/claude_code/mcp/backend/anubis.ex new file mode 100644 index 00000000..fc2fc303 --- /dev/null +++ b/lib/claude_code/mcp/backend/anubis.ex @@ -0,0 +1,126 @@ +if Code.ensure_loaded?(Anubis.Server) do + defmodule ClaudeCode.MCP.Backend.Anubis do + @moduledoc false + @behaviour ClaudeCode.MCP.Backend + + alias ClaudeCode.MCP.Server, as: MCPServer + + @impl true + def list_tools(server_module) do + %{tools: tool_modules} = server_module.__tool_server__() + + Enum.map(tool_modules, fn module -> + %{ + "name" => module.__tool_name__(), + "description" => module.__description__(), + "inputSchema" => module.input_schema() + } + end) + end + + @impl true + def call_tool(server_module, tool_name, params, assigns) do + %{tools: tool_modules} = server_module.__tool_server__() + + case Enum.find(tool_modules, &(&1.__tool_name__() == tool_name)) do + nil -> + {:error, "Tool '#{tool_name}' not found"} + + module -> + atom_params = ClaudeCode.MapUtils.safe_atomize_keys(params) + + case validate_params(module, atom_params) do + :ok -> execute_tool(module, atom_params, assigns) + {:error, error_msg} -> {:validation_error, "Invalid params: #{error_msg}"} + end + end + end + + @impl true + def server_info(server_module) do + %{name: name} = server_module.__tool_server__() + %{"name" => name, "version" => "1.0.0"} + end + + @impl true + def compatible?(module) when is_atom(module) do + Code.ensure_loaded?(module) and + function_exported?(module, :start_link, 1) and + not MCPServer.sdk_server?(module) and + has_behaviour?(module, Anubis.Server) + end + + defp has_behaviour?(module, behaviour) do + :attributes + |> module.module_info() + |> Keyword.get_values(:behaviour) + |> List.flatten() + |> Enum.member?(behaviour) + end + + defp validate_params(module, params) do + schema = module.input_schema() + properties = schema["properties"] || %{} + required = schema["required"] || [] + + errors = + Enum.flat_map(required, fn field -> + key = String.to_existing_atom(field) + if Map.has_key?(params, key), do: [], else: ["#{field} is required"] + end) ++ + Enum.flat_map(params, fn {key, value} -> + key_str = Atom.to_string(key) + + case Map.get(properties, key_str) do + nil -> [] + prop_schema -> validate_type(key_str, value, prop_schema) + end + end) + + case errors do + [] -> :ok + errors -> {:error, Enum.join(errors, ", ")} + end + end + + defp validate_type(name, value, %{"type" => "integer"}) when not is_integer(value), + do: ["#{name}: expected integer, got #{inspect(value)}"] + + defp validate_type(name, value, %{"type" => "string"}) when not is_binary(value), + do: ["#{name}: expected string, got #{inspect(value)}"] + + defp validate_type(name, value, %{"type" => "number"}) when not is_number(value), + do: ["#{name}: expected number, got #{inspect(value)}"] + + defp validate_type(name, value, %{"type" => "boolean"}) when not is_boolean(value), + do: ["#{name}: expected boolean, got #{inspect(value)}"] + + defp validate_type(_name, _value, _schema), do: [] + + defp execute_tool(module, params, assigns) do + case module.execute(params, assigns) do + {:ok, value} when is_binary(value) -> + {:ok, %{"content" => [%{"type" => "text", "text" => value}], "isError" => false}} + + {:ok, value} when is_map(value) or is_list(value) -> + {:ok, %{"content" => [%{"type" => "text", "text" => Jason.encode!(value)}], "isError" => false}} + + {:ok, value} -> + {:ok, %{"content" => [%{"type" => "text", "text" => to_string(value)}], "isError" => false}} + + {:error, message} when is_binary(message) -> + {:ok, %{"content" => [%{"type" => "text", "text" => message}], "isError" => true}} + + {:error, message} -> + {:ok, %{"content" => [%{"type" => "text", "text" => to_string(message)}], "isError" => true}} + end + rescue + e -> + {:ok, + %{ + "content" => [%{"type" => "text", "text" => "Tool error: #{Exception.message(e)}"}], + "isError" => true + }} + end + end +end diff --git a/lib/claude_code/mcp/backend/hermes.ex b/lib/claude_code/mcp/backend/hermes.ex new file mode 100644 index 00000000..ae252834 --- /dev/null +++ b/lib/claude_code/mcp/backend/hermes.ex @@ -0,0 +1,102 @@ +if Code.ensure_loaded?(Hermes.Server) do + defmodule ClaudeCode.MCP.Backend.Hermes do + @moduledoc false + @behaviour ClaudeCode.MCP.Backend + + alias ClaudeCode.MCP.Server, as: MCPServer + alias Hermes.Server.Component + alias Hermes.Server.Component.Schema + alias Hermes.Server.Frame + alias Hermes.Server.Response + + @impl true + def list_tools(server_module) do + %{tools: tool_modules} = server_module.__tool_server__() + + Enum.map(tool_modules, fn module -> + %{ + "name" => module.__tool_name__(), + "description" => module.__description__(), + "inputSchema" => module.input_schema() + } + end) + end + + @impl true + def call_tool(server_module, tool_name, params, assigns) do + %{tools: tool_modules} = server_module.__tool_server__() + + case Enum.find(tool_modules, &(&1.__tool_name__() == tool_name)) do + nil -> + {:error, "Tool '#{tool_name}' not found"} + + module -> + atom_params = ClaudeCode.MapUtils.safe_atomize_keys(params) + + case validate_params(module, atom_params) do + {:ok, validated} -> + execute_tool(module, validated, assigns) + + {:error, errors} -> + error_msg = Schema.format_errors(errors) + {:validation_error, "Invalid params: #{error_msg}"} + end + end + end + + @impl true + def server_info(server_module) do + %{name: name} = server_module.__tool_server__() + %{"name" => name, "version" => "1.0.0"} + end + + @impl true + def compatible?(module) when is_atom(module) do + Code.ensure_loaded?(module) and + function_exported?(module, :start_link, 1) and + not MCPServer.sdk_server?(module) and + has_behaviour?(module, Hermes.Server) + end + + defp has_behaviour?(module, behaviour) do + :attributes + |> module.module_info() + |> Keyword.get_values(:behaviour) + |> List.flatten() + |> Enum.member?(behaviour) + end + + # -- Private helpers -- + + defp validate_params(module, params) do + raw_schema = module.__mcp_raw_schema__() + peri_schema = Component.__clean_schema_for_peri__(raw_schema) + Peri.validate(peri_schema, params) + end + + defp execute_tool(module, params, assigns) do + frame = Frame.new(assigns) + + try do + case module.execute(params, frame) do + {:reply, response, _frame} -> + {:ok, Response.to_protocol(response)} + + {:error, %{message: error_msg}, _frame} -> + {:ok, + %{ + "content" => [%{"type" => "text", "text" => to_string(error_msg)}], + "isError" => true + }} + end + rescue + e -> + {:ok, + %{ + "content" => [%{"type" => "text", "text" => "Tool error: #{Exception.message(e)}"}], + "isError" => true + }} + end + end + end +end diff --git a/lib/claude_code/mcp/router.ex b/lib/claude_code/mcp/router.ex index a8b4770c..b25ff136 100644 --- a/lib/claude_code/mcp/router.ex +++ b/lib/claude_code/mcp/router.ex @@ -3,16 +3,13 @@ defmodule ClaudeCode.MCP.Router do Dispatches JSONRPC requests to in-process MCP tool server modules. Handles the MCP protocol methods (`initialize`, `tools/list`, `tools/call`) - by routing to the appropriate tool module's `execute/2` callback. + by routing to the appropriate tool module via the MCP backend. This module is called by the adapter when it receives an `mcp_message` control request from the CLI for a `type: "sdk"` server. """ - alias Hermes.Server.Component - alias Hermes.Server.Component.Schema - alias Hermes.Server.Frame - alias Hermes.Server.Response + alias ClaudeCode.MCP.Backend.Anubis, as: Backend @doc """ Handles a JSONRPC request for the given tool server module. @@ -24,7 +21,7 @@ defmodule ClaudeCode.MCP.Router do * `server_module` - A module that uses `ClaudeCode.MCP.Server` and exports `__tool_server__/0` * `message` - A decoded JSONRPC request map with `"method"` key - * `assigns` - Optional map of assigns to set on the Hermes frame + * `assigns` - Optional map of assigns passed to tools (available to tools that define `execute/2`) ## Supported Methods @@ -44,14 +41,12 @@ defmodule ClaudeCode.MCP.Router do def handle_request(server_module, message, assigns \\ %{}) def handle_request(server_module, %{"method" => method} = message, assigns) do - %{tools: tool_modules, name: server_name} = server_module.__tool_server__() - case method do "initialize" -> jsonrpc_result(message, %{ "protocolVersion" => "2024-11-05", "capabilities" => %{"tools" => %{}}, - "serverInfo" => %{"name" => server_name, "version" => "1.0.0"} + "serverInfo" => Backend.server_info(server_module) }) "notifications/" <> _ -> @@ -59,79 +54,28 @@ defmodule ClaudeCode.MCP.Router do %{"jsonrpc" => "2.0", "result" => %{}} "tools/list" -> - tools = Enum.map(tool_modules, &tool_definition/1) + tools = Backend.list_tools(server_module) jsonrpc_result(message, %{"tools" => tools}) "tools/call" -> %{"params" => %{"name" => name, "arguments" => args}} = message - call_tool(tool_modules, name, args, message, assigns) - - _ -> - jsonrpc_error(message, -32_601, "Method '#{method}' not supported") - end - end - - defp tool_definition(module) do - %{ - "name" => module.__tool_name__(), - "description" => module.__description__(), - "inputSchema" => module.input_schema() - } - end - - defp call_tool(tool_modules, name, args, message, assigns) do - case Enum.find(tool_modules, &(&1.__tool_name__() == name)) do - nil -> - jsonrpc_error(message, -32_601, "Tool '#{name}' not found") - module -> - atom_args = atomize_keys(args) + case Backend.call_tool(server_module, name, args, assigns) do + {:ok, result} -> + jsonrpc_result(message, result) - case validate_params(module, atom_args) do - {:ok, validated} -> - execute_tool(module, validated, assigns, message) + {:error, msg} -> + jsonrpc_error(message, -32_601, msg) - {:error, errors} -> - error_msg = Schema.format_errors(errors) - jsonrpc_error(message, -32_602, "Invalid params: #{error_msg}") + {:validation_error, msg} -> + jsonrpc_error(message, -32_602, msg) end - end - end - defp validate_params(module, params) do - raw_schema = module.__mcp_raw_schema__() - peri_schema = Component.__clean_schema_for_peri__(raw_schema) - Peri.validate(peri_schema, params) - end - - defp execute_tool(module, params, assigns, message) do - frame = Frame.new(assigns) - - try do - case module.execute(params, frame) do - {:reply, response, _frame} -> - jsonrpc_result(message, Response.to_protocol(response)) - - {:error, %{message: error_msg}, _frame} -> - jsonrpc_result(message, %{ - "content" => [%{"type" => "text", "text" => to_string(error_msg)}], - "isError" => true - }) - end - rescue - e -> - jsonrpc_result(message, %{ - "content" => [%{"type" => "text", "text" => "Tool error: #{Exception.message(e)}"}], - "isError" => true - }) + _ -> + jsonrpc_error(message, -32_601, "Method '#{method}' not supported") end end - # Tool schema `field` declarations create atoms at compile time, so - # safe_atomize_keys will convert the top-level parameter keys to atoms. - # Nested map values stay as string-keyed (only top-level is converted). - defp atomize_keys(map) when is_map(map), do: ClaudeCode.MapUtils.safe_atomize_keys(map) - defp jsonrpc_result(%{"id" => id}, result) do %{"jsonrpc" => "2.0", "id" => id, "result" => result} end diff --git a/lib/claude_code/mcp/server.ex b/lib/claude_code/mcp/server.ex index 7e88e234..5f5ca913 100644 --- a/lib/claude_code/mcp/server.ex +++ b/lib/claude_code/mcp/server.ex @@ -1,10 +1,9 @@ defmodule ClaudeCode.MCP.Server do @moduledoc """ - Macro for generating Hermes MCP tool modules from a concise DSL. + Macro for generating MCP tool modules from a concise DSL. - Each `tool` block becomes a nested module that implements - Hermes.Server.Component with type `:tool`. The generated modules - have proper schema definitions, execute wrappers, and metadata. + Each `tool` block becomes a nested module with schema definitions, + execute wrappers, and metadata. ## Usage @@ -31,21 +30,19 @@ defmodule ClaudeCode.MCP.Server do Each `tool` block generates a nested module (e.g., `MyApp.Tools.Add`) that: - - Uses `Hermes.Server.Component, type: :tool` - Has `__tool_name__/0` returning the string tool name - - Has a `schema` block with the user's field declarations - - Has `__description__/0` returning the tool description (via `@moduledoc`) - - Has `execute/2` implementing the Hermes tool callback - - Wraps user return values into proper Hermes responses + - Has `__description__/0` returning the tool description + - Has `input_schema/0` returning JSON Schema for the tool's parameters + - Has `execute/2` accepting `(params, assigns)` and returning `{:ok, value}` or `{:error, message}` ## Return Value Wrapping The user's `execute` function can return: - - `{:ok, binary}` - wrapped into a text response - - `{:ok, map | list}` - wrapped into a JSON response - - `{:ok, other}` - converted to string and wrapped into text response - - `{:error, message}` - wrapped into an MCP execution error + - `{:ok, binary}` - returned as text content + - `{:ok, map | list}` - returned as JSON content + - `{:ok, other}` - converted to string and returned as text content + - `{:error, message}` - returned as error content """ @doc """ @@ -108,51 +105,24 @@ defmodule ClaudeCode.MCP.Server do tool_name_str = Atom.to_string(name) {field_asts, execute_ast} = split_tool_block(block) - schema_block = build_schema_block(field_asts) - {execute_wrapper, user_execute_def} = build_execute(execute_ast, tool_name_str) + schema_def = build_input_schema(field_asts) + {execute_wrapper, user_execute_def} = build_execute(execute_ast) quote do defmodule Module.concat(__MODULE__, unquote(module_name)) do - @moduledoc unquote(description) + @moduledoc false - use Hermes.Server.Component, type: :tool - - alias Hermes.MCP.Error - alias Hermes.Server.Response + import ClaudeCode.MCP.Server, only: [field: 2, field: 3] @doc false def __tool_name__, do: unquote(tool_name_str) - unquote(schema_block) - - unquote(user_execute_def) - @doc false - def __wrap_result__({:ok, value}, frame) when is_binary(value) do - response = Response.text(Response.tool(), value) - - {:reply, response, frame} - end - - def __wrap_result__({:ok, value}, frame) when is_map(value) or is_list(value) do - response = Response.json(Response.tool(), value) - - {:reply, response, frame} - end + def __description__, do: unquote(description) - def __wrap_result__({:ok, value}, frame) do - response = Response.text(Response.tool(), to_string(value)) + unquote(schema_def) - {:reply, response, frame} - end - - def __wrap_result__({:error, message}, frame) when is_binary(message) do - {:error, Error.execution(message), frame} - end - - def __wrap_result__({:error, message}, frame) do - {:error, Error.execution(to_string(message)), frame} - end + unquote(user_execute_def) unquote(execute_wrapper) end @@ -161,6 +131,13 @@ defmodule ClaudeCode.MCP.Server do end end + @doc false + defmacro field(name, type, opts \\ []) do + quote do + @_fields {unquote(name), unquote(type), unquote(opts)} + end + end + # -- Private helpers for AST manipulation -- # Splits the tool block AST into field declarations and execute def(s). @@ -186,31 +163,67 @@ defmodule ClaudeCode.MCP.Server do defp execute_def?({:def, _, [{:execute, _, _} | _]}), do: true defp execute_def?(_), do: false - # Wraps field AST nodes in a `schema do ... end` block - defp build_schema_block([]) do + # Builds the input_schema/0 function from field declarations + defp build_input_schema([]) do quote do - schema do + Module.register_attribute(__MODULE__, :_fields, accumulate: true) + + @doc false + def input_schema do + %{"type" => "object", "properties" => %{}, "required" => []} end end end - defp build_schema_block(field_asts) do - body = - case field_asts do - [single] -> single - multiple -> {:__block__, [], multiple} + defp build_input_schema(field_asts) do + quote do + Module.register_attribute(__MODULE__, :_fields, accumulate: true) + + unquote_splicing(field_asts) + + @before_compile {ClaudeCode.MCP.Server, :__before_compile_schema__} + end + end + + @doc false + defmacro __before_compile_schema__(env) do + fields = env.module |> Module.get_attribute(:_fields) |> Enum.reverse() + + properties = + for {name, type, _opts} <- fields, into: %{} do + {Atom.to_string(name), type_to_json_schema(type)} end + required = + for {name, _type, opts} <- fields, + Keyword.get(opts, :required, false), + do: Atom.to_string(name) + quote do - schema do - unquote(body) + @doc false + def input_schema do + %{ + "type" => "object", + "properties" => unquote(Macro.escape(properties)), + "required" => unquote(required) + } end end end + @doc false + def type_to_json_schema(:string), do: %{"type" => "string"} + def type_to_json_schema(:integer), do: %{"type" => "integer"} + def type_to_json_schema(:float), do: %{"type" => "number"} + def type_to_json_schema(:number), do: %{"type" => "number"} + def type_to_json_schema(:boolean), do: %{"type" => "boolean"} + def type_to_json_schema(:map), do: %{"type" => "object"} + def type_to_json_schema(:list), do: %{"type" => "array"} + def type_to_json_schema(other), do: %{"type" => to_string(other)} + # Builds the execute/2 wrapper and the renamed user execute function. # Detects whether user's execute is arity 1 or 2. - defp build_execute(execute_defs, _tool_name) do + defp build_execute(execute_defs) do # Rename all user `def execute` clauses to `defp __user_execute__` user_defs = Enum.map(execute_defs, fn {:def, meta, [{:execute, name_meta, args} | body]} -> @@ -224,19 +237,17 @@ defmodule ClaudeCode.MCP.Server do case arity do 1 -> quote do - @impl true - def execute(params, frame) do - result = __user_execute__(params) - __wrap_result__(result, frame) + @doc false + def execute(params, _assigns) do + __user_execute__(params) end end _2 -> quote do - @impl true - def execute(params, frame) do - result = __user_execute__(params, frame) - __wrap_result__(result, frame) + @doc false + def execute(params, assigns) do + __user_execute__(params, assigns) end end end diff --git a/mix.exs b/mix.exs index 1b39c2c5..f031082e 100644 --- a/mix.exs +++ b/mix.exs @@ -60,7 +60,8 @@ defmodule ClaudeCode.MixProject do {:nimble_options, "~> 1.0"}, {:nimble_ownership, "~> 1.0"}, {:telemetry, "~> 1.2"}, - {:hermes_mcp, "~> 0.14"}, + {:anubis_mcp, "~> 0.17", optional: true}, + {:hermes_mcp, "~> 0.14", optional: true}, # Development and test dependencies {:styler, "~> 1.0", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 319a854f..3ddb8896 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "anubis_mcp": {:hex, :anubis_mcp, "0.17.1", "0cac76adfc006330a58340b5bef3386846faa268aa94c59bbfe8a5a2f63cb0b5", [:mix], [{:burrito, "~> 1.0", [hex: :burrito, repo: "hexpm", optional: true]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:peri, "0.6.2", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cb695352a9f0852fdf2860cfe5de03d0080ccffa20134047014d95a19f2ad589"}, "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, diff --git a/test/claude_code/cli/command_test.exs b/test/claude_code/cli/command_test.exs index f58b5dcd..893b082b 100644 --- a/test/claude_code/cli/command_test.exs +++ b/test/claude_code/cli/command_test.exs @@ -1,3 +1,10 @@ +defmodule MyApp.MCPServer do + @moduledoc false + @behaviour Anubis.Server + + def start_link(_opts), do: {:ok, self()} +end + defmodule ClaudeCode.CLI.CommandTest do use ExUnit.Case, async: true diff --git a/test/claude_code/mcp/backend/anubis_test.exs b/test/claude_code/mcp/backend/anubis_test.exs new file mode 100644 index 00000000..23512293 --- /dev/null +++ b/test/claude_code/mcp/backend/anubis_test.exs @@ -0,0 +1,139 @@ +defmodule ClaudeCode.MCP.Backend.AnubisTest do + use ExUnit.Case, async: true + + alias ClaudeCode.MCP.Backend.Anubis, as: Backend + + defmodule AnubisAddTool do + @moduledoc false + def __tool_name__, do: "add" + def __description__, do: "Add two numbers" + + def input_schema, + do: %{ + "type" => "object", + "properties" => %{"x" => %{"type" => "integer"}, "y" => %{"type" => "integer"}}, + "required" => ["x", "y"] + } + + def execute(%{x: x, y: y}, _assigns), do: {:ok, "#{x + y}"} + end + + defmodule AnubisMapTool do + @moduledoc false + def __tool_name__, do: "return_map" + def __description__, do: "Return structured data" + + def input_schema, do: %{"type" => "object", "properties" => %{"key" => %{"type" => "string"}}, "required" => ["key"]} + + def execute(%{key: key}, _assigns), do: {:ok, %{key: key, value: "data"}} + end + + defmodule AnubisFailTool do + @moduledoc false + def __tool_name__, do: "failing_tool" + def __description__, do: "Always fails" + def input_schema, do: %{"type" => "object"} + def execute(_params, _assigns), do: {:error, "Something went wrong"} + end + + defmodule AnubisRaiseTool do + @moduledoc false + def __tool_name__, do: "raise_tool" + def __description__, do: "Raises" + def input_schema, do: %{"type" => "object"} + def execute(_params, _assigns), do: raise("kaboom") + end + + defmodule AnubisTestServer do + @moduledoc false + + def __tool_server__, + do: %{name: "anubis-test", tools: [AnubisAddTool, AnubisMapTool, AnubisFailTool, AnubisRaiseTool]} + end + + describe "list_tools/1" do + test "returns tool definitions" do + tools = Backend.list_tools(AnubisTestServer) + assert length(tools) == 4 + add = Enum.find(tools, &(&1["name"] == "add")) + assert add["description"] == "Add two numbers" + assert add["inputSchema"]["type"] == "object" + end + end + + describe "server_info/1" do + test "returns server name and version" do + info = Backend.server_info(AnubisTestServer) + assert info["name"] == "anubis-test" + assert info["version"] == "1.0.0" + end + end + + describe "call_tool/4" do + test "text result" do + assert {:ok, result} = Backend.call_tool(AnubisTestServer, "add", %{"x" => 5, "y" => 3}, %{}) + assert result["content"] == [%{"type" => "text", "text" => "8"}] + assert result["isError"] == false + end + + test "JSON result for maps" do + assert {:ok, result} = + Backend.call_tool(AnubisTestServer, "return_map", %{"key" => "hello"}, %{}) + + [%{"type" => "text", "text" => json}] = result["content"] + decoded = Jason.decode!(json) + assert decoded["key"] == "hello" + end + + test "error result" do + assert {:ok, result} = + Backend.call_tool(AnubisTestServer, "failing_tool", %{}, %{}) + + assert result["isError"] == true + [%{"type" => "text", "text" => text}] = result["content"] + assert text =~ "Something went wrong" + end + + test "unknown tool" do + assert {:error, msg} = Backend.call_tool(AnubisTestServer, "nonexistent", %{}, %{}) + assert msg =~ "nonexistent" + end + + test "exception handling" do + assert {:ok, result} = Backend.call_tool(AnubisTestServer, "raise_tool", %{}, %{}) + assert result["isError"] == true + [%{"type" => "text", "text" => text}] = result["content"] + assert text =~ "kaboom" + end + + test "passes assigns to tool" do + defmodule AssignsTool do + @moduledoc false + def __tool_name__, do: "whoami" + def __description__, do: "Returns user" + def input_schema, do: %{"type" => "object"} + + def execute(_params, assigns) do + case assigns do + %{user: user} -> {:ok, "User: #{user}"} + _ -> {:error, "No user"} + end + end + end + + defmodule AssignsServer do + @moduledoc false + def __tool_server__, do: %{name: "assigns-test", tools: [AssignsTool]} + end + + assert {:ok, result} = Backend.call_tool(AssignsServer, "whoami", %{}, %{user: "alice"}) + assert result["content"] == [%{"type" => "text", "text" => "User: alice"}] + end + end + + describe "compatible?/1" do + test "returns false for regular modules" do + refute Backend.compatible?(String) + end + end +end diff --git a/test/claude_code/mcp/backend/hermes_test.exs b/test/claude_code/mcp/backend/hermes_test.exs new file mode 100644 index 00000000..3275f8da --- /dev/null +++ b/test/claude_code/mcp/backend/hermes_test.exs @@ -0,0 +1,30 @@ +defmodule ClaudeCode.MCP.Backend.HermesTest do + use ExUnit.Case, async: true + + alias ClaudeCode.MCP.Backend.Hermes, as: Backend + + describe "compatible?/1" do + test "returns true for modules with start_link/1 that are not SDK servers" do + defmodule FakeHermesModule do + @moduledoc false + @behaviour Hermes.Server + + def start_link(_opts), do: {:ok, self()} + end + + assert Backend.compatible?(FakeHermesModule) + end + + test "returns false for SDK server modules" do + refute Backend.compatible?(ClaudeCode.TestTools) + end + + test "returns false for regular modules" do + refute Backend.compatible?(String) + end + + test "returns false for non-existent modules" do + refute Backend.compatible?(DoesNotExist) + end + end +end diff --git a/test/claude_code/mcp/mcp_test.exs b/test/claude_code/mcp/mcp_test.exs new file mode 100644 index 00000000..fe4e8e0f --- /dev/null +++ b/test/claude_code/mcp/mcp_test.exs @@ -0,0 +1,41 @@ +defmodule ClaudeCode.MCPTest do + use ExUnit.Case, async: true + + alias ClaudeCode.MCP + + describe "backend_for/1" do + test "returns :sdk for SDK server modules" do + assert MCP.backend_for(ClaudeCode.TestTools) == :sdk + end + + test "returns {:subprocess, Backend.Anubis} for Anubis subprocess modules" do + defmodule FakeAnubisServer do + @moduledoc false + @behaviour Anubis.Server + + def start_link(_opts), do: {:ok, self()} + end + + assert MCP.backend_for(FakeAnubisServer) == {:subprocess, ClaudeCode.MCP.Backend.Anubis} + end + + test "returns {:subprocess, Backend.Hermes} for Hermes subprocess modules" do + defmodule FakeHermesServer do + @moduledoc false + @behaviour Hermes.Server + + def start_link(_opts), do: {:ok, self()} + end + + assert MCP.backend_for(FakeHermesServer) == {:subprocess, ClaudeCode.MCP.Backend.Hermes} + end + + test "returns :unknown for unrecognized modules" do + assert MCP.backend_for(String) == :unknown + end + + test "returns :unknown for non-existent modules" do + assert MCP.backend_for(DoesNotExist) == :unknown + end + end +end diff --git a/test/claude_code/mcp/router_test.exs b/test/claude_code/mcp/router_test.exs index 004b6b12..2cb3fe20 100644 --- a/test/claude_code/mcp/router_test.exs +++ b/test/claude_code/mcp/router_test.exs @@ -222,11 +222,11 @@ defmodule ClaudeCode.MCP.RouterTest do @moduledoc false use Server, name: "scoped" - tool :whoami, "Returns the current user from frame assigns" do - def execute(_params, frame) do - case frame.assigns do + tool :whoami, "Returns the current user from assigns" do + def execute(_params, assigns) do + case assigns do %{scope: %{user: user}} -> {:ok, "Current user: #{user}"} - _ -> {:error, "No scope in frame"} + _ -> {:error, "No scope"} end end end @@ -259,7 +259,7 @@ defmodule ClaudeCode.MCP.RouterTest do assert response["result"]["isError"] == true [%{"type" => "text", "text" => text}] = response["result"]["content"] - assert text =~ "No scope in frame" + assert text =~ "No scope" end end end diff --git a/test/claude_code/mcp/server_test.exs b/test/claude_code/mcp/server_test.exs index df3a1c75..33645319 100644 --- a/test/claude_code/mcp/server_test.exs +++ b/test/claude_code/mcp/server_test.exs @@ -7,7 +7,6 @@ defmodule ClaudeCode.MCP.ServerTest do alias ClaudeCode.TestTools.GetTime alias ClaudeCode.TestTools.Greet alias ClaudeCode.TestTools.ReturnMap - alias Hermes.Server.Response describe "__tool_server__/0" do test "returns server metadata with name and tool modules" do @@ -49,7 +48,6 @@ defmodule ClaudeCode.MCP.ServerTest do test "tool with no fields has empty object schema" do schema = GetTime.input_schema() - assert schema["type"] == "object" end @@ -59,43 +57,46 @@ defmodule ClaudeCode.MCP.ServerTest do end end - describe "execute/2 wrapping" do - setup do - %{frame: %Hermes.Server.Frame{assigns: %{}}} + describe "execute/2" do + test "returns {:ok, value} for text results" do + assert {:ok, "7"} = Add.execute(%{x: 3, y: 4}, %{}) end - test "wraps {:ok, binary} into {:reply, text_response, frame}", %{frame: frame} do - assert {:reply, response, ^frame} = - Add.execute(%{x: 3, y: 4}, frame) - - protocol = Response.to_protocol(response) - assert protocol["content"] == [%{"type" => "text", "text" => "7"}] - assert protocol["isError"] == false + test "returns {:ok, map} for map results" do + assert {:ok, %{key: "test", value: "data"}} = ReturnMap.execute(%{key: "test"}, %{}) end - test "wraps {:ok, map} into {:reply, json_response, frame}", %{frame: frame} do - assert {:reply, response, ^frame} = - ReturnMap.execute(%{key: "test"}, frame) + test "returns {:error, message} for failing tools" do + assert {:error, "Something went wrong"} = FailingTool.execute(%{}, %{}) + end - protocol = Response.to_protocol(response) - [%{"type" => "text", "text" => json_text}] = protocol["content"] - decoded = Jason.decode!(json_text) - assert decoded["key"] == "test" - assert decoded["value"] == "data" + test "execute/1 tools ignore assigns" do + assert {:ok, time_str} = GetTime.execute(%{}, %{}) + assert {:ok, _, _} = DateTime.from_iso8601(time_str) end + end - test "wraps {:error, message} into {:error, Error, frame}", %{frame: frame} do - assert {:error, %Hermes.MCP.Error{message: "Something went wrong"}, ^frame} = - FailingTool.execute(%{}, frame) + describe "execute/2 with assigns" do + defmodule AssignsTools do + @moduledoc false + use Server, name: "assigns-test" + + tool :whoami, "Returns user from assigns" do + def execute(_params, assigns) do + case assigns do + %{user: user} -> {:ok, "User: #{user}"} + _ -> {:error, "No user"} + end + end + end end - test "execute/1 tools receive params without frame", %{frame: frame} do - assert {:reply, response, ^frame} = - GetTime.execute(%{}, frame) + test "passes assigns to arity-2 execute" do + assert {:ok, "User: alice"} = AssignsTools.Whoami.execute(%{}, %{user: "alice"}) + end - protocol = Response.to_protocol(response) - [%{"type" => "text", "text" => time_str}] = protocol["content"] - assert {:ok, _, _} = DateTime.from_iso8601(time_str) + test "empty assigns when not provided" do + assert {:error, "No user"} = AssignsTools.Whoami.execute(%{}, %{}) end end