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] @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(get_schema(:mcp_schema)) end end if unquote(type) == :prompt do @impl true def arguments do alias Hermes.Server.Component.Schema Schema.to_prompt_arguments(get_schema(:mcp_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 """ defmacro schema(do: schema_def) do quote do import Peri defschema :mcp_schema, unquote(schema_def) 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 end