The agent-loop side of the LLM adapters: list a hub's (or one client's) tools in a provider's format, then run the tool calls a model made and answer them in the same format.
{:ok, listing} = ConduitMcp.Client.Adapters.tools(MyApp.Hub, :anthropic)
request = %{"model" => model, "messages" => messages, "tools" => listing.tools}
# ... the response stops with "tool_use" ...
case ConduitMcp.Client.Adapters.execute_tool_calls(MyApp.Hub, :anthropic, response,
toolset: listing.toolset, principal: user_id) do
{:ok, results} -> continue_with(%{"role" => "user", "content" => results})
{:halt, results} -> stop_the_loop(results)
endThe provider modules (ConduitMcp.Client.Adapters.Anthropic, OpenAI,
Gemini) stay pure; this module adds what needs processes: listing,
running calls concurrently through ConduitMcp.Client.Pipeline, and
telemetry.
Hub or client
hub_or_client is a ConduitMcp.Client.Hub name (tools are named
server__tool, see ConduitMcp.Client.Hub) or a ConduitMcp.Client
name (tools keep their own, sanitized names: a namespace: false
toolset). A name registered as both is treated as the hub.
Both are governed: a hub always has a policy (R6). A client without
:policy, :hooks, :tools or :validate has no policy, so model
calls through it are sent as asked; configure a policy on clients you
hand to a model.
Results
execute_tool_calls/4 answers every call in the model's order, each in
the provider's result shape keyed by the call's id (call.call_id; for
Gemini also the exposed name, via ConduitMcp.Client.ToolSet.exposed_name/3):
- a call to a name the toolset does not hold, or with non-object arguments, gets the provider adapter's error answer and sends nothing (HB-2);
- a call that completes gets its
ConduitMcp.Client.ToolResult(is_errorresults included), shaped for a model (shape: :llm, PI-2) unlessshape: nilis given; - a call that fails (denied, timed out, server unavailable, ...) gets
an error answer with the
ConduitMcp.Client.Errormessage.
When an approver answered {:deny, reason, :halt} for any call, the
return is {:halt, results}: answer the model if you like, but stop the
agent loop. Otherwise it is {:ok, results}.
Taint (HB-3)
The calls of one turn run concurrently and see the same :taint. Once
their results are in the model's context, add their servers
(call.server_id) to the next turn's taint: so
ConduitMcp.Client.Policy.tainted/1 rules can react.
Summary
Types
What tools/3 returns: the provider's tool definitions, the toolset they
were built from (pass it to execute_tool_calls/4 as :toolset) and,
for OpenAI strict mode, the null_paths to pass as :null_paths.
Functions
Runs the tool calls in a model response through the governed pipeline and returns the provider-shaped answers in the model's order (see "Results").
Lists hub_or_client's tools (toolset/2) in provider's format.
The toolset of hub_or_client for a model: ConduitMcp.Client.Hub.list_tools/2
for a hub; for a client, its presented cached tools
(ConduitMcp.Client.cached_tools/2, listing them first when nothing is
cached) under their own names (namespace: false).
Types
@type listing() :: %{ tools: [map()], toolset: ConduitMcp.Client.ToolSet.t(), null_paths: %{ optional(String.t()) => [ConduitMcp.Client.Adapters.OpenAI.null_path()] } }
What tools/3 returns: the provider's tool definitions, the toolset they
were built from (pass it to execute_tool_calls/4 as :toolset) and,
for OpenAI strict mode, the null_paths to pass as :null_paths.
@type provider() :: :anthropic | :openai | :gemini
Functions
@spec execute_tool_calls(term(), provider(), term(), keyword()) :: {:ok, [map()]} | {:halt, [map()]} | {:error, ConduitMcp.Client.Error.t()}
Runs the tool calls in a model response through the governed pipeline and returns the provider-shaped answers in the model's order (see "Results").
calls_or_response is what the provider adapter parses (a response,
message, content/part list or output item list), or a list of
ConduitMcp.Client.ToolCalls for tools in the toolset.
Options
:toolset- the toolset the model was offered (fromtools/3); built withtoolset/2when absent. Calls resolve against it only.:api-:chat | :responses, required for:openai.:null_paths- OpenAI strict-mode null paths (fromtools/3).:max_concurrency- calls run at once (defaultSystem.schedulers_online/0).:call_timeout- milliseconds one call may take in all, approval and hooks included (default 300 000); a call over it is killed and answered with a:timeouterror.:structured,:error_prefix- passed to the provider's result builder.:shape-:llm(default) ornil.- every other option goes to
ConduitMcp.Client.Pipeline.run/3(:principal,:assigns,:taint,:timeout,:meta, progress options).:taskis not supported.
@spec tools(term(), provider(), keyword()) :: {:ok, listing()} | {:error, ConduitMcp.Client.Error.t()}
Lists hub_or_client's tools (toolset/2) in provider's format.
Returns {:ok, %{tools: definitions, toolset: toolset, null_paths: map}}:
:anthropic-ConduitMcp.Client.Adapters.Anthropic.tools/2(:cache_control);:openai-ConduitMcp.Client.Adapters.OpenAI.tools/2(:apirequired,:strict); each tool sent non-strict understrict: :autoemits[:conduit_mcp, :client, :adapter, :strict_fallback];:gemini-ConduitMcp.Client.Adapters.Gemini.function_declarations/1(wrap them in%{"functionDeclarations" => tools}).
Only tools in the toolset are emitted, so filtered, colliding and down
servers' tools never reach the model (PO-7). Takes the toolset/2
options too.
@spec toolset(term(), keyword()) :: {:ok, ConduitMcp.Client.ToolSet.t()} | {:error, ConduitMcp.Client.Error.t()}
The toolset of hub_or_client for a model: ConduitMcp.Client.Hub.list_tools/2
for a hub; for a client, its presented cached tools
(ConduitMcp.Client.cached_tools/2, listing them first when nothing is
cached) under their own names (namespace: false).
Options
:assigns, :principal, :timeout, :refresh (hub only) and :prefix
(client only; a hub uses its own :prefix).