Building a Client
Let's explore how to connect your Elixir application to MCP servers. What possibilities open up when your code can leverage AI-enhanced services?
Starting Simple
Remember our first client? Let's understand what's happening:
defmodule MyApp.WeatherClient do
use Hermes.Client,
name: "MyApp", # How you introduce yourself
version: "1.0.0", # Your client's version
protocol_version: "2024-11-05", # MCP protocol target version
capabilities: [:roots] # What features you support
end
When you add this to your supervision tree, something interesting happens:
{MyApp.WeatherClient,
transport: {:stdio, command: "weather-server", args: []}}
The client automatically:
- Launches the weather server as a subprocess
- Negotiates capabilities
- Maintains the connection
- Handles all the protocol details
How might you use this connection?
Discovering Capabilities
What can a connected server actually do? Let's find out:
# What's this server about?
info = MyApp.WeatherClient.get_server_info()
# => %{"name" => "Weather Server", "version" => "2.0.0", ...}
# What capabilities does it offer?
caps = MyApp.WeatherClient.get_server_capabilities()
# => %{"tools" => %{"listChanged" => false}, ...}
# What tools are available?
{:ok, %{result: %{"tools" => tools}}} = MyApp.WeatherClient.list_tools()
Enum.each(tools, fn tool ->
IO.puts("#{tool["name"]}: #{tool["description"]}")
end)
# => get_weather: Get current weather for a location
# => get_forecast: Get weather forecast
Notice how we're exploring the server's interface dynamically?
Using Tools
Now for the interesting part - actually using these discovered tools:
# Simple tool call
{:ok, %{result: weather}} =
MyApp.WeatherClient.call_tool("get_weather", %{
"location" => "San Francisco"
})
# Tool with complex parameters
{:ok, %{result: forecast}} =
MyApp.WeatherClient.call_tool("get_forecast", %{
"location" => "Tokyo",
"days" => 5,
"units" => "metric"
})
What happens if something goes wrong?
case MyApp.WeatherClient.call_tool("get_weather", %{"location" => ""}) do
{:ok, %{is_error: false, result: weather}} ->
# Success path
{:ok, %{is_error: true, result: error}} ->
# The tool itself reported an error
IO.puts("Tool error: #{error["message"]}")
{:error, error} ->
# Protocol or connection error
IO.puts("Connection error: #{inspect(error)}")
end
Working with Resources
Some servers expose resources - think files, databases, or any readable content:
# What resources are available?
{:ok, %{result: %{"resources" => resources}}} =
MyApp.WeatherClient.list_resources()
# Read a specific resource
{:ok, %{result: %{"contents" => contents}}} =
MyApp.WeatherClient.read_resource("weather://stations/KSFO")
# Resources can have multiple content types
for content <- contents do
case content do
%{"text" => text} ->
IO.puts("Text content: #{text}")
%{"blob" => blob} ->
IO.puts("Binary data: #{byte_size(blob)} bytes")
end
end
Transport Options
How does your client actually connect to servers? Let's explore the options:
# Local subprocess
transport: {:stdio, command: "python", args: ["-m", "my_server"]}
# HTTP endpoint
transport: {:streamable_http, base_url: "http://localhost:8000"}
# WebSocket for real-time
transport: {:websocket, base_url: "ws://localhost:8000"}
# Server-Sent Events
transport: {:sse, base_url: "http://localhost:8000"}
Which transport should you choose?
- STDIO: Perfect for local tools and subprocess isolation
- HTTP: Great for remote services and web APIs
- WebSocket: When you need bidirectional real-time communication
- SSE: For servers that push updates to clients (deprecated)
Advanced Patterns
Multiple Client Instances
Need to connect to multiple servers? No problem:
children = [
Supervisor.child_spec(
{MyApp.WeatherClient,
name: :weather_us,
transport: {:stdio, command: "weather-server", args: ["--region", "US"]}},
id: :weather_us
),
Supervisor.child_spec(
{MyApp.WeatherClient,
name: :weather_eu,
transport: {:stdio, command: "weather-server", args: ["--region", "EU"]}},
id: :weather_eu
)
]
# Use specific instances
MyApp.WeatherClient.call_tool(:weather_us, "get_weather", %{location: "NYC"})
MyApp.WeatherClient.call_tool(:weather_eu, "get_weather", %{location: "Paris"})
Handling Timeouts
Long-running operations? Adjust timeouts:
# 5 minute timeout for slow operations
opts = [timeout: 300_000]
MyApp.WeatherClient.call_tool("analyze_historical_data", params, opts)
Progress Tracking
Need to track progress on long-running operations? Here's how:
# Generate a unique token for this operation
progress_token = Hermes.MCP.ID.generate_progress_token()
# Option 1: Just track with a token
MyApp.WeatherClient.call_tool("analyze_data", params,
progress: [token: progress_token]
)
# Option 2: Receive real-time updates
callback = fn ^progress_token, progress, total ->
percentage = if total, do: "#{progress}/#{total}", else: "#{progress}"
IO.puts("Progress: #{percentage}")
end
MyApp.WeatherClient.call_tool("analyze_data", params,
progress: [token: progress_token, callback: callback]
)
The server sends progress notifications that your callback receives automatically.
Graceful Shutdown
When you're done:
MyApp.WeatherClient.close()
This cleanly shuts down the connection and any associated resources.
What's Next?
Now that you understand clients, what interests you?
- Building your own server to expose functionality?
- Exploring specific recipes for common patterns?
- Understanding how to handle errors gracefully?
The client abstraction handles all the protocol complexity - you just focus on using the capabilities. What will you connect to first?