Current section
Files
Jump to
Current section
Files
lib/codex/mcp/client.ex
defmodule Codex.MCP.Client do
@moduledoc """
Minimal MCP client responsible for performing the handshake with external servers and
providing lightweight tool discovery/invocation helpers with caching and retries.
"""
defstruct transport: nil, capabilities: %{}, tool_cache: %{}
@type transport_ref :: {module(), term()}
@type capabilities :: %{optional(String.t()) => term()}
@type t :: %__MODULE__{
transport: transport_ref(),
capabilities: capabilities(),
tool_cache: map()
}
@doc """
Performs a handshake against the given transport.
"""
@spec handshake(transport_ref(), keyword()) :: {:ok, t()} | {:error, term()}
def handshake({mod, state} = transport, opts \\ []) when is_atom(mod) do
request = %{
"type" => "handshake",
"client" => Keyword.get(opts, :client, "codex-elixir"),
"version" => Keyword.get(opts, :version, "0.0.0")
}
:ok = mod.send(state, request)
with {:ok, response} <- mod.recv(state),
{:ok, caps} <- extract_capabilities(response) do
{:ok, %__MODULE__{transport: transport, capabilities: caps}}
else
{:error, reason} -> {:error, reason}
end
end
@doc """
Returns capabilities advertised by the MCP server.
"""
@spec capabilities(t()) :: capabilities()
def capabilities(%__MODULE__{capabilities: caps}), do: caps
@doc """
Lists available tools, applying allow/block filters and caching results unless `cache?: false`
is supplied.
"""
@spec list_tools(t(), keyword()) :: {:ok, [map()], t()} | {:error, term()}
def list_tools(%__MODULE__{} = client, opts \\ []) do
cache? = Keyword.get(opts, :cache?, true)
case {cache?, client.tool_cache} do
{true, %{tools: tools}} when is_list(tools) ->
{:ok, filter_tools(tools, opts), client}
_ ->
fetch_tools(client, opts)
end
end
@doc """
Calls a tool with optional retry/backoff and approval callbacks.
"""
@spec call_tool(t(), String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()}
def call_tool(%__MODULE__{} = client, tool, args, opts \\ []) when is_binary(tool) do
retries = Keyword.get(opts, :retries, 0)
backoff = Keyword.get(opts, :backoff, fn _ -> :ok end)
approval = Keyword.get(opts, :approval)
context = Keyword.get(opts, :context, %{})
with :ok <- run_approval(approval, tool, args, context) do
do_call_tool(client, tool, args, retries, backoff, 0)
end
end
defp extract_capabilities(%{"type" => "handshake.ack"} = response),
do: extract_capabilities(response, Map.get(response, "capabilities"))
defp extract_capabilities(%{"capabilities" => caps}), do: normalize_capabilities(caps)
defp extract_capabilities(_other), do: {:error, :invalid_handshake}
defp extract_capabilities(%{"capabilities" => caps}, _), do: normalize_capabilities(caps)
defp extract_capabilities(_other, _caps), do: {:error, :invalid_handshake}
defp normalize_capabilities(caps) when is_map(caps), do: {:ok, stringify_keys(caps)}
defp normalize_capabilities(caps) when is_list(caps) do
normalized =
caps
|> Enum.map(fn cap -> {to_string(cap), %{}} end)
|> Map.new()
{:ok, normalized}
end
defp normalize_capabilities(_other), do: {:error, :invalid_handshake}
defp stringify_keys(value) when is_map(value) do
Map.new(value, fn {key, val} -> {to_string(key), stringify_keys(val)} end)
end
defp stringify_keys(list) when is_list(list), do: Enum.map(list, &stringify_keys/1)
defp stringify_keys(other), do: other
defp fetch_tools(%__MODULE__{transport: {mod, state}} = client, opts) do
:ok = mod.send(state, %{"type" => "list_tools"})
with {:ok, response} <- mod.recv(state),
{:ok, tools} <- normalize_tools(response) do
filtered = filter_tools(tools, opts)
updated = %{client | tool_cache: %{tools: filtered}}
{:ok, filtered, updated}
else
{:error, reason} -> {:error, reason}
end
end
defp normalize_tools(%{"tools" => tools}) when is_list(tools) do
{:ok,
Enum.map(tools, fn
%{} = tool -> stringify_keys(tool)
other -> %{"name" => to_string(other)}
end)}
end
defp normalize_tools(_), do: {:error, :invalid_tools_response}
defp filter_tools(tools, opts) do
allow = Keyword.get(opts, :allow)
deny = Keyword.get(opts, :deny, [])
filter_fun = Keyword.get(opts, :filter)
tools
|> Enum.filter(fn tool ->
name = tool |> Map.get("name") |> to_string()
cond do
is_list(allow) and name not in Enum.map(allow, &to_string/1) -> false
name in Enum.map(deny, &to_string/1) -> false
is_function(filter_fun) -> truthy?(filter_fun.(tool))
true -> true
end
end)
end
defp truthy?(value), do: value not in [false, nil]
defp run_approval(nil, _tool, _args, _context), do: :ok
defp run_approval(fun, tool, args, context) when is_function(fun) do
case safe_apply(fun, tool, args, context) do
{:deny, reason} -> {:error, {:approval_denied, reason}}
:deny -> {:error, {:approval_denied, :denied}}
false -> {:error, {:approval_denied, :denied}}
_ -> :ok
end
end
defp run_approval(_other, _tool, _args, _context), do: :ok
defp do_call_tool(
%__MODULE__{transport: {mod, state}} = client,
tool,
args,
retries,
backoff,
attempt
) do
:ok =
mod.send(state, %{
"type" => "call_tool",
"tool" => tool,
"arguments" => args
})
case mod.recv(state) do
{:ok, %{"result" => result}} ->
{:ok, stringify_keys(result)}
{:ok, %{"error" => reason}} ->
retry_or_error(client, tool, args, retries, backoff, attempt, reason)
{:error, reason} ->
retry_or_error(client, tool, args, retries, backoff, attempt, reason)
end
end
defp retry_or_error(client, tool, args, retries, backoff, attempt, reason) do
if attempt < retries do
safe_backoff(backoff, attempt + 1)
do_call_tool(client, tool, args, retries, backoff, attempt + 1)
else
{:error, reason}
end
end
defp safe_backoff(fun, attempt) when is_function(fun, 1), do: fun.(attempt)
defp safe_backoff(_fun, _attempt), do: :ok
defp safe_apply(fun, tool, args, context) when is_function(fun, 3),
do: fun.(tool, args, context)
defp safe_apply(fun, tool, args, _context) when is_function(fun, 2), do: fun.(tool, args)
defp safe_apply(fun, tool, _args, _context) when is_function(fun, 1), do: fun.(tool)
end