How-tos for MCP protocol features that sit beside tools, resources, and prompts: elicitation, sampling, roots, ping, progress, and cancellation. Each section shows the handler and the client call. This is not a spec reprint.
MCP 2026-07-28 deprecated Roots and Sampling. ExMCP keeps both throughout 1.x. New work should pass directories through tool parameters or resource URIs, and call an LLM provider API directly.
Elicitation
A server can pause tools/call, resources/read, or prompts/get and ask
the client for structured input. On MCP 2026-07-28 that pause is an
input_required result (MRTR). Enable it on the server and declare the
matching client capability:
MyServer.start_link(transport: :beam, protocol_mode: :modern_only, mrtr: true)
{:ok, client} =
ExMCP.Client.start_link(
transport: :beam,
server: server,
protocol_mode: :modern_only,
capabilities: %{"elicitation" => %{"form" => %{}, "url" => %{}}},
handler: {MyClientHandler, []}
)See Multi Round-Trip Requests for key-ring setup. The six elicitation rows below use that path.
Form mode
The server returns an elicitation/create input request with a
JSON Schema 2020-12 requestedSchema. The client handler collects values
and returns action plus optional content.
# Server
@impl true
def handle_call_tool("onboard", _args, state) do
case ExMCP.Server.Context.input_responses() do
nil ->
requests = %{
"profile" =>
ExMCP.Server.elicit(%{
message: "Choose a display name",
requested_schema: %{
"$schema" => "https://json-schema.org/draft/2020-12/schema",
"type" => "object",
"properties" => %{
"name" => %{"type" => "string"}
},
"required" => ["name"]
}
})
}
{:input_required, requests, %{"step" => "profile"}, state}
%{"profile" => %{"content" => %{"name" => name}}} ->
{:ok, %{content: [%{type: "text", text: "Welcome, #{name}"}]}, state}
end
end
# Client
@impl true
def handle_elicitation_create(message, requested_schema, state) do
case present_form(message, requested_schema) do
{:accept, data} ->
{:ok, %{action: "accept", content: data}, state}
:decline ->
{:ok, %{action: "decline"}, state}
:cancel ->
{:ok, %{action: "cancel"}, state}
end
endExMCP.Client.call_tool/3 retries the original method with the handler's
response. You do not POST elicitation/create yourself on a modern
connection. The raw
%{"method" => "elicitation/create", "params" => %{"message" => ..., "requestedSchema" => ...}} map is equivalent to ExMCP.Server.elicit/1.
URL mode
URL mode sends the user to a page instead of a form. Advertise
elicitation.url and implement handle_url_elicitation/4:
# Server
requests = %{
"login" =>
ExMCP.Server.elicit(%{
message: "Sign in to continue",
mode: "url",
url: "https://auth.example.com/login",
elicitationId: "elicit-login-1"
})
}
{:input_required, requests, state}
# Client
@impl true
def handle_url_elicitation(message, url, elicitation_id, state) do
_ = {message, open_browser(url)}
{:ok, %{action: "accept", content: %{"authenticated" => true}},
Map.put(state, :elicitation_id, elicitation_id)}
endIf the handler only implements handle_elicitation_create/3, URL-mode
requests still arrive there. The second argument is then a map with
"mode", "url", and "elicitationId". handle_url_elicitation/3
remains for 1.x compatibility and does not receive the id.
Schema validation
requestedSchema is a JSON Schema 2020-12 object
(https://json-schema.org/draft/2020-12/schema). Validate accepted content
before returning it:
@impl true
def handle_elicitation_create(_message, requested_schema, state) do
content = collect_from_user(requested_schema)
case ExMCP.Content.SchemaValidator.validate_schema(content, requested_schema) do
:ok ->
{:ok, %{action: "accept", content: content}, state}
{:error, _errors} ->
{:ok, %{action: "decline"}, state}
end
endExMCP.Content.SchemaValidator is an experimental helper. Prefer keeping
schemas small and local; remote $ref values are rejected unless you opt
into the JSON Schema resource policy.
Default values
Put default on a property. The client can pre-fill the form; the server
still sees whatever the user accepts.
"requestedSchema" => %{
"$schema" => "https://json-schema.org/draft/2020-12/schema",
"type" => "object",
"properties" => %{
"name" => %{"type" => "string", "default" => "guest"},
"notify" => %{"type" => "boolean", "default" => true}
},
"required" => ["name"]
}For automated tests only, config :ex_mcp, elicitation_auto_accept: true
fills defaults through ExMCP.Client.ElicitationHandler. Do not enable that
in production.
Enum values
Constrain a string (or other) field with enum:
"requestedSchema" => %{
"$schema" => "https://json-schema.org/draft/2020-12/schema",
"type" => "object",
"properties" => %{
"role" => %{
"type" => "string",
"enum" => ["reader", "editor", "admin"],
"default" => "reader"
}
},
"required" => ["role"]
}The client should only accept one of those values. The same schema is what
ExMCP.Content.SchemaValidator.validate_schema/2 checks.
Complete notification
After a URL-mode flow finishes out of band, the client notifies the server
with notifications/elicitation/complete. There is no dedicated wrapper;
use ExMCP.Client.notify/3 with the elicitationId from
handle_url_elicitation/4:
:ok =
ExMCP.Client.notify(client, "notifications/elicitation/complete", %{
"elicitationId" => elicitation_id
})
# Server
@impl true
def handle_elicitation_complete(elicitation_id, state) do
{:ok, Map.put(state, :last_elicitation, elicitation_id)}
endSampling
Sampling lets a server ask the client to call a model. MCP 2026-07-28
deprecated it; ExMCP retains ExMCP.Server.create_message/2 and
ExMCP.Client.Handler.handle_create_message/2 throughout 1.x. New code
should call the LLM provider directly.
# Server (legacy server-to-client request, or your own MRTR wrapper)
{:ok, result} =
ExMCP.Server.create_message(server, %{
"messages" => [
%{"role" => "user", "content" => %{"type" => "text", "text" => "Summarize the diff"}}
],
"maxTokens" => 256
})
# Client — human approval is required
@impl true
def handle_create_message(params, state) do
case get_user_approval(params) do
:approved ->
{:ok,
%{
role: "assistant",
content: %{type: "text", text: "Looks good."},
model: "gpt-4",
stopReason: "stop"
}, state}
:denied ->
{:error, "User denied sampling request", state}
end
endOn a modern connection, pause a tool instead of sending an independent server request:
{:input_required,
%{
"draft" => %{
"method" => "sampling/createMessage",
"params" => %{
"messages" => [
%{"role" => "user", "content" => %{"type" => "text", "text" => "Summarize the diff"}}
],
"maxTokens" => 256
}
}
}, state}The client must declare %{"sampling" => %{}}. The same
handle_create_message/2 callback satisfies the MRTR input.
Roots
Roots are informational directory hints, not an authorization boundary. MCP 2026-07-28 deprecated them; ExMCP retains the callbacks throughout 1.x.
# Client exposes roots the server may ask for
@impl true
def handle_list_roots(state) do
{:ok, [%{uri: "file:///projects", name: "Projects"}], state}
end
# Server asks the connected client
{:ok, %{roots: roots}} = ExMCP.Server.list_roots(server)
# Client asks the server (only if the server implements handle_list_roots/1)
{:ok, result} = ExMCP.Client.list_roots(client)
# Server publishes notifications/roots/list_changed
:ok = ExMCP.Server.notify_roots_changed(server)Prefer passing directories or files through tool parameters, resource URIs, or server configuration.
Protocol ping
ExMCP.Client.ping/2 is the protocol liveness check. It is not a tool
named "ping".
{:ok, _result} = ExMCP.Client.ping(client)
{:ok, _result} = ExMCP.Client.ping(client, timeout: 2_000)On a legacy connection this sends JSON-RPC ping. On a modern
(2026-07-28) connection it calls server/discover instead. A server can
also ping the connected client:
{:ok, _result} = ExMCP.Server.ping(server)
# Client
@impl true
def handle_ping(state), do: {:ok, %{}, state}Progress notifications
The client opts in by sending _meta.progressToken on a request. The
server reports progress against that token.
# Client — token on the request
{:ok, result} =
ExMCP.Client.call_tool(client, "import", %{}, progress_token: "job-42")
# Client — modern HTTP delivers events to the handler
@impl true
def handle_progress(_request_id, %{"progressToken" => token, "progress" => n} = note, state) do
_ = {token, n, Map.get(note, "total"), Map.get(note, "message")}
{:ok, state}
end
# Server — request-scoped, writes notifications/progress on the owning stream
@impl true
def handle_call_tool("import", _args, state) do
if ExMCP.Server.Context.progress_token() do
:ok = ExMCP.Server.Context.report_progress(25, 100, "Reading")
:ok = ExMCP.Server.Context.report_progress(100, 100, "Done")
end
{:ok, %{content: [%{type: "text", text: "imported"}]}, state}
endOn BEAM, stdio, and other non-stream helpers you can still publish by token:
:ok = ExMCP.Server.notify_progress(server, "job-42", 50)
:ok = ExMCP.Server.notify_progress(server, "job-42", 50, 100)ExMCP.Client.call_tool/4 also accepts :meta and merges it with :progress_token.
Cancellation
Cancel an in-flight request by id. On modern Streamable HTTP this closes
that request's POST response stream. Other transports send
notifications/cancelled. The server MAY stop the work.
task =
Task.async(fn ->
ExMCP.Client.call_tool(client, "slow_import", %{})
end)
[request_id | _] = ExMCP.Client.get_pending_requests(client)
:ok = ExMCP.Client.send_cancelled(client, request_id, "User cancelled")A server can emit the same notification toward a client request:
:ok = ExMCP.Server.cancel_request(server, request_id, "superseded")You cannot cancel initialize. send_cancelled/3 returns
{:error, :cannot_cancel_initialize} in that case.
This is request cancellation, not the experimental Tasks extension
(ExMCP.Client.cancel_task/3).
A long-running handler can check the current request between steps. The server MAY stop; ExMCP does not automatically abort the JSON-RPC request.
@impl true
def handle_call_tool("slow_import", _args, state) do
Enum.reduce_while(import_steps(), {:ok, state}, fn step, {:ok, state} ->
if ExMCP.Server.Context.cancelled?() do
{:halt, {:error, "Import cancelled", state}}
else
{:cont, run_import_step(step, state)}
end
end)
end