Current section

Files

Jump to
hermes_mcp lib hermes server component.ex
Raw

lib/hermes/server/component.ex

defmodule Hermes.Server.Component do
@moduledoc false
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")
annotations = Keyword.get(opts, :annotations)
quote do
@behaviour unquote(behaviour_module)
import Hermes.Server.Component, only: [schema: 1, field: 3, field: 2]
import Hermes.Server.Frame
@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
if unquote(annotations) != nil do
@impl true
def annotations, do: unquote(annotations)
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
{required, remaining_opts} = Keyword.pop(opts, :required, false)
type = if required, do: {:required, type}, else: type
quote do
{unquote(name), {:mcp_field, unquote(type), unquote(remaining_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, __inject_transforms__(value)}
end)
end
def __clean_schema_for_peri__(schema), do: __inject_transforms__(schema)
defp __inject_transforms__({type, {:default, default}})
when type in ~w(date datetime naive_datetime time)a do
base = __inject_transforms__(type)
{base, {:default, default}}
end
defp __inject_transforms__(:date) do
{:custom, &__validate_date__/1}
end
defp __inject_transforms__(:time) do
{:custom, &__validate_time__/1}
end
defp __inject_transforms__(:datetime) do
{:custom, &__validate_datetime__/1}
end
defp __inject_transforms__(:naive_datetime) do
{:custom, &__validate_naive_datetime__/1}
end
defp __inject_transforms__({:required, type}) do
{:required, __inject_transforms__(type)}
end
defp __inject_transforms__({:list, type}) do
{:list, __inject_transforms__(type)}
end
defp __inject_transforms__(type), do: type
defp __validate_date__(value) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> {:ok, date}
{:error, _} -> {:error, "invalid ISO 8601 date format", []}
end
end
defp __validate_date__(%Date{} = date), do: {:ok, date}
defp __validate_date__(_),
do: {:error, "expected ISO 8601 date string or Date struct", []}
defp __validate_time__(value) when is_binary(value) do
case Time.from_iso8601(value) do
{:ok, time} -> {:ok, time}
{:error, _} -> {:error, "invalid ISO 8601 time format", []}
end
end
defp __validate_time__(%Time{} = time), do: {:ok, time}
defp __validate_time__(_),
do: {:error, "expected ISO 8601 time string or Time struct", []}
defp __validate_datetime__(value) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime, _} -> {:ok, datetime}
{:error, _} -> {:error, "invalid ISO 8601 datetime format", []}
end
end
defp __validate_datetime__(%DateTime{} = datetime), do: {:ok, datetime}
defp __validate_datetime__(_),
do: {:error, "expected ISO 8601 datetime string or DateTime struct", []}
defp __validate_naive_datetime__(value) when is_binary(value) do
# NaiveDateTime.from_iso8601 accepts Z suffix but we want to reject it
if String.ends_with?(value, "Z") or String.match?(value, ~r/[+-]\d{2}:\d{2}$/) do
{:error, "NaiveDateTime cannot have timezone information", []}
else
case NaiveDateTime.from_iso8601(value) do
{:ok, naive_datetime} -> {:ok, naive_datetime}
{:error, _} -> {:error, "invalid ISO 8601 naive datetime format", []}
end
end
end
defp __validate_naive_datetime__(%NaiveDateTime{} = naive_datetime),
do: {:ok, naive_datetime}
defp __validate_naive_datetime__(_),
do:
{:error, "expected ISO 8601 naive datetime string or NaiveDateTime struct", []}
end