ConduitMcp.Client (ConduitMCP v0.11.0)

Copy Markdown View Source

An MCP client: one supervised connection to one MCP server, speaking the modern (2026-07-28) or a legacy (2025-11-25, 2025-06-18) protocol era, whichever the server supports.

Starting

Add a client to your supervision tree. Starting never blocks on the server: the connection is established in the background.

children = [
  {ConduitMcp.Client,
   name: MyApp.Docs,
   transport: {ConduitMcp.Client.Transport.InProcess, server: MyApp.DocsServer}}
]

Options are validated when the child spec is built, so a typo fails at boot naming the key; see ConduitMcp.Client.Config for all of them. :name may be any term ({MyApp.Docs, tenant_id} works) and is how every function below addresses the client. The application starts no clients itself; it only runs the registry they register in.

Each client is a supervisor (:one_for_all) of a Task.Supervisor, the owner of its governance table (grants, the approval baseline, built schemas; see "Governance") and a ConduitMcp.Client.Connection. Use await_ready/2 when you need the handshake done before going on; requests made earlier simply wait for it (within their own timeout).

Calling

Every request function runs in the calling process and returns {:ok, value} or {:error, %ConduitMcp.Client.Error{}}. A tool that ran and reported failure is not an error: call_tool/4 returns {:ok, %ConduitMcp.Client.ToolResult{is_error: true}}. Errors are for protocol, transport and limit failures (match on kind).

Request options, accepted by every request function:

  • :timeout - milliseconds before the request is cancelled (default the client's :request_timeout, 60 s).
  • :on_progress - a one-arity function, or a pid sent {:mcp_progress, name, params}, for notifications/progress.
  • :reset_timeout_on_progress - restart :timeout on each progress notification, capped by :max_total_timeout.
  • :max_total_timeout - see above; also caps a deadline paused while the client's handler answers a legacy server request tied to the call.
  • :meta - a map merged into the request's _meta. Keys the protocol reserves (progressToken, io.modelcontextprotocol/*) are dropped.
  • :assigns - a map given to ConduitMcp.Client.Handler callbacks as ctx.assigns for input requests made on behalf of this call (default the client's :assigns).
  • :traceparent - the W3C trace context to send in a modern request's _meta (default: the caller's Logger.metadata()[:traceparent], as OpenTelemetry-style integrations set it). Dropped unless well formed; a traceparent in :meta wins. Only traceparent is propagated, never tracestate or baggage.

Eras

With the default protocol: :auto the client probes server/discover and falls back to initialize only on legacy evidence. The era decides the wire format; the functions here work in both, with these exceptions: ping/2 sends server/discover to a modern server, set_log_level/2 is a request in legacy and a per-request _meta value in modern, and notify_roots_changed/1 is legacy only.

In the modern era call_tool/4, read_resource/3 and get_prompt/4 may get resultType: "input_required"; the client runs the multi-round-trip loop (ConduitMcp.Client.Request.MRTR) in the caller, answering each input request with its ConduitMcp.Client.Handler in the calling process. An input request the handler does not declare fails the call with kind: :protocol before any callback runs; a callback that fails or refuses fails it with kind: :input_handler. Legacy servers ask with JSON-RPC requests instead, which the connection hands to the handler in a supervised task (see ConduitMcp.Client.Connection). Both go through the same safety checks (ConduitMcp.Client.Handler).

Listing and the cache

list_tools/2 and friends fetch one page. list_all_tools/2 and friends fetch every page (capped: 100 pages, 10 000 items, a repeated or over-1 KiB cursor fails with kind: :pagination_cycle) and keep the result in the client's ConduitMcp.Client.Cache, which serves it again while the server's ttlMs hint says it is fresh. cached_tools/2 and tool/3 read the cache without asking the server. A result the server marks private is served only while the client sends the credentials it was fetched with (set_auth/3 changes them; see "Private results" in ConduitMcp.Client.Cache).

Every listed tool goes through intake: a tool whose name is outside the spec charset ([A-Za-z0-9_.-], 1-128 characters), whose definition is malformed, whose name repeats, or (over HTTP) whose x-mcp-header annotations are invalid is dropped with a warning and [:conduit_mcp, :client, :tool, :rejected]; the others carry a ConduitMcp.Client.Fingerprint.

Tasks

call_tool/4 with task: :return | :await lets a long tool call run as a server task (the modern tasks extension, or a legacy task-augmented call); see ConduitMcp.Client.Task, get_task/3, await_task/3 and cancel_task/3.

Governance

A plain client sends what it is asked to. Configure any of :policy (ConduitMcp.Client.Policy, with an :on_ask approver and ConduitMcp.Client.Grants), :hooks (ConduitMcp.Client.Hooks), :tools (a ConduitMcp.Client.ToolFilter) or :validate (ConduitMcp.Client.SchemaValidator) and every call_tool/4 runs through ConduitMcp.Client.Pipeline, in the caller: hidden tools vanish from listings, pre-hooks and validation run before policy decides on the final arguments, and post-hooks see the result. Hooks and approvers run in tasks under the client's Task.Supervisor, never in the connection.

Notifications

subscribe_notifications/2 sends server notifications to a process as {:mcp_notification, name, method, params}; see ConduitMcp.Client.Connection for coalescing of list_changed. subscribe/2 asks for updates of one resource in either era (legacy resources/subscribe, modern subscriptions/listen); a list_changed invalidates and refreshes the cached list it names.

Transports

ConduitMcp.Client.Transport.Stdio runs a local server process; ConduitMcp.Client.Transport.StreamableHTTP (needs :finch) talks to a remote one; ConduitMcp.Client.Transport.InProcess serves a server module in this VM for tests. Over HTTP, a modern call_tool/4 sends the tool's Mcp-Param-* headers computed from the cached tool definition (see list_all_tools/2); a server rejecting them (-32020) makes the client refresh the tools and send the call once more.

Summary

Types

The :name a client was started with.

A page of a list request; next_cursor is nil on the last page.

What the server said about itself in the handshake.

The connection's state as status/1 reports it. reason explains a :closed state and is nil otherwise.

Functions

call_tool/4 in a task under the client's Task.Supervisor (Task.Supervisor.async_nolink/2): await it with Task.await/2 or Task.yield/2. A crash in the task does not take the caller down. Exits with :noproc if the client is not running.

Tells a client using OAuth (auth: {:oauth, ...}) that the host stored new tokens: a connection waiting for interactive authorization checks the token store now and resumes, instead of on its next request. ConduitMcp.Client.OAuth.handle_callback/3 calls it when given client:. Returns :ok without waiting for the check, or {:error, %Error{kind: :auth, reason: :oauth_not_configured}}.

Waits until the client is ready to send requests. Returns :ok, or {:error, %Error{kind: :timeout}} after timeout ms, or the :closed error at once if the connection gave up.

Waits for a server task to finish and returns its tool result, polling at the task's pollIntervalMs and answering its input requests with the client's handler (modern) or reading tasks/result (legacy). :timeout bounds the whole wait (default the client's :request_timeout); when it passes the task is cancelled and {:error, %Error{kind: :timeout}} returned. :assigns is passed to the handler.

The tools of the last list_all_tools/2 (or list_changed refresh) as cached, fresh or not, without asking the server. {:error, %Error{kind: :not_found, reason: :not_cached}} before the first listing. Takes :assigns and :principal (see list_tools/2).

Calls tool name with args (string- or atom-keyed; sent as given).

Asks the server to cancel a task (tasks/cancel). Cancellation is cooperative: the task may still finish.

The child spec for a client. Validates the options (raising ArgumentError naming a bad key); the id is {ConduitMcp.Client, name}, so one supervisor can hold many clients.

Closes the connection: pending requests fail with kind: :closed, the transport is closed, and the client stays in :closed (answering every request at once) until reconnect/1.

Asks for completions of argument of ref. Returns the server's string-keyed CompleteResult ("completion").

The negotiated protocol era (:modern or :legacy), kept while the client reconnects. {:error, %Error{kind: :unavailable}} before the first handshake.

Gets prompt name with args. Prompt arguments are strings on the wire, so keys and values are converted: atoms, numbers and booleans with to_string/1, other non-strings as JSON. nil values are dropped. Returns the server's string-keyed GetPromptResult ("messages").

Reads the current state of a server task (tasks/get).

The server's instructions as they may be put in a model's context (PI-1): {:ok, nil} unless the client was started with include_instructions: true (they arrive with the handshake and are written by the server, so they are kept out of system prompts by default). When included they are capped at 4 KiB with invisible and direction-changing characters removed, and, unless the client is trusted: true, prefixed with a line naming the server they came from. server_info/1 still returns them raw.

Lists every prompt through the cache, as list_all_tools/2 does for tools.

Lists every resource template through the cache, as list_all_tools/2 does for tools.

Lists every resource through the cache, as list_all_tools/2 does for tools.

Lists every tool, following nextCursor, through the client's ConduitMcp.Client.Cache: a fresh cached list is returned without asking the server; otherwise the connection refreshes it (one refresh at a time, shared by concurrent callers) and the new list is returned and cached.

Lists one page of prompts (:cursor as in list_tools/2).

Lists one page of resource templates, as the server's string-keyed ResourceTemplate maps (:cursor as in list_tools/2).

Lists one page of resources (:cursor as in list_tools/2).

Lists one page of tools. Pass the previous page's next_cursor as :cursor for the next one. Tools that fail intake are left out (see "Listing and the cache" above); the page is never cached.

Sends notifications/roots/list_changed (legacy only; the client declares roots.listChanged when its handler implements list_roots/1). In the modern era, or without a roots handler, returns {:error, %Error{kind: :protocol}}.

Checks the server answers: a legacy ping, or server/discover for a modern server (which has no ping).

Reads the resource at uri; returns the server's string-keyed ReadResourceResult ("contents").

Drops the current connection (pending requests fail with kind: :transport) and connects again at once, with the reconnect count reset. Also revives a :closed client.

What the server reported in the handshake: its ServerInfo, capabilities and instructions, with the negotiated era and version. {:error, %Error{kind: :unavailable, reason: :not_ready}} while the client is not ready.

Replaces the credentials the client sends with auth, any form the transport's :auth option takes (ConduitMcp.Client.Transport.StreamableHTTP: nil, {:bearer, token}, a one-arity function, {:oauth, opts}, {:client_credentials, opts}), checked like that option. Raises ArgumentError when it is invalid, or when the transport takes no :auth.

Sets the server's log level ("debug" … "emergency", string or atom). Legacy: sends logging/setLevel. Modern: the level is sent in the _meta of every later request; a modern server sends no log notifications for requests without one.

Starts a client (see ConduitMcp.Client.Config for the options). Returns once the processes exist, without waiting for the server.

The connection's current state, read without messaging it.

Subscribes to updates of the resource at uri, whichever era the server speaks. Updates arrive as {:mcp_notification, name, "notifications/resources/updated", %{"uri" => uri}} to the processes subscribed with subscribe_notifications/2.

Sends server notifications to pid (default: the caller) as {:mcp_notification, name, method, params}. Idempotent; the subscription ends when pid exits or unsubscribe_notifications/2 is called. notifications/progress goes to the request's caller instead.

The cached definition of tool name (one cache row; the server is not asked). {:error, %Error{kind: :not_found}} when the last listing did not include it, or nothing was listed yet, or (on a governed client) the tool is hidden. Takes :assigns and :principal (see list_tools/2).

Ends a subscribe/2 subscription to uri (legacy resources/unsubscribe; modern: a listen without uri).

Stops sending server notifications to pid (default: the caller).

Types

client()

@type client() :: term()

The :name a client was started with.

page(key, item)

@type page(key, item) :: %{required(key) => [item], next_cursor: String.t() | nil}

A page of a list request; next_cursor is nil on the last page.

server_info()

@type server_info() :: %{
  era: :modern | :legacy,
  version: String.t(),
  server_info: ConduitMcp.Client.ServerInfo.t() | nil,
  capabilities: map(),
  instructions: String.t() | nil
}

What the server said about itself in the handshake.

status()

@type status() :: %{
  state:
    :connecting
    | :discovering
    | :initializing
    | :ready
    | :authorizing
    | :reconnecting
    | :closed,
  era: :modern | :legacy | nil,
  version: String.t() | nil,
  reason: term()
}

The connection's state as status/1 reports it. reason explains a :closed state and is nil otherwise.

Functions

async_call_tool(client, name, args \\ %{}, opts \\ [])

@spec async_call_tool(client(), String.t(), map(), keyword()) :: Task.t()

call_tool/4 in a task under the client's Task.Supervisor (Task.Supervisor.async_nolink/2): await it with Task.await/2 or Task.yield/2. A crash in the task does not take the caller down. Exits with :noproc if the client is not running.

task = ConduitMcp.Client.async_call_tool(:docs, "echo", %{"message" => "hi"})
{:ok, %ConduitMcp.Client.ToolResult{}} = Task.await(task)

authorized(client)

@spec authorized(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}

Tells a client using OAuth (auth: {:oauth, ...}) that the host stored new tokens: a connection waiting for interactive authorization checks the token store now and resumes, instead of on its next request. ConduitMcp.Client.OAuth.handle_callback/3 calls it when given client:. Returns :ok without waiting for the check, or {:error, %Error{kind: :auth, reason: :oauth_not_configured}}.

:ok = ConduitMcp.Client.authorized(:docs)

await_ready(client, timeout \\ 5000)

@spec await_ready(client(), timeout()) :: :ok | {:error, ConduitMcp.Client.Error.t()}

Waits until the client is ready to send requests. Returns :ok, or {:error, %Error{kind: :timeout}} after timeout ms, or the :closed error at once if the connection gave up.

:ok = ConduitMcp.Client.await_ready(:docs, 5_000)

await_task(client, task, opts \\ [])

Waits for a server task to finish and returns its tool result, polling at the task's pollIntervalMs and answering its input requests with the client's handler (modern) or reading tasks/result (legacy). :timeout bounds the whole wait (default the client's :request_timeout); when it passes the task is cancelled and {:error, %Error{kind: :timeout}} returned. :assigns is passed to the handler.

{:ok, %ConduitMcp.Client.ToolResult{}} = ConduitMcp.Client.await_task(:docs, task)

cached_tools(client, opts \\ [])

@spec cached_tools(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Tool.t()]} | {:error, ConduitMcp.Client.Error.t()}

The tools of the last list_all_tools/2 (or list_changed refresh) as cached, fresh or not, without asking the server. {:error, %Error{kind: :not_found, reason: :not_cached}} before the first listing. Takes :assigns and :principal (see list_tools/2).

{:ok, tools} = ConduitMcp.Client.cached_tools(:docs)

call_tool(client, name, args \\ %{}, opts \\ [])

Calls tool name with args (string- or atom-keyed; sent as given).

A tool that ran and failed is {:ok, %ToolResult{is_error: true}}.

Tasks

  • task: :return - if the server runs the call as a task, return {:ok, %ConduitMcp.Client.Task{}} at once (poll it with get_task/3, await_task/3).
  • task: :await - if the server runs the call as a task, poll it until it finishes and return its ToolResult. :timeout bounds the whole wait; when it passes the task is cancelled (tasks/cancel), as it is if the calling process dies.
  • task_ttl: ms - the legacy task.ttl to request.

Modern servers decide per call whether to create a task; the client declares the tasks extension only when :task is given, so without it the server must answer directly. Legacy servers get a task-augmented call only when they declare tasks.requests.tools.call and the tool's execution.taskSupport allows it: "required" always (awaited when no :task is given), "optional" only with :task. With :task, a call made before the first handshake waits for it (within :timeout), since the era decides how the call becomes a task; without it the call is queued as usual and sent plain.

{:ok, %ConduitMcp.Client.ToolResult{content: [%{"text" => "hi"}]}} =
  ConduitMcp.Client.call_tool(:docs, "echo", %{"message" => "hi"})

Governance

When the client is configured with :policy, :hooks, :tools or :validate, or the call passes :shape, the call runs through ConduitMcp.Client.Pipeline (selection, pre-hooks, validation, policy and approval, post-hooks); otherwise it is sent as given. Pipeline options:

  • :principal - who the call is made for (in ctx, and the key of "always allow" grants). Ignored by a plain client.
  • :shape - :llm to shape the result for a model.

A governed call may also fail with kind: :not_found (hidden or unknown tool), :denied (policy, approver or hook) or :hook (a pre-hook failed).

cancel_task(client, task, opts \\ [])

@spec cancel_task(client(), ConduitMcp.Client.Task.t() | String.t(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}

Asks the server to cancel a task (tasks/cancel). Cancellation is cooperative: the task may still finish.

:ok = ConduitMcp.Client.cancel_task(:docs, task)

child_spec(init_arg)

@spec child_spec(keyword()) :: Supervisor.child_spec()

The child spec for a client. Validates the options (raising ArgumentError naming a bad key); the id is {ConduitMcp.Client, name}, so one supervisor can hold many clients.

iex> spec =
...>   ConduitMcp.Client.child_spec(
...>     name: :docs,
...>     transport: {ConduitMcp.Client.Transport.InProcess, server: ConduitMcp.TestServer}
...>   )
iex> spec.id
{ConduitMcp.Client, :docs}

close(client)

@spec close(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}

Closes the connection: pending requests fail with kind: :closed, the transport is closed, and the client stays in :closed (answering every request at once) until reconnect/1.

:ok = ConduitMcp.Client.close(:docs)

complete(client, ref, argument, opts \\ [])

@spec complete(
  client(),
  {:prompt | :resource, String.t()} | map(),
  {String.t(), String.t()} | map(),
  keyword()
) :: {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}

Asks for completions of argument of ref. Returns the server's string-keyed CompleteResult ("completion").

ref is {:prompt, name}, {:resource, uri_template} or a wire reference map; argument is {name, value} or a wire map.

Options

The request options, plus :context - already-resolved arguments, %{"arguments" => %{...}}.

{:ok, %{"completion" => %{"values" => _}}} =
  ConduitMcp.Client.complete(:docs, {:prompt, "greeting"}, {"name", "A"})

era(client)

@spec era(client()) ::
  {:ok, :modern | :legacy} | {:error, ConduitMcp.Client.Error.t()}

The negotiated protocol era (:modern or :legacy), kept while the client reconnects. {:error, %Error{kind: :unavailable}} before the first handshake.

{:ok, :modern} = ConduitMcp.Client.era(:docs)

get_prompt(client, name, args \\ %{}, opts \\ [])

@spec get_prompt(client(), String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}

Gets prompt name with args. Prompt arguments are strings on the wire, so keys and values are converted: atoms, numbers and booleans with to_string/1, other non-strings as JSON. nil values are dropped. Returns the server's string-keyed GetPromptResult ("messages").

On a governed client the :on_prompt_get hooks (which may rewrite args) and the policy's prompt rules run first, as for read_resource/3.

{:ok, %{"messages" => [_ | _]}} =
  ConduitMcp.Client.get_prompt(:docs, "greeting", %{name: "Ada"})

get_task(client, task, opts \\ [])

Reads the current state of a server task (tasks/get).

{:ok, %ConduitMcp.Client.Task{status: :working}} = ConduitMcp.Client.get_task(:docs, task)

instructions_for_llm(client)

@spec instructions_for_llm(client()) ::
  {:ok, String.t() | nil} | {:error, ConduitMcp.Client.Error.t()}

The server's instructions as they may be put in a model's context (PI-1): {:ok, nil} unless the client was started with include_instructions: true (they arrive with the handshake and are written by the server, so they are kept out of system prompts by default). When included they are capped at 4 KiB with invisible and direction-changing characters removed, and, unless the client is trusted: true, prefixed with a line naming the server they came from. server_info/1 still returns them raw.

{:ok, nil} = ConduitMcp.Client.instructions_for_llm(:docs)

list_all_prompts(client, opts \\ [])

@spec list_all_prompts(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Prompt.t()]} | {:error, ConduitMcp.Client.Error.t()}

Lists every prompt through the cache, as list_all_tools/2 does for tools.

{:ok, [%ConduitMcp.Client.Prompt{} | _]} = ConduitMcp.Client.list_all_prompts(:docs)

list_all_resource_templates(client, opts \\ [])

@spec list_all_resource_templates(client(), keyword()) ::
  {:ok, [map()]} | {:error, ConduitMcp.Client.Error.t()}

Lists every resource template through the cache, as list_all_tools/2 does for tools.

{:ok, [%{"uriTemplate" => _} | _]} = ConduitMcp.Client.list_all_resource_templates(:docs)

list_all_resources(client, opts \\ [])

@spec list_all_resources(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Resource.t()]}
  | {:error, ConduitMcp.Client.Error.t()}

Lists every resource through the cache, as list_all_tools/2 does for tools.

{:ok, [%ConduitMcp.Client.Resource{} | _]} = ConduitMcp.Client.list_all_resources(:docs)

list_all_tools(client, opts \\ [])

@spec list_all_tools(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Tool.t()]} | {:error, ConduitMcp.Client.Error.t()}

Lists every tool, following nextCursor, through the client's ConduitMcp.Client.Cache: a fresh cached list is returned without asking the server; otherwise the connection refreshes it (one refresh at a time, shared by concurrent callers) and the new list is returned and cached.

Options

  • :timeout - per page, and the longest the caller waits (default the client's :request_timeout). A refresh the caller stopped waiting for still completes and fills the cache.
  • :refresh - true to ignore a fresh cached list (default false).
  • :assigns, :principal - for a governed client's filter and hooks.

Pagination is capped (100 pages, 10 000 items, a repeated or over-1 KiB cursor): over a cap the result is {:error, %Error{kind: :pagination_cycle}} and nothing is cached.

{:ok, [%ConduitMcp.Client.Tool{} | _]} = ConduitMcp.Client.list_all_tools(:docs)

list_prompts(client, opts \\ [])

@spec list_prompts(client(), keyword()) ::
  {:ok, page(:prompts, ConduitMcp.Client.Prompt.t())}
  | {:error, ConduitMcp.Client.Error.t()}

Lists one page of prompts (:cursor as in list_tools/2).

{:ok, %{prompts: [%ConduitMcp.Client.Prompt{name: "greeting"} | _]}} =
  ConduitMcp.Client.list_prompts(:docs)

list_resource_templates(client, opts \\ [])

@spec list_resource_templates(client(), keyword()) ::
  {:ok, page(:resource_templates, map())}
  | {:error, ConduitMcp.Client.Error.t()}

Lists one page of resource templates, as the server's string-keyed ResourceTemplate maps (:cursor as in list_tools/2).

{:ok, %{resource_templates: [%{"uriTemplate" => "item://{id}"} | _]}} =
  ConduitMcp.Client.list_resource_templates(:docs)

list_resources(client, opts \\ [])

@spec list_resources(client(), keyword()) ::
  {:ok, page(:resources, ConduitMcp.Client.Resource.t())}
  | {:error, ConduitMcp.Client.Error.t()}

Lists one page of resources (:cursor as in list_tools/2).

{:ok, %{resources: [%ConduitMcp.Client.Resource{} | _]}} =
  ConduitMcp.Client.list_resources(:docs)

list_tools(client, opts \\ [])

@spec list_tools(client(), keyword()) ::
  {:ok, page(:tools, ConduitMcp.Client.Tool.t())}
  | {:error, ConduitMcp.Client.Error.t()}

Lists one page of tools. Pass the previous page's next_cursor as :cursor for the next one. Tools that fail intake are left out (see "Listing and the cache" above); the page is never cached.

On a governed client (see call_tool/4) the page holds only the tools the :tools filter shows (function patterns see :assigns and :principal), with a usable schema when :validate is on, after the :on_tools_listed hooks (ConduitMcp.Client.Hooks); the same applies to list_all_tools/2, cached_tools/2 and tool/3.

{:ok, %{tools: [%ConduitMcp.Client.Tool{name: "echo"} | _], next_cursor: nil}} =
  ConduitMcp.Client.list_tools(:docs)

notify_roots_changed(client)

@spec notify_roots_changed(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}

Sends notifications/roots/list_changed (legacy only; the client declares roots.listChanged when its handler implements list_roots/1). In the modern era, or without a roots handler, returns {:error, %Error{kind: :protocol}}.

:ok = ConduitMcp.Client.notify_roots_changed(:docs)

ping(client, opts \\ [])

@spec ping(client(), keyword()) :: :ok | {:error, ConduitMcp.Client.Error.t()}

Checks the server answers: a legacy ping, or server/discover for a modern server (which has no ping).

:ok = ConduitMcp.Client.ping(:docs, timeout: 1_000)

read_resource(client, uri, opts \\ [])

@spec read_resource(client(), String.t(), keyword()) ::
  {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}

Reads the resource at uri; returns the server's string-keyed ReadResourceResult ("contents").

On a governed client the :on_resource_read hooks run first, then the policy's resource rules when its :scope includes :resources (an ask rule denies: reads have no approver); :principal is passed to them.

{:ok, %{"contents" => [%{"text" => _} | _]}} =
  ConduitMcp.Client.read_resource(:docs, "test://resource1")

reconnect(client)

@spec reconnect(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}

Drops the current connection (pending requests fail with kind: :transport) and connects again at once, with the reconnect count reset. Also revives a :closed client.

:ok = ConduitMcp.Client.reconnect(:docs)

server_info(client)

@spec server_info(client()) ::
  {:ok, server_info()} | {:error, ConduitMcp.Client.Error.t()}

What the server reported in the handshake: its ServerInfo, capabilities and instructions, with the negotiated era and version. {:error, %Error{kind: :unavailable, reason: :not_ready}} while the client is not ready.

{:ok, %{capabilities: %{"tools" => _}}} = ConduitMcp.Client.server_info(:docs)

set_auth(client, auth, opts \\ [])

@spec set_auth(client(), term(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}

Replaces the credentials the client sends with auth, any form the transport's :auth option takes (ConduitMcp.Client.Transport.StreamableHTTP: nil, {:bearer, token}, a one-arity function, {:oauth, opts}, {:client_credentials, opts}), checked like that option. Raises ArgumentError when it is invalid, or when the transport takes no :auth.

Everything bound to the old credentials ends: the client reconnects as reconnect/1 does (requests in flight fail with kind: :transport, listen streams and a legacy session close, an OAuth authorization in progress is abandoned). Cached results the server marked private are deleted before this returns when the credentials' identity changes, and fetched again under the new ones; public results stay (see "Private results" in ConduitMcp.Client.Cache). A :closed client stays closed.

OAuth options naming no :store keep the client's current token store; change :principal to act for another user (tokens are stored per principal). The change lasts as long as the client process: a client restarted by its supervisor starts with its configured credentials.

:ok = ConduitMcp.Client.set_auth(:docs, {:bearer, new_token})

Options:

  • :credential_id - with an :auth function, any term naming whose tokens it returns (the transport's :credential_id). Without it, private results fetched with the function are never served from the cache.

set_log_level(client, level)

@spec set_log_level(client(), String.t() | atom()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}

Sets the server's log level ("debug" … "emergency", string or atom). Legacy: sends logging/setLevel. Modern: the level is sent in the _meta of every later request; a modern server sends no log notifications for requests without one.

:ok = ConduitMcp.Client.set_log_level(:docs, :warning)

start_link(opts)

Starts a client (see ConduitMcp.Client.Config for the options). Returns once the processes exist, without waiting for the server.

{:ok, _pid} =
  ConduitMcp.Client.start_link(
    name: :docs,
    transport: {ConduitMcp.Client.Transport.InProcess, server: MyApp.DocsServer}
  )

status(client)

@spec status(client()) :: {:ok, status()} | {:error, ConduitMcp.Client.Error.t()}

The connection's current state, read without messaging it.

{:ok, %{state: :ready, era: :modern}} = ConduitMcp.Client.status(:docs)

subscribe(client, uri, opts \\ [])

@spec subscribe(client(), String.t(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}

Subscribes to updates of the resource at uri, whichever era the server speaks. Updates arrive as {:mcp_notification, name, "notifications/resources/updated", %{"uri" => uri}} to the processes subscribed with subscribe_notifications/2.

  • Legacy: sends resources/subscribe (the server must declare resources.subscribe, else kind: :protocol, reason :subscriptions_not_supported) and answers with the server's answer.
  • Modern: adds uri to the subscriptions/listen filter (a new listen replaces the old one once the server acknowledges it) and answers :ok when the acknowledgment honours uri, else kind: :protocol, reason :subscription_not_acknowledged.

The subscription is kept across reconnects and session renewals (sent again each time the connection becomes ready). A call made before the connection is ready waits for it, up to :timeout (default the client's :request_timeout).

:ok = ConduitMcp.Client.subscribe(:docs, "file:///notes.md")

subscribe_notifications(client, pid \\ self())

@spec subscribe_notifications(client(), pid()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}

Sends server notifications to pid (default: the caller) as {:mcp_notification, name, method, params}. Idempotent; the subscription ends when pid exits or unsubscribe_notifications/2 is called. notifications/progress goes to the request's caller instead.

:ok = ConduitMcp.Client.subscribe_notifications(:docs)

receive do
  {:mcp_notification, :docs, "notifications/tools/list_changed", _params} -> :refresh
end

tool(client, name, opts \\ [])

@spec tool(client(), String.t(), keyword()) ::
  {:ok, ConduitMcp.Client.Tool.t()} | {:error, ConduitMcp.Client.Error.t()}

The cached definition of tool name (one cache row; the server is not asked). {:error, %Error{kind: :not_found}} when the last listing did not include it, or nothing was listed yet, or (on a governed client) the tool is hidden. Takes :assigns and :principal (see list_tools/2).

{:ok, %ConduitMcp.Client.Tool{name: "echo"}} = ConduitMcp.Client.tool(:docs, "echo")

unsubscribe(client, uri, opts \\ [])

@spec unsubscribe(client(), String.t(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}

Ends a subscribe/2 subscription to uri (legacy resources/unsubscribe; modern: a listen without uri).

:ok = ConduitMcp.Client.unsubscribe(:docs, "file:///notes.md")

unsubscribe_notifications(client, pid \\ self())

@spec unsubscribe_notifications(client(), pid()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}

Stops sending server notifications to pid (default: the caller).

:ok = ConduitMcp.Client.unsubscribe_notifications(:docs)