Skip to main content

lib/exagent/mcp/protocol.ex

defmodule ExAgent.MCP.Protocol do
  @moduledoc """
  Pure JSON-RPC 2.0 encoding/decoding for the Model Context Protocol, plus the
  mapping from an MCP tool spec to an `ExAgent.Tool`.

  Splitting the protocol out (no ports, no processes) keeps it trivially
  unit-testable. `ExAgent.MCP.Client` owns the transport and calls into here.
  """

  alias ExAgent.Tool

  @type decoded ::
          {:response, id :: term(), result :: map()}
          | {:error_response, id :: term(), error :: map()}
          | {:notification, method :: String.t(), params :: map()}
          | :ignore

  @doc "Encode a JSON-RPC request (with an `id`) as a newline-terminated binary."
  @spec encode_request(term(), String.t(), map()) :: iodata()
  def encode_request(id, method, params) when is_binary(method) do
    Jason.encode_to_iodata!(%{
      "jsonrpc" => "2.0",
      "id" => id,
      "method" => method,
      "params" => params
    }) ++
      "\n"
  end

  @doc "Encode a JSON-RPC notification (no `id`, no reply expected)."
  @spec encode_notification(String.t(), map()) :: iodata()
  def encode_notification(method, params) when is_binary(method) do
    Jason.encode_to_iodata!(%{"jsonrpc" => "2.0", "method" => method, "params" => params}) ++ "\n"
  end

  @doc """
  Decode one complete JSON-RPC line.

    * a response with a `result` → `{:response, id, result}`
    * a response with an `error` → `{:error_response, id, error}`
    * a notification (no `id`) → `{:notification, method, params}`
    * anything unparseable / unrelated → `:ignore`
  """
  @spec decode(binary()) :: decoded()
  def decode(line) when is_binary(line) do
    line = String.trim(line)

    case Jason.decode(line) do
      {:ok, %{"id" => id, "result" => result}} when id != nil ->
        {:response, id, result}

      {:ok, %{"id" => id, "error" => error}} when id != nil ->
        {:error_response, id, error}

      {:ok, %{"method" => method, "params" => params}} ->
        {:notification, method, params || %{}}

      _ ->
        :ignore
    end
  end

  @doc """
  Map an MCP `tools/list` entry to an `ExAgent.Tool` whose `call` forwards to the
  server via `call_fun`. The tool's result text is extracted from the MCP
  `content` blocks; an MCP `isError: true` becomes `{:error, _}`.
  """
  @spec to_tool(map(), (String.t(), map() -> {:ok, String.t()} | {:error, term()})) :: Tool.t()
  def to_tool(spec, call_fun) when is_function(call_fun, 2) do
    name = spec["name"]
    schema = spec["inputSchema"] || spec["input_schema"] || %{type: "object", properties: %{}}

    Tool.new(
      name: name,
      description: spec["description"] || "MCP tool #{name}",
      parameters_json_schema: schema,
      takes_ctx: false,
      call: fn args -> call_fun.(name, args) end
    )
  end

  @doc "Extract the concatenated text from an MCP `tools/call` result."
  @spec result_to_text(map()) :: {:ok, String.t()} | {:error, term()}
  def result_to_text(%{"isError" => true} = result),
    do: {:error, extract_text(result)}

  def result_to_text(%{"content" => content}) when is_list(content),
    do: {:ok, extract_text(%{"content" => content})}

  def result_to_text(other), do: {:ok, inspect(other)}

  defp extract_text(%{"content" => content}) when is_list(content) do
    content
    |> Enum.map_join(fn
      %{"type" => "text", "text" => t} -> t
      %{"text" => t} -> t
      block -> inspect(block)
    end)
  end

  defp extract_text(other), do: inspect(other)
end