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
- No supervision tree required
- Callbacks receive the
Plug.Connfor request context - Each HTTP request runs in parallel (limited only by Bandit's process pool)
Example (Using DSL - Recommended)
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
endExample (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
endThen 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}"}]}}
endMutable 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 tool execution.
Handle autocompletion for prompt arguments or resource template parameters.
Handle getting a prompt.
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
Callbacks
@callback handle_call_tool(conn(), tool_name(), tool_params()) :: {:ok, result :: map()} | {:error, error :: map()}
Handle tool execution.
@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}}}.
@callback handle_get_prompt(conn(), prompt_name(), prompt_args()) :: {:ok, messages :: map()} | {:error, error :: map()}
Handle getting a prompt.
Handle listing available prompts.
The arity-2 variant receives request params for pagination support.
@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.
@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 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 reading a resource.
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 subscribing to resource changes.
Receives the resource URI to subscribe to.
Handle unsubscribing from resource changes.
Receives the resource URI to unsubscribe from.