ExMCP.Client (ex_mcp v1.0.0-rc.8)

Copy Markdown View Source

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"})

Summary

Functions

Sends a batch of requests to the server.

Convenience alias for call_tool/4.

Requests cooperative cancellation of a task.

Returns a specification to start this module under a supervisor.

Clears all remembered protocol-era observations.

Requests completion suggestions from the server.

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

Disconnects the client gracefully, cleaning up all resources.

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

Finds a matching tool from a list of tools.

Gets the list of pending request IDs.

Gets the client status.

Reads the current full state of a task.

Lists available resource templates.

Lists available tools from the server.

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

Sends a log message to the server as a notification.

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

Gets the negotiated protocol version with the server.

Sends a notification to the server.

Convenience alias for batch_request/3.

Sends a cancellation notification for a pending request.

Gets server capabilities.

Gets server information.

Sets the log level for the server.

Starts a client process with the given options.

Subscribes to notifications for a resource.

Convenience alias for list_tools/2.

Unsubscribes from notifications for a resource.

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

Types

connection_spec()

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

t()

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

Functions

batch_request(client, requests, timeout \\ 30000)

@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(client, tool_name, args \\ %{}, opts \\ [])

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

Convenience alias for call_tool/4.

call_tool(client, tool_name, arguments, timeout_or_opts \\ 30000)

@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(client, task_id, opts \\ [])

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

Requests cooperative cancellation of a task.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear_era_observations()

@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(client, ref, argument, opts \\ [])

@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(connection_spec, opts \\ [])

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

@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(client, opts \\ [])

@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(tools, name, opts \\ [])

@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(client, name_or_pattern \\ nil, opts \\ [])

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

@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(client, prompt_name, arguments \\ %{}, timeout_or_opts \\ [])

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

Gets a prompt with the given arguments.

get_status(client)

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

Gets the client status.

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

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

Reads the current full state of a task.

list_prompts(client, timeout_or_opts \\ [])

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

Lists available prompts.

list_resource_templates(client, timeout_or_opts \\ [])

@spec list_resource_templates(t(), keyword() | timeout()) ::
  {:ok, %{required(String.t()) => [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(client, timeout_or_opts \\ [])

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

Lists available resources.

list_roots(client, timeout_or_opts \\ [])

@spec list_roots(t(), keyword() | timeout()) ::
  {:ok, %{required(String.t()) => [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(client, timeout_or_opts \\ [])

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

Lists available tools from the server.

Options

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

listen(client, notification_filter, opts \\ [])

@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(client, level, message)

@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(client, level, message, data)

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

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

Gets the negotiated protocol version with the server.

notify(client, method, params \\ %{})

@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(client, opts_or_timeout \\ [])

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

Pings the server.

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

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

Reads a resource by URI.

send_batch(client, requests, timeout \\ 30000)

@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(client, request_id, reason \\ nil)

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

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

Gets server capabilities.

server_info(client)

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

Gets server information.

set_log_level(client, level)

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

@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(client, reason \\ :normal)

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

Stops the client.

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

@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(client, opts \\ [])

@spec tools(
  t(),
  keyword()
) :: {:ok, %{required(String.t()) => [map()]}} | {:error, any()}

Convenience alias for list_tools/2.

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

@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(client, task_id, input_responses, opts \\ [])

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

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