defmodule AgentSea.MCP.Client do
@moduledoc """
An MCP client: performs the `initialize` handshake over a transport, caches the
server's tool list, and calls tools. Transport-agnostic (see
`AgentSea.MCP.Transport`).
"""
use GenServer
@protocol_version "2024-11-05"
# --- Client API ---
@doc """
Start a client. Options:
* `:transport` — `{transport_module, ref}` (required)
* `:name` — optional process name
"""
def start_link(opts) do
{gen_opts, init_opts} = Keyword.split(opts, [:name])
GenServer.start_link(__MODULE__, init_opts, gen_opts)
end
@doc "The server's advertised tools (raw MCP tool maps)."
def list_tools(client), do: GenServer.call(client, :list_tools)
@doc "Call a tool by name with arguments; returns the textual result."
def call_tool(client, name, args), do: GenServer.call(client, {:call_tool, name, args})
@doc "Server info from the handshake (`nil` until initialized)."
def server_info(client), do: GenServer.call(client, :server_info)
# --- Server ---
@impl true
def init(opts) do
transport = Keyword.fetch!(opts, :transport)
{:ok, %{transport: transport, tools: [], server_info: nil}, {:continue, :initialize}}
end
@impl true
def handle_continue(:initialize, state) do
case request(state, "initialize", initialize_params()) do
{:ok, result} ->
tools =
case request(state, "tools/list", %{}) do
{:ok, %{"tools" => tools}} when is_list(tools) -> tools
_ -> []
end
{:noreply, %{state | tools: tools, server_info: Map.get(result, "serverInfo")}}
{:error, _reason} ->
# Stay up with no tools; a real client might retry or stop.
{:noreply, state}
end
end
@impl true
def handle_call(:list_tools, _from, state), do: {:reply, state.tools, state}
def handle_call(:server_info, _from, state), do: {:reply, state.server_info, state}
def handle_call({:call_tool, name, args}, _from, state) do
case request(state, "tools/call", %{"name" => name, "arguments" => args}) do
{:ok, result} ->
if Map.get(result, "isError", false) do
{:reply, {:error, {:tool_error, result_text(result)}}, state}
else
{:reply, {:ok, result_text(result)}, state}
end
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
# --- Helpers ---
defp request(%{transport: {module, ref}}, method, params),
do: module.request(ref, method, params)
defp initialize_params do
%{
"protocolVersion" => @protocol_version,
"capabilities" => %{},
"clientInfo" => %{"name" => "agentsea", "version" => "0.1.0"}
}
end
defp result_text(%{"content" => content}) when is_list(content) do
content
|> Enum.filter(&(&1["type"] == "text"))
|> Enum.map_join("\n", & &1["text"])
end
defp result_text(other), do: inspect(other)
end