Current section
Files
Jump to
Current section
Files
lib/hermes/client.ex
defmodule Hermes.Client do
@moduledoc """
A GenServer implementation of an MCP (Model Context Protocol) client.
This module handles the client-side implementation of the MCP protocol,
including initialization, request/response handling, and maintaining
protocol state.
> ## Notes {: .info}
>
> For initialization and setup, check our [Installation & Setup](./installation.html) and
> the [Client Usage](./client_usage.html) guides for reference.
"""
use GenServer
import Peri
alias Hermes.Client.Operation
alias Hermes.Client.Request
alias Hermes.Client.State
alias Hermes.MCP.Error
alias Hermes.MCP.Message
alias Hermes.MCP.Response
require Hermes.MCP.Message
require Logger
@default_protocol_version "2024-11-05"
@type t :: GenServer.server()
@typedoc """
MCP client transport options
- `:layer` - The transport layer to use, either `Hermes.Transport.STDIO` or `Hermes.Transport.SSE` (required)
- `:name` - The transport optional custom name
"""
@type transport ::
list(
{:layer, Hermes.Transport.STDIO | Hermes.Transport.SSE}
| {:name, GenServer.server()}
)
@typedoc """
MCP client metadata info
- `:name` - The name of the client (required)
- `:version` - The version of the client
"""
@type client_info :: %{
required(:name | String.t()) => String.t(),
optional(:version | String.t()) => String.t()
}
@typedoc """
MCP client capabilities
- `:roots` - Capabilities related to the roots resource
- `:listChanged` - Whether the client can handle listChanged notifications
- `:sampling` - Capabilities related to sampling
MCP describes these client capabilities on it [specification](https://spec.modelcontextprotocol.io/specification/2024-11-05/client/)
"""
@type capabilities :: %{
optional(:roots | String.t()) => %{
optional(:listChanged | String.t()) => boolean
},
optional(:sampling | String.t()) => %{}
}
@typedoc """
MCP client initialization options
- `:name` - Following the `GenServer` patterns described on "Name registration".
- `:transport` - The MCP transport options
- `:client_info` - Information about the client
- `:capabilities` - Client capabilities to advertise to the MCP server
- `:protocol_version` - Protocol version to use (defaults to "2024-11-05")
Any other option support by `GenServer`.
"""
@type option ::
{:name, GenServer.name()}
| {:transport, transport}
| {:client_info, map}
| {:capabilities, map}
| {:protocol_version, String.t()}
| GenServer.option()
@default_client_capabilities %{"roots" => %{"listChanged" => true}, "sampling" => %{}}
defschema(:parse_options, [
{:name, {{:custom, &Hermes.genserver_name/1}, {:default, __MODULE__}}},
{:transport,
[
layer: {:required, :atom},
name: {:oneof, [{:custom, &Hermes.genserver_name/1}, :pid, {:tuple, [:atom, :any]}]}
]},
{:client_info, {:required, :map}},
{:capabilities, {:map, {:default, @default_client_capabilities}}},
{:protocol_version, {:string, {:default, @default_protocol_version}}}
])
@doc """
Starts a new MCP client process.
"""
@spec start_link(Enumerable.t(option)) :: GenServer.on_start()
def start_link(opts) do
opts = parse_options!(opts)
GenServer.start_link(__MODULE__, Map.new(opts), name: opts[:name])
end
@doc """
Sends a ping request to the server to check connection health. Returns `:pong` if successful.
## Options
* `:timeout` - Request timeout in milliseconds (default: 30s)
* `:progress` - Progress tracking options
* `:token` - A unique token to track progress (string or integer)
* `:callback` - A function to call when progress updates are received
"""
@spec ping(t, keyword) :: :pong | {:error, Error.t()}
def ping(client, opts \\ []) when is_list(opts) do
operation =
Operation.new(%{
method: "ping",
params: %{},
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
GenServer.call(client, {:operation, operation})
end
@doc """
Lists available resources from the server.
## Options
* `:cursor` - Pagination cursor for continuing a previous request
* `:timeout` - Request timeout in milliseconds
* `:progress` - Progress tracking options
* `:token` - A unique token to track progress (string or integer)
* `:callback` - A function to call when progress updates are received
"""
@spec list_resources(t, keyword) :: {:ok, Response.t()} | {:error, Error.t()}
def list_resources(client, opts \\ []) do
cursor = Keyword.get(opts, :cursor)
params = if cursor, do: %{"cursor" => cursor}, else: %{}
operation =
Operation.new(%{
method: "resources/list",
params: params,
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
GenServer.call(client, {:operation, operation})
end
@doc """
Reads a specific resource from the server.
## Options
* `:timeout` - Request timeout in milliseconds
* `:progress` - Progress tracking options
* `:token` - A unique token to track progress (string or integer)
* `:callback` - A function to call when progress updates are received
"""
@spec read_resource(t, String.t(), keyword) :: {:ok, Response.t()} | {:error, Error.t()}
def read_resource(client, uri, opts \\ []) do
operation =
Operation.new(%{
method: "resources/read",
params: %{"uri" => uri},
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
GenServer.call(client, {:operation, operation})
end
@doc """
Lists available prompts from the server.
## Options
* `:cursor` - Pagination cursor for continuing a previous request
* `:timeout` - Request timeout in milliseconds
* `:progress` - Progress tracking options
* `:token` - A unique token to track progress (string or integer)
* `:callback` - A function to call when progress updates are received
"""
@spec list_prompts(t, keyword) :: {:ok, Response.t()} | {:error, Error.t()}
def list_prompts(client, opts \\ []) do
cursor = Keyword.get(opts, :cursor)
params = if cursor, do: %{"cursor" => cursor}, else: %{}
operation =
Operation.new(%{
method: "prompts/list",
params: params,
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
GenServer.call(client, {:operation, operation})
end
@doc """
Gets a specific prompt from the server.
## Options
* `:timeout` - Request timeout in milliseconds
* `:progress` - Progress tracking options
* `:token` - A unique token to track progress (string or integer)
* `:callback` - A function to call when progress updates are received
"""
@spec get_prompt(t, String.t(), map() | nil, keyword) ::
{:ok, Response.t()} | {:error, Error.t()}
def get_prompt(client, name, arguments \\ nil, opts \\ []) do
params = %{"name" => name}
params = if arguments, do: Map.put(params, "arguments", arguments), else: params
operation =
Operation.new(%{
method: "prompts/get",
params: params,
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
GenServer.call(client, {:operation, operation})
end
@doc """
Lists available tools from the server.
## Options
* `:cursor` - Pagination cursor for continuing a previous request
* `:timeout` - Request timeout in milliseconds
* `:progress` - Progress tracking options
* `:token` - A unique token to track progress (string or integer)
* `:callback` - A function to call when progress updates are received
"""
@spec list_tools(t, keyword) :: {:ok, Response.t()} | {:error, Error.t()}
def list_tools(client, opts \\ []) do
cursor = Keyword.get(opts, :cursor)
params = if cursor, do: %{"cursor" => cursor}, else: %{}
operation =
Operation.new(%{
method: "tools/list",
params: params,
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
GenServer.call(client, {:operation, operation})
end
@doc """
Calls a tool on the server.
## Options
* `:timeout` - Request timeout in milliseconds
* `:progress` - Progress tracking options
* `:token` - A unique token to track progress (string or integer)
* `:callback` - A function to call when progress updates are received
"""
@spec call_tool(t, String.t(), map() | nil, keyword) ::
{:ok, Response.t()} | {:error, Error.t()}
def call_tool(client, name, arguments \\ nil, opts \\ []) do
params = %{"name" => name}
params = if arguments, do: Map.put(params, "arguments", arguments), else: params
operation =
Operation.new(%{
method: "tools/call",
params: params,
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
GenServer.call(client, {:operation, operation})
end
@doc """
Merges additional capabilities into the client's capabilities.
"""
@spec merge_capabilities(t, map()) :: map()
def merge_capabilities(client, additional_capabilities) do
GenServer.call(client, {:merge_capabilities, additional_capabilities})
end
@doc """
Gets the server's capabilities as reported during initialization.
Returns `nil` if the client has not been initialized yet.
"""
@spec get_server_capabilities(t) :: map() | nil
def get_server_capabilities(client) do
GenServer.call(client, :get_server_capabilities)
end
@doc """
Gets the server's information as reported during initialization.
Returns `nil` if the client has not been initialized yet.
"""
@spec get_server_info(t) :: map() | nil
def get_server_info(client) do
GenServer.call(client, :get_server_info)
end
@doc """
Sets the minimum log level for the server to send log messages.
## Parameters
* `client` - The client process
* `level` - The minimum log level (debug, info, notice, warning, error, critical, alert, emergency)
Returns {:ok, result} if successful, {:error, reason} otherwise.
"""
@spec set_log_level(t, String.t()) :: {:ok, Response.t()} | {:error, Error.t()}
def set_log_level(client, level) when level in ~w(debug info notice warning error critical alert emergency) do
operation =
Operation.new(%{
method: "logging/setLevel",
params: %{"level" => level}
})
GenServer.call(client, {:operation, operation})
end
@doc """
Registers a callback function to be called when log messages are received.
## Parameters
* `client` - The client process
* `callback` - A function that takes three arguments: level, data, and logger name
The callback function will be called whenever a log message notification is received.
"""
@spec register_log_callback(t, State.log_callback()) :: :ok
def register_log_callback(client, callback) when is_function(callback, 3) do
GenServer.call(client, {:register_log_callback, callback})
end
@doc """
Unregisters a previously registered log callback.
## Parameters
* `client` - The client process
* `callback` - The callback function to unregister
"""
@spec unregister_log_callback(t) :: :ok
def unregister_log_callback(client) do
GenServer.call(client, :unregister_log_callback)
end
@doc """
Registers a callback function to be called when progress notifications are received
for the specified progress token.
## Parameters
* `client` - The client process
* `progress_token` - The progress token to watch for (string or integer)
* `callback` - A function that takes three arguments: progress_token, progress, and total
The callback function will be called whenever a progress notification with the
matching token is received.
"""
@spec register_progress_callback(
t,
String.t() | integer(),
State.progress_callback()
) ::
:ok
def register_progress_callback(client, progress_token, callback)
when is_function(callback, 3) and (is_binary(progress_token) or is_integer(progress_token)) do
GenServer.call(client, {:register_progress_callback, progress_token, callback})
end
@doc """
Unregisters a previously registered progress callback for the specified token.
## Parameters
* `client` - The client process
* `progress_token` - The progress token to stop watching (string or integer)
"""
@spec unregister_progress_callback(t, String.t() | integer()) :: :ok
def unregister_progress_callback(client, progress_token) when is_binary(progress_token) or is_integer(progress_token) do
GenServer.call(client, {:unregister_progress_callback, progress_token})
end
@doc """
Sends a progress notification to the server for a long-running operation.
## Parameters
* `client` - The client process
* `progress_token` - The progress token provided in the original request (string or integer)
* `progress` - The current progress value (number)
* `total` - The optional total value for the operation (number)
Returns `:ok` if notification was sent successfully, or `{:error, reason}` otherwise.
"""
@spec send_progress(t, String.t() | integer(), number(), number() | nil) ::
:ok | {:error, term()}
def send_progress(client, progress_token, progress, total \\ nil)
when is_number(progress) and (is_binary(progress_token) or is_integer(progress_token)) do
GenServer.call(client, {:send_progress, progress_token, progress, total})
end
@doc """
Cancels an in-progress request.
## Parameters
* `client` - The client process
* `request_id` - The ID of the request to cancel
* `reason` - Optional reason for cancellation
## Returns
* `:ok` if the cancellation was successful
* `{:error, reason}` if an error occurred
* `{:not_found, request_id}` if the request ID was not found
"""
@spec cancel_request(t, String.t(), String.t()) ::
:ok | {:error, Error.t()}
def cancel_request(client, request_id, reason \\ "client_cancelled") do
GenServer.call(client, {:cancel_request, request_id, reason})
end
@doc """
Cancels all pending requests.
## Parameters
* `client` - The client process
* `reason` - Optional reason for cancellation (defaults to "client_cancelled")
## Returns
* `{:ok, requests}` - A list of the Request structs that were cancelled
* `{:error, reason}` - If an error occurred
"""
@spec cancel_all_requests(t, String.t()) ::
{:ok, list(Request.t())} | {:error, Error.t()}
def cancel_all_requests(client, reason \\ "client_cancelled") do
GenServer.call(client, {:cancel_all_requests, reason})
end
@doc """
Closes the client connection and terminates the process.
"""
@spec close(t) :: :ok
def close(client) do
GenServer.cast(client, :close)
end
# GenServer Callbacks
@impl true
def init(%{} = opts) do
layer = opts.transport[:layer]
name = opts.transport[:name] || layer
transport = %{layer: layer, name: name}
state =
State.new(%{
client_info: opts.client_info,
capabilities: opts.capabilities,
protocol_version: opts.protocol_version,
transport: transport
})
Logger.metadata(mcp_client: opts.name, mcp_transport: opts.transport)
{:ok, state, :hibernate}
end
@impl true
def handle_call({:operation, %Operation{} = operation}, from, state) do
params_with_token = State.add_progress_token_to_params(operation.params, operation.progress_opts)
with :ok <- State.validate_capability(state, operation.method),
{request_id, updated_state} = State.add_request_from_operation(state, operation, from),
{:ok, request_data} <- encode_request(operation.method, params_with_token, request_id),
:ok <- send_to_transport(state.transport, request_data) do
{:noreply, updated_state}
else
{:error, _reason} = err -> {:reply, err, state}
end
end
def handle_call({:merge_capabilities, additional_capabilities}, _from, state) do
updated = State.merge_capabilities(state, additional_capabilities)
{:reply, updated.capabilities, updated}
end
def handle_call(:get_server_capabilities, _from, state) do
{:reply, State.get_server_capabilities(state), state}
end
def handle_call(:get_server_info, _from, state) do
{:reply, State.get_server_info(state), state}
end
def handle_call({:register_log_callback, callback}, _from, state) do
updated_state = State.set_log_callback(state, callback)
{:reply, :ok, updated_state}
end
def handle_call(:unregister_log_callback, _from, state) do
updated_state = State.clear_log_callback(state)
{:reply, :ok, updated_state}
end
def handle_call({:register_progress_callback, token, callback}, _from, state) do
updated_state = State.register_progress_callback(state, token, callback)
{:reply, :ok, updated_state}
end
def handle_call({:unregister_progress_callback, token}, _from, state) do
updated_state = State.unregister_progress_callback(state, token)
{:reply, :ok, updated_state}
end
def handle_call({:send_progress, progress_token, progress, total}, _from, state) do
result =
with {:ok, notification} <-
Message.encode_progress_notification(progress_token, progress, total) do
send_to_transport(state.transport, notification)
end
{:reply, result, state}
end
def handle_call({:cancel_request, request_id, reason}, _from, state) do
with true <- Map.has_key?(state.pending_requests, request_id),
:ok <- send_cancellation(state, request_id, reason) do
{request, updated_state} = State.remove_request(state, request_id)
error =
Error.client_error(:request_cancelled, %{
message: "Request cancelled by client",
reason: reason
})
GenServer.reply(request.from, {:error, error})
{:reply, :ok, updated_state}
else
false -> {:reply, Error.client_error(:request_not_found), state}
error -> {:reply, error, state}
end
end
def handle_call({:cancel_all_requests, reason}, _from, state) do
pending_requests = State.list_pending_requests(state)
if Enum.empty?(pending_requests) do
{:reply, {:ok, []}, state}
else
# Process all pending requests
cancelled_requests =
for request <- pending_requests do
# Send cancellation notification and ignore errors
_ = send_cancellation(state, request.id, reason)
# Notify the original caller with error
error =
Error.client_error(:request_cancelled, %{
message: "Request cancelled by client",
reason: reason
})
GenServer.reply(request.from, {:error, error})
# Return the request for the response
request
end
# Return with empty pending requests map and list of cancelled requests
{:reply, {:ok, cancelled_requests}, %{state | pending_requests: %{}}}
end
end
@impl true
def handle_cast(:close, state) do
{:stop, :normal, state}
end
def handle_cast(:initialize, state) do
Logger.debug("Making initial client <> server handshake")
params = %{
"protocolVersion" => state.protocol_version,
"capabilities" => state.capabilities,
"clientInfo" => state.client_info
}
operation =
Operation.new(%{
method: "initialize",
params: params
})
{request_id, updated_state} =
State.add_request_from_operation(state, operation, {self(), make_ref()})
with {:ok, request_data} <- encode_request("initialize", params, request_id),
:ok <- send_to_transport(state.transport, request_data) do
{:noreply, updated_state}
else
err -> {:stop, err, state}
end
rescue
e ->
err = Exception.format(:error, e, __STACKTRACE__)
Logger.error("Failed to initialize client: #{err}")
{:stop, :unexpected, state}
end
@impl true
def handle_cast({:response, response_data}, state) do
case Message.decode(response_data) do
{:ok, [error]} when Message.is_error(error) ->
Logger.debug("Received server error response: #{inspect(error)}")
{:noreply, handle_error_response(error, error["id"], state)}
{:ok, [response]} when Message.is_response(response) ->
Logger.debug("Received server response: #{response["id"]}")
{:noreply, handle_success_response(response, response["id"], state)}
{:ok, [notification]} when Message.is_notification(notification) ->
method = notification["method"]
Logger.debug("Received server notification: #{method}")
{:noreply, handle_notification(notification, state)}
{:error, error} ->
Logger.error("Failed to decode response: #{inspect(error)}")
{:noreply, state}
end
rescue
e ->
err = Exception.format(:error, e, __STACKTRACE__)
Logger.error("Failed to handle response: #{err}")
{:noreply, state}
end
@impl true
def handle_info({:request_timeout, request_id}, state) do
case State.handle_request_timeout(state, request_id) do
{nil, state} ->
{:noreply, state}
{request, updated_state} ->
elapsed_ms = Request.elapsed_time(request)
error =
Error.client_error(:request_timeout, %{
message: "Request timed out after #{elapsed_ms}ms"
})
GenServer.reply(request.from, {:error, error})
# Send cancellation notification when a request times out
_ = send_cancellation(updated_state, request_id, "timeout")
{:noreply, updated_state}
end
end
@impl true
def terminate(reason, %{client_info: %{"name" => name}} = state) do
Logger.warning("Terminating #{name} MCP client with #{inspect(reason)}, closing transport")
pending_requests = State.list_pending_requests(state)
if length(pending_requests) > 0 do
Logger.warning("Closing client with #{length(pending_requests)} pending requests")
end
for request <- pending_requests do
send_notification(state, "notifications/cancelled", %{
"requestId" => request.id,
"reason" => "client closed"
})
end
state.transport.layer.shutdown(state.transport.name)
end
# Response handling
defp handle_error_response(%{"error" => json_error, "id" => id}, id, state) do
case State.remove_request(state, id) do
{nil, state} ->
Logger.warning("Received error response for unknown request ID: #{id}")
state
{request, updated_state} ->
# Convert JSON-RPC error to our domain error
error = Error.from_json_rpc(json_error)
# Unblock original caller with error
GenServer.reply(request.from, {:error, error})
updated_state
end
end
defp handle_success_response(%{"id" => id, "result" => %{"serverInfo" => _} = result}, id, state) do
case State.remove_request(state, id) do
{nil, state} ->
state
{_request, updated_state} ->
# Update server info in state
updated_state =
State.update_server_info(
updated_state,
result["capabilities"],
result["serverInfo"]
)
Logger.info("Initialized successfully, notifying server")
# Confirm to the server the handshake is complete
:ok = send_notification(updated_state, "notifications/initialized")
updated_state
end
end
defp handle_success_response(%{"id" => id, "result" => result}, id, state) do
case State.remove_request(state, id) do
{nil, state} ->
Logger.warning("Received response for unknown request ID: #{id}")
state
{request, updated_state} ->
# Convert to our domain response
response = Response.from_json_rpc(%{"result" => result, "id" => id})
if request.method == "ping" do
GenServer.reply(request.from, :pong)
else
GenServer.reply(request.from, {:ok, response})
end
updated_state
end
end
defp handle_success_response(%{"id" => id}, _id, state) do
Logger.warning("Received malformed response for request ID: #{id}")
state
end
# Notification handling
defp handle_notification(%{"method" => "notifications/progress"} = notification, state) do
handle_progress_notification(notification, state)
end
defp handle_notification(%{"method" => "notifications/message"} = notification, state) do
handle_log_notification(notification, state)
end
defp handle_notification(%{"method" => "notifications/cancelled"} = notification, state) do
handle_cancelled_notification(notification, state)
end
defp handle_notification(_, state), do: state
defp handle_cancelled_notification(%{"params" => params}, state) do
request_id = params["requestId"]
reason = Map.get(params, "reason", "unknown")
{request, updated_state} = State.remove_request(state, request_id)
if request do
Logger.info("Request #{request_id} cancelled by server: #{reason}")
error =
Error.client_error(:request_cancelled, %{
message: "Request cancelled by server",
reason: reason
})
GenServer.reply(request.from, {:error, error})
end
updated_state
end
defp handle_progress_notification(%{"params" => params}, state) do
progress_token = params["progressToken"]
progress = params["progress"]
total = Map.get(params, "total")
if callback = State.get_progress_callback(state, progress_token) do
# Execute the callback in a separate process to avoid blocking
Task.start(fn -> callback.(progress_token, progress, total) end)
end
state
end
defp handle_log_notification(%{"params" => params}, state) do
level = params["level"]
data = params["data"]
logger = Map.get(params, "logger")
# Execute callback if registered
if callback = State.get_log_callback(state) do
Task.start(fn -> callback.(level, data, logger) end)
end
# Log to Elixir's Logger for convenience
log_to_logger(level, data, logger)
state
end
defp log_to_logger(level, data, logger) do
prefix = if logger, do: "[#{logger}] ", else: ""
message = "#{prefix}#{inspect(data)}"
# Map MCP log levels to Elixir Logger levels
case level do
level when level in ["debug"] ->
Logger.debug(message)
level when level in ["info", "notice"] ->
Logger.info(message)
level when level in ["warning"] ->
Logger.warning(message)
level when level in ["error", "critical", "alert", "emergency"] ->
Logger.error(message)
_ ->
Logger.info(message)
end
end
# Helper functions
defp encode_request(method, params, request_id) do
Message.encode_request(%{"method" => method, "params" => params}, request_id)
end
defp encode_notification(method, params) do
Message.encode_notification(%{"method" => method, "params" => params})
end
defp send_cancellation(state, request_id, reason) do
params = %{
"requestId" => request_id,
"reason" => reason
}
send_notification(state, "notifications/cancelled", params)
end
defp send_to_transport(transport, data) do
with {:error, reason} <- transport.layer.send_message(transport.name, data) do
{:error, Error.transport_error(:send_failure, %{original_reason: reason})}
end
end
defp send_notification(state, method, params \\ %{}) do
with {:ok, notification_data} <- encode_notification(method, params) do
send_to_transport(state.transport, notification_data)
end
end
end