Skip to main content

lib/noizu/mcp/json_rpc.ex

defmodule Noizu.MCP.JsonRpc do
  @moduledoc """
  JSON-RPC 2.0 framing for MCP.

  Pure data layer: decodes a wire binary into one of four message structs and
  encodes them back. Per MCP 2025-06-18+ JSON-RPC batching is **not** supported —
  arrays are rejected as invalid requests.
  """

  alias Noizu.MCP.Error

  defmodule Request do
    @moduledoc "An inbound or outbound JSON-RPC request (expects a response)."
    @type t :: %__MODULE__{id: integer() | String.t(), method: String.t(), params: map() | nil}
    @enforce_keys [:id, :method]
    defstruct [:id, :method, params: nil]
  end

  defmodule Notification do
    @moduledoc "A JSON-RPC notification (no response expected)."
    @type t :: %__MODULE__{method: String.t(), params: map() | nil}
    @enforce_keys [:method]
    defstruct [:method, params: nil]
  end

  defmodule Response do
    @moduledoc "A successful JSON-RPC response."
    @type t :: %__MODULE__{id: integer() | String.t(), result: map()}
    @enforce_keys [:id]
    defstruct [:id, result: %{}]
  end

  defmodule ErrorResponse do
    @moduledoc "A JSON-RPC error response."
    @type t :: %__MODULE__{id: integer() | String.t() | nil, error: Noizu.MCP.Error.t()}
    @enforce_keys [:error]
    defstruct [:id, :error]
  end

  @type id :: integer() | String.t()
  @type message :: Request.t() | Notification.t() | Response.t() | ErrorResponse.t()

  @doc """
  Decode a wire binary into a message struct.

  Returns `{:error, %ErrorResponse{}}` pre-shaped for replying to the sender when
  the payload is malformed (parse error / invalid request).
  """
  @spec decode(binary()) :: {:ok, message()} | {:error, ErrorResponse.t()}
  def decode(binary) when is_binary(binary) do
    case Jason.decode(binary) do
      {:ok, decoded} -> classify(decoded)
      {:error, _} -> {:error, %ErrorResponse{id: nil, error: Error.parse_error()}}
    end
  end

  defp classify(list) when is_list(list) do
    {:error,
     %ErrorResponse{
       id: nil,
       error: Error.invalid_request("JSON-RPC batching is not supported by MCP")
     }}
  end

  defp classify(%{"jsonrpc" => "2.0"} = map), do: classify_message(map)

  defp classify(_other) do
    {:error,
     %ErrorResponse{id: nil, error: Error.invalid_request("Expected a JSON-RPC 2.0 object")}}
  end

  defp classify_message(%{"method" => method} = map) when is_binary(method) do
    params = validate_params(map["params"])

    case {Map.fetch(map, "id"), params} do
      {_, :invalid} ->
        {:error,
         %ErrorResponse{
           id: valid_id(map["id"]),
           error: Error.invalid_request("params must be an object")
         }}

      {{:ok, id}, params} when is_integer(id) or is_binary(id) ->
        {:ok, %Request{id: id, method: method, params: params}}

      {{:ok, _bad_id}, _} ->
        {:error,
         %ErrorResponse{id: nil, error: Error.invalid_request("id must be a string or integer")}}

      {:error, params} ->
        {:ok, %Notification{method: method, params: params}}
    end
  end

  defp classify_message(%{"id" => id, "error" => %{} = error})
       when is_integer(id) or is_binary(id) do
    {:ok, %ErrorResponse{id: id, error: Error.from_map(error)}}
  end

  defp classify_message(%{"id" => id, "result" => result})
       when (is_integer(id) or is_binary(id)) and is_map(result) do
    {:ok, %Response{id: id, result: result}}
  end

  defp classify_message(map) do
    {:error,
     %ErrorResponse{
       id: valid_id(map["id"]),
       error: Error.invalid_request("Not a valid JSON-RPC request, notification, or response")
     }}
  end

  defp validate_params(nil), do: nil
  defp validate_params(%{} = params), do: params
  defp validate_params(_), do: :invalid

  defp valid_id(id) when is_integer(id) or is_binary(id), do: id
  defp valid_id(_), do: nil

  @doc "Encode a message struct to wire iodata."
  @spec encode!(message()) :: iodata()
  def encode!(message), do: Jason.encode_to_iodata!(to_map(message))

  @doc "Render a message struct as a plain map (without JSON encoding)."
  @spec to_map(message()) :: map()
  def to_map(%Request{id: id, method: method, params: params}) do
    %{"jsonrpc" => "2.0", "id" => id, "method" => method}
    |> put_unless_nil("params", params)
  end

  def to_map(%Notification{method: method, params: params}) do
    %{"jsonrpc" => "2.0", "method" => method}
    |> put_unless_nil("params", params)
  end

  def to_map(%Response{id: id, result: result}) do
    %{"jsonrpc" => "2.0", "id" => id, "result" => result || %{}}
  end

  def to_map(%ErrorResponse{id: id, error: error}) do
    %{"jsonrpc" => "2.0", "id" => id, "error" => Error.to_map(error)}
  end

  defp put_unless_nil(map, _key, nil), do: map
  defp put_unless_nil(map, key, value), do: Map.put(map, key, value)
end