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

View Source

This module implements the standard MCP specification.

Behaviour for implementing MCP server handlers.

This behaviour defines callbacks for handling all MCP protocol operations including tools, resources, prompts, and the new sampling/LLM integration features.

The handler behaviour pattern is an implementation detail but all callbacks correspond to official MCP protocol methods.

Metadata (_meta) Support

Handlers receive metadata passed by clients through the _meta field:

  • For handle_call_tool/3: The _meta field is included in the arguments map
  • For list operations: The cursor parameter may be a map containing _meta
  • For other operations: Check the params for _meta field

Example accessing progress token in a tool:

def handle_call_tool("my_tool", arguments, state) do
  {meta, args} = Map.pop(arguments, "_meta")
  progress_token = meta && meta["progressToken"]

  if progress_token do
    # Report progress via Server.notify_progress/5
  end

  # Process args without _meta...
end

Basic Example

defmodule MyServer do
  use ExMCP.Server.Handler

  @impl true
  def handle_initialize(params, state) do
    # Check client's protocol version
    client_version = params["protocolVersion"]

    # Accept 2025-03-26 or propose 2024-11-05 as fallback
    negotiated_version = case client_version do
      "2025-03-26" -> "2025-03-26"
      "2024-11-05" -> "2024-11-05"
      _ -> "2025-03-26"  # Propose latest as default
    end

    {:ok, %{
      protocolVersion: negotiated_version,
      serverInfo: %{
        name: "my-server",
        version: "1.0.0"
      },
      capabilities: %{
        tools: %{},
        resources: %{},
        prompts: %{},
        sampling: %{}  # Enable LLM features
      }
    }, state}
  end

  @impl true
  def handle_list_tools(state) do
    tools = [
      %{
        name: "calculate",
        description: "Perform calculations",
        inputSchema: %{
          type: "object",
          properties: %{
            expression: %{type: "string"}
          },
          required: ["expression"]
        }
      }
    ]
    {:ok, tools, state}
  end

  @impl true
  def handle_call_tool("calculate", params, state) do
    # Access progress token if provided
    progress_token = get_in(params, ["_meta", "progressToken"])

    # Your tool implementation
    case eval_expression(params["expression"]) do
      {:ok, result} ->
        # Send progress updates if token provided
        if progress_token do
          ExMCP.Server.notify_progress(self(), progress_token, 100, 100)
        end

        {:ok, [%{type: "text", text: "Result: #{result}"}], state}

      {:error, reason} ->
        # Return tool execution error with isError flag
        error_result = %{
          content: [%{type: "text", text: "Calculation failed: #{reason}"}],
          isError: true
        }
        {:ok, error_result, state}
    end
  end
end

Advanced Features

Structured Tool Output (Draft Feature)

Draft Feature

This implements the MCP specification feature from version 2025-06-18.

Example implementation:

defmodule WeatherServer do
  use ExMCP.Server.Handler

  @impl true
  def handle_list_tools(_cursor, state) do
    tools = [
      %{
        name: "get_weather",
        description: "Get current weather data",
        inputSchema: %{
          type: "object",
          properties: %{
            location: %{type: "string", description: "City name"}
          },
          required: ["location"]
        },
        # Draft feature: declare expected output structure
        outputSchema: %{
          type: "object",
          properties: %{
            temperature: %{type: "number", description: "Temperature in Celsius"},
            conditions: %{type: "string", description: "Weather conditions"},
            humidity: %{type: "number", description: "Humidity percentage"}
          },
          required: ["temperature", "conditions"]
        }
      }
    ]
    {:ok, tools, nil, state}
  end

  @impl true
  def handle_call_tool("get_weather", %{"location" => location}, state) do
    # Fetch weather data (example implementation)
    # In real code, this would call an actual weather API
    temp = 22.5
    conditions = "Partly cloudy"
    humidity = 65

    # Return both unstructured and structured content
    result = %{
      content: [%{
        type: "text",
        text: "Current weather in #{location}: #{temp}°C, #{conditions}"
      }],
      # Draft feature: structured content matching outputSchema
      structuredContent: %{
        "temperature" => temp,
        "conditions" => conditions,
        "humidity" => humidity
      }
    }

    {:ok, result, state}
  end

  # ... other callbacks ...
end

Sampling/LLM Integration

@impl ExMCP.Server.Handler
def handle_create_message(params, state) do
  messages = params["messages"]
  model_prefs = params["modelPreferences"]

  # Integrate with your LLM provider
  response = call_llm_api(messages, model_prefs)

  result = %{
    content: %{type: "text", text: response.text},
    model: response.model,
    stopReason: "stop"
  }

  {:ok, result, state}
end

Progress Notifications

For long-running operations, use progress tokens:

@impl true
def handle_call_tool("process_file", params, state) do
  progress_token = get_in(params, ["_meta", "progressToken"])
  file_path = params["path"]

  # Start async processing with progress updates
  Task.start(fn ->
    process_with_progress(file_path, progress_token, self())
  end)

  {:ok, [%{type: "text", text: "Processing started"}], state}
end

defp process_with_progress(path, token, server) when token != nil do
  # Send progress updates
  ExMCP.Server.notify_progress(server, token, 0, 100)
  # ... processing ...
  ExMCP.Server.notify_progress(server, token, 50, 100)
  # ... more processing ...
  ExMCP.Server.notify_progress(server, token, 100, 100)
end

Dynamic Content Notifications

Notify clients when your server's content changes:

def add_new_tool(server, tool_def) do
  # Add tool to your server state
  # Then notify clients
  ExMCP.Server.notify_tools_changed(server)
end

def update_resource(server, uri) do
  # Update the resource
  # Then notify clients
  ExMCP.Server.notify_resource_updated(server, uri)
end

Callback Reference

The use macro provides default implementations for optional callbacks. You only need to implement the callbacks for features your server supports.

Summary

Callbacks

Handles a completion request for argument autocompletion.

Handles a sampling create message request.

Handles a notifications/elicitation/complete notification.

Handles getting a prompt.

Handles the initialize request from a client.

Handles listing available prompts.

Handles listing resource templates.

Handles listing available resources.

Handles listing available roots.

Handles listing available tools.

Handles reading a resource.

Handles setting the log level for the server.

Handles resource subscription.

Handles a tasks/cancel request.

Handles a tasks/get request.

Handles a tasks/list request.

Handles a tasks/result request.

Handles resource unsubscription.

Functions

Builds server capabilities based on which callbacks are implemented.

Types

initialize_result()

@type initialize_result() :: ExMCP.Types.initialize_result()

prompt()

@type prompt() :: ExMCP.Types.prompt()

resource()

@type resource() :: ExMCP.Types.resource()

state()

@type state() :: any()

tool()

@type tool() :: ExMCP.Types.tool()

Callbacks

handle_call_tool(name, arguments, state)

@callback handle_call_tool(name :: String.t(), arguments :: map(), state()) ::
  {:ok, ExMCP.Types.tool_result() | [map()], state()} | {:error, any(), state()}

Handles a tool call.

The result can be returned in multiple formats:

  1. Simple format (array of content items): {:ok, [%{type: "text", text: "Success"}], state}

  2. Extended format (with isError flag): {:ok, %{content: [%{type: "text", text: "Error occurred"}], isError: true}, state}

  3. Structured output format (2025-06-18 feature): {:ok, %{ content: [%{type: "text", text: "Weather data"}], structuredContent: %{

     "temperature" => 22.5,
     "conditions" => "Partly cloudy",
     "humidity" => 65

    } }, state}

Draft Feature

Structured tool output is available in MCP specification 2025-06-18.

Use the extended format with isError: true to indicate tool execution errors that should be reported to the client as part of the result (not protocol errors).

When returning structured content, tools should provide both unstructured content (for backwards compatibility) and structured content that conforms to the tool's declared outputSchema.

handle_complete(ref, params, state)

(optional)
@callback handle_complete(ref :: String.t(), params :: map(), state()) ::
  {:ok, result :: map(), state()} | {:error, any(), state()}

Handles a completion request for argument autocompletion.

This callback is invoked when a client requests completion suggestions for tool arguments, resource URIs, or prompt arguments.

Parameters

  • ref: Reference type (e.g., "argument")
  • params: Map containing:
    • name: The argument/parameter name to complete
    • value: The partial value to complete

Return Value

Should return a map with:

  • completion: List of completion suggestion strings

Example

def handle_complete("argument", %{"name" => "file_path", "value" => "/home/"}, state) do
  completions = ["/home/user/", "/home/documents/", "/home/downloads/"]
  {:ok, %{completion: completions}, state}
end

Note: Servers should declare the completion capability to advertise support.

handle_create_message(params, state)

(optional)
@callback handle_create_message(params :: ExMCP.Types.create_message_params(), state()) ::
  {:ok, ExMCP.Types.create_message_result(), state()} | {:error, any(), state()}

Handles a sampling create message request.

handle_elicitation_complete(elicitation_id, state)

(optional)
@callback handle_elicitation_complete(elicitation_id :: String.t(), state()) ::
  {:ok, state()} | {:error, any(), state()}

Handles a notifications/elicitation/complete notification.

Called when the client notifies that a URL-mode elicitation has completed.

handle_get_prompt(name, arguments, state)

(optional)
@callback handle_get_prompt(name :: String.t(), arguments :: map(), state()) ::
  {:ok, ExMCP.Types.prompt_message(), state()} | {:error, any(), state()}

Handles getting a prompt.

handle_initialize(params, state)

@callback handle_initialize(params :: map(), state()) ::
  {:ok, initialize_result(), state()} | {:error, any(), state()}

Handles the initialize request from a client.

The params map contains:

  • "protocolVersion" - The client's requested protocol version
  • "capabilities" - The client's declared capabilities
  • "clientInfo" - Information about the client implementation

Version Negotiation

The server should check the client's protocol version and either:

  1. Accept it by returning the same version
  2. Propose an alternative supported version
  3. Return an error if no compatible version exists

Example

def handle_initialize(params, state) do
  client_version = params["protocolVersion"]

  # Accept supported versions or propose latest
  negotiated_version = case client_version do
    "2025-03-26" -> "2025-03-26"
    "2024-11-05" -> "2024-11-05"
    _ -> "2025-03-26"  # Propose latest for unknown versions
  end

  # Use version-aware capabilities
  capabilities = ExMCP.Server.Capabilities.build_capabilities(__MODULE__, negotiated_version)

  {:ok, %{
    protocolVersion: negotiated_version,
    serverInfo: %{name: "my-server", version: "1.0.0"},
    capabilities: capabilities
  }, state}
end

handle_list_prompts(cursor, state)

(optional)
@callback handle_list_prompts(cursor :: String.t() | nil, state()) ::
  {:ok, prompts :: [prompt()], next_cursor :: String.t() | nil, state()}
  | {:error, any(), state()}

Handles listing available prompts.

Supports pagination via optional cursor parameter. Should return prompts and optional nextCursor for pagination.

handle_list_resource_templates(cursor, state)

(optional)
@callback handle_list_resource_templates(cursor :: String.t() | nil, state()) ::
  {:ok, resource_templates :: [ExMCP.Types.resource_template()],
   next_cursor :: String.t() | nil, state()}
  | {:error, any(), state()}

Handles listing resource templates.

Supports pagination via optional cursor parameter. Should return resource templates and optional nextCursor for pagination.

handle_list_resources(cursor, state)

(optional)
@callback handle_list_resources(cursor :: String.t() | nil, state()) ::
  {:ok, resources :: [resource()], next_cursor :: String.t() | nil, state()}
  | {:error, any(), state()}

Handles listing available resources.

Supports pagination via optional cursor parameter. Should return resources and optional nextCursor for pagination.

handle_list_roots(state)

(optional)
@callback handle_list_roots(state()) ::
  {:ok, [ExMCP.Types.root()], state()} | {:error, any(), state()}

Handles listing available roots.

handle_list_tools(cursor, state)

@callback handle_list_tools(cursor :: String.t() | nil, state()) ::
  {:ok, tools :: [tool()], next_cursor :: String.t() | nil, state()}
  | {:error, any(), state()}

Handles listing available tools.

Supports pagination via optional cursor parameter. Should return tools and optional nextCursor for pagination.

handle_read_resource(uri, state)

(optional)
@callback handle_read_resource(uri :: String.t(), state()) ::
  {:ok, ExMCP.Types.resource_contents(), state()} | {:error, any(), state()}

Handles reading a resource.

handle_set_log_level(level, state)

(optional)
@callback handle_set_log_level(level :: String.t(), state()) ::
  {:ok, state()} | {:error, any(), state()}

Handles setting the log level for the server.

This callback is called when the client sends a logging/setLevel request. The level parameter will be one of: "debug", "info", "warning", "error".

The implementation should adjust the server's logging verbosity accordingly.

Draft Feature

This implements the MCP specification feature (logging/setLevel) from version 2025-03-26.

@doc api: :public

handle_subscribe_resource(uri, state)

(optional)
@callback handle_subscribe_resource(uri :: String.t(), state()) ::
  {:ok, map(), state()} | {:error, any(), state()}

Handles resource subscription.

handle_task_cancel(task_id, state)

(optional)
@callback handle_task_cancel(task_id :: String.t(), state()) ::
  {:ok, map(), state()} | {:error, any(), state()}

Handles a tasks/cancel request.

Cancels a running task.

handle_task_get(task_id, state)

(optional)
@callback handle_task_get(task_id :: String.t(), state()) ::
  {:ok, map(), state()} | {:error, any(), state()}

Handles a tasks/get request.

Returns the current state of a task by ID.

handle_task_list(cursor, state)

(optional)
@callback handle_task_list(cursor :: String.t() | nil, state()) ::
  {:ok, tasks :: [map()], next_cursor :: String.t() | nil, state()}
  | {:error, any(), state()}

Handles a tasks/list request.

Returns a list of known tasks.

handle_task_result(task_id, state)

(optional)
@callback handle_task_result(task_id :: String.t(), state()) ::
  {:ok, map(), state()} | {:error, any(), state()}

Handles a tasks/result request.

Returns the result of a completed task.

handle_unsubscribe_resource(uri, state)

(optional)
@callback handle_unsubscribe_resource(uri :: String.t(), state()) ::
  {:ok, map(), state()} | {:error, any(), state()}

Handles resource unsubscription.

ExMCP Extension

This callback handles the resources/unsubscribe method which is an ExMCP extension. The MCP specification does not define this method.

Functions

build_capabilities(handler_module)

@spec build_capabilities(module()) :: map()

Builds server capabilities based on which callbacks are implemented.

This is a convenience function that can be used in your handle_initialize/2 callback to automatically generate capabilities based on your handler's implemented functions.

Example

def handle_initialize(params, state) do
  capabilities = ExMCP.Server.Handler.build_capabilities(__MODULE__)

  {:ok, %{
    protocolVersion: "2025-03-26",
    serverInfo: %{name: "my-server", version: "1.0.0"},
    capabilities: capabilities
  }, state}
end