# `ConduitMcp.Client.Pipeline`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/pipeline.ex#L1)

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).

# `run`

```elixir
@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 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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
