ExMCP.Client.Handler behaviour (ex_mcp v0.9.0)

View Source

This module implements the standard MCP specification.

Behaviour for handling server-to-client requests in MCP.

The MCP protocol supports bi-directional communication where servers can make requests to clients. This behaviour defines the callbacks that a client handler must implement to respond to these requests.

Example

defmodule MyClientHandler do
  @behaviour ExMCP.Client.Handler

  @impl true
  def init(args) do
    {:ok, %{roots: [%{uri: "file:///home/user", name: "Home"}]}}
  end

  @impl true
  def handle_ping(state) do
    {:ok, %{}, state}
  end

  @impl true
  def handle_list_roots(state) do
    {:ok, state.roots, state}
  end

  @impl true
  def handle_create_message(params, state) do
    # Show to user for approval, then sample LLM
    case get_user_approval(params) do
      :approved ->
        result = sample_llm(params)
        {:ok, result, state}
      :denied ->
        {:error, "User denied the request", state}
    end
  end
end

Summary

Callbacks

Handles a request from the server to sample an LLM.

Handles an elicitation request from the server.

Handles a request to list the client's root directories.

Handles a ping request from the server.

Handles a task status notification from the server.

Handles a URL-mode elicitation request from the server.

Called when the client handler is started.

Called when the handler process is about to terminate.

Types

error_info()

@type error_info() :: String.t() | map()

state()

@type state() :: any()

Callbacks

handle_create_message(params, state)

@callback handle_create_message(params :: map(), state()) ::
  {:ok, map(), state()} | {:error, error_info(), state()}

Handles a request from the server to sample an LLM.

The client has full discretion over which model to select and should inform the user before beginning sampling (human in the loop).

Parameters

The params map contains:

  • messages - List of messages to send to the LLM
  • modelPreferences (optional) - Server's model preferences
  • systemPrompt (optional) - System prompt to use
  • includeContext (optional) - Whether to include MCP context
  • temperature (optional) - Sampling temperature
  • maxTokens (optional) - Maximum tokens to sample
  • tools (optional, 2025-11-25) - List of tool definitions the LLM may call. Each tool has name, description, and inputSchema fields.
  • toolChoice (optional, 2025-11-25) - Controls how the LLM uses tools. A map with a type key: "auto", "none", or "tool" (with name).

Response

The result should contain:

  • role - The role of the created message (usually "assistant")
  • content - The content of the message. May include tool_use and tool_result content blocks when tools are provided.
  • model - The model that was used

Human-in-the-Loop

This callback MUST implement human-in-the-loop approval. The handler can use the ExMCP.Approval behaviour for this, or implement its own approval mechanism. The user must be informed about the sampling request and have the opportunity to approve or deny it.

Example

def handle_create_message(params, state) do
  case get_user_approval(params) do
    :approved ->
      result = %{
        role: "assistant",
        content: %{type: "text", text: "Hello!"},
        model: "gpt-4"
      }
      {:ok, result, state}
    :denied ->
      {:error, "User denied sampling request", state}
  end
end

Example with Tool Calling (2025-11-25)

def handle_create_message(%{"tools" => tools} = params, state) when is_list(tools) do
  # Pass tools to the LLM and handle tool_use responses
  result = %{
    role: "assistant",
    content: %{type: "tool_use", id: "call_1", name: "get_weather", input: %{"city" => "NYC"}},
    model: "gpt-4"
  }
  {:ok, result, state}
end

handle_elicitation_create(message, requested_schema, state)

(optional)
@callback handle_elicitation_create(
  message :: String.t(),
  requested_schema :: map(),
  state()
) ::
  {:ok, map(), state()} | {:error, error_info(), state()}

Handles an elicitation request from the server.

This is a stable protocol feature available in MCP 2025-06-18 and later. The server is requesting additional information from the user through a structured form with JSON schema validation.

Parameters

  • message - Human-readable message explaining what information is needed
  • requested_schema - JSON schema defining the expected response structure

Response

The result should contain:

  • action - One of "accept", "decline", or "cancel"
  • content (optional) - The user's response data (only for "accept")

Example

def handle_elicitation_create(message, requested_schema, state) do
  # Present the elicitation to the user
  case present_elicitation_to_user(message, requested_schema) do
    {:accept, data} ->
      {:ok, %{action: "accept", content: data}, state}
    :decline ->
      {:ok, %{action: "decline"}, state}
    :cancel ->
      {:ok, %{action: "cancel"}, state}
  end
end

handle_list_roots(state)

@callback handle_list_roots(state()) ::
  {:ok, [map()], state()} | {:error, error_info(), state()}

Handles a request to list the client's root directories.

This is called when the server needs to understand what file system locations the client has access to.

Response

The roots should be a list of maps with:

  • uri (required) - The URI of the root (must start with "file://")
  • name (optional) - Human-readable name for the root

Example

def handle_list_roots(state) do
  roots = [
    %{uri: "file:///home/user", name: "Home"},
    %{uri: "file:///projects", name: "Projects"}
  ]
  {:ok, roots, state}
end

handle_ping(state)

@callback handle_ping(state()) :: {:ok, map(), state()} | {:error, error_info(), state()}

Handles a ping request from the server.

The client should respond promptly to indicate it's still alive.

Response

  • {:ok, result, new_state} - Success with empty result
  • {:error, reason, new_state} - Error occurred

handle_task_status(notification, state)

(optional)
@callback handle_task_status(notification :: map(), state()) ::
  {:ok, state()} | {:error, error_info(), state()}

Handles a task status notification from the server.

Called when the server sends a notification about a task state change. Available in protocol version 2025-11-25.

handle_url_elicitation(message, url, state)

(optional)
@callback handle_url_elicitation(message :: String.t(), url :: String.t(), state()) ::
  {:ok, map(), state()} | {:error, error_info(), state()}

Handles a URL-mode elicitation request from the server.

Instead of a form schema, the server sends a URL for the client to navigate to. Available in protocol version 2025-11-25.

Parameters

  • message - Human-readable message explaining what information is needed
  • url - URL for the client to open/navigate to

Response

Same as handle_elicitation_create - action and optional content.

init(args)

@callback init(args :: any()) :: {:ok, state()}

Called when the client handler is started.

Return {:ok, state} to initialize the handler state.

terminate(reason, state)

(optional)
@callback terminate(reason :: term(), state()) :: :ok

Called when the handler process is about to terminate.