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.Logging
alias Hermes.MCP.Error
alias Hermes.MCP.Message
alias Hermes.MCP.Response
alias Hermes.Protocol
alias Hermes.Telemetry
require Hermes.MCP.Message
@default_protocol_version Protocol.latest_version()
@type t :: GenServer.server()
@typedoc """
MCP client transport options
- `:layer` - The transport layer to use, either `Hermes.Transport.STDIO`, `Hermes.Transport.SSE`, `Hermes.Transport.WebSocket`, or `Hermes.Transport.StreamableHTTP` (required)
- `:name` - The transport optional custom name
"""
@type transport ::
list(
{:layer,
Hermes.Transport.STDIO
| Hermes.Transport.SSE
| Hermes.Transport.WebSocket
| Hermes.Transport.StreamableHTTP}
| {: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, {:required, {:custom, &Hermes.client_transport/1}}},
{: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)
protocol_version = opts[:protocol_version]
layer = opts[:transport][:layer]
with :ok <- Protocol.validate_version(protocol_version),
:ok <- Protocol.validate_transport(protocol_version, layer) do
GenServer.start_link(__MODULE__, Map.new(opts), name: opts[:name])
end
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)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
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)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
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)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
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)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
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)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
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)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
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)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
end
@doc """
Merges additional capabilities into the client's capabilities.
"""
@spec merge_capabilities(t, map(), opts :: Keyword.t()) :: map()
def merge_capabilities(client, additional_capabilities, opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:merge_capabilities, additional_capabilities}, timeout)
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, opts :: Keyword.t()) :: map() | nil
def get_server_capabilities(client, opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, :get_server_capabilities, timeout)
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, opts :: Keyword.t()) :: map() | nil
def get_server_info(client, opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, :get_server_info, timeout)
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}
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
end
@doc """
Requests autocompletion suggestions for prompt arguments or resource URIs.
## Parameters
* `client` - The client process
* `ref` - Reference to what is being completed (required)
* For prompts: `%{"type" => "ref/prompt", "name" => prompt_name}`
* For resources: `%{"type" => "ref/resource", "uri" => resource_uri}`
* `argument` - The argument being completed (required)
* `%{"name" => arg_name, "value" => current_value}`
* `opts` - Additional 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
## Returns
Returns `{:ok, response}` with completion suggestions if successful, or `{:error, reason}` if an error occurs.
The response result contains a "completion" object with:
* `values` - List of completion suggestions (maximum 100)
* `total` - Optional total number of matching items
* `hasMore` - Boolean indicating if more results are available
## Examples
# Get completion for a prompt argument
ref = %{"type" => "ref/prompt", "name" => "code_review"}
argument = %{"name" => "language", "value" => "py"}
{:ok, response} = Hermes.Client.complete(client, ref, argument)
# Access the completion values
values = get_in(Response.unwrap(response), ["completion", "values"])
"""
@spec complete(t, map(), map(), keyword()) :: {:ok, Response.t()} | {:error, Error.t()}
def complete(client, ref, argument, opts \\ []) do
params = %{
"ref" => ref,
"argument" => argument
}
operation =
Operation.new(%{
method: "completion/complete",
params: params,
progress_opts: Keyword.get(opts, :progress),
timeout: Keyword.get(opts, :timeout)
})
buffer_timeout = operation.timeout + to_timeout(second: 1)
GenServer.call(client, {:operation, operation}, buffer_timeout)
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(), opts :: Keyword.t()) :: :ok
def register_log_callback(client, callback, opts \\ []) when is_function(callback, 3) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:register_log_callback, callback}, timeout)
end
@doc """
Unregisters a previously registered log callback.
## Parameters
* `client` - The client process
* `callback` - The callback function to unregister
"""
@spec unregister_log_callback(t, opts :: Keyword.t()) :: :ok
def unregister_log_callback(client, opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, :unregister_log_callback, timeout)
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(),
opts :: Keyword.t()
) ::
:ok
def register_progress_callback(client, progress_token, callback, opts \\ [])
when is_function(callback, 3) and (is_binary(progress_token) or is_integer(progress_token)) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:register_progress_callback, progress_token, callback}, timeout)
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(), opts :: Keyword.t()) :: :ok
def unregister_progress_callback(client, progress_token, opts \\ [])
when is_binary(progress_token) or is_integer(progress_token) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:unregister_progress_callback, progress_token}, timeout)
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, opts :: Keyword.t()) ::
:ok | {:error, term()}
def send_progress(client, progress_token, progress, total \\ nil, opts \\ [])
when is_number(progress) and (is_binary(progress_token) or is_integer(progress_token)) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:send_progress, progress_token, progress, total}, timeout)
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(), opts :: Keyword.t()) ::
:ok | {:error, Error.t()}
def cancel_request(client, request_id, reason \\ "client_cancelled", opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:cancel_request, request_id, reason}, timeout)
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(), opts :: Keyword.t()) ::
{:ok, list(Request.t())} | {:error, Error.t()}
def cancel_all_requests(client, reason \\ "client_cancelled", opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:cancel_all_requests, reason}, timeout)
end
@doc """
Adds a root directory to the client's roots list.
## Parameters
* `client` - The client process
* `uri` - The URI of the root directory (must start with "file://")
* `name` - Optional human-readable name for the root
* `opts` - Additional options
* `:timeout` - Request timeout in milliseconds
## Examples
iex> Hermes.Client.add_root(client, "file:///home/user/project", "My Project")
:ok
"""
@spec add_root(t, String.t(), String.t() | nil, opts :: Keyword.t()) :: :ok
def add_root(client, uri, name \\ nil, opts \\ []) when is_binary(uri) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:add_root, uri, name}, timeout)
end
@doc """
Removes a root directory from the client's roots list.
## Parameters
* `client` - The client process
* `uri` - The URI of the root directory to remove
* `opts` - Additional options
* `:timeout` - Request timeout in milliseconds
## Examples
iex> Hermes.Client.remove_root(client, "file:///home/user/project")
:ok
"""
@spec remove_root(t, String.t(), opts :: Keyword.t()) :: :ok
def remove_root(client, uri, opts \\ []) when is_binary(uri) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, {:remove_root, uri}, timeout)
end
@doc """
Gets a list of all root directories.
## Parameters
* `client` - The client process
* `opts` - Additional options
* `:timeout` - Request timeout in milliseconds
## Examples
iex> Hermes.Client.list_roots(client)
[%{uri: "file:///home/user/project", name: "My Project"}]
"""
@spec list_roots(t, opts :: Keyword.t()) :: [State.root()]
def list_roots(client, opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, :list_roots, timeout)
end
@doc """
Clears all root directories.
## Parameters
* `client` - The client process
* `opts` - Additional options
* `:timeout` - Request timeout in milliseconds
## Examples
iex> Hermes.Client.clear_roots(client)
:ok
"""
@spec clear_roots(t, opts :: Keyword.t()) :: :ok
def clear_roots(client, opts \\ []) do
timeout = opts[:timeout] || to_timeout(second: 5)
GenServer.call(client, :clear_roots, timeout)
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
protocol_version = opts.protocol_version
transport = %{layer: layer, name: name}
state =
State.new(%{
client_info: opts.client_info,
capabilities: opts.capabilities,
protocol_version: protocol_version,
transport: transport
})
client_name = get_in(opts, [:client_info, "name"])
Logger.metadata(
mcp_client: opts.name,
mcp_client_name: client_name,
mcp_transport: opts.transport
)
Logging.client_event("initializing", %{
protocol_version: protocol_version,
capabilities: opts.capabilities,
transport: layer
})
Telemetry.execute(
Telemetry.event_client_init(),
%{system_time: System.system_time()},
%{
client_name: client_name,
transport: transport,
protocol_version: protocol_version,
capabilities: opts.capabilities
}
)
{:ok, state, :hibernate}
end
@impl true
def handle_call({:operation, %Operation{} = operation}, from, state) do
method = operation.method
params_with_token =
State.add_progress_token_to_params(operation.params, operation.progress_opts)
with :ok <- State.validate_capability(state, method),
{request_id, updated_state} = State.add_request_from_operation(state, operation, from),
{:ok, request_data} <- encode_request(method, params_with_token, request_id),
:ok <- send_to_transport(state.transport, request_data) do
Telemetry.execute(
Telemetry.event_client_request(),
%{system_time: System.system_time()},
%{method: method, request_id: request_id}
)
{:noreply, updated_state}
else
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
{:reply, :ok, State.set_log_callback(state, callback)}
end
def handle_call(:unregister_log_callback, _from, state) do
{:reply, :ok, State.clear_log_callback(state)}
end
def handle_call({:register_progress_callback, token, callback}, _from, state) do
{:reply, :ok, State.register_progress_callback(state, token, callback)}
end
def handle_call({:unregister_progress_callback, token}, _from, state) do
{:reply, :ok, State.unregister_progress_callback(state, token)}
end
def handle_call({:send_progress, progress_token, progress, total}, _from, state) do
{:reply,
with {:ok, notification} <-
Message.encode_progress_notification(%{
"progressToken" => progress_token,
"progress" => progress,
"total" => total
}) do
send_to_transport(state.transport, notification)
end, state}
end
def handle_call({:add_root, uri, name}, _from, state) do
{:reply, :ok, State.add_root(state, uri, name), {:continue, :roots_list_changed}}
end
def handle_call({:remove_root, uri}, _from, state) do
{:reply, :ok, State.remove_root(state, uri), {:continue, :roots_list_changed}}
end
def handle_call(:list_roots, _from, state) do
{:reply, State.list_roots(state), state}
end
def handle_call(:clear_roots, _from, state) do
{:reply, :ok, State.clear_roots(state), {:continue, :roots_list_changed}}
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.transport(:request_cancelled, %{
message: "Request cancelled by client",
reason: reason
})
GenServer.reply(request.from, {:error, error})
{:reply, :ok, updated_state}
else
false -> {:reply, Error.transport(: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
cancelled_requests =
for request <- pending_requests do
_ = send_cancellation(state, request.id, reason)
error =
Error.transport(:request_cancelled, %{
message: "Request cancelled by client",
reason: reason
})
GenServer.reply(request.from, {:error, error})
request
end
{:reply, {:ok, cancelled_requests}, %{state | pending_requests: %{}}}
end
end
@impl true
def handle_continue(:roots_list_changed, state) do
Task.start(fn -> send_roots_list_changed_notification(state) end)
{:noreply, state}
end
@impl true
def handle_cast(:close, state) do
{:stop, :normal, state}
end
def handle_cast(:initialize, state) do
Logging.client_event("handshake", "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__)
Logging.client_event("initialization_failed", %{error: 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) ->
Logging.message("incoming", "error", error["id"], error)
{:noreply, handle_error_response(error, error["id"], state)}
{:ok, [response]} when Message.is_response(response) ->
Logging.message("incoming", "response", response["id"], response)
{:noreply, handle_success_response(response, response["id"], state)}
{:ok, [notification]} when Message.is_notification(notification) ->
Logging.message("incoming", "notification", nil, notification)
{:noreply, handle_notification(notification, state)}
{:ok, [request]} when Message.is_request(request) ->
Logging.message("incoming", "request", request["id"], request)
handle_server_request(request, state)
{:error, error} ->
Logging.client_event("decode_failed", %{error: error}, level: :warning)
{:noreply, state}
end
rescue
e ->
err = Exception.format(:error, e, __STACKTRACE__)
Logging.client_event("response_handling_failed", %{error: err}, level: :error)
{:noreply, state}
end
# Server request handling
defp handle_server_request(%{"method" => "roots/list", "id" => id}, state) do
roots = State.list_roots(state)
roots_result = %{"roots" => roots}
roots_count = Enum.count(roots)
with {:ok, response_data} <- Message.encode_response(%{"result" => roots_result}, id),
:ok <- send_to_transport(state.transport, response_data) do
Logging.client_event("roots_list_request", %{id: id, roots_count: roots_count})
Telemetry.execute(
Telemetry.event_client_roots(),
%{system_time: System.system_time()},
%{action: :list, count: roots_count, request_id: id}
)
{:noreply, state}
else
err ->
Logging.client_event("roots_list_error", %{id: id, error: err}, level: :error)
Telemetry.execute(
Telemetry.event_client_error(),
%{system_time: System.system_time()},
%{method: "roots/list", request_id: id, error: err}
)
{:noreply, state}
end
end
defp handle_server_request(%{"method" => "ping", "id" => id}, state) do
with {:ok, response_data} <- Message.encode_response(%{"result" => %{}}, id),
:ok <- send_to_transport(state.transport, response_data) do
{:noreply, state}
else
err ->
Logging.client_event("ping_response_error", %{id: id, error: err}, level: :error)
Telemetry.execute(
Telemetry.event_client_error(),
%{system_time: System.system_time()},
%{method: "ping", request_id: id, error: err}
)
{:noreply, state}
end
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.transport(: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
Logging.client_event("terminating", %{
name: name,
reason: reason
})
pending_requests = State.list_pending_requests(state)
pending_count = length(pending_requests)
if pending_count > 0 do
Logging.client_event("pending_requests", %{
count: pending_count
})
end
Telemetry.execute(
Telemetry.event_client_terminate(),
%{system_time: System.system_time()},
%{
client_name: name,
reason: reason,
pending_requests: pending_count
}
)
for request <- pending_requests do
# Reply to the pending request with an error
error =
Error.transport(:request_cancelled, %{
message: "Request cancelled by client",
reason: "client closed"
})
GenServer.reply(request.from, {:error, error})
# Also send the cancellation notification
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} ->
error_code = json_error["code"]
error_msg = json_error["message"]
Logging.client_event("unknown_error_response", %{
id: id,
code: error_code,
message: error_msg
})
state
{request, updated_state} ->
error = Error.from_json_rpc(json_error)
elapsed_ms = Request.elapsed_time(request)
Logging.client_event("error_response", %{
id: id,
method: request.method
})
Telemetry.execute(
Telemetry.event_client_error(),
%{duration: elapsed_ms, system_time: System.system_time()},
%{
id: id,
method: request.method,
error_code: json_error["code"],
error_message: json_error["message"]
}
)
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, state} ->
state = State.update_server_info(state, result["capabilities"], result["serverInfo"])
Logging.client_event("initialized", %{server_info: result["serverInfo"], capabilities: result["capabilities"]})
:ok = send_notification(state, "notifications/initialized")
state
end
end
defp handle_success_response(%{"id" => id, "result" => result}, id, state) do
case State.remove_request(state, id) do
{nil, state} ->
Logging.client_event("unknown_response", %{id: id})
state
{request, updated_state} ->
response = Response.from_json_rpc(%{"result" => result, "id" => id})
elapsed_ms = Request.elapsed_time(request)
Logging.client_event("success_response", %{id: id, method: request.method})
Telemetry.execute(
Telemetry.event_client_response(),
%{duration: elapsed_ms, system_time: System.system_time()},
%{
id: id,
method: request.method,
status: :success
}
)
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
Logging.client_event("malformed_response", %{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
Logging.client_event("request_cancelled", %{
id: request_id,
reason: reason
})
error =
Error.transport(: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
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")
if callback = State.get_log_callback(state) do
Task.start(fn -> callback.(level, data, logger) end)
end
log_to_logger(level, data, logger)
state
end
defp log_to_logger(level, data, logger) do
elixir_level =
case level do
level when level in ["debug"] -> :debug
level when level in ["info", "notice"] -> :info
level when level in ["warning"] -> :warning
level when level in ["error", "critical", "alert", "emergency"] -> :error
_ -> :info
end
Logging.client_event("server_log", %{level: level, data: data, logger: logger}, level: elixir_level)
end
# Helper functions
defp encode_request(method, params, request_id) do
request = %{"method" => method, "params" => params}
Logging.message("outgoing", "request", request_id, request)
Message.encode_request(request, request_id)
end
defp encode_notification(method, params) do
notification = %{"method" => method, "params" => params}
Logging.message("outgoing", "notification", nil, notification)
Message.encode_notification(notification)
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(: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
defp send_roots_list_changed_notification(state) do
Logging.client_event("sending_roots_list_changed", nil)
send_notification(state, "notifications/roots/list_changed")
end
end