ConduitMcp.Server behaviour (ConduitMCP v0.11.0)

Copy Markdown View Source

Behaviour for implementing stateless MCP servers.

An MCP server provides tools, resources, and prompts to LLM clients. Servers implement callbacks to handle client requests concurrently.

Architecture

Server modules are stateless — just pure compiled functions:

  • No GenServer, no Agent, no process overhead in your module
  • Nothing for you to add to your supervision tree for the server itself
  • Callbacks receive the Plug.Conn for request context
  • Each HTTP request runs in parallel (limited only by Bandit's process pool)

The library does run a small supervision tree of its own (ConduitMcp.Application). It starts automatically with the :conduit_mcp application and owns the long-lived ETS tables that must outlive request processes, plus the janitors that sweep them:

Every owner is a ConduitMcp.EtsOwner process that creates a :public table and idles; reads and writes go straight to ETS from the calling process. See ConduitMcp.Application for configuration details.

defmodule MyApp.MCPServer do
  use ConduitMcp.Server

  tool "echo", "Echo back the input" do
    param :message, :string, "Message to echo", required: true

    handle fn _conn, %{"message" => msg} ->
      text(msg)
    end
  end

  tool "calculate", "Perform calculations" do
    param :operation, :string, "Math operation", enum: ~w(add sub mul div), required: true
    param :a, :number, "First number", required: true
    param :b, :number, "Second number", required: true

    handle MyMath, :calculate
  end
end

Example (Manual - Advanced)

defmodule MyApp.MCPServer do
  use ConduitMcp.Server, dsl: false  # Disable DSL

  @tools [
    %{
      "name" => "echo",
      "description" => "Echo back the input",
      "inputSchema" => %{
        "type" => "object",
        "properties" => %{
          "message" => %{"type" => "string", "description" => "Message to echo"}
        },
        "required" => ["message"]
      }
    }
  ]

  @impl true
  def handle_list_tools(_conn) do
    {:ok, %{"tools" => @tools}}
  end

  @impl true
  def handle_call_tool(_conn, "echo", %{"message" => msg}) do
    {:ok, %{"content" => [%{"type" => "text", "text" => msg}]}}
  end
end

Then in your supervision tree, just pass the module:

{Bandit,
 plug: {ConduitMcp.Transport.StreamableHTTP, server_module: MyApp.MCPServer},
 port: 4001}

Using Connection Context

The conn parameter allows access to request metadata:

def handle_call_tool(conn, "private_data", _params) do
  # Check authentication
  user_id = conn.assigns[:user_id]

  # Access headers
  auth_header = Plug.Conn.get_req_header(conn, "authorization")

  {:ok, %{"content" => [%{"type" => "text", "text" => "Data for #{user_id}"}]}}
end

Mutable State

If you need mutable state, use external mechanisms:

# Option 1: ETS
def handle_call_tool(_conn, "increment", _params) do
  :ets.update_counter(:my_counter, :count, 1)
  count = :ets.lookup_element(:my_counter, :count, 2)
  {:ok, %{"content" => [%{"type" => "text", "text" => "Count: #{count}"}]}}
end

# Option 2: Agent/GenServer
def handle_call_tool(_conn, "get_cache", %{"key" => key}) do
  value = MyApp.Cache.get(key)
  {:ok, %{"content" => [%{"type" => "text", "text" => value}]}}
end

Multi round-trip requests (MRTR)

In MCP 2026-07-28 a server asks the client for input — an elicitation, and the deprecated sampling and roots — by answering tools/call, resources/read or prompts/get with an InputRequiredResult; the client retries the same request with the answers. Return {:input_required, input_requests, state} from handle_call_tool/3, handle_read_resource/2 or handle_get_prompt/3 (or a DSL handler or ConduitMcp.Component.execute/2 behind them):

def handle_call_tool(conn, "deploy", args) do
  case conn.assigns[:mcp_input] do
    nil ->
      {:input_required,
       %{"approval" => ConduitMcp.MRTR.elicit("Approve deploy?", schema)},
       %{step: :awaiting_approval, target: args["target"]}}

    %{state: %{"target" => target}, responses: %{"approval" => approval}} ->
      case approval do
        %{"action" => "accept"} -> {:ok, do_deploy(target)}
        _ -> {:ok, %{"content" => [%{"type" => "text", "text" => "Cancelled"}]}}
      end
  end
end
  • input_requests maps server-chosen keys to input requests built with ConduitMcp.MRTR.elicit/2 (or the deprecated sample/2 and list_roots/0). Every request is checked against the capabilities the client declared; a missing one is answered with -32021 and data.requiredCapabilities (HTTP 400) instead.
  • state is sealed into the result's requestState with ConduitMcp.MRTR.State (configure :mrtr_keys; without them the request fails with -32603 and an error log). It must be JSON-encodable and comes back as decoded JSON: atom keys and values become strings.

On the retry the library opens requestState (bound to the protocol version, method, salient params and ConduitMcp.Principal.id/1; any failure is a flat -32602) before the callback runs, and puts %{state: restored_state, responses: input_responses} in conn.assigns[:mcp_input], where input_responses is the request's inputResponses map (%{} when absent). conn.assigns[:mcp_input] is absent on a first round. responses is client input: validate it like any other argument. A callback may return {:input_required, ...} again for another round.

MRTR exists only in the modern era: on a legacy-era request (2025-11-25, 2025-06-18) the same return is logged and answered with -32603 ("MRTR input requests require protocol 2026-07-28"). Telemetry: [:conduit_mcp, :mrtr, :input_required] (see ConduitMcp.Telemetry).

Summary

Types

An MRTR request for client input: input requests keyed by server-chosen ids (values built with ConduitMcp.MRTR), and a JSON-encodable state term handed back in conn.assigns[:mcp_input] on the retry.

A ConduitMcp.Ext.Tasks task request from handle_call_tool/3: create a task and answer with a CreateTaskResult (modern era, extension enabled and declared by the client). See ConduitMcp.Ext.Tasks for attrs.

Callbacks

Handle autocompletion for prompt arguments or resource template parameters.

Handle listing available prompts.

Handle listing resource templates (resources with {param} placeholders).

Handle listing available resources.

Handle listing available tools.

Handle reading a resource.

Handle setting the log level for server-to-client logging.

Handle subscribing to resource changes.

Handle unsubscribing from resource changes.

Types

conn()

@type conn() :: Plug.Conn.t()

input_required()

@type input_required() ::
  {:input_required, input_requests :: %{optional(String.t()) => map()},
   state :: term()}

An MRTR request for client input: input requests keyed by server-chosen ids (values built with ConduitMcp.MRTR), and a JSON-encodable state term handed back in conn.assigns[:mcp_input] on the retry.

prompt_args()

@type prompt_args() :: map()

prompt_name()

@type prompt_name() :: String.t()

task()

@type task() :: {:task, attrs :: keyword() | map()}

A ConduitMcp.Ext.Tasks task request from handle_call_tool/3: create a task and answer with a CreateTaskResult (modern era, extension enabled and declared by the client). See ConduitMcp.Ext.Tasks for attrs.

tool_name()

@type tool_name() :: String.t()

tool_params()

@type tool_params() :: map()

uri()

@type uri() :: String.t()

Callbacks

handle_call_tool(conn, tool_name, tool_params)

(optional)
@callback handle_call_tool(conn(), tool_name(), tool_params()) ::
  {:ok, result :: map()} | {:error, error :: map()} | input_required() | task()

Handle tool execution.

May return {:input_required, input_requests, state} on a modern-era request; see "Multi round-trip requests (MRTR)" in the moduledoc. May return {:task, attrs} to run as a task (ConduitMcp.Ext.Tasks).

handle_complete(conn, ref, argument)

(optional)
@callback handle_complete(conn(), ref :: map(), argument :: map()) ::
  {:ok, map()} | {:error, map()}

Handle autocompletion for prompt arguments or resource template parameters.

Receives the reference type (:prompt or :resource), the reference name, the argument/parameter name, and the partial value typed so far.

Should return {:ok, %{"completion" => %{"values" => [...], "hasMore" => false}}}.

handle_get_prompt(conn, prompt_name, prompt_args)

(optional)
@callback handle_get_prompt(conn(), prompt_name(), prompt_args()) ::
  {:ok, messages :: map()} | {:error, error :: map()} | input_required()

Handle getting a prompt.

May return {:input_required, input_requests, state} on a modern-era request; see "Multi round-trip requests (MRTR)" in the moduledoc.

handle_list_prompts(conn)

(optional)
@callback handle_list_prompts(conn()) ::
  {:ok, %{optional(String.t()) => any()}} | {:error, map()}

Handle listing available prompts.

The arity-2 variant receives request params for pagination support.

handle_list_prompts(conn, params)

(optional)
@callback handle_list_prompts(conn(), params :: map()) ::
  {:ok, %{optional(String.t()) => any()}} | {:error, map()}

handle_list_resource_templates(conn)

(optional)
@callback handle_list_resource_templates(conn()) ::
  {:ok, %{required(String.t()) => [map()]}} | {:error, map()}

Handle listing resource templates (resources with {param} placeholders).

Returns a map with "resourceTemplates" key containing a list of template descriptors, each with at least "uriTemplate".

Implemented automatically by DSL and Endpoint modes; manual-mode servers can implement this to advertise URI templates to clients.

handle_list_resources(conn)

(optional)
@callback handle_list_resources(conn()) ::
  {:ok, %{optional(String.t()) => any()}} | {:error, map()}

Handle listing available resources.

The arity-2 variant receives request params for pagination support.

handle_list_resources(conn, params)

(optional)
@callback handle_list_resources(conn(), params :: map()) ::
  {:ok, %{optional(String.t()) => any()}} | {:error, map()}

handle_list_tools(conn)

(optional)
@callback handle_list_tools(conn()) ::
  {:ok, %{optional(String.t()) => any()}} | {:error, map()}

Handle listing available tools.

The arity-2 variant receives request params (e.g., %{"cursor" => "..."}) for pagination support. Implement arity-2 to support cursor-based pagination.

handle_list_tools(conn, params)

(optional)
@callback handle_list_tools(conn(), params :: map()) ::
  {:ok, %{optional(String.t()) => any()}} | {:error, map()}

handle_read_resource(conn, uri)

(optional)
@callback handle_read_resource(conn(), uri()) ::
  {:ok, content :: map()} | {:error, error :: map()} | input_required()

Handle reading a resource.

May return {:input_required, input_requests, state} on a modern-era request; see "Multi round-trip requests (MRTR)" in the moduledoc.

handle_set_log_level(conn, level)

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

Handle setting the log level for server-to-client logging.

Receives the desired log level as a string (e.g., "debug", "info", "warning", "error").

handle_subscribe_resource(conn, uri)

(optional)
@callback handle_subscribe_resource(conn(), uri()) :: {:ok, map()} | {:error, map()}

Handle subscribing to resource changes.

Receives the resource URI to subscribe to.

handle_unsubscribe_resource(conn, uri)

(optional)
@callback handle_unsubscribe_resource(conn(), uri()) :: {:ok, map()} | {:error, map()}

Handle unsubscribing from resource changes.

Receives the resource URI to unsubscribe from.

Functions

__using__(opts)

(macro)

Declares the module a ConduitMcp.Server.

Options:

  • :dsl — true (default) enables the ConduitMcp.DSL macros; false is manual mode, with default callbacks you override.
  • :instructions — natural-language guidance for clients about this server, returned as instructions by both the legacy initialize result and the modern server/discover result. Generates __instructions__/0. A transport's own :instructions option takes precedence.

Example

use ConduitMcp.Server, instructions: "Use search before fetch."