Skip to main content

lib/noizu/mcp/error.ex

defmodule Noizu.MCP.Error do
  @moduledoc """
  Protocol-level MCP / JSON-RPC error.

  Returned as `{:error, %Noizu.MCP.Error{}}` from handlers to produce a JSON-RPC
  error response. Distinct from *tool execution errors* — return
  `{:error, "message"}` from a tool handler to produce an `isError: true` tool
  result the model can read and self-correct from.

  Use the constructors (`invalid_params/2`, `resource_not_found/1`, `custom/3`, …)
  rather than building the struct by hand so spec error codes stay consistent.
  """

  @type t :: %__MODULE__{
          code: integer(),
          message: String.t(),
          data: term(),
          reason: atom() | nil
        }

  defexception [:code, :message, :data, :reason]

  # JSON-RPC reserved codes
  @parse_error -32_700
  @invalid_request -32_600
  @method_not_found -32_601
  @invalid_params -32_602
  @internal_error -32_603
  # MCP server-specific codes
  @resource_not_found -32_002

  @impl Exception
  def message(%__MODULE__{message: message, code: code}), do: "MCP error #{code}: #{message}"

  @spec parse_error(String.t()) :: t()
  def parse_error(message \\ "Parse error"),
    do: %__MODULE__{code: @parse_error, message: message, reason: :parse_error}

  @spec invalid_request(String.t()) :: t()
  def invalid_request(message \\ "Invalid request"),
    do: %__MODULE__{code: @invalid_request, message: message, reason: :invalid_request}

  @spec method_not_found(String.t()) :: t()
  def method_not_found(method) when is_binary(method) do
    %__MODULE__{
      code: @method_not_found,
      message: "Method not found: #{method}",
      reason: :method_not_found
    }
  end

  @spec invalid_params(String.t(), term()) :: t()
  def invalid_params(message \\ "Invalid params", data \\ nil),
    do: %__MODULE__{code: @invalid_params, message: message, data: data, reason: :invalid_params}

  @spec internal(String.t(), term()) :: t()
  def internal(message \\ "Internal error", data \\ nil),
    do: %__MODULE__{code: @internal_error, message: message, data: data, reason: :internal}

  @spec resource_not_found(String.t()) :: t()
  def resource_not_found(uri) do
    %__MODULE__{
      code: @resource_not_found,
      message: "Resource not found",
      data: %{"uri" => uri},
      reason: :resource_not_found
    }
  end

  @spec capability_not_supported(atom() | String.t()) :: t()
  def capability_not_supported(capability) do
    %__MODULE__{
      code: @invalid_request,
      message: "Capability not supported: #{capability}",
      reason: :capability_not_supported
    }
  end

  @doc "Application-defined error. Codes above -32000 are reserved for the protocol."
  @spec custom(integer(), String.t(), term()) :: t()
  def custom(code, message, data \\ nil) when is_integer(code) and is_binary(message),
    do: %__MODULE__{code: code, message: message, data: data, reason: :custom}

  @doc "Build from a decoded JSON-RPC error object."
  @spec from_map(map()) :: t()
  def from_map(%{} = map) do
    %__MODULE__{
      code: map["code"],
      message: map["message"] || "",
      data: map["data"],
      reason: reason_for_code(map["code"])
    }
  end

  @doc "Render as a JSON-RPC error object map."
  @spec to_map(t()) :: map()
  def to_map(%__MODULE__{} = error) do
    %{"code" => error.code, "message" => error.message}
    |> then(fn map ->
      if is_nil(error.data), do: map, else: Map.put(map, "data", error.data)
    end)
  end

  defp reason_for_code(@parse_error), do: :parse_error
  defp reason_for_code(@invalid_request), do: :invalid_request
  defp reason_for_code(@method_not_found), do: :method_not_found
  defp reason_for_code(@invalid_params), do: :invalid_params
  defp reason_for_code(@internal_error), do: :internal
  defp reason_for_code(@resource_not_found), do: :resource_not_found
  defp reason_for_code(_), do: :custom
end