Skip to main content

lib/noizu/mcp/schema.ex

defmodule Noizu.MCP.Schema do
  @moduledoc """
  JSON Schema validation (2020-12, MCP's default dialect) backed by `JSV`.

  Compiled schemas are cached in `:persistent_term`, keyed by a hash of the raw
  schema, so repeated tool calls don't pay the build cost.
  """

  @cache __MODULE__

  @doc """
  Validate `data` against a raw JSON Schema map (string keys).

  Returns `:ok` or `{:error, message}` where `message` is a human/model-readable
  summary of the violations.
  """
  @spec validate(map(), term()) :: :ok | {:error, String.t()}
  def validate(schema, data) when is_map(schema) do
    case JSV.validate(data, compiled!(schema)) do
      {:ok, _} -> :ok
      {:error, validation_error} -> {:error, format_error(validation_error)}
    end
  end

  @doc "Build (and cache) a compiled schema; raises on an invalid schema."
  @spec compiled!(map()) :: JSV.Root.t()
  def compiled!(schema) when is_map(schema) do
    key = {@cache, :erlang.phash2(schema)}

    case :persistent_term.get(key, nil) do
      nil ->
        root = JSV.build!(schema)
        :persistent_term.put(key, root)
        root

      root ->
        root
    end
  end

  @doc "Check that a schema itself is buildable. Returns `:ok` or `{:error, message}`."
  @spec check(map()) :: :ok | {:error, String.t()}
  def check(schema) when is_map(schema) do
    case JSV.build(schema) do
      {:ok, _} -> :ok
      {:error, error} -> {:error, Exception.message(error)}
    end
  end

  defp format_error(validation_error) do
    normalized = JSV.normalize_error(validation_error)

    (normalized[:details] || [])
    |> Enum.flat_map(fn detail ->
      location = detail[:instanceLocation]

      Enum.map(detail[:errors] || [], fn error ->
        at = if location in [nil, "", "#"], do: "", else: " at #{location}"
        "#{error[:message]}#{at}"
      end)
    end)
    |> case do
      [] -> "Input does not match the expected schema"
      messages -> Enum.join(messages, "; ")
    end
  end
end