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
argsandmeta; the digest is recomputed. A returned call with anotherserver_id,nameorcall_idis 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: trueresults. - 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_listedhooks get the listed tools their matcher selects and may drop or annotate them; a returned tool must be one of those given (sameserver_idandname). This shapes what the host and its model see; it is not an authorization layer (use the:toolsfilter or a policy for that).:on_resource_read/:on_prompt_getgateread_resource/3andget_prompt/4the 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. Withon_error: :ignorethe hook is skipped instead; - failure hook: the original error is returned unchanged;
:on_tools_listedhook: the listing fails with a:hookerror (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.
Summary
Types
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).
One compiled hook. Opaque.
Compiled hooks, each list in run order. Treat as opaque.
Functions
Compiles a :hooks keyword list (see the moduledoc). Raises
ArgumentError naming the event and entry on any invalid input. Compiled
hooks are returned unchanged.
Whether no hook is configured for any event.
Types
@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).
@type hook() :: struct()
One compiled hook. Opaque.
@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.
Functions
Compiles a :hooks keyword list (see the moduledoc). Raises
ArgumentError naming the event and entry on any invalid input. Compiled
hooks are returned unchanged.
Whether no hook is configured for any event.