Noizu.MCP.Client is a supervised GenServer: one client per server
connection, addressable by name or pid.
children = [
{Noizu.MCP.Client,
name: MyApp.FS,
transport: {:stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]},
handler: MyApp.MCPHandler,
client_info: %{name: "myapp", version: "1.0.0"}}
]Transports:
transport: {:stdio, command: "...", args: [...]}
transport: {:streamable_http, url: "https://api.example.com/mcp"}
transport: {:streamable_http, url: ..., auth: {Noizu.MCP.Auth.Static, token: token}}
transport: {MyTransport, opts} # any Noizu.MCP.Transport.Client implRequests issued before the handshake completes are queued; use
Noizu.MCP.Client.await_ready(client, timeout) when you need to block on
connection (e.g. in scripts).
Calling tools
{:ok, tools} = Noizu.MCP.Client.list_tools(MyApp.FS)
{:ok, result} =
Noizu.MCP.Client.call_tool(MyApp.FS, "read_file", %{"path" => "/tmp/a.txt"},
timeout: 60_000,
progress: fn %{"progress" => p} -> ProgressBar.update(p) end)
result.content # [%Noizu.MCP.Types.Content{}]
result.structured # structuredContent map or nil
result.is_error # execution errors come back as results, not {:error, _}Passing progress: automatically attaches a progress token; the callback
runs in its own task. A request that exceeds timeout: is cancelled on
the server (notifications/cancelled) and returns {:error, :timeout}.
Note that list_tools/1 only shows what the server chooses to list — servers
may keep tools hidden yet callable by name (a noizu_mcp server's catalog
tool, when registered, reveals them; see
Toolkits, Categories & Hidden Tools).
The rest of the surface
{:ok, resources} = Noizu.MCP.Client.list_resources(client)
{:ok, templates} = Noizu.MCP.Client.list_resource_templates(client)
{:ok, contents} = Noizu.MCP.Client.read_resource(client, "config://app")
:ok = Noizu.MCP.Client.subscribe_resource(client, "config://app")
:ok = Noizu.MCP.Client.unsubscribe_resource(client, "config://app")
{:ok, prompts} = Noizu.MCP.Client.list_prompts(client)
{:ok, %{messages: messages}} = Noizu.MCP.Client.get_prompt(client, "code_review", %{"code" => src})
{:ok, completion} =
Noizu.MCP.Client.complete(client, {:prompt, "code_review"}, "style", "fr")
:ok = Noizu.MCP.Client.set_log_level(client, :warning)
{:ok, %{}} = Noizu.MCP.Client.ping(client)
Noizu.MCP.Client.server_info(client) # %{name: ..., version: ...}
Noizu.MCP.Client.server_capabilities(client)
Noizu.MCP.Client.instructions(client)list_* functions auto-paginate by default; pass page: :first (then
page: cursor) for manual paging. Generic escape hatches: request/4,
notify/3.
Async requests
ref = Noizu.MCP.Client.async(client, "tools/call", %{"name" => "slow", "arguments" => %{}})
# ... do other work ...
case Noizu.MCP.Client.await(client, ref, 5_000) do
{:ok, result} -> result
{:error, :timeout} -> Noizu.MCP.Client.cancel(client, ref, "took too long")
endHandling server-initiated requests
Servers may call you: LLM sampling, user elicitation, roots listing.
Implement Noizu.MCP.Client.Handler — the capabilities you implement are
the capabilities the client advertises:
defmodule MyApp.MCPHandler do
@behaviour Noizu.MCP.Client.Handler
@impl true
def handle_sampling(params, _state) do
text = MyApp.LLM.complete(params["messages"], max_tokens: params["maxTokens"])
{:ok, %{"role" => "assistant",
"content" => %{"type" => "text", "text" => text},
"model" => "my-model"}}
end
@impl true
def handle_elicitation(%{"message" => msg}, _state) do
case MyApp.UI.confirm(msg) do
{:confirmed, fields} -> {:ok, :accept, fields}
:declined -> {:ok, :decline}
:dismissed -> {:ok, :cancel}
end
end
@impl true
def handle_notification(method, params, _state) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "mcp", {method, params})
:ok
end
endPass it as handler: MyApp.MCPHandler or handler: {MyApp.MCPHandler, state}.
Handler callbacks run in supervised tasks — blocking is fine.
Roots are managed on the client itself (the server is notified of changes automatically):
:ok = Noizu.MCP.Client.set_roots(client, [%Noizu.MCP.Types.Root{uri: "file:///work", name: "work"}])Or implement list_roots/1 on the handler for dynamic roots. To mirror raw
server notifications to a process instead of (or in addition to) a handler,
pass on_notification: pid — messages arrive as
{:mcp_notification, method, params}.
Errors
All functions return {:ok, _} | {:error, reason} where reason is a
%Noizu.MCP.Error{} (protocol error from the server), :timeout,
:closed, or a transport-specific term.
Telemetry
Attach once to every client event with the published event list:
:telemetry.attach_many(
"my-app-mcp",
Noizu.MCP.Client.Telemetry.events(),
&MyApp.MCPTelemetry.handle_event/4,
nil
)Every outbound request emits
[:noizu_mcp, :client, :request, :start | :stop | :exception]. tools/call
also emits the matching :tool event, while tool/resource/template/prompt list
requests emit matching :discovery events. Transport start, send, connection,
and session failures emit [:noizu_mcp, :client, :transport, :exception].
The request ID is stable across the generic and specialized events. To join an MCP call to an application chain or trace, pass allowlisted correlation data:
Noizu.MCP.Client.call_tool(client, "search", args,
telemetry_metadata: %{
correlation_id: chain.request_id,
trace_id: MyApp.Trace.current_trace_id()
}
)Accepted keys are listed by
Noizu.MCP.Client.Telemetry.correlation_keys/0; non-scalar values and unknown
keys are dropped. Events include operation identity, timing, status, client,
transport, request ID, and safe error classification. Arguments, results,
headers, authentication material, and raw error reasons are never emitted.