Building a Client
Copy MarkdownAn MCP client connects your application to a server, whether that server is written in Elixir, Python, TypeScript, or anything else. In Anubis a client is a supervised process that owns one connection, negotiates the handshake, and exposes the server's capabilities as ordinary function calls.
Starting a client
Add Anubis.Client to your supervision tree:
children = [
{Anubis.Client,
name: MyApp.WeatherClient,
transport: {:stdio, command: "weather-server", args: []},
client_info: %{"name" => "MyApp", "version" => "1.0.0"},
capabilities: %{}}
]
Supervisor.start_link(children, strategy: :one_for_one)Four options matter here:
nameregisters the process, and every client function takes this name (or a PID) as its first argument.transportsays how to reach the server. See Transports for the full set.client_infoidentifies your application to the server during the handshake.capabilitiesdeclares what your client supports. An empty map is valid and common. This option is required.
The child spec starts a small supervision tree holding the client process and its transport, linked one_for_all, so a transport crash restarts both cleanly.
The handshake runs asynchronously after startup. If you need to block until the connection is ready, for example in a test or a script, use await_ready/2:
:ok = Anubis.Client.await_ready(MyApp.WeatherClient)Discovering capabilities
Once connected you can ask the server what it offers:
info = Anubis.Client.get_server_info(MyApp.WeatherClient)
caps = Anubis.Client.get_server_capabilities(MyApp.WeatherClient)
{:ok, %{result: %{"tools" => tools}}} = Anubis.Client.list_tools(MyApp.WeatherClient)
for tool <- tools do
IO.puts("#{tool["name"]}: #{tool["description"]}")
endlist_resources/2, list_prompts/2, and list_resource_templates/2 follow the same shape. All list functions accept a cursor: option for pagination when the server paginates.
Calling tools
{:ok, response} =
Anubis.Client.call_tool(MyApp.WeatherClient, "get_forecast", %{
"location" => "Tokyo",
"days" => 5
})Requests return {:ok, %Anubis.MCP.Response{}} or {:error, %Anubis.MCP.Error{}}. The response struct carries the raw result map and an is_error flag, which distinguishes the two failure levels MCP defines:
case Anubis.Client.call_tool(MyApp.WeatherClient, "get_weather", %{"location" => ""}) do
{:ok, %{is_error: false, result: result}} ->
handle_weather(result)
{:ok, %{is_error: true, result: error}} ->
Logger.warning("tool reported failure: #{inspect(error)}")
{:error, %Anubis.MCP.Error{} = error} ->
Logger.error("protocol or transport failure: #{inspect(error)}")
endA tool-level error (is_error: true) means the server ran the tool and the tool failed; the payload usually explains why. A protocol error means the request itself did not complete: the server rejected it, the transport dropped, or the call timed out.
Reading resources and prompts
{:ok, %{result: %{"contents" => contents}}} =
Anubis.Client.read_resource(MyApp.WeatherClient, "weather://stations/KSFO")
for content <- contents do
case content do
%{"text" => text} -> handle_text(text)
%{"blob" => blob} -> handle_binary(blob)
end
endPrompts work the same way, with arguments:
{:ok, %{result: %{"messages" => messages}}} =
Anubis.Client.get_prompt(MyApp.WeatherClient, "storm_briefing", %{"region" => "pacific"})Servers that declare resource subscriptions also support subscribe_resource/3 and unsubscribe_resource/3.
Timeouts and progress
Every request function accepts a timeout: option in milliseconds, defaulting to 30 seconds:
Anubis.Client.call_tool(client, "analyze_dataset", params, timeout: to_timeout(minute: 5))For long-running operations MCP defines progress notifications. Generate a token, pass it with the request, and optionally attach a callback:
token = Anubis.MCP.ID.generate_progress_token()
callback = fn ^token, progress, total ->
IO.puts("progress: #{progress}/#{total || "?"}")
end
Anubis.Client.call_tool(client, "analyze_dataset", params,
progress: [token: token, callback: callback]
)The callback runs each time the server reports progress for that token.
Multiple connections
Each client owns exactly one connection, so connecting to several servers means starting several clients:
children = [
{Anubis.Client,
name: MyApp.SearchClient,
transport: {:stdio, command: "search-server", args: []},
client_info: %{"name" => "MyApp", "version" => "1.0.0"},
capabilities: %{}},
{Anubis.Client,
name: MyApp.FilesClient,
transport: {:streamable_http, base_url: "http://localhost:8000"},
client_info: %{"name" => "MyApp", "version" => "1.0.0"},
capabilities: %{}}
]When connections are created at runtime, for example from user-configured server URLs, start clients under a DynamicSupervisor:
children = [
{DynamicSupervisor, name: MyApp.MCPSupervisor, strategy: :one_for_one}
]
def connect(user_id, url) do
spec =
{Anubis.Client,
name: {:via, Registry, {MyApp.MCPRegistry, user_id}},
transport_name: {:via, Registry, {MyApp.MCPRegistry, {user_id, :transport}}},
transport: {:streamable_http, base_url: url},
client_info: %{"name" => "MyApp", "version" => "1.0.0"},
capabilities: %{}}
DynamicSupervisor.start_child(MyApp.MCPSupervisor, spec)
endAtom names derive a transport name automatically. With :via names you must provide transport_name: yourself, as above. All client functions accept the name or the PID returned by start_child/2.
Client capabilities
Some MCP features flow from server to client: sampling asks your client to run an LLM completion, roots lets the server ask which directories it may touch, and elicitation requests structured input from the user. Declare the ones you support and register a handler:
{Anubis.Client,
name: MyApp.MCPClient,
transport: {:stdio, command: "server", args: []},
client_info: %{"name" => "MyApp", "version" => "1.0.0"},
capabilities: %{"sampling" => %{}, "roots" => %{}}}Anubis.Client.register_sampling_callback(MyApp.MCPClient, fn request ->
{:ok, MyApp.LLM.complete(request)}
end)
Anubis.Client.add_root(MyApp.MCPClient, "file:///home/user/project", "project")Anubis.Client.parse_capability/2 builds the capability map from atom shorthand when you prefer:
capabilities =
Enum.reduce([:roots, {:sampling, list_changed?: true}], %{}, &Anubis.Client.parse_capability/2)Server logs
Servers with the :logging capability can push log messages to your client. You can set the minimum level and handle the stream:
Anubis.Client.set_log_level(MyApp.MCPClient, "warning")
Anubis.Client.register_log_callback(MyApp.MCPClient, fn level, data, logger ->
Logger.log(String.to_existing_atom(level), "mcp[#{logger}]: #{inspect(data)}")
end)Shutting down
Anubis.Client.close(MyApp.MCPClient)This closes the connection and stops the transport. Under a supervisor you rarely call it yourself; stopping the supervisor tree does the same work.
Next steps
- Transports details each transport option and when to pick it.
- Building a Server covers the other side of the connection.
- Recipes includes patterns for progress, logging, and error recovery.