# `ExMCP.Client`
[🔗](https://github.com/azmaveth/ex_mcp/blob/v1.0.0/lib/ex_mcp/client.ex#L1)

Unified MCP client combining the best features of all implementations.

This module provides a clean, consistent API for interacting with MCP servers
while maintaining backward compatibility with existing code.

## Features

- Simple connection with URL strings or transport specs
- Automatic transport fallback via TransportManager
- Automatic reconnection with exponential backoff after unexpected
  transport closure (see `start_link/1`)
- Consistent return values with optional normalization
- Convenience methods for common operations
- Clean separation of concerns

## Examples

    # Connect with URL
    {:ok, client} = ExMCP.Client.connect("http://localhost:8080/mcp")

    # Connect with transport spec
    {:ok, client} = ExMCP.Client.start_link(
      transport: :stdio,
      command: "mcp-server"
    )

    # List and call tools
    {:ok, %{"tools" => tools}} = ExMCP.Client.list_tools(client)
    {:ok, result} = ExMCP.Client.call_tool(client, "weather", %{location: "NYC"})

# `connection_spec`

```elixir
@type connection_spec() :: String.t() | {atom(), keyword()} | [{atom(), keyword()}]
```

# `t`

```elixir
@type t() :: GenServer.server()
```

# `batch_request`

```elixir
@spec batch_request(t(), [{String.t(), map()}], timeout()) ::
  {:ok, [any()]} | {:error, any()}
```

Sends a batch of requests to the server.

This function allows sending multiple requests in a single batch, which can
be more efficient than sending them individually. The server processes the
requests and returns a batch of responses.

## Parameters

- `client` - Client process reference
- `requests` - A list of `{method, params}` tuples for each request.
- `timeout` - Timeout for the entire batch operation (default: 30_000).

## Returns

- `{:ok, results}` - On success, where `results` is a list of `{:ok, result}`
  or `{:error, error}` tuples, in the same order as the original requests.
- `{:error, reason}` - If the batch request fails (e.g., timeout).

## Example

    requests = [
      {"tools/list", %{}},
      {"prompts/list", %{}}
    ]
    {:ok, [tools_result, prompts_result]} = ExMCP.Client.batch_request(client, requests)

# `call`

```elixir
@spec call(t(), String.t(), map(), keyword()) :: {:ok, any()} | {:error, any()}
```

Convenience alias for call_tool/4.

# `call_tool`

```elixir
@spec call_tool(t(), String.t(), map(), keyword() | timeout()) ::
  {:ok, any()} | {:error, any()}
```

Calls a tool with the given arguments.

## Options

- `:timeout` - Request timeout (default: 30000)
- `:format` - Return format (:map or :struct, default: :struct)

# `cancel_task`

```elixir
@spec cancel_task(t(), String.t(), keyword()) :: {:ok, map()} | {:error, any()}
```

Requests cooperative cancellation of a task.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `clear_era_observations`

```elixir
@spec clear_era_observations() :: :ok
```

Clears all remembered protocol-era observations.

This is an operator action intended for configuration changes or recovery
from a previously pinned modern endpoint. To clear only the identity used by
one new connection, pass `reset_era_cache: true` to `start_link/1`.

# `complete`

```elixir
@spec complete(t(), map(), map(), keyword()) :: {:ok, map()} | {:error, any()}
```

Requests completion suggestions from the server.

Sends a `completion/complete` request to get completion suggestions based on
a reference (prompt or resource) and partial input.

## Parameters

- `client` - Client process reference
- `ref` - Reference map describing what to complete:
  - For prompts: `%{"type" => "ref/prompt", "name" => "prompt_name"}`
  - For resources: `%{"type" => "ref/resource", "uri" => "resource_uri"}`
- `argument` - Argument map with completion context:
  - `%{"name" => "argument_name", "value" => "partial_value"}`

## Options

- `:timeout` - Request timeout (default: 5000)
- `:format` - Return format (:map or :struct, default: :struct)

## Returns

- `{:ok, result}` - Success with completion suggestions:
  ```
  %{
    completion: %{
      values: ["suggestion1", "suggestion2", ...],
      total: 10,
      hasMore: false
    }
  }
  ```
- `{:error, error}` - Request failed with error details

## Examples

    # Complete prompt argument
    {:ok, result} = ExMCP.Client.complete(
      client,
      %{"type" => "ref/prompt", "name" => "code_generator"},
      %{"name" => "language", "value" => "java"}
    )

    # Complete resource URI
    {:ok, result} = ExMCP.Client.complete(
      client,
      %{"type" => "ref/resource", "uri" => "file:///"},
      %{"name" => "path", "value" => "/src"}
    )

# `connect`

```elixir
@spec connect(
  connection_spec(),
  keyword()
) :: {:ok, t()} | {:error, any()}
```

Connects to an MCP server using a URL or connection spec.

## Examples

    # URL string
    {:ok, client} = ExMCP.Client.connect("http://localhost:8080/mcp")

    # Transport spec
    {:ok, client} = ExMCP.Client.connect({:stdio, command: "mcp-server"})

    # Multiple transports with fallback
    {:ok, client} = ExMCP.Client.connect([
      "http://localhost:8080/mcp",
      "stdio://mcp-server"
    ])

Returns `{:error, {:invalid_transport_config, reason}}` when the connection
spec cannot be normalized into a valid transport configuration.

# `disconnect`

```elixir
@spec disconnect(t()) :: :ok
```

Disconnects the client gracefully, cleaning up all resources.

This function performs a clean shutdown by:
- Closing the transport connection
- Cancelling health checks
- Stopping the receiver task
- Replying to any pending requests with an error

## Examples

    {:ok, client} = ExMCP.Client.connect("http://localhost:8080/mcp")
    :ok = ExMCP.Client.disconnect(client)

# `discover`

```elixir
@spec discover(
  t(),
  keyword()
) :: {:ok, map()} | {:error, any()}
```

Discovers a modern MCP server's versions, capabilities, and identity.

A successful discovery also updates the client's protocol version,
`server_info`, and `server_capabilities` state. Automatic discovery during
connection establishment is handled separately by the era probe.

# `find_matching_tool`

```elixir
@spec find_matching_tool([map()], String.t() | nil, keyword()) ::
  {:ok, map()} | {:error, :not_found}
```

Finds a matching tool from a list of tools.

## Parameters

- `tools` - List of tool maps
- `name` - Tool name to find (exact match) or pattern (fuzzy match)
- `opts` - Options including :fuzzy for fuzzy matching

## Examples

    tools = [%{"name" => "calculator"}, %{"name" => "weather"}]
    {:ok, tool} = ExMCP.Client.find_matching_tool(tools, "calculator", [])
    {:ok, tool} = ExMCP.Client.find_matching_tool(tools, "calc", fuzzy: true)

# `find_tool`

```elixir
@spec find_tool(t(), String.t() | nil, keyword()) ::
  {:ok, map()} | {:error, :not_found} | {:error, any()}
```

Finds a tool by name or pattern.

## Options

- `:fuzzy` - Enable fuzzy matching (default: false)
- `:timeout` - Request timeout (default: 5000)

# `get_pending_requests`

```elixir
@spec get_pending_requests(t()) :: [ExMCP.Types.request_id()]
```

Gets the list of pending request IDs.

Returns a list of request IDs for requests that are currently in progress.
This can be used with `send_cancelled/3` to cancel specific requests.

## Examples

    {:ok, client} = ExMCP.Client.connect("http://localhost:8080/mcp")
    
    # Start a long-running request
    task = Task.async(fn -> 
      ExMCP.Client.call_tool(client, "slow_tool", %{})
    end)
    
    # Get pending requests  
    pending = ExMCP.Client.get_pending_requests(client)
    # => ["req_123", "req_456"]
    
    # Cancel a specific request
    ExMCP.Client.send_cancelled(client, "req_123", "User cancelled")

# `get_prompt`

```elixir
@spec get_prompt(t(), String.t(), map(), keyword() | timeout()) ::
  {:ok, any()} | {:error, any()}
```

Gets a prompt with the given arguments.

# `get_status`

```elixir
@spec get_status(t()) :: {:ok, map()}
```

Gets the client status.

# `get_task`

```elixir
@spec get_task(t(), String.t(), keyword()) :: {:ok, map()} | {:error, any()}
```

Reads the current full state of a task.

# `list_prompts`

```elixir
@spec list_prompts(t(), keyword() | timeout()) ::
  {:ok, %{required(String.t()) =&gt; [map()]}} | {:error, any()}
```

Lists available prompts.

# `list_resource_templates`

```elixir
@spec list_resource_templates(t(), keyword() | timeout()) ::
  {:ok, %{required(String.t()) =&gt; [map()]}} | {:error, any()}
```

Lists available resource templates.

Sends a `resources/templates/list` request to the server to retrieve the list of
available resource templates.

# `list_resources`

```elixir
@spec list_resources(t(), keyword() | timeout()) ::
  {:ok, %{required(String.t()) =&gt; [map()]}} | {:error, any()}
```

Lists available resources.

# `list_roots`

```elixir
@spec list_roots(t(), keyword() | timeout()) ::
  {:ok, %{required(String.t()) =&gt; [map()]}} | {:error, any()}
```

Lists available roots.

Sends a `roots/list` request to the server to retrieve the list of
available root URIs.

MCP Roots is deprecated as of 2026-07-28 and retained throughout ExMCP 1.x.
New implementations should pass directories or files via tool parameters,
resource URIs, or server configuration.

# `list_tools`

```elixir
@spec list_tools(t(), keyword() | timeout()) ::
  {:ok, %{required(String.t()) =&gt; [map()]}} | {:error, any()}
```

Lists available tools from the server.

## Options

- `:timeout` - Request timeout (default: 5000)
- `:format` - Return format (:map or :struct, default: :struct)

# `listen`

```elixir
@spec listen(t(), map(), keyword()) ::
  {:ok, ExMCP.Client.Subscription.Ref.t()} | {:error, term()}
```

Opens a modern immutable notification subscription and waits for the
server's acknowledgment.

The Tasks extension adds a `"taskIds"` filter whose values receive full
`notifications/tasks` states. The client must declare
`io.modelcontextprotocol/tasks` in its configured capabilities.

# `log_message`

```elixir
@spec log_message(t(), String.t(), String.t()) :: :ok | {:error, any()}
```

Sends a log message to the server as a notification.

This function sends log messages from the client to the server for centralized
logging and monitoring. The message is sent as a notification (fire-and-forget)
following the MCP specification.

MCP protocol Logging is deprecated as of 2026-07-28 and retained throughout
ExMCP 1.x. Prefer stderr for stdio or OpenTelemetry for new observability
integrations.

## Parameters

- `client` - Client process reference
- `level` - Log level string (e.g., "debug", "info", "warning", "error")
- `message` - Log message text

## Returns

- `:ok` - Message sent successfully
- `{:error, reason}` - Failed to send message

## Example

    {:ok, client} = ExMCP.Client.start_link(transport: :http, url: "...")
    :ok = ExMCP.Client.log_message(client, "info", "Operation completed")

# `log_message`

```elixir
@spec log_message(t(), String.t(), String.t(), any()) :: :ok | {:error, any()}
```

Sends a log message with additional data to the server as a notification.

This function sends detailed log messages from the client to the server for
centralized logging and monitoring. The message is sent as a notification
(fire-and-forget) following the MCP specification.

MCP protocol Logging is deprecated as of 2026-07-28 and retained throughout
ExMCP 1.x. Prefer stderr for stdio or OpenTelemetry for new observability
integrations.

## Parameters

- `client` - Client process reference
- `level` - Log level string (e.g., "debug", "info", "warning", "error")
- `message` - Log message text
- `data` - Optional additional data (map or any JSON-serializable value)

## Supported Log Levels

Standard RFC 5424 levels: "debug", "info", "notice", "warning", "error",
"critical", "alert", "emergency"

## Returns

- `:ok` - Message sent successfully
- `{:error, reason}` - Failed to send message

## Examples

    {:ok, client} = ExMCP.Client.start_link(transport: :http, url: "...")

    # Simple log message
    :ok = ExMCP.Client.log_message(client, "info", "User logged in")

    # Log message with additional context
    :ok = ExMCP.Client.log_message(client, "error", "Database connection failed", %{
      host: "db.example.com",
      port: 5432,
      error_code: "CONNECTION_TIMEOUT"
    })

# `negotiated_version`

```elixir
@spec negotiated_version(t()) :: {:ok, String.t() | nil} | {:error, any()}
```

Gets the negotiated protocol version with the server.

# `notify`

```elixir
@spec notify(t(), String.t(), map()) :: :ok
```

Sends a notification to the server.

Notifications are fire-and-forget messages that don't expect a response.

## Parameters

- `client` - Client process reference
- `method` - The method name to notify
- `params` - Parameters for the notification (map)

## Returns

- `:ok` - Notification sent

## Examples

    :ok = ExMCP.Client.notify(client, "resource_updated", %{"uri" => "file://test.txt"})

# `ping`

```elixir
@spec ping(t(), keyword() | integer()) :: {:ok, map()} | {:error, any()}
```

Pings the server.

# `read_resource`

```elixir
@spec read_resource(t(), String.t(), keyword() | timeout()) ::
  {:ok, any()} | {:error, any()}
```

Reads a resource by URI.

# `send_batch`

```elixir
@spec send_batch(t(), [map()], timeout()) :: {:ok, [any()]} | {:error, any()}
```

Convenience alias for batch_request/3.

Sends a batch of JSON-RPC requests. Available in protocol version 2025-03-26 only.

# `send_cancelled`

```elixir
@spec send_cancelled(t(), ExMCP.Types.request_id(), String.t() | nil) ::
  :ok | {:error, :cannot_cancel_initialize}
```

Sends a cancellation notification for a pending request.

For modern streamable HTTP, this closes only the pending request's POST
response stream, which is the protocol-defined cancellation signal. Other
transports send `notifications/cancelled`. The server MAY stop processing
the request if it hasn't completed yet.

## Parameters

- `client` - Client process reference
- `request_id` - The ID of the request to cancel
- `reason` - Optional human-readable reason for cancellation

## Returns

- `:ok` - Cancellation notification sent
- `{:error, :cannot_cancel_initialize}` - Cannot cancel initialize request

## Examples

    :ok = ExMCP.Client.send_cancelled(client, "req_123", "User cancelled")
    :ok = ExMCP.Client.send_cancelled(client, 12345, nil)

# `server_capabilities`

```elixir
@spec server_capabilities(t()) :: {:ok, map()} | {:error, any()}
```

Gets server capabilities.

# `server_info`

```elixir
@spec server_info(t()) :: {:ok, map()} | {:error, any()}
```

Gets server information.

# `set_log_level`

```elixir
@spec set_log_level(GenServer.server(), String.t()) :: {:ok, map()} | {:error, any()}
```

Sets the log level for the server.

Sends a `logging/setLevel` request to configure the server's log verbosity.
This is part of the MCP specification for controlling server logging behavior.

MCP protocol Logging is deprecated as of 2026-07-28 and retained throughout
ExMCP 1.x. This legacy RPC remains available for compatible peers. Prefer
stderr for stdio or OpenTelemetry for new observability integrations.

## Parameters

- `client` - Client process reference
- `level` - Log level string: "debug", "info", "warning", or "error"

## Returns

- `{:ok, result}` - Success with any server response data
- `{:error, error}` - Request failed with error details

## Example

    {:ok, client} = ExMCP.Client.start_link(transport: :http, url: "...")
    {:ok, _} = ExMCP.Client.set_log_level(client, "debug")

# `start_link`

```elixir
@spec start_link(keyword()) :: GenServer.on_start()
```

Starts a client process with the given options.

## Options

- `:transport` - Transport type (:stdio, :http, :beam, etc.)
- `:transports` - List of transports for fallback
- `:name` - Optional GenServer name
- `:handshake_timeout` - Maximum time in milliseconds to wait for the
  server's `initialize` response during connection (default: 10_000).
  On expiry `start_link/1` fails with `{:error, :handshake_timeout}`.
- `:protocol_mode` - Era policy: `:modern_only`, `:legacy_only`,
  `:prefer_modern`, or `:prefer_legacy`.
- `:era_probe_timeout` - Dedicated timeout for the side-effect-free modern
  discovery probe (default: 2_000 milliseconds).
- `:era_cache_legacy_ttl` - How long a successful legacy observation is
  reused before probing for an upgrade again (default: 300_000 milliseconds).
- `:reset_era_cache` - Clear the observation for this exact transport,
  endpoint, and auth configuration before connecting (default: `false`).
- `:trace_context` - Optional W3C `traceparent`, `tracestate`, and allowlisted
  `baggage` values to attach to modern requests.
- `:health_check_interval` - Interval in milliseconds between idle health
  check pings (default: 30_000). Set to `nil` or `0` to disable.
- `:reliability` - Reliability features configuration (optional)
- `:retry_policy` - Default retry policy for all client operations (optional)
- `:reconnect` - Automatically reconnect when the transport closes
  unexpectedly (default: `true`)
- `:max_reconnect_attempts` - Consecutive failed reconnection attempts
  before giving up (default: 10)
- `:reconnect_backoff` - Reconnection backoff policy (keyword list):
  - `:initial` - Delay before the first attempt in ms (default: 1000)
  - `:max` - Maximum delay between attempts in ms (default: 60_000)
  - `:multiplier` - Exponential backoff multiplier (default: 2)

## Automatic Reconnection

When the transport closes unexpectedly while the client is connected, all
pending requests fail with a connection error and the client transitions to
`:reconnecting`. It then re-establishes the connection (including the MCP
handshake) with exponential backoff and jitter. After
`:max_reconnect_attempts` consecutive failures the client gives up and
settles in `:disconnected`. Requests made while reconnecting return
`{:error, :not_connected}`.

Explicit `disconnect/1` or `stop/2` calls never trigger reconnection.
Passing `reconnect: false` disables the behavior entirely.

## Health Checks

While connected and idle, the client sends a protocol `ping` every
`:health_check_interval` milliseconds. If a ping is still unanswered one
full interval later, the transport is treated as closed: pending requests
fail and the reconnection path takes over. Health checks are skipped while
requests are in flight, since those already prove the connection is alive.

The reconnection lifecycle emits telemetry:

- `[:ex_mcp, :client, :reconnect, :attempt]` - measurements
  `%{attempt: n, delay_ms: ms}`, emitted when an attempt is scheduled
- `[:ex_mcp, :client, :reconnect, :success]` - reconnected and re-initialized
- `[:ex_mcp, :client, :reconnect, :error]` - a single attempt failed
- `[:ex_mcp, :client, :reconnect, :timeout]` - gave up after the final attempt

## Reliability Options

The `:reliability` option accepts a keyword list with the following options:

- `:circuit_breaker` - Circuit breaker configuration or `false` to disable
  - `:failure_threshold` - Number of failures before opening (default: 5)
  - `:success_threshold` - Number of successes to close half-open circuit (default: 3)  
  - `:reset_timeout` - Time before transitioning from open to half-open (default: 30_000)
  - `:timeout` - Operation timeout in milliseconds (default: 5_000)
- `:health_check` - Health check configuration or `false` to disable
  - `:check_interval` - Interval between health checks (default: 60_000)
  - `:failure_threshold` - Health check failures before marking unhealthy (default: 3)
  - `:recovery_threshold` - Health check successes before marking healthy (default: 2)

## Reliability Examples

    # Client with circuit breaker protection
    {:ok, client} = ExMCP.Client.start_link(
      transport: :stdio,
      command: "my-server",
      reliability: [
        circuit_breaker: [
          failure_threshold: 3,
          reset_timeout: 10_000
        ]
      ]
    )

    # Client with both circuit breaker and health monitoring
    {:ok, client} = ExMCP.Client.start_link(
      transport: :http,
      url: "http://localhost:8080/mcp",
      reliability: [
        circuit_breaker: [failure_threshold: 5],
        health_check: [check_interval: 30_000]
      ]
    )

## Retry Policy Options

The `:retry_policy` option accepts a keyword list with the following options:

- `:max_attempts` - Maximum number of retry attempts (default: 3)
- `:initial_delay` - Initial delay between retries in milliseconds (default: 200)
- `:max_delay` - Maximum delay between retries in milliseconds (default: 5000)
- `:backoff_factor` - Exponential backoff multiplier (default: 2)
- `:jitter` - Add random jitter to prevent thundering herd (default: true)

Modern HTTP response-stream recovery is deliberately separate from this
generic policy because a broken response has ambiguous delivery semantics.
Operations accept these options:

- `:http_stream_retry` - `:at_least_once` (default) reissues one broken
  request stream with a new JSON-RPC id. `:safe_only` reissues only built-in
  read operations or operations explicitly marked `retry_safe: true`, and
  otherwise returns a transport error whose reason is `:outcome_unknown`.
- `:http_stream_retry_delay` - Delay before the one reissue (default: 200ms),
  bounded by the operation's original deadline.
- `:retry_safe` - Caller-owned safety attestation. Tool annotations such as
  `readOnlyHint` are advisory and are never used as the security decision.

`:safe_only` is intentionally non-conforming and is rejected when the client
is started with `conformance_mode: true`. JSON-RPC ids do not deduplicate
application side effects; tool authors should use an application
idempotency key and server-side deduplication where reissue is possible.

## Retry Policy Examples

    # Client with default retry policy for all operations
    {:ok, client} = ExMCP.Client.start_link(
      transport: :stdio,
      command: "my-server",
      retry_policy: [
        max_attempts: 5,
        initial_delay: 200
      ]
    )

    # Individual operation with custom retry policy
    {:ok, tools} = ExMCP.Client.list_tools(client, 
      retry_policy: [max_attempts: 2, backoff_factor: 1.5])

    # Operation with no retries (override client default)
    {:ok, result} = ExMCP.Client.call_tool(client, "tool", %{}, 
      retry_policy: false)

# `stop`

```elixir
@spec stop(t(), term()) :: :ok
```

Stops the client.

# `subscribe_resource`

```elixir
@spec subscribe_resource(t(), String.t(), keyword()) ::
  {:ok, map() | ExMCP.Client.Subscription.Ref.t()} | {:error, any()}
```

Subscribes to notifications for a resource.

Sends a `resources/subscribe` request to receive notifications when the
specified resource changes. The server will send `notifications/resources/updated`
messages when the subscribed resource is modified.

## Parameters

- `client` - Client process reference
- `uri` - Resource URI to subscribe to (e.g., "file:///path/to/file")

## Options

- `:timeout` - Request timeout (default: 5000)
- `:format` - Return format (:map or :struct, default: :struct)

## Returns

- `{:ok, result}` - Subscription successful
- `{:error, error}` - Subscription failed with error details

## Examples

    {:ok, _result} = ExMCP.Client.subscribe_resource(client, "file:///config.json")

# `tools`

```elixir
@spec tools(
  t(),
  keyword()
) :: {:ok, %{required(String.t()) =&gt; [map()]}} | {:error, any()}
```

Convenience alias for list_tools/2.

# `unsubscribe_resource`

```elixir
@spec unsubscribe_resource(t(), String.t(), keyword()) ::
  {:ok, map()} | {:error, any()}
```

Unsubscribes from notifications for a resource.

Sends a `resources/unsubscribe` request to stop receiving notifications
for the specified resource.

## Parameters

- `client` - Client process reference
- `uri` - Resource URI to unsubscribe from

## Options

- `:timeout` - Request timeout (default: 5000)
- `:format` - Return format (:map or :struct, default: :struct)

## Returns

- `{:ok, result}` - Unsubscription successful
- `{:error, error}` - Unsubscription failed with error details

## Examples

    {:ok, _result} = ExMCP.Client.unsubscribe_resource(client, "file:///config.json")

# `update_task`

```elixir
@spec update_task(t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, any()}
```

Submits responses to a modern task's outstanding input requests.

---

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