ConduitMcp.Server behaviour (ConduitMCP v0.9.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.

Changes in v0.4.0

The server is now fully stateless - just pure compiled functions:

  • No GenServer, no Agent, no process overhead
  • No supervision tree required
  • Callbacks receive the Plug.Conn for request context
  • Each HTTP request runs in parallel (limited only by Bandit's process pool)
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

Summary

Callbacks

Handle autocompletion for prompt arguments or resource template parameters.

Handle listing available prompts.

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()

prompt_args()

@type prompt_args() :: map()

prompt_name()

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

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()}

Handle tool execution.

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()}

Handle getting a prompt.

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_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()}

Handle reading a resource.

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.