Skip to content

Latest commit

 

History

History
811 lines (630 loc) · 28.2 KB

File metadata and controls

811 lines (630 loc) · 28.2 KB

Building an MCP Client with barrel_mcp

barrel_mcp is a pure MCP library. It implements the wire protocol, the transports, and the client state machine. It does not call LLM providers, build prompts, or run an agent loop, those belong to the host application that uses this library.

This guide is task-oriented. Each section answers "I want to do X" with a working snippet, notes, and a pointer to the spec or wire detail. Snippets tagged ```erlang are extracted from this file and compile-checked in CI; snippets tagged ```erl are illustrative output only.

If you have not yet read the architecture, the short summary is: a barrel_mcp_client is a gen_statem that owns one connection to one MCP server. It dispatches inbound responses to waiting callers, server-initiated requests to a host-supplied handler module, and notifications to either subscribers or the same handler.


1. What barrel_mcp gives you, and what it doesn't

It gives you:

  • Streamable HTTP and stdio transports.
  • A spec-conformant MCP client (barrel_mcp_client).
  • Server-to-client request dispatch via the barrel_mcp_client_handler behaviour.
  • OAuth 2.1 + PKCE primitives (RFC 9728, RFC 8414, RFC 8707) and a refresh-only auth handle.
  • A federation registry (barrel_mcp_clients) for hosting many MCP connections in one app.
  • A JSON Schema subset validator (barrel_mcp_schema).

It does not give you:

  • LLM provider HTTP (Anthropic, OpenAI, Hermes, etc.). Implement that in your host application.
  • An agent loop. Drive your own multi-turn loop using the building blocks below.
  • Tool-name namespacing across servers. That's host policy.
  • A browser-based redirect listener for OAuth. Hosts run that step with whatever UI fits their environment.

2. Choose a transport

Transport Use it when Where it lives
Streamable HTTP The server is remote or runs as a long-lived service. You want session resumption and server-initiated requests over SSE. barrel_mcp_client_http
stdio The server is a local subprocess (CLI tools, native MCP servers shipped as binaries). barrel_mcp_client_stdio

In both cases the high-level API on barrel_mcp_client is identical. The transport tuple in the connect spec is the only difference:

%% Streamable HTTP
#{transport => {http, <<"https://server.example/mcp">>}}

%% stdio
#{transport => {stdio, #{command => "/usr/local/bin/mcp-server",
                         args => ["--quiet"]}}}

3. Connect spec reference

barrel_mcp_client:start_link/1 and start/1 accept a single map. Every key is documented below.

Key Type Default Effect
transport {http, Url} | {stdio, #{command, args}} required Which transport to open.
client_info #{name, version} #{name => <<"barrel_mcp_client">>, version => <<"3.0.0">>} Sent in initialize, and in modern _meta.
capabilities map #{} Client capabilities to declare. Booleans become spec-shape objects on the wire (e.g. #{sampling => true} becomes #{<<"sampling">> => #{}}).
handler {Mod, Args} {barrel_mcp_client_handler_default, []} Module implementing barrel_mcp_client_handler to handle server-initiated requests and notifications.
auth none | {bearer, Token} | {oauth, Config} none Authentication. See section 14.
protocol_version auto | binary auto Which revision to speak. auto probes with server/discover and falls back to the initialize handshake. Pin a binary to skip the probe. See section 5.
probe_timeout pos_integer 5000 How long auto waits for the probe before deciding the server is a handshake-era one. Unused when pinned.
max_input_rounds pos_integer 5 How many times a request may be re-issued to satisfy a server's input requests before the client gives up. See section 10.
request_timeout pos_integer 30000 Default per-request timeout in ms.
init_timeout pos_integer 30000 Time allowed for the initialize round-trip.
ping_interval pos_integer | infinity infinity If set, the client sends ping every N ms while in ready.
ping_failure_threshold pos_integer 3 Consecutive ping failures before the connection is closed with reason ping_failed.

4. Connect and close

{ok, Client} = barrel_mcp_client:start_link(#{
    transport => {http, <<"http://127.0.0.1:9090/mcp">>}
}),
%% ... use Client ...
ok = barrel_mcp_client:close(Client).

start_link/1 links the calling process to the client. Use start/1 for unsupervised one-offs (tests, scripts).

The state machine moves connecting → initializing → ready. Calls made before ready return {error, not_ready}. Wait for the first server_capabilities/1 to succeed if you need to gate work on readiness:

wait_ready(Pid, 0) -> {error, not_ready};
wait_ready(Pid, N) ->
    case catch barrel_mcp_client:server_capabilities(Pid) of
        {ok, _} -> ok;
        _ ->
            timer:sleep(100),
            wait_ready(Pid, N - 1)
    end.

5. Protocol era, capabilities, and version

MCP has two eras and the client speaks both. Legacy revisions (2025-11-25 and earlier) open with an initialize handshake; modern ones (2026-07-28) have no handshake and put the protocol version, capabilities and identity in every request's _meta. The Protocol Versions guide covers the model.

protocol_version decides which:

%% Default. Probe with server/discover, fall back to initialize.
#{protocol_version => auto, probe_timeout => 5000}

%% Modern only. No probe.
#{protocol_version => <<"2026-07-28">>}

%% Legacy only. Straight to the handshake, byte-identical to 2.3.0.
#{protocol_version => <<"2025-11-25">>}

Under auto the client sends server/discover on either transport. A DiscoverResult means modern. A -32022 also means modern, since the server understood the probe, so the client retries with a revision from data.supported rather than falling back to a handshake that server may not have. That retry happens once: a server rejecting the revision it just advertised has nothing left to offer. Anything else, an offer with no revision in common, or silence past probe_timeout, falls back to the handshake.

Pin a revision when you know the server. The probe costs a round trip against a legacy one.

Read back what was settled:

{ok, Version} = barrel_mcp_client:protocol_version(Pid).

Methods that a revision removed are answered locally rather than sent and rejected:

{error, {unsupported, <<"ping">>}} = barrel_mcp_client:ping(Pid).

That covers ping/1, set_log_level/2, tasks_list/1 and tasks_result/2 in modern mode, and subscriptions/listen in legacy mode. subscribe/2 and unsubscribe/2 keep working in both, over whichever mechanism the era provides. Ping keepalive turns itself off in modern mode, so a configured ping_interval is ignored rather than producing method-not-found on a timer.

The client declares what it can answer (sampling, roots, elicitation) and the server replies with what it can serve (tools, resources, prompts, logging, completions). After the handshake:

get_caps(Pid) ->
    {ok, ServerCaps} = barrel_mcp_client:server_capabilities(Pid),
    case maps:is_key(<<"tools">>, ServerCaps) of
        true -> ok;
        false -> {error, no_tools}
    end.

Version is negotiated automatically. The client sends ?MCP_CLIENT_PROTOCOL_VERSION (<<"2025-11-25">> today); if the server replies with an older version (e.g. 2025-03-26), the client adopts that and uses it on every subsequent MCP-Protocol-Version header. Read it back with barrel_mcp_client:protocol_version/1.


6. List tools, resources, and prompts

Single-page calls return one chunk:

list_one_page(Pid) ->
    {ok, Tools} = barrel_mcp_client:list_tools(Pid),
    Tools.

Pagination is opt-in. Pass #{want_cursor => true} to receive {ok, Items, NextCursor | undefined}. If the server has more pages than you want to walk by hand, use the *_all helpers:

list_every_tool(Pid) ->
    {ok, AllTools} = barrel_mcp_client:list_tools_all(Pid),
    AllTools.

The same shape applies to list_resources/1,2, list_resource_templates/1,2, and list_prompts/1,2.

Translate to provider tool shapes

barrel_mcp_tool_format converts MCP tool maps to the shapes the LLM provider APIs expect, and vice versa. Use it when you're building an agent host that hands MCP tools to Anthropic / OpenAI models and routes the model's tool calls back through the MCP client.

single_server_loop(McpPid) ->
    {ok, McpTools} = barrel_mcp_client:list_tools_all(McpPid),
    AnthropicTools = barrel_mcp_tool_format:to_anthropic(McpTools),
    %% ... call Anthropic with AnthropicTools, get tool_use blocks ...
    Block = receive_tool_use_block(),
    {Name, Args} = barrel_mcp_tool_format:from_anthropic_call(Block),
    barrel_mcp_client:call_tool(McpPid, Name, Args).
receive_tool_use_block() ->
    %% Replace with your real LLM client.
    #{<<"name">> => <<"echo">>,
      <<"input">> => #{<<"text">> => <<"hi">>}}.

to_openai/1 and from_openai_call/1 follow the same pattern for the OpenAI Chat Completions tool-call envelope.

Federate across servers with the agent aggregator

When the host connects to several MCP servers at once (via barrel_mcp:start_client/2), barrel_mcp_agent collapses every catalog into one namespaced list and routes a model's call back to the right server.

multi_server_loop() ->
    Tools = barrel_mcp_agent:to_anthropic(),
    %% ... call Anthropic with Tools ...
    Block = receive_tool_use_block(),
    {Name, Args} = barrel_mcp_tool_format:from_anthropic_call(Block),
    barrel_mcp_agent:call_tool(Name, Args).

Tool names round-trip through <<"ServerId:ToolName">>. Pick a different separator with #{separator => <<"::">>} if : clashes with one of your tool names.


7. Call a tool

call_echo(Pid) ->
    barrel_mcp_client:call_tool(Pid, <<"echo">>, #{<<"text">> => <<"hi">>}).

call_tool/4 accepts an option map:

call_with_progress(Pid, Token) ->
    barrel_mcp_client:call_tool(
        Pid,
        <<"slow">>,
        #{<<"size">> => 1000},
        #{progress_token => Token, timeout => 60000}).

When progress_token is supplied, the calling process receives one {mcp_progress, Token, Params} message per notifications/progress the server emits, until the request settles (response, cancel, or timeout).

The full echo-client example lives in examples/echo_client/src/echo_client.erl.

Tool results

call_tool/3,4 returns the server's result map. Three shapes the spec allows:

classify(#{<<"isError">> := true} = R) ->
    {error, maps:get(<<"content">>, R)};
classify(#{<<"structuredContent">> := Data} = R) ->
    {structured, Data, maps:get(<<"content">>, R, [])};
classify(#{<<"content">> := Content}) ->
    {ok, Content}.
  • isError: true → the tool reported a domain-level failure (validation, business rule). The content is human-readable.
  • structuredContent → typed payload, optionally paired with human-readable content blocks. When the tool registered an outputSchema, the typed payload conforms to it.
  • Plain content → standard MCP content blocks.

Tasks

If the server registered the tool with long_running => true, call_tool returns immediately with #{<<"taskId">> := Id, <<"status">> := <<"working">>}. Track progress with the methods in section 12.


8. Read and subscribe to resources

read_resource(Pid, Uri) ->
    barrel_mcp_client:read_resource(Pid, Uri).

Subscribe to be notified of updates:

watch_resource(Pid, Uri) ->
    {ok, _} = barrel_mcp_client:subscribe(Pid, Uri),
    receive
        {mcp_resource_updated, Uri, Params} ->
            handle_update(Params)
    after 5000 ->
        timeout
    end.
handle_update(_) -> ok.

The subscription stays in the client's state until you call unsubscribe(Pid, Uri) or close the client. Subscribers are identified by their pid; multiple processes can subscribe to the same URI on the same client.

The mechanism underneath differs by era and the API does not. In legacy mode this is resources/subscribe plus the session's GET stream. In modern mode there is no such method: the client keeps one subscriptions/listen stream open and re-opens it with a new filter as you subscribe and unsubscribe. Either way you get {mcp_resource_updated, Uri, Params}.


Logging

Set the server's log level for the session, and route the inbound notifications/message stream into your application's logger via the handler.

set_debug_level(Pid) ->
    barrel_mcp_client:set_log_level(Pid, <<"debug">>).

Levels match RFC 5424 names: debug, info, notice, warning, error, critical, alert, emergency.

Server introspection

caps(Pid) ->
    barrel_mcp_client:server_capabilities(Pid).

info(Pid) ->
    barrel_mcp_client:server_info(Pid).

negotiated_version(Pid) ->
    barrel_mcp_client:protocol_version(Pid).

server_capabilities/1 is the authoritative source for what the server actually supports (e.g. whether tasks is advertised). Check before calling capability-gated methods.


9. Get prompts and run completion

fetch_prompt(Pid) ->
    barrel_mcp_client:get_prompt(Pid, <<"summarize">>,
                                 #{<<"length">> => <<"short">>}).
ask_completion(Pid) ->
    barrel_mcp_client:complete(Pid,
        #{<<"type">> => <<"ref/prompt">>, <<"name">> => <<"summarize">>},
        #{<<"name">> => <<"length">>, <<"value">> => <<"sho">>}).

complete/3 is the spec-named completion/complete request used to auto-complete prompt argument values.


10. Handle server-initiated requests

The server can call into the client (sampling/createMessage, roots/list, elicitation/create). Implement barrel_mcp_client_handler and supply it as handler => {Mod, Args}.

If the host's roots change after initialize (the user opened a new workspace, granted access to a new directory, etc.) inform the server so it can re-query:

ok = barrel_mcp_client:notify_roots_list_changed(Pid).

The server may follow up with roots/list against your handler.

Three return shapes from handle_request/3:

  • {reply, Result, State}: synchronous answer.
  • {error, Code, Message, State}: JSON-RPC error response.
  • {async, Tag, State}: defer; reply later from any process via barrel_mcp_client:reply_async(Pid, Tag, Result).

Skeleton:

-module(my_handler).
-behaviour(barrel_mcp_client_handler).
-export([init/1, handle_request/3, handle_notification/3, terminate/2]).

init(Args) ->
    {ok, Args}.

handle_request(<<"sampling/createMessage">>, Params, State) ->
    Result = sample_via_llm(Params, State),
    {reply, Result, State};
handle_request(<<"roots/list">>, _, State) ->
    {reply, #{<<"roots">> => [
        #{<<"uri">> => <<"file:///workspace">>,
          <<"name">> => <<"workspace">>}
    ]}, State};
handle_request(Method, _Params, State) ->
    {error, -32601, <<"Method not found: ", Method/binary>>, State}.

handle_notification(_Method, _Params, State) ->
    {ok, State}.

terminate(_Reason, _State) ->
    ok.

sample_via_llm(_, _) ->
    %% Replace with an HTTP call to your LLM provider.
    #{<<"content">> => #{<<"type">> => <<"text">>, <<"text">> => <<"hi">>},
      <<"model">> => <<"placeholder">>,
      <<"role">> => <<"assistant">>}.

The sampling_host example in examples/sampling_host/src/sampling_host.erl shows the full server-to-client round-trip end to end.

The same handler in modern mode

2026-07-28 removed server-to-client requests. A server that needs something answers the call with what it needs instead, and the client re-issues the call carrying the answers. This is a multi round-trip request (MRTR).

Your handler does not change. The client invokes it from the retry loop rather than from an inbound request, with the same methods, the same arguments and the same three return shapes, {async, Tag, State} included. One call_tool/3 still looks like one call to your code; underneath it is two requests with different ids.

What does change:

  • notify_roots_list_changed/1 is dropped, Roots having gone with the rest. It stays callable so the same code compiles.
  • Rounds are bounded by max_input_rounds (default 5). A server that keeps asking gets an error rather than an unbounded exchange.
  • The whole exchange shares the original call's deadline. Retries do not each get a fresh request_timeout.
  • A server must not ask for a capability you did not declare, so declare what your handler answers. Ours refuses to send such a request (-32021) rather than let a handler be surprised.

11. Asynchronous handler replies

When answering a server request takes time (calling an LLM provider, asking a user, etc.), block the model thread instead of the state machine:

handle_request(<<"sampling/createMessage">>, Params, State) ->
    Tag = make_ref(),
    Self = self(),  %% the host process; not the gen_statem
    spawn(fun() ->
        Result = slow_llm_call(Params),
        barrel_mcp_client:reply_async(Self, Tag, Result)
    end),
    {async, Tag, State}.

reply_async/3 may also be used for errors via reply_async(Pid, Tag, {error, Code, Message}).


12. Notifications and tasks

The handler's handle_notification/3 callback receives every inbound notification with its raw params map. Common methods:

  • notifications/resources/updated: also dispatched to subscribers of the URI as {mcp_resource_updated, Uri, Params} (see section 8).
  • notifications/progress: also dispatched to the caller of the request that owns the progress token (see section 7).
  • notifications/tools/list_changed, .../resources/list_changed, .../prompts/list_changed: catalogue updated; re-fetch or invalidate caches.
  • notifications/tasks/status: a long-running task transitioned state. The full task record is in params.
  • notifications/message: server logging stream.
  • notifications/replay_truncated: your Last-Event-ID was outside the server's replay window; resync rather than trust the partial stream.

The handler is the right place to integrate with your application's metrics, logs, or UI.

Task methods

When a tool was registered as long_running on the server, the initial call_tool returns a taskId. Track it with the typed wrappers:

poll_task(Pid, TaskId) ->
    barrel_mcp_client:tasks_get(Pid, TaskId).

list_tasks(Pid) ->
    %% Single page; use `tasks_list_all/1' or
    %% `tasks_list/2' with `#{want_cursor => true}' for paging.
    barrel_mcp_client:tasks_list(Pid).

abort_task(Pid, TaskId) ->
    barrel_mcp_client:tasks_cancel(Pid, TaskId).

fetch_task_result(Pid, TaskId) ->
    %% Returns the recorded result for a `completed' task, or an
    %% error for `failed' / `cancelled' / still-`working' tasks.
    barrel_mcp_client:tasks_result(Pid, TaskId).

Status values on the wire are working, completed, failed, and cancelled; createdAt and lastUpdatedAt are RFC 3339 strings.

When you registered a progress_token on the originating call, the same task usually emits notifications/progress updates that arrive through your handler, so polling is rarely required. Prefer subscribing to notifications/tasks/status in the handler over busy-polling tasks_get/2, then fetch the payload once with tasks_result/2 when the status reaches completed.

In modern mode tasks are an extension. Declare it to be given task handles at all:

%% Spec snippet: a key on barrel_mcp_client:start_link/1's input map.
#{capabilities => #{<<"extensions">> =>
                      #{<<"io.modelcontextprotocol/tasks">> => #{}}}}

Without it a long_running tool runs synchronously and you get its result, rather than a task id you would not know how to poll. The method set also shifts: tasks_list/1 and tasks_result/2 are legacy and answer {error, {unsupported, _}}, tasks_update/3 is modern, and tasks_get/2 and tasks_cancel/2 work in both. Poll tasks_get/2 and read the result off the task.


13. Cancel, time out, ping

cancel_request(Pid, Id) ->
    barrel_mcp_client:cancel(Pid, Id).

The id is the JSON-RPC request id for the in-flight call. barrel_mcp_client increments these internally; in tests you can read pending ids via sys:get_state/1. In production you usually don't cancel by id, you set a timeout on call_tool/4 and let the deadline fire.

Periodic ping is opt-in:

%% Spec snippet, a key on barrel_mcp_client:start_link/1's input map.
#{ping_interval => 30000, ping_failure_threshold => 3}

After three consecutive ping failures (default), the connection closes with reason ping_failed and the linked owner sees the exit.

2026-07-28 removed ping, so in modern mode the cadence turns itself off however you configure it, and ping/1 answers {error, {unsupported, <<"ping">>}} rather than being sent. Use a cheap real request if you need a liveness check there.


14. Authenticate

Static bearer

#{transport => {http, <<"https://server.example/mcp">>},
  auth => {bearer, <<"my-static-token">>}}

barrel_mcp_client_auth_bearer attaches Authorization: Bearer ... on every request. A 401 returns {error, unauthorized} and the caller must restart with a new token.

OAuth 2.1 + PKCE

The interactive authorization-code redirect is a host concern; once you have an access token (and ideally a refresh token), pass them through:

#{transport => {http, <<"https://server.example/mcp">>},
  auth => {oauth, #{
    access_token   => <<"eyJ...">>,
    refresh_token  => <<"opaque">>,
    token_endpoint => <<"https://auth.example/token">>,
    client_id      => <<"my-client">>,
    resource       => <<"https://server.example/mcp">>
  }}}

On 401 the library posts a refresh_token grant to token_endpoint (with the RFC 8707 resource parameter), updates the handle, and retries the original request once.

To drive the initial auth code flow yourself, use the discovery helpers:

discover(Server) ->
    {ok, Resp401, Headers} = first_request_returns_401(Server),
    Www = proplists:get_value(<<"www-authenticate">>, Headers),
    PrmUrl = barrel_mcp_client_auth_oauth:parse_www_authenticate(Www),
    {ok, Prm} = barrel_mcp_client_auth_oauth:discover_protected_resource(PrmUrl),
    [Issuer | _] = maps:get(<<"authorization_servers">>, Prm),
    {ok, AS} = barrel_mcp_client_auth_oauth:discover_authorization_server(Issuer),
    {Url, Verifier, _State} = barrel_mcp_client_auth_oauth:build_authorization_url(
        maps:get(<<"authorization_endpoint">>, AS),
        #{client_id => <<"my-client">>,
          redirect_uri => <<"http://localhost:38080/cb">>,
          resource => maps:get(<<"resource">>, Prm)}),
    {Url, Verifier, AS, Resp401}.
first_request_returns_401(_) ->
    {ok, ignore, [{<<"www-authenticate">>,
                   <<"Bearer resource_metadata=\"https://srv/.well-known/oauth-protected-resource\"">>}]}.

Record the state and the AS issuer alongside the verifier. When the redirect comes back, check them before sending the code anywhere:

ok = barrel_mcp_client_auth_oauth:validate_callback(
       QueryParams,
       #{state => State, issuer => Issuer, as_metadata => AS}).

Without this a client talking to more than one authorization server can be handed a code minted by one of them at another's endpoint and cannot tell. Check error responses the same way: on a mismatch the error and error_description are not yours to act on or display, because you cannot tell who wrote them.

Then exchange the code:

{ok, Tokens} = barrel_mcp_client_auth_oauth:exchange_code(
    maps:get(<<"token_endpoint">>, AS),
    #{code => Code,
      code_verifier => Verifier,
      client_id => <<"my-client">>,
      redirect_uri => <<"http://localhost:38080/cb">>,
      resource => maps:get(<<"resource">>, Prm)}).

Then start the client with the tokens above.

Where the client_id comes from is its own decision, and dynamic registration is no longer the default answer. See Client registration.


15. Schema-validate before calling

barrel_mcp_schema:validate/2 covers the JSON Schema subset MCP tools actually use. Cache the schema returned by tools/list, then validate before dispatching:

call_validated(Pid, Name, Args, Schema) ->
    case barrel_mcp_schema:validate(Args, Schema) of
        ok -> barrel_mcp_client:call_tool(Pid, Name, Args);
        {error, Errors} -> {error, {invalid_args, Errors}}
    end.

This is opt-in, many hosts trust the LLM output enough to skip it. Use it when you want a clear error before the request reaches the server.


16. Federate many MCP servers

{ok, _} = barrel_mcp:start_client(<<"github">>, #{
    transport => {http, <<"https://mcp.github.example/">>},
    auth => {bearer, GhToken}
}),
{ok, _} = barrel_mcp:start_client(<<"local-files">>, #{
    transport => {stdio, #{command => "/usr/local/bin/mcp-files"}}
}),

GitHub = barrel_mcp:whereis_client(<<"github">>),
{ok, Tools} = barrel_mcp_client:list_tools(GitHub).

Each connection is a supervised worker. Crashes are isolated; the registry's monitor prunes the dead entry automatically. Tool-name namespacing across servers is your call; a common pattern is <<ServerId/binary, "::", ToolName/binary>> when surfacing the catalogue to an LLM.


17. Errors you can see

Return Cause
{error, not_ready} Call made before the connection settled (a handshake or a probe). Wait for ready.
{error, {unsupported, Method}} Server didn't advertise the capability the call requires, or the negotiated revision does not have the method.
{error, {Code, Message}} Server returned a JSON-RPC error.
{error, cancelled} Caller invoked cancel/2.
{error, timeout} The per-request timeout fired. Under MRTR this is the deadline for the whole exchange, not per round.
{error, {too_many_input_rounds, Max}} The server kept asking for input past max_input_rounds.
{error, {input_failed, Code, Message}} Your handler returned an error for one of the server's input requests.
{error, unauthorized} 401 with no usable refresh path.
{error, {protocol_version, Server, Supported}} Server's version is outside the client's supported list. Init failed.

Errors that arrive as {error, {Code, Message}} and are worth matching on: -32020 (a request's headers disagreed with its body; the tool's annotations changed, so re-fetch tools/list and retry once), -32021 (the server needs a capability you did not declare) and -32022 (the revision you named is not served).

Start-up failures stop the process rather than returning: {probe_failed, Why} when a pinned modern connection could not be established, and {init_failed, _} when the handshake was refused.


18. Production checklist

  • Run clients under a supervisor. barrel_mcp:start_client/2 does this for you; for ad-hoc clients call barrel_mcp_client:start_link/1 inside your own supervision tree.
  • Set request_timeout to a value that matches your SLO. The default 30 s is generous.
  • Set ping_interval if your transport is a long-lived HTTP connection that may sit idle behind proxies.
  • Implement handle_notification/3 to forward notifications/message to your logging system; this is how MCP servers emit operational signals.
  • Validate tool inputs with barrel_mcp_schema:validate/2 before forwarding model output.
  • For OAuth, persist refresh tokens; the in-memory handle dies with the client.

See also