ConduitMcp.Client.Pipeline (ConduitMCP v0.11.0)

Copy Markdown View Source

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:

  1. 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}}.
  2. Select: a tool hidden by the :tools filter (ConduitMcp.Client.ToolFilter.visible?/3) fails exactly like an unknown one (PO-7), so its name cannot be probed.
  3. Fingerprint: the approved fingerprints are gathered (see "Approvals") for the policy's pin.
  4. Pre-hooks (ConduitMcp.Client.Hooks), which may rewrite the arguments, deny, halt with a synthetic result, or fail closed.
  5. Input-schema validation (validate: [arguments: true]): invalid arguments are never sent.
  6. 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_rule when nothing matched and there is no approver).
  7. Ask: an :ask decision is settled by a live grant (ConduitMcp.Client.Grants) or by the :on_ask approver.
  8. Encode: the arguments' digest must still be the one policy and the approver saw (otherwise {:error, %Error{kind: :hook, reason: :args_changed}}); meta becomes the request's _meta, the protocol's reserved keys winning.
  9. 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.
  10. Output-schema validation (validate: [structured_content: true]).
  11. Post-hooks on a result (including is_error results), or failure hooks on an error from step 3 on.
  12. Shaping for a model, with shape: :llm (below).
  13. 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 (see ConduitMcp.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 with reason: :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 structuredContent strings) 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_link URIs 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-size structuredContent are 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

run(client, target, opts \\ [])

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 in ctx, and the key grants are recorded and looked up under (default nil).
  • :shape - :llm to shape the result for a model (see the moduledoc).
  • :taint - the server ids (strings) whose output entered the current turn, as a list or MapSet (default none); see "Taint".
  • :tool - an already resolved ConduitMcp.Client.Tool for the same {server_id, name}, skipping the cache lookup.

Returns what call_tool/4 returns.