Skip to main content

lib/agent_sea/mcp/client.ex

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