Current section
Files
Jump to
Current section
Files
lib/hermes/client/state.ex
defmodule Hermes.Client.State do
@moduledoc """
Manages state for the Hermes MCP client.
This module provides a structured representation of client state,
including capabilities, server info, and request tracking.
## State Structure
Each client state includes:
- `client_info`: Information about the client
- `capabilities`: Client capabilities
- `server_capabilities`: Server capabilities received during initialization
- `server_info`: Server information received during initialization
- `protocol_version`: MCP protocol version being used
- `request_timeout`: Default timeout for requests
- `transport`: Transport module or transport info
- `pending_requests`: Map of pending requests with details and timers
- `progress_callbacks`: Map of callbacks for progress tracking
- `log_callback`: Callback for handling log messages
## Examples
```elixir
# Create a new client state
state = Hermes.Client.State.new(%{
client_info: %{"name" => "MyClient", "version" => "1.0.0"},
capabilities: %{"resources" => %{}},
protocol_version: "2024-11-05",
request_timeout: 30000,
transport: %{layer: Hermes.Transport.SSE, name: MyTransport}
})
# Add a request to the state
{request_id, updated_state} = Hermes.Client.State.add_request(state, "ping", %{}, from)
# Get server capabilities
server_capabilities = Hermes.Client.State.get_server_capabilities(state)
```
"""
alias Hermes.Client.Request
alias Hermes.MCP.Error
alias Hermes.MCP.ID
@type progress_callback :: (String.t() | integer(), number(), number() | nil -> any())
@type log_callback :: (String.t(), term(), String.t() | nil -> any())
@type t :: %__MODULE__{
client_info: map(),
capabilities: map(),
server_capabilities: map() | nil,
server_info: map() | nil,
protocol_version: String.t(),
request_timeout: integer(),
transport: map(),
pending_requests: %{String.t() => Request.t()},
progress_callbacks: %{String.t() => progress_callback()},
log_callback: log_callback() | nil
}
defstruct [
:client_info,
:capabilities,
:server_capabilities,
:server_info,
:protocol_version,
:request_timeout,
:transport,
pending_requests: %{},
progress_callbacks: %{},
log_callback: nil
]
@doc """
Creates a new client state with the given options.
## Parameters
* `opts` - Map containing the initialization options
## Options
* `:client_info` - Information about the client (required)
* `:capabilities` - Client capabilities to advertise
* `:protocol_version` - Protocol version to use
* `:request_timeout` - Default timeout for requests in milliseconds
* `:transport` - Transport configuration
## Examples
iex> Hermes.Client.State.new(%{
...> client_info: %{"name" => "MyClient", "version" => "1.0.0"},
...> capabilities: %{"resources" => %{}},
...> protocol_version: "2024-11-05",
...> request_timeout: 30000,
...> transport: %{layer: Hermes.Transport.SSE, name: MyTransport}
...> })
%Hermes.Client.State{
client_info: %{"name" => "MyClient", "version" => "1.0.0"},
capabilities: %{"resources" => %{}},
protocol_version: "2024-11-05",
request_timeout: 30000,
transport: %{layer: Hermes.Transport.SSE, name: MyTransport}
}
"""
@spec new(map()) :: t()
def new(opts) do
%__MODULE__{
client_info: opts.client_info,
capabilities: opts.capabilities,
protocol_version: opts.protocol_version,
request_timeout: opts.request_timeout,
transport: opts.transport
}
end
@doc """
Adds a new request to the state and returns the request ID and updated state.
## Parameters
* `state` - The current client state
* `method` - The method being requested
* `params` - The parameters for the request
* `from` - The GenServer.from for the caller
## Examples
iex> {req_id, updated_state} = Hermes.Client.State.add_request(state, "ping", %{}, {pid, ref})
iex> is_binary(req_id)
true
iex> map_size(updated_state.pending_requests) > map_size(state.pending_requests)
true
"""
@spec add_request(t(), String.t(), map(), GenServer.from()) :: {String.t(), t()}
def add_request(state, method, _params, from) do
request_id = ID.generate_request_id()
timer_ref = Process.send_after(self(), {:request_timeout, request_id}, state.request_timeout)
request =
Request.new(%{
id: request_id,
method: method,
from: from,
timer_ref: timer_ref
})
pending_requests = Map.put(state.pending_requests, request_id, request)
{request_id, %{state | pending_requests: pending_requests}}
end
@doc """
Gets a request by ID.
## Parameters
* `state` - The current client state
* `id` - The request ID to retrieve
## Examples
iex> Hermes.Client.State.get_request(state, "req_123")
{{pid, ref}, "ping", timer_ref, start_time} # or nil if not found
"""
@spec get_request(t(), String.t()) :: Request.t() | nil
def get_request(state, id) do
Map.get(state.pending_requests, id)
end
@doc """
Removes a request and returns its info along with the updated state.
## Parameters
* `state` - The current client state
* `id` - The request ID to remove
## Examples
iex> {request_info, updated_state} = Hermes.Client.State.remove_request(state, "req_123")
iex> request_info.method
"ping"
iex> request_info.elapsed_ms > 0
true
"""
@spec remove_request(t(), String.t()) :: {Request.t() | nil, t()}
def remove_request(state, id) do
case Map.pop(state.pending_requests, id) do
{nil, _} ->
{nil, state}
{request, updated_requests} ->
# Cancel the timeout timer
Process.cancel_timer(request.timer_ref)
{request, %{state | pending_requests: updated_requests}}
end
end
@doc """
Handles a request timeout, cancelling the timer and returning the updated state.
## Parameters
* `state` - The current client state
* `id` - The request ID that timed out
## Examples
iex> Hermes.Client.State.handle_request_timeout(state, "req_123")
{%{from: from, method: "ping", elapsed_ms: 30000}, updated_state}
"""
@spec handle_request_timeout(t(), String.t()) :: {Request.t() | nil, t()}
def handle_request_timeout(state, id) do
case Map.pop(state.pending_requests, id) do
{nil, _} ->
{nil, state}
{request, updated_requests} ->
{request, %{state | pending_requests: updated_requests}}
end
end
@doc """
Registers a progress callback for a token.
## Parameters
* `state` - The current client state
* `token` - The progress token to register a callback for
* `callback` - The callback function to call when progress updates are received
## Examples
iex> updated_state = Hermes.Client.State.register_progress_callback(state, "token123", fn token, progress, total -> IO.inspect({token, progress, total}) end)
iex> Map.has_key?(updated_state.progress_callbacks, "token123")
true
"""
@spec register_progress_callback(t(), String.t(), progress_callback()) :: t()
def register_progress_callback(state, token, callback) when is_function(callback, 3) do
progress_callbacks = Map.put(state.progress_callbacks, token, callback)
%{state | progress_callbacks: progress_callbacks}
end
@doc """
Gets a progress callback for a token.
## Parameters
* `state` - The current client state
* `token` - The progress token to get the callback for
## Examples
iex> callback = Hermes.Client.State.get_progress_callback(state, "token123")
iex> is_function(callback, 3)
true
"""
@spec get_progress_callback(t(), String.t()) :: progress_callback() | nil
def get_progress_callback(state, token) do
Map.get(state.progress_callbacks, token)
end
@doc """
Unregisters a progress callback for a token.
## Parameters
* `state` - The current client state
* `token` - The progress token to unregister the callback for
## Examples
iex> updated_state = Hermes.Client.State.unregister_progress_callback(state, "token123")
iex> Map.has_key?(updated_state.progress_callbacks, "token123")
false
"""
@spec unregister_progress_callback(t(), String.t()) :: t()
def unregister_progress_callback(state, token) do
progress_callbacks = Map.delete(state.progress_callbacks, token)
%{state | progress_callbacks: progress_callbacks}
end
@doc """
Sets the log callback.
## Parameters
* `state` - The current client state
* `callback` - The callback function to call when log messages are received
## Examples
iex> updated_state = Hermes.Client.State.set_log_callback(state, fn level, data, logger -> IO.inspect({level, data, logger}) end)
iex> is_function(updated_state.log_callback, 3)
true
"""
@spec set_log_callback(t(), log_callback()) :: t()
def set_log_callback(state, callback) when is_function(callback, 3) do
%{state | log_callback: callback}
end
@doc """
Clears the log callback.
## Parameters
* `state` - The current client state
## Examples
iex> updated_state = Hermes.Client.State.clear_log_callback(state)
iex> is_nil(updated_state.log_callback)
true
"""
@spec clear_log_callback(t()) :: t()
def clear_log_callback(state) do
%{state | log_callback: nil}
end
@doc """
Gets the log callback.
## Parameters
* `state` - The current client state
## Examples
iex> callback = Hermes.Client.State.get_log_callback(state)
iex> is_function(callback, 3) or is_nil(callback)
true
"""
@spec get_log_callback(t()) :: log_callback() | nil
def get_log_callback(state) do
state.log_callback
end
@doc """
Updates server info and capabilities after initialization.
## Parameters
* `state` - The current client state
* `server_capabilities` - The server capabilities received from initialization
* `server_info` - The server information received from initialization
## Examples
iex> updated_state = Hermes.Client.State.update_server_info(state, %{"resources" => %{}}, %{"name" => "TestServer"})
iex> updated_state.server_capabilities
%{"resources" => %{}}
iex> updated_state.server_info
%{"name" => "TestServer"}
"""
@spec update_server_info(t(), map(), map()) :: t()
def update_server_info(state, server_capabilities, server_info) do
%{state | server_capabilities: server_capabilities, server_info: server_info}
end
@doc """
Returns a list of all pending requests.
## Parameters
* `state` - The current client state
## Examples
iex> requests = Hermes.Client.State.list_pending_requests(state)
iex> length(requests) > 0
true
iex> hd(requests).method
"ping"
"""
@spec list_pending_requests(t()) :: list(Request.t())
def list_pending_requests(state) do
Map.values(state.pending_requests)
end
@doc """
Gets the server capabilities.
## Parameters
* `state` - The current client state
## Examples
iex> Hermes.Client.State.get_server_capabilities(state)
%{"resources" => %{}, "tools" => %{}}
"""
@spec get_server_capabilities(t()) :: map() | nil
def get_server_capabilities(state) do
state.server_capabilities
end
@doc """
Gets the server info.
## Parameters
* `state` - The current client state
## Examples
iex> Hermes.Client.State.get_server_info(state)
%{"name" => "TestServer", "version" => "1.0.0"}
"""
@spec get_server_info(t()) :: map() | nil
def get_server_info(state) do
state.server_info
end
@doc """
Merges additional capabilities into the client's capabilities.
## Parameters
* `state` - The current client state
* `additional_capabilities` - The capabilities to merge
## Examples
iex> updated_state = Hermes.Client.State.merge_capabilities(state, %{"tools" => %{"execute" => true}})
iex> updated_state.capabilities["tools"]["execute"]
true
"""
@spec merge_capabilities(t(), map()) :: t()
def merge_capabilities(state, additional_capabilities) do
updated_capabilities = deep_merge(state.capabilities, additional_capabilities)
%{state | capabilities: updated_capabilities}
end
@doc """
Validates if a method is supported by the server's capabilities.
## Parameters
* `state` - The current client state
* `method` - The method to validate
## Returns
* `:ok` if the method is supported
* `{:error, %Hermes.MCP.Error{}}` if the method is not supported
## Examples
iex> Hermes.Client.State.validate_capability(state_with_resources, "resources/list")
:ok
iex> {:error, error} = Hermes.Client.State.validate_capability(state_without_tools, "tools/list")
iex> error.reason
:method_not_found
"""
@spec validate_capability(t(), String.t()) :: :ok | {:error, Error.t()}
def validate_capability(%{server_capabilities: nil}, _method) do
{:error, Error.client_error(:server_capabilities_not_set)}
end
def validate_capability(%{server_capabilities: _}, "ping"), do: :ok
def validate_capability(%{server_capabilities: _}, "initialize"), do: :ok
def validate_capability(%{server_capabilities: server_capabilities}, method) do
capability = String.split(method, "/", parts: 2)
if valid_capability?(server_capabilities, capability) do
:ok
else
{:error, Error.method_not_found(%{method: method})}
end
end
# Helper functions
defp valid_capability?(_capabilities, ["ping"]), do: true
defp valid_capability?(_capabilities, ["initialize"]), do: true
defp valid_capability?(capabilities, ["resources", sub]) when sub in ~w(subscribe unsubscribe) do
if resources = Map.get(capabilities, "resources") do
valid_capability?(resources, [sub, nil])
end
end
defp valid_capability?(%{} = capabilities, [capability, _]) do
Map.has_key?(capabilities, capability)
end
defp deep_merge(map1, map2) do
Map.merge(map1, map2, fn
_, v1, v2 when is_map(v1) and is_map(v2) -> deep_merge(v1, v2)
_, _, v2 -> v2
end)
end
end