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

Host code that runs around a client's tool calls (and, optionally, its
tool listings, resource reads and prompt gets), configured with the
client's `:hooks` option and run by `ConduitMcp.Client.Pipeline`.

    {ConduitMcp.Client,
     name: :fs,
     transport: {...},
     hooks: [
       pre_tool_call: [
         {:all, &MyApp.Audit.before/2, id: :audit},
         {"fs/write_*", {MyApp.Paths, :normalize}, id: :normalize}
       ],
       post_tool_call: [{"fs/*", &MyApp.Redact.result/3, id: :redact}],
       tool_call_failed: [{:all, &MyApp.Alert.failed/3, id: :alert, on_error: :ignore}]
     ]}

## Events

| Event | Called as | Returns |
|---|---|---|
| `:pre_tool_call` | `fun.(%ToolCall{}, ctx)` | `:cont`, `{:cont, %ToolCall{}}`, `{:deny, reason}` or `{:halt, %ToolResult{}}` |
| `:post_tool_call` | `fun.(%ToolCall{}, %ToolResult{}, ctx)` | `:cont` or `{:cont, %ToolResult{}}` |
| `:tool_call_failed` | `fun.(%ToolCall{}, %Error{}, ctx)` | `:cont` or `{:recover, %ToolResult{}}` |
| `:on_tools_listed` | `fun.([%Tool{}], ctx)` | `:cont` or `{:cont, [%Tool{}]}` |
| `:on_resource_read` | `fun.(uri, ctx)` | `:cont` or `{:deny, reason}` |
| `:on_prompt_get` | `fun.(name, args, ctx)` | `:cont`, `{:cont, args}` or `{:deny, reason}` |

  * A pre-hook may rewrite only the call's `args` and `meta`; the digest
    is recomputed. A returned call with another `server_id`, `name` or
    `call_id` is a bad return. `{:deny, reason}` fails the call with
    `{:error, %Error{kind: :denied, reason: {:hook_denied, hook_id},
    data: reason}}`; `{:halt, result}` answers it with a synthetic result
    and nothing is sent (policy does not run; post-hooks do).
  * Post-hooks also see `is_error: true` results.
  * Failure hooks run instead of post-hooks when the call ends in an error
    (denied, transport, protocol, timeout, a failed hook, ...).
    `{:recover, result}` turns the error into that result and ends the
    chain.
  * `:on_tools_listed` hooks get the listed tools their matcher selects
    and may drop or annotate them; a returned tool must be one of those
    given (same `server_id` and `name`). This shapes what the host and
    its model see; it is not an authorization layer (use the `:tools`
    filter or a policy for that).
  * `:on_resource_read` / `:on_prompt_get` gate `read_resource/3` and
    `get_prompt/4` the way pre-hooks gate calls.

**No hook can allow anything** (HK-5): there is no allow return, and a
hook returning one is a bad return. Policy is the only allow authority,
and it runs after the pre-hooks, on the final arguments.

`ctx` is a map with `:client`, `:server_id`, `:era`, `:assigns` and
`:principal` (from the call's options).

## Hook specs

`{matcher, fun}` or `{matcher, fun, opts}`, where `fun` is a function of
the event's arity or `{Module, :function}` (checked when the options are
validated) and `matcher` is:

  * `:all` (or `:any`, `"*"`) - every call;
  * `"server/tool"` - `*` globs within one segment; a bare `"tool"` means
    any server (resource hooks glob the URI, prompt hooks the name);
  * `{server, %Regex{}}` - the regex is matched against the tool name
    (URI, prompt name).

Matchers compare loosely, like `ConduitMcp.Client.Policy` deny rules
(case-folded, with `-`, `.` and `_` equivalent), so a redaction hook on
`"fs/read_file"` also covers `Read-File`.

Options:

  * `:id` - an atom or string naming the hook in errors and telemetry
    (default `{event, index}`); ids must be unique.
  * `:timeout` - milliseconds (default the client's `:hook_timeout`,
    5 000).
  * `:on_error` - `:fail` (default) or `:ignore`, for post-hooks and
    failure hooks only.

## Order (HK-4)

Hooks run one at a time, each `{:cont, value}` feeding the next, in onion
order: **global** (`:all`), then **server** (`"server/*"`), then **tool**
(any other matcher) for pre-hooks, tool listing, resource and prompt
hooks; in declaration order within a level. Post-hooks and failure hooks
run in the reverse order.

## Failures (HK-1, HK-2)

Every hook runs in a task under the client's `Task.Supervisor` with its
deadline, never in the connection process. A hook that raises, throws,
exits (even `exit(:kill)`), times out or returns anything outside its
contract:

  * pre-hook (and resource/prompt hook): fails the call with
    `{:error, %Error{kind: :hook, reason: {:hook_failed, hook_id, :raise |
    :exit | :timeout | :bad_return}}}`; no request is sent;
  * post-hook: the result is **withheld**: the caller gets
    `{:ok, %ToolResult{is_error: true}}` with a fixed text, and the raw
    result is dropped without being logged. With `on_error: :ignore` the
    hook is skipped instead;
  * failure hook: the original error is returned unchanged;
  * `:on_tools_listed` hook: the listing fails with a `:hook` error
    (rather than an empty list that looks like "no tools").

What a failing hook raised is never logged or put in telemetry: it may
quote the arguments or result.

## Telemetry

Each run emits `[:conduit_mcp, :client, :hook, :stop]` (status `:cont`,
`:rewrite`, `:deny`, `:halt` or `:recover`) or
`[:conduit_mcp, :client, :hook, :exception]` (reason `:raise`, `:exit`,
`:timeout` or `:bad_return`); see `ConduitMcp.Telemetry`.

# `env`

```elixir
@type env() :: %{task_sup: pid(), hook_timeout: pos_integer(), telemetry: map()}
```

What a runner needs from the pipeline: the client's task supervisor, the
default timeout, and the telemetry base metadata (with `:tool_name` when
there is one).

# `hook`

```elixir
@type hook() :: struct()
```

One compiled hook. Opaque.

# `t`

```elixir
@type t() :: %ConduitMcp.Client.Hooks{
  on_prompt_get: [hook()],
  on_resource_read: [hook()],
  on_tools_listed: [hook()],
  post_tool_call: [hook()],
  pre_tool_call: [hook()],
  tool_call_failed: [hook()]
}
```

Compiled hooks, each list in run order. Treat as opaque.

# `compile`

```elixir
@spec compile(keyword() | t()) :: t()
```

Compiles a `:hooks` keyword list (see the moduledoc). Raises
`ArgumentError` naming the event and entry on any invalid input. Compiled
hooks are returned unchanged.

# `empty?`

```elixir
@spec empty?(t()) :: boolean()
```

Whether no hook is configured for any event.

---

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