# Hermes.Server Explained ## Overview `Hermes.Server` is a behaviour module that provides the foundation for implementing Model Context Protocol (MCP) servers in Elixir. ## `use Hermes.Server` Directive ```elixir defmodule MyMCPServer do use Hermes.Server # ... callbacks ... end ``` ### What `use Hermes.Server` Does The `use` macro injects several things into your module: 1. **Behaviour Implementation** - Requires you to implement specific callbacks - Provides compile-time checks that all required callbacks are implemented 2. **Default Implementations** (if any) - May provide default implementations for optional callbacks - You can override these by implementing them yourself 3. **Helper Functions** - May inject utility functions for common MCP operations ### Required Callbacks When you `use Hermes.Server`, you must implement these callbacks: | Callback | Arity | Purpose | |----------|-------|---------| | `server_info/0` | 0 | Returns server metadata (name, version) | | `server_capabilities/0` | 0 | Declares what features the server supports | | `supported_protocol_versions/0` | 0 | Lists MCP protocol versions supported | | `init/2` | 2 | Initializes server state | | `handle_tool_call/3` | 3 | Handles tool execution requests | | `handle_resource_read/2` | 2 | Handles resource read requests | | `handle_request/2` | 2 | Handles generic MCP requests | ## The `init/2` Function ```elixir @impl Hermes.Server @spec init(any(), any()) :: {:ok, state :: any()} def init(args, frame) do {:ok, %{counter: 0}} end ``` ### Function Signature ```elixir init(args, frame) :: {:ok, state} | {:stop, reason} ``` ### Arguments Explained #### 1. `args` - Initialization Arguments **Type:** `any()` (typically a map or keyword list) **Purpose:** Configuration and initialization data passed when the server starts **What it contains:** - Server configuration options - Connection parameters - Initial state data - Environment-specific settings **Example values:** ```elixir # Example 1: Keyword list args = [port: 4000, debug: true, max_connections: 100] # Example 2: Map args = %{ database_url: "postgres://localhost/mydb", cache_enabled: true, log_level: :info } # Example 3: Custom struct args = %ServerConfig{ name: "my-server", features: [:tools, :resources], timeout: 5000 } ``` **Common usage:** ```elixir def init(args, frame) do # Extract configuration debug_mode = Keyword.get(args, :debug, false) port = Keyword.get(args, :port, 4000) # Initialize state with args state = %{ debug: debug_mode, port: port, counter: 0, started_at: DateTime.utc_now() } {:ok, state} end ``` #### 2. `frame` - Communication Frame/Context **Type:** `any()` (typically a map or struct) **Purpose:** Communication context and transport layer information **What it contains:** - Transport information (HTTP, stdio, WebSocket, etc.) - Connection metadata - Request context - Session information - Client capabilities **Example structure:** ```elixir frame = %{ transport: :http, # or :stdio, :websocket client_info: %{ name: "claude-desktop", version: "1.0.0" }, protocol_version: "2024-11-05", session_id: "sess_abc123", request_id: 1, headers: %{ "user-agent" => "MCP-Client/1.0", "content-type" => "application/json" }, remote_ip: "192.168.1.100", connection_pid: #PID<0.234.0> } ``` **Common usage:** ```elixir def init(args, frame) do # Log client connection info Logger.info("Client connected: #{inspect(frame.client_info)}") # Store session information state = %{ session_id: frame.session_id, client_name: frame.client_info.name, transport: frame.transport, counter: 0 } # Maybe adjust behavior based on transport state = if frame.transport == :stdio do Map.put(state, :buffered_output, true) else state end {:ok, state} end ``` ### Return Value The `init/2` function must return one of: #### Success: `{:ok, state}` ```elixir def init(args, frame) do state = %{ counter: 0, settings: args, session: frame.session_id } {:ok, state} end ``` #### Failure: `{:stop, reason}` ```elixir def init(args, frame) do case validate_license(args.license_key) do :ok -> {:ok, %{licensed: true}} {:error, reason} -> {:stop, {:invalid_license, reason}} end end ``` ## Complete Example with Detailed init/2 ```elixir defmodule MyMCPServer do use Hermes.Server require Logger @impl Hermes.Server def server_info do %{name: "my-elixir-mcp-server", version: "1.0.0"} end @impl Hermes.Server def server_capabilities do %{ tools: %{listChanged: true}, resources: %{subscribe: false, listChanged: false} } end @impl Hermes.Server def supported_protocol_versions do ["2024-11-05"] end @impl Hermes.Server def init(args, frame) do # Log initialization Logger.info("=== MCP Server Initializing ===") Logger.info("Args: #{inspect(args)}") Logger.info("Frame: #{inspect(frame)}") # Extract configuration from args config = %{ debug: Keyword.get(args, :debug, false), max_tools: Keyword.get(args, :max_tools, 100), cache_enabled: Keyword.get(args, :cache_enabled, true) } # Extract session info from frame session = %{ id: Map.get(frame, :session_id, generate_session_id()), client_name: get_in(frame, [:client_info, :name]) || "unknown", transport: Map.get(frame, :transport, :unknown), started_at: DateTime.utc_now() } # Initialize server state state = %{ # From args config: config, # From frame session: session, # Internal state counter: 0, tool_call_count: 0, resource_reads: 0, cache: %{} } # Maybe perform some initialization work if config.cache_enabled do Logger.info("Cache enabled, pre-warming...") state = Map.put(state, :cache, prewarm_cache()) end Logger.info("Server initialized successfully") {:ok, state} end # Rest of callbacks... @impl Hermes.Server def handle_tool_call(name, arguments, state) do # Use state initialized in init/2 new_state = %{state | tool_call_count: state.tool_call_count + 1} Logger.info("Tool call ##{new_state.tool_call_count}: #{name}") # ... tool implementation ... end # Helper functions defp generate_session_id do :crypto.strong_rand_bytes(16) |> Base.encode16() end defp prewarm_cache do # Preload frequently used data %{ "common_data" => load_common_data(), "config" => load_config() } end end ``` ## Practical Examples ### Example 1: Database-Backed Server ```elixir def init(args, frame) do # Connect to database using args db_url = Keyword.fetch!(args, :database_url) case MyApp.Repo.start_link(url: db_url) do {:ok, repo_pid} -> state = %{ repo: repo_pid, session_id: frame.session_id, counter: 0 } {:ok, state} {:error, reason} -> {:stop, {:database_connection_failed, reason}} end end ``` ### Example 2: API Key Validation ```elixir def init(args, frame) do # Validate API key from args api_key = Keyword.get(args, :api_key) case validate_api_key(api_key) do {:ok, user_id} -> state = %{ user_id: user_id, session_id: frame.session_id, api_key: api_key, counter: 0 } {:ok, state} :error -> Logger.error("Invalid API key provided") {:stop, :unauthorized} end end ``` ### Example 3: Per-Session Configuration ```elixir def init(args, frame) do # Different behavior based on client client_name = get_in(frame, [:client_info, :name]) state = %{ counter: 0, session_id: frame.session_id, features: case client_name do "claude-desktop" -> [:all_features] "mobile-app" -> [:basic_features] _ -> [:limited_features] end } Logger.info("Client #{client_name} connected with features: #{inspect(state.features)}") {:ok, state} end ``` ### Example 4: Resource Initialization ```elixir def init(args, frame) do # Load resources and tools dynamically tools_dir = Keyword.get(args, :tools_dir, "priv/tools") resources_dir = Keyword.get(args, :resources_dir, "priv/resources") state = %{ counter: 0, session_id: frame.session_id, tools: load_tools_from_directory(tools_dir), resources: load_resources_from_directory(resources_dir) } Logger.info("Loaded #{length(state.tools)} tools and #{length(state.resources)} resources") {:ok, state} end ``` ## Debugging init/2 ### Log Everything (Development) ```elixir def init(args, frame) do Logger.debug(""" === MCP Server Init === Args: #{inspect(args, pretty: true, limit: :infinity)} Frame: #{inspect(frame, pretty: true, limit: :infinity)} ======================= """) {:ok, %{counter: 0}} end ``` ### Pattern Match to Understand Structure ```elixir def init(args, %{session_id: session_id, client_info: client_info} = frame) do # Now you know frame has these fields Logger.info("Session: #{session_id}") Logger.info("Client: #{client_info.name}") {:ok, %{counter: 0, session: session_id}} end ``` ## Common Pitfalls ### ❌ Don't Block in init/2 ```elixir # BAD: Blocks the supervisor def init(args, frame) do # This might take 30 seconds! data = fetch_remote_data_synchronously() {:ok, %{data: data}} end ``` ```elixir # GOOD: Defer expensive work def init(args, frame) do # Send yourself a message to fetch data after init completes send(self(), :fetch_remote_data) {:ok, %{data: nil, loading: true}} end def handle_info(:fetch_remote_data, state) do data = fetch_remote_data_synchronously() {:noreply, %{state | data: data, loading: false}} end ``` ### ❌ Don't Assume args Structure ```elixir # BAD: Crashes if args is a map def init(args, frame) do port = Keyword.get(args, :port) # KeyError if args is a map! {:ok, %{port: port}} end ``` ```elixir # GOOD: Handle different arg types def init(args, frame) do port = case args do %{port: p} -> p args when is_list(args) -> Keyword.get(args, :port) _ -> 4000 # default end {:ok, %{port: port}} end ``` ## Summary | Aspect | Details | |--------|---------| | **args** | Configuration data passed at server start | | **frame** | Communication context and session info | | **Return** | `{:ok, state}` to start or `{:stop, reason}` to abort | | **Purpose** | Initialize server state before handling requests | | **Timing** | Called once when server process starts | | **Access** | Both args and frame available during entire init | The `init/2` callback is your opportunity to: - ✅ Validate configuration - ✅ Connect to external resources - ✅ Initialize state - ✅ Log startup information - ✅ Fail fast if prerequisites aren't met Everything initialized here is available in `state` for all subsequent callback functions!