Hermolaos Architecture
View SourceThis document describes the internal architecture of the Hermolaos MCP client library.
Overview
Hermolaos is an Elixir client for the Model Context Protocol (MCP). It provides a clean API for connecting to MCP servers and invoking tools, reading resources, and using prompts.
┌─────────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────────┤
│ Hermolaos API │
│ connect/2, list_tools/1, call_tool/3, ping/1, etc. │
├─────────────────────────────────────────────────────────────┤
│ Hermolaos.Pool (optional) │
│ Connection pooling & load balancing │
├─────────────────────────────────────────────────────────────┤
│ Hermolaos.Client.Connection │
│ GenServer state machine per connection │
├────────────────────────┬────────────────────────────────────┤
│ Transport Layer │ Protocol Layer │
│ ┌──────────────────┐ │ ┌──────────────────────────────┐ │
│ │ Transport.Stdio │ │ │ Protocol.JsonRpc │ │
│ │ Transport.HTTP │ │ │ Protocol.Messages │ │
│ │ MessageBuffer │ │ │ Protocol.Capabilities │ │
│ └──────────────────┘ │ │ Protocol.Errors │ │
│ │ └──────────────────────────────┘ │
├────────────────────────┴────────────────────────────────────┤
│ Client Support Modules │
│ RequestTracker (ETS) │ NotificationHandler │
└─────────────────────────────────────────────────────────────┘Module Structure
Public API (lib/hermolaos.ex)
The main entry point for users. Provides a simple, consistent API that delegates to the underlying connection management.
Key functions:
connect/2- Establish a new MCP connectiondisconnect/1- Close a connectionlist_tools/1,call_tool/3- Tool operationslist_resources/1,read_resource/2- Resource operationslist_prompts/1,get_prompt/3- Prompt operationsping/1- Health check
Connection Management (lib/hermolaos/client/connection.ex)
A GenServer that manages the lifecycle of a single MCP connection. Implements a state machine:
┌──────────────┐ ┌────────────────┐ ┌───────────────┐ ┌─────────┐
│ disconnected │────▶│ connecting │────▶│ initializing │────▶│ ready │
└──────────────┘ └────────────────┘ └───────────────┘ └─────────┘
▲ │
└────────────────────── error/disconnect ────────────────────────┘States:
- disconnected: Initial state, no transport active
- connecting: Transport is starting up
- initializing: MCP initialize handshake in progress
- ready: Connection is fully established, can process requests
Transport Layer
Transport Behaviour (lib/hermolaos/transport/behaviour.ex)
Defines the interface that all transports must implement:
@callback start_link(opts :: keyword()) :: GenServer.on_start()
@callback send_message(transport :: t(), message :: binary()) :: :ok | {:error, term()}
@callback stop(transport :: t()) :: :okStdio Transport (lib/hermolaos/transport/stdio.ex)
Uses Erlang ports to communicate with subprocess MCP servers:
┌─────────────────┐ ┌─────────────────┐
│ Hermolaos Client │ │ MCP Server │
│ │ │ (subprocess) │
│ ┌───────────┐ │ stdin/stdout │ │
│ │ Port │──┼────────────────────┼──▶ server.exe │
│ └───────────┘ │ │ │
└─────────────────┘ └─────────────────┘Features:
- Spawns server as subprocess via
Port.open/2 - Binary, line-based communication
- Automatic process cleanup on connection close
- Environment variable passthrough
HTTP Transport (lib/hermolaos/transport/http.ex)
Uses Req to communicate with HTTP-based MCP servers:
┌─────────────────┐ ┌─────────────────┐
│ Hermolaos Client │ HTTP POST │ MCP Server │
│ │────────────────────▶│ (HTTP) │
│ ┌───────────┐ │◀────────────────────│ │
│ │ Req │ │ JSON / SSE │ │
│ └───────────┘ │ └─────────────────┘
└─────────────────┘Features:
- JSON-RPC over HTTP POST
- Server-Sent Events (SSE) for streaming responses
- Session ID tracking via
Mcp-Session-Idheader - Connection pooling via Finch (Req's HTTP client)
Message Buffer (lib/hermolaos/transport/message_buffer.ex)
Handles the newline-delimited JSON format used by MCP:
{"jsonrpc":"2.0","id":1,"method":"ping"}\n
{"jsonrpc":"2.0","id":2,"method":"tools/list"}\nFeatures:
- Accumulates partial data chunks
- Extracts complete messages on newline boundaries
- Handles edge cases (empty lines, partial JSON)
- Tracks statistics (bytes received, parse errors)
Protocol Layer
JSON-RPC (lib/hermolaos/protocol/json_rpc.ex)
Implements JSON-RPC 2.0 message encoding/decoding:
Message types:
- Request:
{id, method, params}- Expects response - Notification:
{method, params}- No response expected - Response:
{id, result}- Success response - Error Response:
{id, error}- Error response
Messages (lib/hermolaos/protocol/messages.ex)
Builds MCP-specific message payloads:
Messages.initialize(client_info, capabilities)
Messages.tools_list()
Messages.tools_call("tool_name", %{arg: "value"})
Messages.resources_read("file:///path")Capabilities (lib/hermolaos/protocol/capabilities.ex)
Handles capability negotiation between client and server:
# Client capabilities (what we support)
%{
"roots" => %{"listChanged" => true},
"sampling" => %{}
}
# Server capabilities (what server supports)
%{
"tools" => %{},
"resources" => %{"subscribe" => true},
"prompts" => %{}
}Errors (lib/hermolaos/protocol/errors.ex)
Defines MCP error codes and provides helpers:
| Code | Name | Description |
|---|---|---|
| -32700 | Parse Error | Invalid JSON |
| -32600 | Invalid Request | Not a valid JSON-RPC request |
| -32601 | Method Not Found | Unknown method |
| -32602 | Invalid Params | Invalid method parameters |
| -32603 | Internal Error | Internal JSON-RPC error |
| -32001 | Request Timeout | Request timed out |
| -32002 | Resource Not Found | Resource doesn't exist |
| -32003 | Capability Not Supported | Server doesn't support capability |
Request Tracking (lib/hermolaos/client/request_tracker.ex)
ETS-backed storage for correlating requests with responses:
┌────────────────────────────────────────────────────────────┐
│ ETS Table │
├────────┬─────────────┬────────────────┬───────────────────┤
│ ID │ Method │ From │ Timeout Ref │
├────────┼─────────────┼────────────────┼───────────────────┤
│ 1 │ tools/list │ {pid, ref} │ #Reference<...> │
│ 2 │ ping │ {pid, ref} │ #Reference<...> │
└────────┴─────────────┴────────────────┴───────────────────┘Features:
- O(1) lookups via ETS
- Monotonically increasing integer IDs
- Automatic timeout handling with per-request timers
- Statistics tracking (tracked, completed, failed, timed out)
Notification Handling (lib/hermolaos/client/notification_handler.ex)
Behaviour for handling server-initiated messages:
defmodule MyHandler do
@behaviour Hermolaos.Client.NotificationHandler
@impl true
def handle_notification({:notification, "notifications/tools/list_changed", _}, state) do
# Tools list changed, maybe refresh cache
{:ok, state}
end
endBuilt-in handlers:
DefaultNotificationHandler- Logs notificationsPubSubNotificationHandler- Broadcasts via Phoenix.PubSub or Registry
Connection Pool (lib/hermolaos/pool.ex)
Manages multiple connections for high-throughput scenarios:
┌─────────────────────────────────────────────────────────────┐
│ Hermolaos.Pool │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DynamicSupervisor │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Conn #1 │ │ Conn #2 │ │ Conn #3 │ ... │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Strategies: :round_robin | :random | :least_busy │
└─────────────────────────────────────────────────────────────┘Request Flow
Making a Tool Call
1. User calls Hermolaos.call_tool(conn, "tool_name", args)
│
2. Connection.call_tool/3 called
│
3. RequestTracker assigns ID and stores caller info
│
4. Message built: Messages.tools_call("tool_name", args)
│
5. JSON-RPC encoded: JsonRpc.encode_request(id, "tools/call", params)
│
6. Transport.send_message(transport, json)
│
7. Transport sends to server (stdio/HTTP)
│
▼ (async - server processing)
│
8. Server response arrives at transport
│
9. MessageBuffer extracts complete JSON message
│
10. JsonRpc.decode/1 parses response
│
11. RequestTracker.complete/2 retrieves caller
│
12. GenServer.reply(from, {:ok, result})
│
13. User receives {:ok, %{content: [...]}}MCP Initialization Handshake
Client Server
│ │
│──── initialize ─────────────────────▶│
│ {protocolVersion, clientInfo, │
│ capabilities} │
│ │
│◀──── response ────────────────────────│
│ {protocolVersion, serverInfo, │
│ capabilities} │
│ │
│──── notifications/initialized ──────▶│
│ (no response expected) │
│ │
│ Connection Ready │Concurrency Model
- Each
Connectionis an isolated GenServer (crash isolation) RequestTrackeruses ETS for concurrent-safe lookups- Pool uses
DynamicSupervisorfor connection management - Transports handle I/O asynchronously
Error Handling
Transport Errors
- Connection closed → All pending requests failed
- Send error → Request fails immediately
- Process crash → Supervisor restarts transport
Protocol Errors
- Parse error → Error response to client
- Invalid request → Error response to client
- Timeout → Request fails with timeout error
Request Errors
- Server returns error → Wrapped in
Hermolaos.Error - Timeout →
{:error, %Hermolaos.Error{code: -32001}}