The behaviour for answering a server's requests for input: sampling, elicitation and roots, plus optional notification and log hooks.
One module serves both protocol eras:
- Legacy (
2025-11-25,2025-06-18): the server sends JSON-RPC requests (sampling/createMessage,elicitation/create,roots/list). Each runs in a task under the client'sTask.Supervisor, never in the connection, killed after:handler_timeout(5 minutes for elicitation, 60 seconds otherwise) and answered-32603; at most:max_server_requests(8) run at once (over the cap the server gets-32000busy). A servernotifications/cancelledkills the task, and a crash answers-32603. A request tied to an in-flight call (itsrelatedrequest id; on stdio, the one call in flight if there is exactly one) pauses that call's deadline, bounded by its:max_total_timeout, and gets that call's:assigns. - Modern (
2026-07-28): there are no server-to-client requests. Atools/call,resources/readorprompts/getmay returnresultType: "input_required"withinputRequests; the client calls this module in the calling process for every input request, then retries the original request with the answers (MRTR). A raise or an{:error, _}aborts the call with%ConduitMcp.Client.Error{kind: :input_handler}. Input requests of a server task (ConduitMcp.Client.await_task/3) are answered the same way and sent withtasks/update.
Capabilities come from the callbacks
The client advertises only what this module implements (see
ConduitMcp.Client.Negotiation.client_capabilities/2):
handle_sampling/2⇒samplinghandle_elicitation/2⇒elicitation, with the modes fromelicitation_modes/0(default[:form])list_roots/1⇒roots
No handler, or a handler with none of those callbacks, advertises nothing:
clientCapabilities is {}. A server request or MRTR input request for an
undeclared capability is refused before any callback runs (-32601 for a
legacy request, a :protocol error for MRTR).
Results
Callbacks return string-keyed maps shaped like the spec's result types:
{:ok, CreateMessageResult}, {:ok, ElicitResult}, {:ok, ListRootsResult}.
A legacy request may be refused with {:error, %{"code" => integer, "message" => binary}}, which is sent as the JSON-RPC error. A user who
declines an elicitation is a result, not an error:
{:ok, %{"action" => "decline"}}.
Safety checks applied around the callbacks
Both eras go through the same checks, before and after the callback:
- Budget: input requests of one server are rate limited
(
:input_rate_limit, 10/s with bursts of 20). - Sampling: this callback is the approval point.
maxTokensmust be a positive integer and is clamped tosampling: [max_tokens: _](4096);includeContextis coerced to"none"unlesssampling: [context: true]; a legacy sampling request that arrives while no call is in flight is refused unlesssampling: [allow_idle: true]; a request whosemessagesalready hold more thansampling: [max_tool_iterations: _](5) assistanttool_useturns is refused. Refusals (idle, tool loop, over budget) usesampling: [rejection_code: _](default-1). - Elicitation: the client never fetches or opens a URL. A mode the
handler does not declare is refused with
-32602. URL mode accepts only absolutehttpsURLs without userinfo (javascript:,http:,file:are refused with-32602); the context carries the full:url, its:host,:idn?(a punycodexn--label) and:origin_mismatch?(true unless the URL has the server's origin, which HTTP transports report andelicitation: [origin: _]sets; always true when unknown). Form schemas must be the spec's flat primitive subset (else-32602), and the context carries:fields,%{name => %{sensitive_suspected: boolean}}flagging secret-looking fields. Answers are validated before they are sent:"action"must be"accept","decline"or"cancel", a formacceptmust fit the schema, a URLacceptcarries no content (else-32603). - Roots are asked per server (the context has
:server_id); onlyfile://URIs on the local host are sent, with./..resolved.
Notifications
handle_notification/3 and handle_log/3 run in a process of their own,
one notification at a time in arrival order; a raise is logged and
skipped, and beyond 1 000 waiting notifications new ones are dropped.
A server's log entries (notifications/message) are limited per
connection by the client's :log_rate_limit (50 per second, bursts of
100); the rest are dropped. Those that pass reach handle_log/3 and
notification subscribers sanitized: only level, logger and data are
kept, every string in them goes through ConduitMcp.Reflect.text/2 (no
control, bidi or line-separator characters; data strings clamped to
4 096 characters, nesting cut below 16 levels), and a level outside the
RFC 5424 set becomes "info".
Context
Every callback receives a context map (notification callbacks get only
:client, :server_id and :era):
:client- the client's:name.:server_id- the operator-assigned id of the asking server.:era-:modernor:legacy.:assigns- the:assignsof the related call, or the client's:assignswhen the request is not tied to a call.:related_request_id- the in-flight request this input belongs to, ornil(alwaysnilfor modern input).- Elicitation adds
:mode(:formor:url) and the keys above.
Summary
Types
Context passed to every callback; see the moduledoc for keys.
An elicitation mode the handler can present.
A JSON-RPC error object refusing a legacy request.
Callbacks
The elicitation modes this handler can present. Defaults to [:form] when
not implemented. Only consulted when handle_elicitation/2 is implemented.
Answers elicitation/create with an ElicitResult map ("action" is
"accept", "decline" or "cancel").
Called with each notifications/message log entry: level is one of
"debug" … "emergency" (clamped), params the sanitized params (see
"Notifications" above). The return value is ignored.
Called with every server notification the client does not consume itself
(method and string-keyed params). The return value is ignored. It runs
outside the connection process.
Answers sampling/createMessage with a CreateMessageResult map.
Answers roots/list with a ListRootsResult map
(%{"roots" => [%{"uri" => "file:///...", "name" => ...}]}).
Types
Context passed to every callback; see the moduledoc for keys.
@type elicitation_mode() :: :form | :url
An elicitation mode the handler can present.
A JSON-RPC error object refusing a legacy request.
Callbacks
@callback elicitation_modes() :: [elicitation_mode(), ...]
The elicitation modes this handler can present. Defaults to [:form] when
not implemented. Only consulted when handle_elicitation/2 is implemented.
Answers elicitation/create with an ElicitResult map ("action" is
"accept", "decline" or "cancel").
Called with each notifications/message log entry: level is one of
"debug" … "emergency" (clamped), params the sanitized params (see
"Notifications" above). The return value is ignored.
Called with every server notification the client does not consume itself
(method and string-keyed params). The return value is ignored. It runs
outside the connection process.
Answers sampling/createMessage with a CreateMessageResult map.
Answers roots/list with a ListRootsResult map
(%{"roots" => [%{"uri" => "file:///...", "name" => ...}]}).