ConduitMcp.Client.Hooks (ConduitMCP v0.11.0)

Copy Markdown View Source

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

EventCalled asReturns
:pre_tool_callfun.(%ToolCall{}, ctx):cont, {:cont, %ToolCall{}}, {:deny, reason} or {:halt, %ToolResult{}}
:post_tool_callfun.(%ToolCall{}, %ToolResult{}, ctx):cont or {:cont, %ToolResult{}}
:tool_call_failedfun.(%ToolCall{}, %Error{}, ctx):cont or {:recover, %ToolResult{}}
:on_tools_listedfun.([%Tool{}], ctx):cont or {:cont, [%Tool{}]}
:on_resource_readfun.(uri, ctx):cont or {:deny, reason}
:on_prompt_getfun.(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.

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.

t()

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

env()

@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()

@type hook() :: struct()

One compiled hook. Opaque.

t()

@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

compile(hooks)

@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?(hooks)

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

Whether no hook is configured for any event.