The governed tool call: selection, hooks, schema validation, policy and
approval around one tools/call, run in the calling process.
ConduitMcp.Client.call_tool/4 goes through the pipeline when the client
is configured with any of :policy, :hooks, :tools (a filter) or
:validate, or when the call asks for shape: :llm; otherwise it sends
the call as given (a plain client has no policy, R6). run/3 is the
entry point for code that already holds a ConduitMcp.Client.ToolCall or
a resolved ConduitMcp.Client.Tool (a hub, an LLM adapter loop).
Steps (R4)
Every step runs once per logical call, in this order:
- Resolve the tool in the client's cache (listing the tools first if
the cache has none). An unknown tool is
{:error, %Error{kind: :not_found, reason: :unknown_tool}}. - Select: a tool hidden by the
:toolsfilter (ConduitMcp.Client.ToolFilter.visible?/3) fails exactly like an unknown one (PO-7), so its name cannot be probed. - Fingerprint: the approved fingerprints are gathered (see "Approvals") for the policy's pin.
- Pre-hooks (
ConduitMcp.Client.Hooks), which may rewrite the arguments, deny, halt with a synthetic result, or fail closed. - Input-schema validation (
validate: [arguments: true]): invalid arguments are never sent. - Policy (
ConduitMcp.Client.Policy.evaluate/4) on the final arguments: deny > pin > ask > allow > default. A deny is{:error, %Error{kind: :denied, reason: decision.reason, data: %Policy.Decision{}}}(reason: :no_rulewhen nothing matched and there is no approver). - Ask: an
:askdecision is settled by a live grant (ConduitMcp.Client.Grants) or by the:on_askapprover. - Encode: the arguments' digest must still be the one policy and
the approver saw (otherwise
{:error, %Error{kind: :hook, reason: :args_changed}});metabecomes the request's_meta, the protocol's reserved keys winning. - Send, including the modern multi-round-trip loop: each input
request goes through capability gating, the sampling/elicitation
safety checks and the
ConduitMcp.Client.Handler, in the caller; nothing above runs again per round. - Output-schema validation (
validate: [structured_content: true]). - Post-hooks on a result (including
is_errorresults), or failure hooks on an error from step 3 on. - Shaping for a model, with
shape: :llm(below). - The
[:conduit_mcp, :client, :tool_call, :stop]event.
Approvals
:on_ask is fun.(%ToolCall{}, ctx) or {Module, :function}, called
in a task under :ask_timeout (default 60 s) with the exact arguments
that will be sent and their args_digest (PO-11). It must return:
{:allow, :once}- send this call;{:allow, :always}- send it and record a grant (seeConduitMcp.Client.Grants) for the call's:principal, this tool and its current fingerprint, for:grant_ttl(default 24 h);{:deny, reason}-{:error, %Error{kind: :denied, reason: :ask_denied, data: reason}};{:deny, reason, :halt}- the same withreason: :ask_halt, telling an agent loop to stop.
Anything else, a raise, an exit or a timeout is a deny with
reason: {:ask_failed, :bad_return | :raise | :exit | :timeout}. The
approver cannot change the arguments: rewriting belongs in pre-hooks.
The fingerprints policy pins to (ctx.approved_fingerprints, PO-6) come
from the client's :approved_fingerprints option: :first_listing
(default) records the fingerprints of the first full tool list a client
with a policy reads (with ConduitMcp.Client.list_all_tools/2,
ConduitMcp.Client.cached_tools/2, or its first call), so a later change
to a tool's definition asks again, and a tool first listed later is new
(allow_new_tools); a map of tool name to fingerprint pins exactly
those. A live grant for the call's principal adds the granted
fingerprint. The baseline lives as long as the client's processes; it is
recorded again after a restart.
Context
Hooks, the approver, filter functions and policy predicates get ctx:
:client, :server_id (the call's provenance: the server the call goes
to), :era, :assigns (the call's, else the client's), :principal
(the call's :principal option, else nil), :taint (the call's
:taint option as a MapSet, see "Taint"), :has_approver? and, from
step 3, :approved_fingerprints (the entry for the called tool, when it
has one).
Taint (HB-3)
A model that read one server's output may be steered by it into calling
another server's tool (cross-server exfiltration). The library cannot
see the model's context, so the host says which servers' output entered
the current turn with taint: ["web", "mail"], and policy rules decide
what that means, for example asking before any open-world tool once
untrusted output is in context:
ask: [{:all, [{:annotation, :open_world, true}, ConduitMcp.Client.Policy.tainted()]}]ConduitMcp.Client.Policy.tainted/1 matches when the taint set holds a
server other than the called tool's own (minus an :ignore list); any
function rule can read ctx.taint directly.
Shaping for a model (PI-2)
With shape: :llm a ToolResult is rewritten for an LLM's context:
provenance: %{server_id:, truncated:, flags:}says where it came from and what was changed;- text (including embedded resource text, link names and
structuredContentstrings) loses invisible and direction-changing characters (bidi controls, zero-width characters, Unicode tag characters), flagged:bidi,:zero_width,:tag_chars; - images other than JPEG, PNG, GIF and WebP (notably SVG), audio other
than WAV, MP3, Ogg, WebM and FLAC, and non-image blobs are replaced by
a text placeholder (
:media_dropped); unknown block types too (:unsupported_content); resource_linkURIs are kept as text and never fetched, and nothing else (icons, embedded URIs) is fetched either;_meta(of the result and of each block) and unknown keys are dropped;- the whole result is capped at the client's
:max_result_bytes_for_llm(default 100 KiB): the block that crosses it is cut on a character boundary, later blocks and an over-sizestructuredContentare dropped, and a final text block says so ("[truncated by the MCP client: ...]").
Server instructions are not part of a tool result; see
ConduitMcp.Client.instructions_for_llm/1.
Telemetry
[:conduit_mcp, :client, :policy, :decision] per policy evaluation and
[:conduit_mcp, :client, :hook, :stop | :exception] per hook run (see
ConduitMcp.Telemetry); neither carries arguments or results. With the
client's telemetry: [capture_payloads: ...], the tool_call span's
:arguments are the arguments given to run/3 (the span opens before
the pre-hooks, and a denied call sends nothing) and its :result is
what run/3 returns (after post-hooks and shaping).
Summary
Functions
Runs a governed tool call on client.
Functions
@spec run( ConduitMcp.Client.client(), ConduitMcp.Client.ToolCall.t() | {String.t(), map()}, keyword() ) :: {:ok, ConduitMcp.Client.ToolResult.t() | ConduitMcp.Client.Task.t()} | {:error, ConduitMcp.Client.Error.t()}
Runs a governed tool call on client.
target is {name, args} (the server's tool name and the arguments) or
a ConduitMcp.Client.ToolCall for this client's server_id (its meta
and call_id are kept).
Options
Every ConduitMcp.Client.call_tool/4 option (:task, :task_ttl,
:timeout, :meta, :assigns, progress options), plus:
:principal- who the call is made for: passed inctx, and the key grants are recorded and looked up under (defaultnil).:shape-:llmto shape the result for a model (see the moduledoc).:taint- the server ids (strings) whose output entered the current turn, as a list orMapSet(default none); see "Taint".:tool- an already resolvedConduitMcp.Client.Toolfor the same{server_id, name}, skipping the cache lookup.
Returns what call_tool/4 returns.