defmodule Hermes.Server.Component do @moduledoc """ High-level API for defining MCP server components (tools, prompts, resources). This module provides a simplified way to define server components with automatic schema validation using Peri, reducing boilerplate while maintaining type safety. ## Component Types - **Tools**: Functions that can be invoked by clients with parameters - **Prompts**: Templates that generate messages based on arguments - **Resources**: Data providers identified by URIs ## Usage ### Tool Example defmodule MyServer.Tools.Calculator do @moduledoc "Performs arithmetic operations on two numbers" use Hermes.Server.Component, type: :tool schema %{ operation: {:required, {:enum, ["add", "subtract", "multiply", "divide"]}}, a: {:required, :float}, b: {:required, :float} } @impl true def execute(%{operation: "add", a: a, b: b}, frame) do {:ok, a + b, frame} end @impl true def execute(%{operation: "divide", a: a, b: 0}, frame) do {:error, "Cannot divide by zero"} end end ### Prompt Example defmodule MyServer.Prompts.CodeReview do @moduledoc "Generate code review prompts for various programming languages" use Hermes.Server.Component, type: :prompt schema %{ language: {:required, :string}, code: {:required, :string}, focus_areas: {:string, {:default, "general quality"}} } @impl true def get_messages(%{language: lang, code: code, focus_areas: focus}, frame) do messages = [ %{ "role" => "user", "content" => %{ "type" => "text", "text" => "Review this \#{lang} code focusing on \#{focus}..." } } ] {:ok, messages, frame} end end ### Resource Example defmodule MyServer.Resources.Config do @moduledoc "Application configuration file" use Hermes.Server.Component, type: :resource, uri: "file:///config/app.json", mime_type: "application/json" @impl true def read(_params, frame) do case File.read("config/app.json") do {:ok, content} -> {:ok, content, frame} {:error, reason} -> {:error, "Failed to read config: \#{inspect(reason)}"} end end end # Resource with query parameters defmodule MyServer.Resources.UserData do @moduledoc "User data with filtering support" use Hermes.Server.Component, type: :resource, uri: "users://data" schema %{ user_id: {:required, :string}, include_metadata: {:boolean, {:default, false}} } @impl true def read(%{user_id: id, include_metadata: meta?}, frame) do data = fetch_user_data(id, meta?) {:ok, Jason.encode!(data), frame} end end ## Schema Validation Components can define parameter schemas using the `schema/1` macro, which leverages Peri for validation. The schema is automatically: - Validated before callback execution - Converted to JSON Schema for the MCP protocol - Used to generate helpful error messages ## Automatic Features - **Description**: Extracted from `@moduledoc` - **Validation**: Parameters/arguments validated before execution - **Error Handling**: Detailed validation errors with paths - **JSON Schema**: Automatic conversion for protocol compliance """ alias Hermes.Server.Component.Prompt alias Hermes.Server.Component.Resource alias Hermes.Server.Component.Tool @doc false # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity defmacro __using__(opts) when is_list(opts) do {type, opts} = Keyword.pop!(opts, :type) if type not in [:tool, :prompt, :resource] do raise ArgumentError, "Invalid component type: #{type}. Must be :tool, :prompt, or :resource" end behaviour_module = get_behaviour_module(type) uri = Keyword.get(opts, :uri) mime_type = Keyword.get(opts, :mime_type, "text/plain") quote do @behaviour unquote(behaviour_module) import Hermes.Server.Component, only: [schema: 1, field: 3, field: 2] @doc false def __mcp_component_type__, do: unquote(type) @doc false def __description__, do: @moduledoc if unquote(type) == :tool do @impl true def input_schema do alias Hermes.Server.Component.Schema Schema.to_json_schema(__mcp_raw_schema__()) end end if unquote(type) == :prompt do @impl true def arguments do alias Hermes.Server.Component.Schema Schema.to_prompt_arguments(__mcp_raw_schema__()) end end if unquote(type) == :resource do @impl true def uri, do: unquote(uri) @impl true def mime_type, do: unquote(mime_type) defoverridable uri: 0, mime_type: 0 end end end @doc """ Defines the parameter schema for the component. The schema uses Peri's validation DSL and is automatically validated before the component's callback is executed. ## Examples schema do %{ query: {:required, :string}, limit: {:integer, {:default, 10}}, filters: %{ status: {:enum, ["active", "inactive", "pending"]}, created_after: :datetime } } end # With field metadata for JSON Schema (no braces needed!) schema do field(:email, {:required, :string}, format: "email", description: "User's email address") field(:age, :integer, description: "Age in years") field :address, description: "User's address" do field(:street, {:required, :string}) field(:city, :string) field(:country, :string, description: "ISO 3166-1 alpha-2 code") end end """ defmacro schema(do: schema_def) do wrapped_schema = case schema_def do {:%{}, _, _} = map_ast -> map_ast {:__block__, _, field_calls} -> {:%{}, [], field_calls} single_field -> {:%{}, [], [single_field]} end quote do import Peri alias Hermes.Server.Component @doc false def __mcp_raw_schema__, do: unquote(wrapped_schema) defschema :mcp_schema, Component.__clean_schema_for_peri__(unquote(wrapped_schema)) end end @doc """ Defines a field with metadata for JSON Schema generation. Supports both simple fields and nested objects with their own fields. ## Examples # Simple field field :email, {:required, :string}, format: "email", description: "User's email address" field :age, :integer, description: "Age in years" # Nested field field :user do field :name, {:required, :string} field :email, :string, format: "email" end # Nested field with metadata field :profile, description: "User profile information" do field :bio, :string, description: "Short biography" field :avatar_url, :string, format: "uri" end """ defmacro field(name, type \\ nil, opts \\ []) defmacro field(name, opts, do: block) when is_list(opts) and opts != [] do build_nested_field(name, opts, block) end defmacro field(name, nil, do: block) do build_nested_field(name, [], block) end defmacro field(name, [do: block], []) do build_nested_field(name, [], block) end defmacro field(name, type, opts) when not is_nil(type) and is_list(opts) do quote do {unquote(name), {:mcp_field, unquote(type), unquote(opts)}} end end defp build_nested_field(name, opts, block) do nested_content = case block do {:__block__, _, expressions} -> {:%{}, [], expressions} single_expr -> {:%{}, [], [single_expr]} end quote do {unquote(name), {:mcp_field, unquote(nested_content), unquote(opts)}} end end defp get_behaviour_module(:tool), do: Tool defp get_behaviour_module(:prompt), do: Prompt defp get_behaviour_module(:resource), do: Resource @doc """ Extracts the description from a component module's moduledoc. ## Parameters * `module` - The component module atom ## Returns * The module's `@moduledoc` content as a string * Empty string if no moduledoc is defined ## Examples iex> defmodule MyTool do ...> @moduledoc "A helpful tool" ...> use Hermes.Server.Component, type: :tool ...> end iex> Hermes.Server.Component.get_description(MyTool) "A helpful tool" """ def get_description(module) when is_atom(module) do if function_exported?(module, :__description__, 0) do module.__description__() else "" end end @doc """ Gets the component type (:tool, :prompt, or :resource). ## Parameters * `module` - The component module atom ## Returns * `:tool` - If the module is a tool component * `:prompt` - If the module is a prompt component * `:resource` - If the module is a resource component ## Examples iex> defmodule MyTool do ...> use Hermes.Server.Component, type: :tool ...> end iex> Hermes.Server.Component.get_type(MyTool) :tool """ def get_type(module) when is_atom(module) do module.__mcp_component_type__() end @doc """ Checks if a module is a valid component. ## Parameters * `module` - The module atom to check ## Returns * `true` if the module uses `Hermes.Server.Component` * `false` otherwise ## Examples iex> defmodule MyTool do ...> use Hermes.Server.Component, type: :tool ...> end iex> Hermes.Server.Component.component?(MyTool) true iex> defmodule NotAComponent do ...> def hello, do: :world ...> end iex> Hermes.Server.Component.component?(NotAComponent) false """ def component?(module) when is_atom(module) do not is_nil(get_type(module)) end @doc false def __clean_schema_for_peri__(schema) when is_map(schema) do Map.new(schema, fn {key, {:mcp_field, type, _opts}} -> {key, __clean_schema_for_peri__(type)} {key, nested} when is_map(nested) -> {key, __clean_schema_for_peri__(nested)} {key, value} -> {key, value} end) end def __clean_schema_for_peri__(schema), do: schema end