Version: 0.11.0 | Last Updated: 2026-02-06


Table of Contents

  1. What is MCP (Model Context Protocol)
  2. MCP Server Types
  3. Creating Tools with deftool Macro
  4. Tool Schema (JSON Schema Format)
  5. Tool Execution and Return Values
  6. Creating SDK MCP Servers
  7. Tool Naming Convention
  8. Using External MCP Servers
  9. Combining MCP with Hooks and Permissions
  10. Best Practices

What is MCP (Model Context Protocol)

MCP (Model Context Protocol) is an open protocol that enables integration between LLM applications and external tools/data sources. It uses JSON-RPC 2.0 for communication and provides a standardized way to extend Claude's capabilities with custom tools.

Core Concepts

TermDescription
MCP ServerA provider of tools, resources, or prompts
ToolA function/capability that Claude can invoke
ResourceData or context that Claude can access
HostThe LLM application (Claude Agent SDK)
ClientThe MCP client that connects host to server

Protocol Notes (Python Parity)

  • SDK MCP routing implements initialize, tools/list, tools/call, and notifications/initialized.
  • resources/list and prompts/list return JSON-RPC method-not-found errors (matching the Python SDK).
  • Tool names are kept as strings end-to-end; the registry normalizes tool names to strings to avoid atom leakage.

Why Use MCP?

  • Standardized Protocol: Works across different LLM applications
  • In-Process Execution: SDK MCP tools run without subprocess overhead
  • Type Safety: JSON Schema validation for tool inputs
  • Lifecycle Hooks: Integrate with the SDK's hook system for validation/logging
  • Security: Fine-grained permission control over tool execution

MCP Server Types

The Claude Agent SDK supports two types of MCP servers:

1. SDK MCP Servers (In-Process)

Run directly within your Elixir application with no subprocess overhead.

# Define tools inline and execute them in your BEAM VM
server = ClaudeAgentSDK.create_sdk_mcp_server(
  name: "my-tools",
  version: "1.0.0",
  tools: [MyTools.Calculator, MyTools.DateHelper]
)

Benefits:

  • Zero subprocess overhead
  • Direct access to your application state
  • Native Elixir error handling
  • Hot code reloading support

2. External MCP Servers (Subprocess)

Traditional MCP servers running as separate processes via stdio transport.

# Use existing MCP server packages
external_server = %{
  type: :stdio,
  command: "npx",
  args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"]
}

Benefits:

  • Use existing MCP server ecosystem
  • Language-agnostic (Node.js, Python, etc.)
  • Process isolation

Comparison

FeatureSDK MCPExternal MCP
OverheadMinimalSubprocess spawn
State AccessDirectIPC required
LanguageElixir onlyAny
Hot ReloadYesNo
Error HandlingNativeJSON-RPC
Existing EcosystemBuild your ownNPM packages

Creating Tools with deftool Macro

The deftool macro provides a clean DSL for defining MCP tools.

Basic Syntax

defmodule MyTools do
  use ClaudeAgentSDK.Tool

  deftool :tool_name, "Tool description", %{schema} do
    def execute(input) do
      # Your implementation
      {:ok, %{"content" => [%{"type" => "text", "text" => "result"}]}}
    end
  end
end

Complete Example

defmodule Calculator do
  use ClaudeAgentSDK.Tool

  deftool :add, "Add two numbers together", %{
    type: "object",
    properties: %{
      a: %{type: "number", description: "First number to add"},
      b: %{type: "number", description: "Second number to add"}
    },
    required: ["a", "b"]
  } do
    def execute(%{"a" => a, "b" => b}) do
      result = a + b
      {:ok, %{"content" => [%{"type" => "text", "text" => "#{a} + #{b} = #{result}"}]}}
    end
  end

  deftool :multiply, "Multiply two numbers", %{
    type: "object",
    properties: %{
      a: %{type: "number", description: "First number"},
      b: %{type: "number", description: "Second number"}
    },
    required: ["a", "b"]
  } do
    def execute(%{"a" => a, "b" => b}) do
      result = a * b
      {:ok, %{"content" => [%{"type" => "text", "text" => "#{a} * #{b} = #{result}"}]}}
    end
  end

  deftool :factorial, "Calculate factorial of a number", %{
    type: "object",
    properties: %{
      n: %{type: "integer", description: "Non-negative integer", minimum: 0, maximum: 20}
    },
    required: ["n"]
  } do
    def execute(%{"n" => n}) when n >= 0 do
      result = factorial_calc(n)
      {:ok, %{"content" => [%{"type" => "text", "text" => "#{n}! = #{result}"}]}}
    end

    def execute(%{"n" => n}) do
      {:error, "Invalid input: #{n} must be non-negative"}
    end

    defp factorial_calc(0), do: 1
    defp factorial_calc(n), do: n * factorial_calc(n - 1)
  end
end

Tool Annotations

The deftool macro accepts a 5th argument with options, including :annotations for MCP tool annotations. Annotations are metadata hints for the client about tool behavior:

deftool :read_file, "Read a file from disk", %{
  type: "object",
  properties: %{
    path: %{type: "string", description: "File path to read"}
  },
  required: ["path"]
},
  annotations: %{
    readOnlyHint: true,
    destructiveHint: false,
    idempotentHint: true,
    openWorldHint: false,
    title: "Read File"
  } do
  def execute(%{"path" => path}) do
    case File.read(path) do
      {:ok, content} ->
        {:ok, %{"content" => [%{"type" => "text", "text" => content}]}}
      {:error, reason} ->
        {:error, "Failed to read #{path}: #{inspect(reason)}"}
    end
  end
end

Standard annotation fields:

AnnotationTypeDescription
titlestringHuman-readable display name
readOnlyHintbooleanTool does not modify state
destructiveHintbooleanTool may perform destructive operations
idempotentHintbooleanRepeated calls produce the same result
openWorldHintbooleanTool interacts with external entities

Annotations are included in tools/list responses and help clients make informed decisions about tool execution.

What deftool Generates

The macro creates a nested module for each tool:

# deftool :add, ... generates:
defmodule Calculator.Add do
  @name :add
  @description "Add two numbers together"
  @schema %{...}

  def name, do: @name
  def description, do: @description
  def schema, do: @schema

  # Your execute/1 implementation
  def execute(%{"a" => a, "b" => b}), do: ...
end

Tool Schema (JSON Schema Format)

Tool schemas follow JSON Schema draft-07 format with some MCP-specific conventions.

Simple Schema Helper

For common tool patterns, use the simple_schema/1 helper to reduce boilerplate:

alias ClaudeAgentSDK.Tool

# List of atoms - all string properties, all required
Tool.simple_schema([:name, :path])
# => %{type: "object", properties: %{name: %{type: "string"}, path: %{type: "string"}}, required: ["name", "path"]}

# Keyword list with types
Tool.simple_schema(name: :string, count: :number, enabled: :boolean)

# With descriptions
Tool.simple_schema(
  name: {:string, "User's full name"},
  age: {:number, "Age in years"}
)

# Optional fields
Tool.simple_schema(
  name: :string,
  email: {:string, optional: true}
)

Supported types: :string, :number, :integer, :boolean, :array, :object

Example in deftool:

defmodule Calculator do
  use ClaudeAgentSDK.Tool
  alias ClaudeAgentSDK.Tool

  deftool :add,
          "Add two numbers",
          Tool.simple_schema(
            a: {:number, "First number"},
            b: {:number, "Second number"}
          ) do
    def execute(%{"a" => a, "b" => b}) do
      {:ok, %{"content" => [%{"type" => "text", "text" => "#{a} + #{b} = #{a + b}"}]}}
    end
  end
end

Schema Structure

%{
  type: "object",           # Always "object" for tool inputs
  properties: %{            # Define each input parameter
    param_name: %{
      type: "string",       # Type: string, number, integer, boolean, array, object
      description: "...",   # Human-readable description (shown to Claude)
      enum: ["a", "b"],     # Optional: allowed values
      default: "value"      # Optional: default value
    }
  },
  required: ["param_name"]  # List of required parameters
}

Common Property Types

String

%{
  type: "string",
  description: "A text value",
  minLength: 1,
  maxLength: 1000,
  pattern: "^[a-z]+$"  # Regex pattern
}

Number / Integer

%{
  type: "number",        # or "integer"
  description: "A numeric value",
  minimum: 0,
  maximum: 100,
  exclusiveMinimum: 0,
  exclusiveMaximum: 100
}

Boolean

%{
  type: "boolean",
  description: "True or false",
  default: false
}

Array

%{
  type: "array",
  description: "List of items",
  items: %{type: "string"},  # Type of array elements
  minItems: 1,
  maxItems: 10,
  uniqueItems: true
}

Enum

%{
  type: "string",
  description: "One of the allowed values",
  enum: ["option1", "option2", "option3"]
}

Nested Object

%{
  type: "object",
  properties: %{
    config: %{
      type: "object",
      properties: %{
        enabled: %{type: "boolean"},
        timeout: %{type: "integer"}
      }
    }
  }
}

Complete Schema Example

deftool :search_files, "Search for files matching criteria", %{
  type: "object",
  properties: %{
    directory: %{
      type: "string",
      description: "Directory to search in"
    },
    pattern: %{
      type: "string",
      description: "Glob pattern to match files",
      default: "*"
    },
    recursive: %{
      type: "boolean",
      description: "Search recursively",
      default: true
    },
    file_types: %{
      type: "array",
      description: "File extensions to include",
      items: %{type: "string"},
      default: []
    },
    max_results: %{
      type: "integer",
      description: "Maximum number of results",
      minimum: 1,
      maximum: 1000,
      default: 100
    }
  },
  required: ["directory"]
} do
  def execute(input) do
    # Implementation
  end
end

Tool Execution and Return Values

Success Return Format

Tools must return a tuple with content blocks:

{:ok, %{
  "content" => [
    %{"type" => "text", "text" => "Your result here"}
  ]
}}

Multiple Content Blocks

{:ok, %{
  "content" => [
    %{"type" => "text", "text" => "Primary result"},
    %{"type" => "text", "text" => "Additional information"}
  ]
}}

Error Return Format

For handled errors:

{:error, "Error description"}

For errors that should be visible to Claude:

{:ok, %{
  "content" => [
    %{"type" => "text", "text" => "Error: Invalid input"}
  ],
  "is_error" => true
}}

Return Value Examples

Simple Text Result

def execute(%{"query" => query}) do
  result = perform_search(query)
  {:ok, %{"content" => [%{"type" => "text", "text" => result}]}}
end

JSON Result

def execute(%{"id" => id}) do
  data = fetch_data(id)
  json = Jason.encode!(data, pretty: true)
  {:ok, %{"content" => [%{"type" => "text", "text" => json}]}}
end

Structured Result with Metadata

def execute(input) do
  {result, metadata} = process(input)

  content = """
  ## Result
  #{result}

  ## Metadata
  - Duration: #{metadata.duration_ms}ms
  - Items processed: #{metadata.count}
  """

  {:ok, %{"content" => [%{"type" => "text", "text" => content}]}}
end

Error Handling

def execute(%{"file_path" => path}) do
  case File.read(path) do
    {:ok, content} ->
      {:ok, %{"content" => [%{"type" => "text", "text" => content}]}}

    {:error, :enoent} ->
      {:error, "File not found: #{path}"}

    {:error, :eacces} ->
      {:ok, %{
        "content" => [%{"type" => "text", "text" => "Permission denied: #{path}"}],
        "is_error" => true
      }}

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

Creating SDK MCP Servers

Use ClaudeAgentSDK.create_sdk_mcp_server/1 to create in-process MCP servers.

Basic Usage

server = ClaudeAgentSDK.create_sdk_mcp_server(
  name: "my-tools",
  version: "1.0.0",
  tools: [MyTools.Add, MyTools.Multiply]
)

Server Configuration

OptionTypeRequiredDescription
nameStringYesUnique server identifier
versionStringNoServer version (defaults to 1.0.0)
toolsListYesList of tool modules
supervisorpid/nameNoDynamicSupervisor to start the registry under

Server Structure

The returned server map contains:

%{
  type: :sdk,                    # Identifies as SDK MCP server
  name: "my-tools",              # Server name
  version: "1.0.0",              # Server version
  registry_pid: #PID<0.123.0>    # Tool registry process
}

Direct registry calls use string tool names:

{:ok, result} =
  ClaudeAgentSDK.Tool.Registry.execute_tool(server.registry_pid, "add", %{"a" => 1, "b" => 2})

Using with Options

defmodule MathServer do
  def create do
    ClaudeAgentSDK.create_sdk_mcp_server(
      name: "math",
      version: "1.0.0",
      tools: [Calculator.Add, Calculator.Multiply, Calculator.Factorial]
    )
  end
end

# In your query
server = MathServer.create()

options = %ClaudeAgentSDK.Options{
  mcp_servers: %{"math" => server},
  allowed_tools: ["mcp__math__add", "mcp__math__multiply", "mcp__math__factorial"],
  permission_mode: :bypass_permissions
}

ClaudeAgentSDK.query("What is 15 + 27, then multiply by 3?", options)
|> Enum.to_list()

You can also pass a JSON string or file path via mcp_servers (alias for mcp_config):

options = %Options{mcp_servers: "/path/to/mcp.json"}

Complete Example

defmodule MyApp.Tools.DateTime do
  use ClaudeAgentSDK.Tool

  deftool :current_time, "Get current time in specified timezone", %{
    type: "object",
    properties: %{
      timezone: %{
        type: "string",
        description: "Timezone (e.g., 'UTC', 'America/New_York')",
        default: "UTC"
      },
      format: %{
        type: "string",
        description: "Output format",
        enum: ["iso8601", "human", "unix"],
        default: "iso8601"
      }
    },
    required: []
  } do
    def execute(%{"timezone" => tz, "format" => format}) do
      now = DateTime.utc_now()

      result = case format do
        "iso8601" -> DateTime.to_iso8601(now)
        "human" -> Calendar.strftime(now, "%B %d, %Y at %H:%M:%S")
        "unix" -> DateTime.to_unix(now) |> to_string()
      end

      {:ok, %{"content" => [%{"type" => "text", "text" => "Current time (#{tz}): #{result}"}]}}
    end

    def execute(input) do
      execute(Map.merge(%{"timezone" => "UTC", "format" => "iso8601"}, input))
    end
  end
end

defmodule MyApp.ToolServer do
  alias MyApp.Tools.DateTime

  def start do
    ClaudeAgentSDK.create_sdk_mcp_server(
      name: "datetime",
      version: "1.0.0",
      tools: [DateTime.CurrentTime]
    )
  end
end

# Usage
server = MyApp.ToolServer.start()

options = %ClaudeAgentSDK.Options{
  mcp_servers: %{"datetime" => server},
  allowed_tools: ["mcp__datetime__current_time"]
}

ClaudeAgentSDK.query("What time is it?", options)

Tool Naming Convention

MCP tools follow a strict naming convention: mcp__<server>__<tool>

Format

mcp__<server_name>__<tool_name>
     ^           ^
     |           |
     |           +-- Double underscore separator
     +-- Prefix for MCP tools

Examples

Server NameTool NameFull Tool Name
calculatoraddmcp__calculator__add
math-toolsmultiplymcp__math-tools__multiply
my_serverdo_somethingmcp__my_server__do_something

Using Tool Names

In allowed_tools

options = %Options{
  mcp_servers: %{"calc" => server},
  allowed_tools: [
    "mcp__calc__add",
    "mcp__calc__multiply",
    "mcp__calc__divide"
  ]
}

In Hooks

alias ClaudeAgentSDK.Hooks.{Matcher, Output}

hooks = %{
  pre_tool_use: [
    # Match specific MCP tool
    Matcher.new("mcp__calc__add", [&log_add/3]),

    # Match all tools from a server (regex)
    Matcher.new("mcp__calc__.*", [&audit_calc/3]),

    # Match all MCP tools
    Matcher.new("mcp__.*", [&log_mcp_usage/3])
  ]
}

Tool Name in Hook Input

def my_hook(input, _tool_use_id, _context) do
  case input["tool_name"] do
    "mcp__calc__add" -> handle_add(input)
    "mcp__calc__multiply" -> handle_multiply(input)
    _ -> Output.allow()
  end
end

Using External MCP Servers

External MCP servers run as separate processes and communicate via stdio transport.

Basic Configuration

external_server = %{
  type: :stdio,
  command: "npx",
  args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"]
}

options = %ClaudeAgentSDK.Options{
  mcp_servers: %{"filesystem" => external_server}
}

Server Configuration Fields

FieldTypeRequiredDescription
type:stdioYesTransport type (currently only stdio)
commandStringYesCommand to execute
argsListNoCommand arguments
envMapNoEnvironment variables

Common External Servers

Filesystem Server

%{
  type: :stdio,
  command: "npx",
  args: ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"]
}

GitHub Server

%{
  type: :stdio,
  command: "npx",
  args: ["-y", "@modelcontextprotocol/server-github"],
  env: %{"GITHUB_TOKEN" => System.get_env("GITHUB_TOKEN")}
}

Custom Server

%{
  type: :stdio,
  command: "python",
  args: ["-m", "my_mcp_server"],
  env: %{"CONFIG_PATH" => "/etc/my_server/config.json"}
}

Combining SDK and External Servers

# SDK MCP server
calc_server = ClaudeAgentSDK.create_sdk_mcp_server(
  name: "calc",
  version: "1.0.0",
  tools: [Calculator.Add, Calculator.Multiply]
)

# External MCP server
fs_server = %{
  type: :stdio,
  command: "npx",
  args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/workspace"]
}

options = %ClaudeAgentSDK.Options{
  mcp_servers: %{
    "calc" => calc_server,
    "filesystem" => fs_server
  },
  allowed_tools: [
    "mcp__calc__add",
    "mcp__calc__multiply",
    "mcp__filesystem__read_file",
    "mcp__filesystem__write_file"
  ]
}

Combining MCP with Hooks and Permissions

MCP tools integrate seamlessly with the SDK's hook and permission systems.

Pre-Tool Hooks for MCP

alias ClaudeAgentSDK.Hooks.{Matcher, Output}

def validate_mcp_tool(input, _tool_use_id, _context) do
  case input do
    %{"tool_name" => "mcp__calc__" <> _op, "tool_input" => params} ->
      # Validate numeric inputs
      if valid_numbers?(params) do
        Output.allow()
      else
        Output.deny("Invalid numeric input")
      end

    _ ->
      Output.allow()
  end
end

hooks = %{
  pre_tool_use: [
    Matcher.new("mcp__calc__.*", [&validate_mcp_tool/3])
  ]
}

Post-Tool Hooks for Auditing

def audit_mcp_usage(input, tool_use_id, _context) do
  Logger.info("MCP Tool Used",
    tool: input["tool_name"],
    tool_use_id: tool_use_id,
    input: input["tool_input"],
    result: input["tool_response"]
  )

  %{}  # Don't modify behavior
end

hooks = %{
  post_tool_use: [
    Matcher.new("mcp__.*", [&audit_mcp_usage/3])
  ]
}

Permission Callbacks

alias ClaudeAgentSDK.Permission.Result

permission_callback = fn context ->
  case context.tool_name do
    "mcp__filesystem__write_file" ->
      # Only allow writes to specific directory
      path = context.tool_input["path"]
      if String.starts_with?(path, "/tmp/workspace/") do
        Result.allow()
      else
        Result.deny("Writes only allowed in /tmp/workspace/")
      end

    "mcp__calc__" <> _ ->
      # Always allow calculator tools
      Result.allow()

    _ ->
      Result.allow()
  end
end

options = %ClaudeAgentSDK.Options{
  mcp_servers: %{"calc" => calc_server, "filesystem" => fs_server},
  can_use_tool: permission_callback,
  permission_mode: :default
}

Complete Integration Example

defmodule MyApp.SecureMCPSetup do
  alias ClaudeAgentSDK.{Options, Permission.Result}
  alias ClaudeAgentSDK.Hooks.{Matcher, Output}

  def build_options do
    calc_server = build_calc_server()
    hooks = build_hooks()
    permission_callback = build_permission_callback()

    %Options{
      mcp_servers: %{"calc" => calc_server},
      allowed_tools: ["mcp__calc__add", "mcp__calc__multiply"],
      hooks: hooks,
      can_use_tool: permission_callback,
      permission_mode: :default
    }
  end

  defp build_calc_server do
    ClaudeAgentSDK.create_sdk_mcp_server(
      name: "calc",
      version: "1.0.0",
      tools: [Calculator.Add, Calculator.Multiply]
    )
  end

  defp build_hooks do
    %{
      pre_tool_use: [
        # Log all MCP tool invocations
        Matcher.new("mcp__.*", [&log_invocation/3]),
        # Validate calculator inputs
        Matcher.new("mcp__calc__.*", [&validate_numbers/3])
      ],
      post_tool_use: [
        # Audit all MCP results
        Matcher.new("mcp__.*", [&audit_result/3])
      ]
    }
  end

  defp build_permission_callback do
    fn context ->
      # Add rate limiting for MCP tools
      if rate_limit_exceeded?(context.tool_name) do
        Result.deny("Rate limit exceeded")
      else
        Result.allow()
      end
    end
  end

  defp log_invocation(input, tool_use_id, _context) do
    IO.puts("[MCP] Invoking #{input["tool_name"]} (#{tool_use_id})")
    %{}
  end

  defp validate_numbers(%{"tool_input" => params}, _id, _ctx) do
    if Enum.all?(Map.values(params), &is_number/1) do
      Output.allow()
    else
      Output.deny("All inputs must be numbers")
    end
  end
  defp validate_numbers(_, _, _), do: Output.allow()

  defp audit_result(input, tool_use_id, _context) do
    IO.puts("[MCP] Completed #{input["tool_name"]} (#{tool_use_id})")
    %{}
  end

  defp rate_limit_exceeded?(_tool_name), do: false
end

Best Practices

Tool Design

  1. Single Responsibility: Each tool should do one thing well
  2. Clear Descriptions: Write descriptions that help Claude understand when to use the tool
  3. Validate Inputs: Use JSON Schema constraints and runtime validation
  4. Handle Errors Gracefully: Return meaningful error messages
# Good: Clear, focused tool
deftool :get_user_by_id, "Fetch a user record by their unique ID", %{
  type: "object",
  properties: %{
    user_id: %{type: "string", description: "Unique user identifier (UUID format)"}
  },
  required: ["user_id"]
} do
  def execute(%{"user_id" => id}) do
    case Users.get(id) do
      {:ok, user} -> {:ok, %{"content" => [%{"type" => "text", "text" => format_user(user)}]}}
      {:error, :not_found} -> {:error, "User not found: #{id}"}
    end
  end
end

# Bad: Too broad, unclear purpose
deftool :do_stuff, "Does things with data", %{...}

Schema Design

  1. Use Descriptive Property Names: Self-documenting schemas
  2. Add Descriptions: Help Claude understand each parameter
  3. Set Constraints: Use min/max, patterns, enums
  4. Provide Defaults: For optional parameters
# Good: Well-documented schema
%{
  type: "object",
  properties: %{
    query: %{
      type: "string",
      description: "Search query string",
      minLength: 1,
      maxLength: 500
    },
    limit: %{
      type: "integer",
      description: "Maximum results to return",
      minimum: 1,
      maximum: 100,
      default: 10
    },
    sort_order: %{
      type: "string",
      description: "Result ordering",
      enum: ["asc", "desc"],
      default: "desc"
    }
  },
  required: ["query"]
}

Error Handling

  1. Pattern Match Inputs: Handle unexpected input gracefully
  2. Use is_error Flag: For errors Claude should know about
  3. Provide Context: Help Claude understand what went wrong
def execute(%{"file_path" => path}) do
  case File.read(path) do
    {:ok, content} ->
      {:ok, %{"content" => [%{"type" => "text", "text" => content}]}}

    {:error, :enoent} ->
      {:ok, %{
        "content" => [%{"type" => "text", "text" => "File not found: #{path}. Please check the path and try again."}],
        "is_error" => true
      }}

    {:error, :eacces} ->
      {:ok, %{
        "content" => [%{"type" => "text", "text" => "Permission denied reading: #{path}"}],
        "is_error" => true
      }}

    {:error, reason} ->
      {:error, "Unexpected error reading #{path}: #{inspect(reason)}"}
  end
end

# Always handle unmatched patterns
def execute(input) do
  {:error, "Invalid input format: #{inspect(input)}"}
end

Security

  1. Validate All Inputs: Never trust user/Claude input
  2. Use Hooks for Authorization: Integrate with your auth system
  3. Limit Scope: Only expose necessary functionality
  4. Audit Usage: Log tool invocations for monitoring
# Security hook example
def security_check(input, _tool_use_id, context) do
  tool = input["tool_name"]
  user = context["user_id"]

  if authorized?(user, tool) do
    Output.allow()
  else
    Logger.warn("Unauthorized tool access", tool: tool, user: user)
    Output.deny("Not authorized to use #{tool}")
  end
end

Performance

  1. Keep Tools Fast: Aim for < 100ms execution time
  2. Use Async for Slow Operations: Spawn tasks for long-running work
  3. Cache When Appropriate: Avoid redundant computations
  4. Set Timeouts: Prevent hanging operations
def execute(%{"url" => url}) do
  # Use Task with timeout for external calls
  task = Task.async(fn -> HTTPClient.get(url) end)

  case Task.yield(task, 5000) || Task.shutdown(task) do
    {:ok, {:ok, response}} ->
      {:ok, %{"content" => [%{"type" => "text", "text" => response.body}]}}

    {:ok, {:error, reason}} ->
      {:error, "HTTP request failed: #{reason}"}

    nil ->
      {:error, "Request timed out after 5 seconds"}
  end
end

Naming Conventions

  1. Use snake_case for Tool Names: calculate_total, not calculateTotal
  2. Use Descriptive Server Names: user-management, not um
  3. Group Related Tools: Single server with multiple related tools
# Good: Organized tool groupings
defmodule UserTools do
  use ClaudeAgentSDK.Tool

  deftool :get_user, "...", %{...}
  deftool :create_user, "...", %{...}
  deftool :update_user, "...", %{...}
  deftool :delete_user, "...", %{...}
end

server = ClaudeAgentSDK.create_sdk_mcp_server(
  name: "user-management",
  version: "1.0.0",
  tools: [UserTools.GetUser, UserTools.CreateUser, UserTools.UpdateUser, UserTools.DeleteUser]
)

Examples

See these example files for working implementations:

  • examples/sdk_mcp_tools_live.exs - Basic SDK MCP tools
  • examples/advanced_features/sdk_mcp_live_demo.exs - Comprehensive MCP demo
  • examples/streaming_tools/sdk_mcp_streaming.exs - Streaming with MCP tools

Quick Reference

Create a Tool

defmodule MyTool do
  use ClaudeAgentSDK.Tool

  deftool :name, "description", %{type: "object", properties: %{...}} do
    def execute(input), do: {:ok, %{"content" => [%{"type" => "text", "text" => "result"}]}}
  end
end

Create a Server

server = ClaudeAgentSDK.create_sdk_mcp_server(
  name: "server-name",
  version: "1.0.0",
  tools: [MyTool.Name]
)

Use in Query

options = %ClaudeAgentSDK.Options{
  mcp_servers: %{"server-name" => server},
  allowed_tools: ["mcp__server-name__name"]
}

ClaudeAgentSDK.query("prompt", options)

Documentation

MCP Status API

Query the MCP server status at runtime:

{:ok, status} = ClaudeAgentSDK.Client.get_mcp_status(client)
IO.inspect(status, label: "MCP status")

Async Tool Dispatch

SDK MCP tools/call requests are dispatched asynchronously via TaskSupervisor, so long-running tool execution no longer blocks the Client callback path. Configure the execution timeout via application config:

config :claude_agent_sdk, tool_execution_timeout_ms: 30_000

Documentation


Need Help?

iex> h ClaudeAgentSDK.Tool
iex> h ClaudeAgentSDK.create_sdk_mcp_server
iex> h ClaudeAgentSDK.Options