Current section
Files
Jump to
Current section
Files
lib/hermes/server/component.ex
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