Internals

View Source

How barrel_mcp is wired on the client side. Read this if you are extending the library, debugging a stuck connection, or generating code that introspects the architecture.

This file is paired with Building a client; the building guide is task-oriented, this one is structural.

1. Module map

ModuleRole
barrel_mcpTop-level façade. start_client/2, notify_resource_updated/1,2, sampling_create_message/3, etc.
barrel_mcp_clientThe client gen_statem. Owns one connection.
barrel_mcp_client_supSupervises client workers (transient).
barrel_mcp_clientsFederation registry: ServerId → pid().
barrel_mcp_client_transportBehaviour: connect/2, send/2, close/1.
barrel_mcp_client_stdioTransport impl over open_port/2.
barrel_mcp_client_httpTransport impl over Streamable HTTP (POST + SSE GET).
barrel_mcp_client_handlerBehaviour for server-initiated requests and notifications.
barrel_mcp_client_handler_defaultNo-op default handler.
barrel_mcp_client_authBehaviour: init/1, header/1, refresh/2.
barrel_mcp_client_auth_bearerStatic-token impl.
barrel_mcp_client_auth_oauthOAuth 2.1 + PKCE impl + discovery helpers.
barrel_mcp_protocolJSON-RPC envelope codec; shared with the server.
barrel_mcp_paginationCursor walker for */list requests.
barrel_mcp_schemaJSON Schema subset validator.

2. Supervision tree

barrel_mcp_sup (one_for_one)
 barrel_mcp_registry      -- server-side registry of tools/resources/prompts
 barrel_mcp_session       -- server-side session manager
 barrel_mcp_client_sup    -- one_for_one of barrel_mcp_client workers
    client(<<"server-1">>)
    client(<<"server-2">>)
 barrel_mcp_clients       -- registry: ServerId -> pid + monitor

barrel_mcp_clients is a gen_server that owns the lookup table and serializes registration so two callers can't race on the same ServerId. Lookups (whereis_client/1, list_clients/0) hit the ETS table directly without crossing the process boundary.

3. Client state machine

barrel_mcp_client is a gen_statem in state_functions mode.

                         start_link/1
                              
                              
                       
                        connecting   open transport, send `initialize'
                       
                              transport up
                             
                       
                        initializing   state-timeout = init_timeout
                       
                              initialize response (negotiated version)
                              + notifications/initialized
                              + open SSE GET (HTTP only)
                             
                       
                        ready   all client API calls go here
                       
                           
              close/1    transport_closed | ping_failed
                              
                       
                        closing 
                       

State transitions:

  • connecting → initializing — internal event after open_transport/1.
  • initializing → ready — successful initialize response, version in ?MCP_CLIENT_SUPPORTED_VERSIONS.
  • initializing → stop {init_failed, _} — server returned a JSON-RPC error to initialize or omitted protocolVersion.
  • ready → closing — caller cast close, or stop on mcp_closed, or stop with ping_failed.

4. Inbound message flow

transport process (stdio port owner | http gen_server)
        
          {mcp_in, TransportPid, Json}
        
barrel_mcp_client gen_statem (info handler)
        
         decode_envelope/1 -> request | response | notification | error
        
         response/error  -> ETS pending lookup -> gen_statem:reply(Caller, _)
         request         -> handler:handle_request/3 -> send response
         notification    -> handler:handle_notification/3
                               (+ subscriber routing for resources/updated)
                               (+ progress routing for notifications/progress)

The transport process owns the wire — socket, port, SSE buffer — and forwards one JSON-RPC envelope per {mcp_in, _, _} message. The gen_statem never reads the wire directly. This means transports can implement framing however suits them (line-delimited for stdio, SSE-event-delimited for HTTP) without leaking that into the state machine.

When the transport ends, it sends {mcp_closed, TransportPid, Reason} and the gen_statem stops with that reason.

5. Outbound flow

caller                              barrel_mcp_client gen_statem
                                             
    gen_statem:call({request, M, P, T})      
   
                                              next_id/1
                                              pending#{Id => #pending{...}}
                                              encode_request/3
                                              transport:send/2
                                             
                                      transport process
                                             
                                             
                                      ... server ...
                                             
                                             
                                      {mcp_in, _, ResponseJson}
                                             
                                     match by id, gen_statem:reply
   

pending is a map keyed by request id. Each entry tracks the caller {From}, the method name, the deadline, and the optional progress token. The state machine drops the entry on settle and clears any matching state-timeout.

6. Behaviour contracts

barrel_mcp_client_transport

-callback connect(Owner :: pid(), Opts :: map()) ->
    {ok, pid()} | {error, term()}.
-callback send(TransportPid :: pid(), JsonBinary :: iodata()) ->
    ok | {error, term()}.
-callback close(TransportPid :: pid()) -> ok.

The transport process MUST emit {mcp_in, TransportPid, JsonBinary} to Owner for every complete inbound JSON-RPC envelope. On shutdown — peer disconnect, port exit, fatal error — it MUST emit {mcp_closed, TransportPid, Reason} exactly once.

barrel_mcp_client_handler

-callback init(Args :: term()) -> {ok, state()} | {error, term()}.
-callback handle_request(Method :: binary(),
                         Params :: map(),
                         State :: state()) ->
    {reply, Result :: term(), state()} |
    {error, Code :: integer(), Message :: binary(), state()} |
    {async, Tag :: term(), state()}.
-callback handle_notification(Method :: binary(),
                              Params :: map(),
                              State :: state()) -> {ok, state()}.
-callback terminate(Reason :: term(), State :: state()) -> any().

The default handler (barrel_mcp_client_handler_default) returns method_not_found for every request and ignores every notification. Hosts only implement what they declare in their capabilities.

The {async, Tag, State} reply form lets a handler defer the response while the state machine continues to process other inbound traffic. The host posts the actual reply via barrel_mcp_client:reply_async/3.

barrel_mcp_client_auth

-callback init(Config :: term()) -> {ok, handle()} | {error, term()}.
-callback header(handle()) -> {ok, binary()} | none | {error, term()}.
-callback refresh(handle(), WwwAuthenticate :: binary() | undefined) ->
    {ok, handle()} | {error, term()}.

The HTTP transport calls header/1 on every outgoing request. On a 401 it calls refresh/2 once with the WWW-Authenticate header value, then retries the original request with the new handle. The caller-facing constructor is barrel_mcp_client_auth:new/1 which wraps the chosen impl as {Module, State} (or none for no auth).

7. State record fields

FieldTypeHolds
specmapThe connect spec passed to start_link/1.
transport{Mod, Pid}Active transport.
request_idintMonotonic counter for outbound JSON-RPC ids.
pendingmapId ⇒ #pending{caller, method, deadline, progress_token}.
handler_modatomThe host's handler module.
handler_statetermThe handler's per-instance state.
async_repliesmapTag ⇒ Id for {async, Tag, _} requests.
subscriptionsmapUri ⇒ [pid()] for notifications/resources/updated.
progressmapToken ⇒ pid() for notifications/progress.
ping_failuresintConsecutive ping errors; resets on success.
server_capabilitiesmapWhat the server advertised at init.
server_infomapname, version from the server.
protocol_versionbinaryNegotiated version, after init.

8. Wire format reference

Streamable HTTP

Headers attached on every outgoing request:

HeaderWhenNotes
content-type: application/jsonalwaysrequest body is a JSON-RPC envelope (or empty for GET).
accept: application/json, text/event-streamalwaysthe server may answer with either.
mcp-session-id: <id>after the initialize POST returns oneechoed on every subsequent POST/GET/DELETE.
mcp-protocol-version: <version>after init completesthe negotiated version.
authorization: Bearer ...when an auth handle attaches onebearer or OAuth-fronted.
last-event-id: <id>reconnecting the GET SSEnot yet a full replay path; tracked but not yet replayed.

The POST endpoint may answer with a JSON envelope or with an SSE stream. The transport classifies on content-type and either forwards the single envelope or parses SSE events until the matching done. SSE event format:

id: <id>
event: <name>
data: <JSON-RPC envelope>
\n

The transport ignores event:, captures id: for resumability, and forwards each data: payload as one mcp_in message.

The GET endpoint opens a long-lived SSE for unsolicited server-to-client traffic. A 405 here means the server doesn't support unsolicited streams; the client silently drops the GET and only receives server-initiated requests interleaved on POST responses.

DELETE on close, with Mcp-Session-Id.

stdio

Line-delimited JSON-RPC. One envelope per line. The transport reads up to a 1 MiB line limit (configurable in barrel_mcp_client_stdio). Anything larger fails framing with {mcp_closed, _, line_too_long}.

stdin and stdout are the only channels. stderr from the subprocess is discarded by default; redirect it in your launcher if you need it.

9. Where to look in the source

Want to readFile
State machine + public APIsrc/barrel_mcp_client.erl
Streamable HTTP transportsrc/barrel_mcp_client_http.erl
stdio transportsrc/barrel_mcp_client_stdio.erl
Handler behaviour + defaultsrc/barrel_mcp_client_handler.erl, src/barrel_mcp_client_handler_default.erl
OAuth flowsrc/barrel_mcp_client_auth_oauth.erl
Federationsrc/barrel_mcp_clients.erl, src/barrel_mcp_client_sup.erl
Pagination walkersrc/barrel_mcp_pagination.erl
Schema validatorsrc/barrel_mcp_schema.erl
Wire envelope codecsrc/barrel_mcp_protocol.erl