Skip to main content

lib/noizu/mcp/server/tool.ex

defmodule Noizu.MCP.Server.Tool do
  @moduledoc """
  Define an MCP tool as a module.

      defmodule MyApp.MCP.GetWeather do
        use Noizu.MCP.Server.Tool,
          description: "Get current weather for a location",
          annotations: [read_only_hint: true]

        input do
          field :location, :string, required: true, description: "City name or zip"
          field :units, :enum, values: [:celsius, :fahrenheit], default: :celsius
          field :days, :integer, min: 1, max: 14, default: 1
        end

        output do
          field :temperature, :number, required: true
          field :conditions, :string, required: true
        end

        @impl true
        def call(%{location: location, units: units, days: days}, ctx) do
          Noizu.MCP.Ctx.report_progress(ctx, 0.5)
          {:ok, %{temperature: 21.0, conditions: "clear"}}
        end
      end

  ## `use` options

    * `:name` — wire name; defaults to the module basename underscored
      (`MyApp.MCP.GetWeather` → `"get_weather"`)
    * `:description` — tells the model when and why to use the tool
    * `:title` — human-readable display name
    * `:annotations` — keyword list of behavior hints (`:read_only_hint`,
      `:destructive_hint`, `:idempotent_hint`, `:open_world_hint`, `:title`)
    * `:icons`, `:meta` — passed through to the wire definition
    * `:hidden` — when `true`, the tool is omitted from `tools/list` responses
      but remains callable. Useful for internal or privileged tools.
    * `:category` — free-form grouping label. Rides on the wire in
      `_meta.category` (merged into `:meta`) and is filterable through the
      built-in `Noizu.MCP.Server.Tools.Catalog` tool.

  Need several small tools in one module? See `Noizu.MCP.Server.Toolkit`.

  ## Schemas

  `input do ... end` declares the input schema with the `field` DSL — see the
  Tools & Schemas guide for field types. Arguments are validated against the
  compiled JSON Schema before `c:call/2` runs (failures become `isError: true`
  results the model can correct), then delivered **atom-keyed** with defaults
  applied and `:enum` values cast to atoms.

  Prefer raw JSON Schema? Use the escape hatch — arguments are then validated
  but delivered **string-keyed**, uncast:

      input_schema %{
        "type" => "object",
        "properties" => %{"query" => %{"type" => "string"}},
        "required" => ["query"]
      }

  `input_schema`/`output_schema` also accept the schema as **raw JSON text**
  (decoded at compile time — malformed JSON is a compile error):

      input_schema \"\"\"
      {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
      \"\"\"

  `output do ... end` (or `output_schema %{...}`) declares `outputSchema`; map
  return values are validated against it and sent as `structuredContent`.

  ## Return values from `c:call/2`

    * `{:ok, String.t()}` — single text content block
    * `{:ok, map()}` — structured content (requires/uses output schema if declared)
    * `{:ok, Content.t() | [Content.t()]}` — explicit content blocks
    * `{:ok, ToolResult.t()}` — full control
    * `{:error, String.t() | Content.t() | [Content.t()]}` — tool *execution*
      error (`isError: true`, visible to the model)
    * `{:error, Noizu.MCP.Error.t()}` — protocol error
  """

  alias Noizu.MCP.Types

  @doc "Execute the tool. See the module docs for argument and return contracts."
  @callback call(args :: map(), ctx :: Noizu.MCP.Ctx.t()) ::
              {:ok, term()} | {:error, term()}

  @doc "The wire definition advertised by `tools/list`."
  @callback definition() :: Types.Tool.t()

  @doc "Normalized runtime descriptor(s) — one-element list for classic tool modules."
  @callback __mcp_tools__() :: [Noizu.MCP.Server.Tool.Spec.t()]

  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      @behaviour Noizu.MCP.Server.Tool
      import Noizu.MCP.Server.Tool,
        only: [input: 1, output: 1, input_schema: 1, output_schema: 1]

      @__mcp_tool_opts__ opts
      @__mcp_input_schema__ nil
      @__mcp_input_cast_plan__ nil
      @__mcp_output_schema__ nil

      @before_compile Noizu.MCP.Server.Tool
    end
  end

  @doc "Declare the input schema with the `field` DSL."
  defmacro input(do: block) do
    fields = Noizu.MCP.Server.Tool.Fields.extract(block, __CALLER__)
    schema = Noizu.MCP.Server.Tool.Fields.to_json_schema(fields)
    cast_plan = Noizu.MCP.Server.Tool.Fields.to_cast_plan(fields)

    quote do
      @__mcp_input_schema__ unquote(Macro.escape(schema))
      @__mcp_input_cast_plan__ unquote(Macro.escape(cast_plan))
    end
  end

  @doc "Declare the input schema as a raw JSON Schema map (string keys)."
  defmacro input_schema(schema) do
    quote do
      @__mcp_input_schema__ unquote(schema)
      @__mcp_input_cast_plan__ nil
    end
  end

  @doc "Declare the output schema with the `field` DSL."
  defmacro output(do: block) do
    fields = Noizu.MCP.Server.Tool.Fields.extract(block, __CALLER__)
    schema = Noizu.MCP.Server.Tool.Fields.to_json_schema(fields)

    quote do
      @__mcp_output_schema__ unquote(Macro.escape(schema))
    end
  end

  @doc "Declare the output schema as a raw JSON Schema map (string keys)."
  defmacro output_schema(schema) do
    quote do
      @__mcp_output_schema__ unquote(schema)
    end
  end

  defmacro __before_compile__(env) do
    opts = Module.get_attribute(env.module, :__mcp_tool_opts__)

    default_name =
      env.module
      |> Module.split()
      |> List.last()
      |> Macro.underscore()

    name = Keyword.get(opts, :name, default_name)

    input_schema =
      case Module.get_attribute(env.module, :__mcp_input_schema__) do
        nil ->
          %{"type" => "object"}

        schema ->
          Noizu.MCP.Server.Tool.Fields.decode_schema!(
            schema,
            "#{inspect(env.module)} input_schema"
          )
      end

    output_schema =
      case Module.get_attribute(env.module, :__mcp_output_schema__) do
        nil ->
          nil

        schema ->
          Noizu.MCP.Server.Tool.Fields.decode_schema!(
            schema,
            "#{inspect(env.module)} output_schema"
          )
      end

    meta =
      case {opts[:meta], opts[:category]} do
        {nil, nil} -> nil
        {meta, nil} -> meta
        {meta, category} -> Map.put(meta || %{}, "category", category)
      end

    definition = %Noizu.MCP.Types.Tool{
      name: name,
      title: opts[:title],
      description: opts[:description],
      input_schema: input_schema,
      output_schema: output_schema,
      annotations: opts[:annotations],
      icons: opts[:icons],
      meta: meta
    }

    spec = %Noizu.MCP.Server.Tool.Spec{
      module: env.module,
      fun: :call,
      arity: 2,
      definition: definition,
      cast_plan: Module.get_attribute(env.module, :__mcp_input_cast_plan__),
      output_schema: output_schema,
      hidden: opts[:hidden] == true
    }

    quote do
      @impl Noizu.MCP.Server.Tool
      def definition, do: unquote(Macro.escape(definition))

      @impl Noizu.MCP.Server.Tool
      def __mcp_tools__, do: unquote(Macro.escape([spec]))
    end
  end
end